# Search via creatable library nodes ## Context and problem statement Crabidy's library is a lazily-fetched tree served by providers; today it is read-only and only exposes the user's favorites (playlists, followed artists). There is no way to find anything new. The desired interface (set by the user) is not a search dialog but **tree editing**: a search subtree under the provider in which the user *creates* nodes. Pressing `%` inside a creatable node prompts for a term; the term becomes a child node whose contents are the search results. Creatable places must be visibly marked in the UI. `tidaldy` already has a `search()` explorer stub (`search/artists`, response dumped to the debug log), so the Tidal endpoint family (`search/tracks|artists|albums`) is known to exist but its response shape is unverified against our models. ## Decisions Per the user's instruction, decisions from here on were taken autonomously and are recorded with rationale below (Options → Decision per topic). The one user-set constraint is the interface itself: creatable nodes in the tree, `%` to create, terms become persistent nodes. ## Assumptions - **Search is per-provider.** The search subtree lives at `/tidal/search`, not at the global root; a future provider brings its own. Consistent with prefix routing. - **Created nodes are ephemeral**, like every other piece of server state (the queue dies with the process, `SaveQueue` is a stub). Terms live in memory in the Tidal client for the process lifetime. Persistence is future work. - **"Editable" is scoped down to "creatable" for v1.** The described flow only needs create. The RPC is named so that `RemoveLibraryNode` / `RenameLibraryNode` can join it later without redesign (open question below). - **Search results are a view, not a copy.** Fetched fresh on node expansion, like playlists and albums are today. No caching, no staleness handling. ## Options considered ### 1. Mechanism: what does the wire contract look like? **A — search-specific RPC** (`Search(query) → results`), TUI renders results in an ad-hoc pane. Least server work, but it is exactly the interface the user rejected: no tree nodes, nothing persistent to revisit, and a second navigation model in the TUI. **B — generic node creation (chosen)**: `CreateLibraryNode(parent_path, title) → LibraryNode`, routed through `ProviderOrchestrator` by prefix like `GetLibraryNode`. The provider decides what creation *means* under a given parent; for `/tidal/search` the title is the search term. `LibraryNode` and `LibraryNodeChild` gain `bool is_creatable` so any client can mark creatable places generically. This matches the user's mental model, and the same RPC later covers other creatable things (e.g. new playlists). ### 2. Result shape: how do results hang in the tree? **A — full nesting**: results live entirely under `/tidal/search//artists///`. Self-contained navigation, but `TidalPath` grows a parallel copy of every variant (search versions of artist, album, both track kinds), `parse_path` needs recursive suffix matching, and the same Tidal entity gets yet another path identity. **B — canonical-path children (chosen)**: `/tidal/search/` carries the **track** results directly as its `tracks` (queueable in place, paths `/tidal/search//` — one new track variant), while artist and album results are children whose paths are **canonical** (`/tidal/artists/`, `/tidal/artists//`). Diving into an artist result reuses the existing artist/album/track machinery unchanged — `get_lib_node(/tidal/artists/)` already works for any id, favorite or not. Cost of B, accepted and documented: after diving from a search result into an artist, `h` (ascend) follows the canonical parent to `/tidal/artists` (favorites), not back to the search node. This is the existing "identity is context-dependent" trade-off from `architecture/overview.md` pointing the other way. B wins because it adds two path variants instead of five and cannot drift from the canonical browse behavior. **Term nodes are not queueable** (decided during api-design): the server's `resolve_tracks` breadth-first-sweeps *all* children of a queueable node, so a queueable term node would turn "queue this search" into the top tracks *plus every album of every artist result*. `is_queable = false` on the term node keeps queueing explicit: individual track results (and any artist/album dived into) queue normally. Queueing the term node itself yields nothing, exactly like the existing category nodes (`playlists`, `artists`). ### 3. Term encoding in paths Terms are user text; paths are `/`-separated and split by `path_segments`. A term like `AC/DC` or `100%` must not corrupt the tree. **A — reject problematic characters**: surprising, and `%`-in-term is a plausible music query. **B — percent-encode the segment (chosen)**: the term is percent-encoded into the path segment (`AC/DC` → `AC%2FDC`); the node `title` keeps the raw term for display. Round-trip helpers live next to the other path helpers in `crabidy-core` (`encode_segment` / `decode_segment`), implemented with the `percent-encoding` crate (tiny, maintained, already in the dependency tree via `reqwest`/`url`). Idempotency: creating an existing term returns the existing node rather than erroring; empty/whitespace-only terms are rejected with `ProviderError::InvalidInput` (new variant, also covers "this parent is not creatable" as `NotSupported`). ### 4. TUI text entry First text input in the TUI. **A — reuse the bindings table** by adding an input scope: wrong tool — free text is not a finite set of chords. **B — a modal input line (chosen)**: `%` (Library scope, only when the *currently open* node — not the selected child — has `is_creatable`) opens a one-line input overlay at the bottom of the library pane. While it is open the bindings table is bypassed entirely except `Esc` (cancel) and `Enter` (submit); every other printable char appends, `Backspace` deletes. No cursor movement or paste handling in v1. On submit the TUI sends `CreateNode(parent, term)`, and navigates into the returned node. The modality mechanism generalizes what the help modal introduced: the `(focus, help_open)` arguments of `bindings::lookup` become a single `InputMode`-aware gate (exact shape decided in api-design). `%` itself is a `BINDINGS` entry, so it shows up in the help modal like everything else. ### 5. Marking creatable nodes in the UI Children with `is_creatable` render with a `%` marker suffix (e.g. `search [%]`) in `COLOR_SECONDARY`, and when the open node itself is creatable the pane title shows the hint (`search — % to add`). No new keybinding needed to discover it: the marker is the affordance, the help modal documents `%`. ## Structure New/changed pieces, hatched by crate: ```d2 direction: right tui: cbd-tui { input: input overlay (new) bindings: "% binding + input-mode gate" library: creatable marker } core: crabidy-core { proto: "proto: CreateLibraryNode rpc,\nis_creatable fields" trait: "ProviderClient::create_lib_node (new)" enc: "encode_segment / decode_segment (new)" } server: crabidy-server { orchestrator: "ProviderOrchestrator:\nCreateLibraryNode command,\nprefix routing" } tidaldy: tidaldy { search: "typed search requests (new)" terms: "search_terms: RwLock (new state)" paths: "TidalPath::Search, SearchTerm,\nSearchTrack (new variants)" } tui.input -> core.proto: CreateLibraryNode core.proto -> server.orchestrator server.orchestrator -> tidaldy.terms: create under /tidal/search tidaldy.search -> core.trait: results as LibraryNode ``` Path shape after the change (green = track paths, dashed = canonical jumps): ```d2 direction: right tidal: "/tidal" search: "/tidal/search (creatable)" term: "/tidal/search/" strack: "/tidal/search//" { style.fill: "#e8f4e8" } artists: "/tidal/artists" artist: "/tidal/artists/" album: "/tidal/artists//" tidal -> search search -> term: created via % term -> strack: track results term -> artist: artist result (canonical path) {style.stroke-dash: 3} term -> album: album result (canonical path) {style.stroke-dash: 3} artists -> artist artist -> album ``` Create flow: ```d2 shape: sequence_diagram user: User tui: cbd-tui rpc: gRPC service orch: ProviderOrchestrator tidal: tidaldy::Client api: Tidal REST API user -> tui: "% inside /tidal/search" tui -> tui: open input overlay (bindings bypassed) user -> tui: types term, Enter tui -> rpc: CreateLibraryNode(/tidal/search, term) rpc -> orch: ProviderCommand::CreateLibraryNode orch -> tidal: create_lib_node(parent, title) tidal -> tidal: store term (idempotent) tidal -> api: "search/tracks|artists|albums?query=term" api -> tidal: results tidal -> orch: "LibraryNode /tidal/search/" orch -> rpc: node rpc -> tui: node tui -> tui: ReplaceLibraryNode (navigate into results) ``` ## Boundaries and interfaces (high level) - **proto**: `rpc CreateLibraryNode(CreateLibraryNodeRequest) returns (CreateLibraryNodeResponse)`; request = `parent_path`, `title`; response = the created `LibraryNode`. `LibraryNode.is_creatable = 7`, `LibraryNodeChild.is_creatable = 4` — additive, wire-compatible. - **ProviderClient**: new required method `create_lib_node(parent_path, title) -> Result`. `ProviderError` gains `NotSupported` and `InvalidInput` variants. The orchestrator's synthetic root and the server's mock provider return `NotSupported`. - **ProviderCommand**: new `CreateLibraryNode { parent_path, title, result_tx }`, same bounded(1)-reply pattern and 30s-timeout discipline as the existing commands. - **tidaldy**: `TidalPath::{Search, SearchTerm, SearchTrack}`; typed `search_tracks/search_artists/search_albums` (first page, limit 20 per category — search is exploratory, not exhaustive; `make_paginated_request`'s fetch-everything loop is wrong for it); `search_terms: RwLock>` following the existing login-state locking discipline (never held across await). - **cbd-tui**: input overlay state on `App`; `MessageFromUi::CreateNode`; `rpc.rs` client method; `%` in `BINDINGS` (Library scope); creatable marker in the library list rendering. ## Risks - **Unverified search response shape.** Our `Track`/`Artist`/`Album` models may not match `search/*` payloads (the explorer stub exists precisely because this was unexplored). Mitigation: first implement task runs the explorer request against the live API and locks the models down; if the shapes differ, only `tidaldy::models` grows search-specific wrappers. - **Trait change ripples.** Adding a required `ProviderClient` method touches the server's mock provider and any test doubles. Deliberate: a default "not supported" impl would hide missing implementations silently. - **Input overlay vs. terminal reality.** Paste arrives as a burst of char events (fine: they append), IME composition is untested. v1 accepts this. - **Term nodes are invisible to other clients** until they re-fetch `/tidal/search` — there is no library update stream. Accepted; browsing is pull-based today. ## Open questions - Delete/rename of created nodes (`RemoveLibraryNode`?) — the RPC family and `is_creatable` flag anticipate it; not in v1. - Should created terms persist across restarts (they'd fit a small TOML next to the token store)? Deferred with the queue-persistence question. - Combined-search ranking: v1 shows tracks, then artists, then albums in fixed category order; relevance interleaving would need the combined `search` endpoint.