# Queue selection, visual mode, and the register Marks and visual mode in the queue pane, paired with a vim-style **register**: deleting or yanking queue entries puts them in the register, pasting brings them back. One model, implemented in both clients. ## Context and problem statement The library pane has marks (`s`) and visual mode (`v`/`V`); the queue pane has neither — `d` removes exactly the row under the cursor, one at a time, and `queue.rs` carries the standing note *"FIXME: mark multiple tracks on queue and remove them"*. Clearing 200 tracks with `c`/`C`, or deleting the wrong row, is unrecoverable: there is no undo anywhere in the system. Meanwhile `p` in the queue means "insert the **library** selection after this track" — a cross-pane action with no name for what it is inserting. The ask is to close both gaps with one concept borrowed from vim: a client register that deletes and yanks write to, and paste reads from. That turns multi-delete into a safe operation (it is recoverable), gives "move these three tracks down" a natural spelling (`d` then `P`), and gives `p` a single meaning. ## Assumptions (confirmed) - **Only explicit commands write the register**: `y` (both panes), and `d`, `c`, `C` (queue). Marking or moving the cursor never writes it. This was the one point of disagreement — an implicit fill from the library selection would have kept `p` backward-compatible, but it means `s` in the library silently clobbers a clipboard you are about to paste. - **Both `p` (after cursor) and `P` (before cursor)**, so `d` then `P` restores exactly what you deleted, and `d` … `p` is a move. - **`c`/`C` fill the register too** — the destructive ops most worth undoing. - **One unnamed register**, but the type is shaped so named registers (`"a`) are additive rather than a rewrite. - **Both clients, now.** `cbd-web` gets queue marks, queue visual mode, and the register — *plus* the library visual mode that was deferred when `v`/`V` landed in the TUI, so the two clients do not drift further. - The register is **per-client, in-memory, one level deep**. It is not a server-side undo log; another client cannot undo your delete, and a restart forgets it. Same expectations as vim. ## What the protocol already gives us No `.proto` change, no server change. The two RPCs this feature needs are already shaped for it: - `Remove { positions: repeated uint32 }` — already takes **many** positions, so multi-delete is a client-side gather. - `Insert { position, paths: repeated string }` — takes a **path list**, so a register of paths pastes with the existing call. That is what makes this a client-only feature. ## Options considered ### What the register holds 1. **Resolved tracks** (`Track` messages, as deleted). Exact: what you deleted is what comes back, and the UI can show real titles. But it cannot express "the album node I yanked in the library", and pasting still has to send paths, so the extra fidelity buys nothing at the wire. 2. **Paths, with titles alongside for display** (chosen). It is what both RPCs speak; a yanked library *node* expands at paste time, which is a feature (`y` an album, `p` it into the queue); and the register is one `Vec` plus labels. The cost is honest and worth stating: paste **re-resolves**. A yanked child of a search term whose term has since been deleted may not come back, and a node's track count is unknown until paste, so the UI can only say "3 entries", not "12 tracks". ### Keeping marks alive in a moving queue This is the only genuinely new problem. The library never had it: a listing is a snapshot you re-fetch deliberately. The queue is server-pushed and rebuilt on **every** change — another client appending, playback advancing, and each chunk of a streaming resolve. 1. **Remap marks by index.** Cheapest, and silently wrong: if playback advanced and the queue shifted by one, `d` deletes the wrong tracks. Rejected — a data-losing failure mode. 2. **Clear marks on every snapshot.** Never wrong, but a resolve streaming in or another client's append wipes a selection mid-flow, which makes the feature feel broken exactly when the queue is busy. 3. **Greedy in-order match on track path** (chosen). Walk the old and new path lists together and carry a mark to the new position of the same path; drop marks whose track is gone. Survives appends, removals, and playback advancing. Duplicate paths (the same track queued twice) are ambiguous by nature — in-order matching degrades sanely, keeping the *n*-th occurrence marked. If the lists diverge past recognition (no common prefix worth speaking of), clear rather than guess. The safety rule that makes any of this sound: **positions handed to `Remove` are always read off the newest snapshot**, never a remembered index. ### Where marks live The two clients are shaped differently and this is not worth papering over: - `cbd-tui` owns a `Vec` per pane and already has `marked` on it (the queue builds every row `marked: false` today). - `cbd-web` owns **no** queue list at all — just `QueueCursor { selected }`, with rows rendered straight from the server's `Queue` signal. So the shared thing is the *rule*, not the struct: marks are a set of positions plus the path list they were taken against, reconciled on each snapshot. The TUI keeps them in its `UiItem`s (it rebuilds that list anyway); the web client keeps a position set beside the signal. Both implement the same reconciliation, and both unit-test it against the same cases. ## Decisions **D1 — `Register` holds paths plus display labels.** One unnamed slot: ```rust struct Register { /// Library paths, in the order they were yanked or deleted. Empty means /// nothing to paste. paths: Vec, /// Row labels for the status line only — never sent anywhere. labels: Vec, } ``` Named registers stay additive: the owner becomes a small map keyed by a register name, with `None` meaning the unnamed one. Not built now. **D2 — Only `y`, `d`, `c`, `C` write the register.** Marks, visual mode, and cursor movement never do. A write **overwrites**: there is no history and no numbered registers. **D3 — `p` pastes after the cursor, `P` before it.** Both are queue-only (there is nothing to paste into a library listing). Paste sends `Insert { position, paths }` and leaves the register intact, so you can paste twice. `d` then `P` is an exact restore; `d` … `p` is a move. **D4 — `y` works in both panes.** In the library it yanks the marked items, or the cursor item, under the same `is_queable` gate that `a`/`Enter` use — so yanking cannot put an unqueueable folder in the register. In the queue it yanks the marked rows, or the cursor row. `y` clears the marks it consumed, exactly as queueing does today. **D5 — Queue marks reconcile per snapshot** by the greedy in-order path match above, clearing on divergence. `Remove`/`Insert` positions always come from the newest snapshot. **D6 — `d` in the queue deletes every marked row** (or the cursor row when nothing is marked), in one `Remove` call, and writes them to the register first. `c`/`C` write the tracks they are about to drop. **D7 — Queue visual mode mirrors the library's** anchored paint: `v` (and `V`) enters, movement toggles the marks of the swept range against the anchor so moving back reverses, `Esc` or any non-movement action leaves it. The mark+visual logic is extracted so both panes share one implementation per client, rather than a second copy in the queue. One concrete wrinkle: queue rows are built `is_queable: false, is_deletable: false`, so the library's mark gate would refuse all of them. The extracted code takes the gate as a parameter — the queue's is "always allowed". **D8 — `cbd-web` reaches parity in the same change**, including the library visual mode it does not have yet. Its queue marks live beside the server signal (D-above); its keymap and help overlay gain the same rows as the TUI's. **D9 — Keys.** The queue gains `s`, `v`, `V`, `y`, `P`; the library gains `y`. No chord collides with an existing one in either scope. **D10 — `p` changes meaning, and that is a breaking change** to muscle memory: it pastes the register instead of the library selection. The browse→queue flows that do not go through `p` (`a` append, `L` queue-next, `Enter` replace) are untouched, so the cost is confined to "insert what I picked on the left at this exact position", which becomes `y` then `p`. Documented in the key tables and in the book. ## Structure ```d2 direction: right lib: Library pane { libsel: "marked rows,\nelse cursor row\n(is_queable gate)" } q: Queue pane { qsel: "marked rows,\nelse cursor row" } reg: "Register (per client, one slot)\npaths + labels" { shape: cylinder } rpc: Server (unchanged) { ins: "Insert { position, paths }" rem: "Remove { positions }" } lib.libsel -> reg: "y" q.qsel -> reg: "y · d · c · C" q.qsel -> rpc.rem: "d · c · C\n(positions from the\nnewest snapshot)" reg -> rpc.ins: "p (after cursor)\nP (before cursor)" rpc -> q: "Queue snapshot\n(marks reconciled)" ``` Mark reconciliation, on every queue snapshot: ```d2 direction: down snap: "Queue snapshot arrives" cmp: "Walk old paths and new paths\nin order" carry: "Carry each mark to the new\nposition of the same path" drop: "Drop marks whose track is gone" clear: "Clear all marks" act: "d / y / c / C read positions\nfrom this snapshot only" snap -> cmp cmp -> carry: recognizable cmp -> clear: "diverged past\nrecognition" carry -> drop drop -> act clear -> act ``` ## Boundaries and interfaces - **Register** — owned by each client's app state, written only by the four commands, read only by paste. No I/O, no server involvement; a pure value that is trivially unit-testable. - **Mark reconciliation** — one function per client, `(old_paths, new_paths, marks) -> marks`, tested against: append, remove-before, remove-marked, playback advance, streaming resolve, duplicate paths, and wholesale replacement. - **Pane selection** — both panes expose "the rows this action applies to" the way the library's `get_selected` already does; `y`/`d` consume it. - **Server** — untouched. This whole feature is two clients. ## Risks - **Mark drift** deleting the wrong tracks. The reconciliation rule and the newest-snapshot rule exist for this; it needs the strongest tests in the feature. - **`p`'s changed meaning** surprising existing users. Mitigated by leaving `a`/`L`/`Enter` alone and documenting the change; not avoidable if `p` is to have one meaning. - **Silent paste shortfall** when a path no longer resolves — you paste 5 and get 4. The server already skips unresolvable paths in a resolve; the clients should say what they pasted rather than claim success blindly. - **Two implementations drifting** (the very thing D8 is fixing for visual mode). Same rule, same test cases, both landed together. - **Duplicate-path ambiguity** in reconciliation is inherent, not solvable without a per-entry queue id in the proto. In-order matching is the honest approximation; a queue id is the escape hatch if it ever bites. ## Open questions None blocking. Deferred by choice: named registers (D1 leaves room), a numbered/history register stack, `y` in the queue putting entries somewhere persistent (that is what `w` and saved queues are for), and a per-entry queue id in the proto to make reconciliation exact.