Skip to content

TUI: yank a selection, a citation, or a range of records #152

Description

@tony

Summary

Copy in the explorer today is a detail-pane feature that copies exactly one thing: the body of the record under the cursor. A tmux-style visual select and yank already ships (0.1.0a42, #140), and Textual's native mouse selection already works on the detail body — but a mouse selection has no reachable copy key outside macOS, the results list has no selection model at all, and nothing can yank a reference to a record rather than its text. This issue covers the remaining selection and yank surface: making an existing native selection copyable, giving visual mode a real binding, yanking a provenance citation, yanking a canonical ID once #80 lands, and selecting a range of records in the results list.

What ships today

Everything below is on master and was verified by reading src/agentgrep/ui/ and by driving App.run_test pilots. Version-sensitive observations were made against Textual 8.2.8; pyproject.toml floors textual>=3.2.0, so older resolutions may differ.

Detail pane, whole-record copy. DetailScroll (src/agentgrep/ui/widgets/detail.py) binds ydetail.copy_source and Ydetail.copy_rendered. Both delegate to the HUD layout in src/agentgrep/ui/layouts/_hud_detail_interaction.py. y copies _detail_body_text, the body already bounded by DETAIL_BODY_MAX_CHARS (64 * 1024 characters) and DETAIL_BODY_MAX_LINES (1000), both defined in src/agentgrep/_text.py, and notifies copied source or copied source (truncated). Y copies _detail_rendered_plain, the flattened rendered projection (Markdown flattened, JSON pretty-printed at indent=2 when the pretty-print is bounded), and always notifies copied rendered text.

Detail pane, visual select and yank. DetailScroll.on_key routes keys into handle_detail_visual_key before the stock bindings fire. Outside visual mode only v and space are claimed. Inside it: h/j/k/l and the arrows move the selection cursor, 0/home and $/end jump to the line edges, g/G jump to the document edges, v/space re-anchor, y/enter yank, and escape/q cancel. The yank reads the widget's native get_selection over a textual.selection.Selection and calls App.copy_to_clipboard. Selection is inclusive of the cell under the cursor, matching tmux copy-mode-vi. tests/test_detail_visual_yank.py locks this behaviour.

Entering visual mode swaps the pane to raw source. _begin_detail_visual repaints the body Static with a plain Text of the bounded source so selection extraction is exact and identical for text, Markdown, and JSON bodies. Verified with a pilot: on a Markdown record the rendered view has no fences, pressing v makes ```python visible again, and escape restores the rendered view — while _detail_raw_mode stays False throughout. That is a deliberate trade-off documented in the method, not an accident, but it means the pane visibly changes shape when you start selecting.

Mouse selection works, and then dead-ends. The body is a plain Static with Textual's default ALLOW_SELECT = True. A pilot mouse-down plus a button-held MouseMove over #detail-body populates app.screen.selections with a real Selection, and mouse-up leaves it in place. Textual's Screen binds ctrl+c,super+cscreen.copy_text for exactly this case, but the HUD layout binds ctrl+csmart_quit ("Stop / Quit"). Resolved bindings on the live screen confirm the shadowing: ctrl+csmart_quit, super+cscreen.copy_text. Pressing ctrl+c with a live selection left the clipboard untouched and armed the confirm-exit gutter; super+c copied the selected substring. So the only key that copies a mouse selection is the super (Cmd) chord, which Linux and WSL terminals generally do not emit.

y ignores a native selection. With a live mouse selection on the body, pressing y copies the entire bounded body, not the selected span. Verified with a pilot.

The results list has no selection of any kind. SearchResultsList (src/agentgrep/ui/widgets/results.py) sets ALLOW_SELECT = False, so there is no text selection there. Its only cursor state is highlighted: reactive[int | None] — there is no anchor, no marked set, no range. With the results list focused and a row highlighted, y, Y, v, and space are all no-ops (verified by pilot: clipboard unchanged, highlight unchanged, screen.selections empty, app still running).

Nothing yanks metadata or a reference. The provenance block lives in a separate #detail-meta Static and carries Agent, Kind, Store, Adapter, Timestamp, Model, and Path rows, plus Cwd / Repo / Worktree rows when the record carries an origin. Visual mode assigns screen.selections = {self._detail_body: ...}, replacing the whole map, so a visual selection can never span the header. Neither y nor Y includes any of it. There is no key that copies a path, a session id, or a record identifier.

The grep-log layout has no yank vocabulary. src/agentgrep/ui/layouts/greplog.py is a search input over a RichLog with four bindings, of which ctrl+c is app.quit. A pilot on that layout finds no y, Y, or v binding and no detail pane. Its RichLog is natively selectable (ALLOW_SELECT = True), and super+c still resolves to screen.copy_text — so it inherits the same dead-end as the HUD without any of the yank commands.

Discoverability and remappability are uneven. y and Y are real Bindings with ids, and detail.copy_source / detail.copy_rendered are both in REMAPPABLE_BINDING_IDS in src/agentgrep/ui/keymaps.py, so a user keymaps.toml can move them. Visual mode is not a Binding — it is intercepted in on_key, and no v binding exists anywhere in src/agentgrep/ui/ — so it appears in no footer and in no /keys help panel, and cannot be remapped. docs/tui/index.md and docs/tui/reference.md do not mention copy, yank, clipboard, or visual select anywhere; the only user-facing description is the 0.1.0a42 changelog entry.

How the clipboard actually works

Every copy path in src/ calls App.copy_to_clipboard and nothing else — three call sites, all in _hud_detail_interaction.py. In Textual 8.2.8 that method base64-encodes the string and writes one ESC ] 52 ; c ; <base64> BEL to the driver — plain OSC 52. Textual's own screen.copy_text funnels into the same method. There is no subprocess fallback: searching src/, tests/, docs/, and scripts/ for pyperclip, xclip, xsel, wl-copy, and pbcopy returns nothing (pyperclip appears in uv.lock only as a transitive dependency of fastmcp-slim, never imported). Nothing in the repository configures, probes, or documents OSC 52 behaviour.

Consequences worth stating here rather than rediscovering in review:

  • It is fire-and-forget. OSC 52 has no acknowledgement, so copied source is a claim that the sequence was written, not that a clipboard changed. There is currently no way for a user to tell a working terminal from a silent no-op.
  • ssh is not the problem; the terminal is. The sequence rides the tty stream rather than a local helper process, so it crosses ssh without a clipboard helper on the remote side. Failures come from terminals that do not implement OSC 52 — Textual's own docstring for copy_to_clipboard notes it does not work on macOS Terminal.
  • tmux needs opt-in. tmux(1) documents that it only attempts this when the terminfo description has an Ms entry, and its behaviour is governed by set-clipboard: on accepts the escape to create a tmux buffer and sets the terminal clipboard, external sets the terminal clipboard but ignores an application's attempt to set tmux buffers, and off does neither. tmux 3.7b started with an empty config reports external, so under that default a y does not populate a tmux buffer and whether anything lands at all depends on the outer terminal.
  • Payload size. A whole-body y is bounded at 64 * 1024 characters and 1000 lines, and base64 inflates by 4/3, so a full ASCII body goes out as roughly 85 KiB in one escape sequence — larger for non-ASCII text, because the cap counts characters while the encode runs over UTF-8 bytes. Whether every target terminal and multiplexer accepts a single OSC 52 write that large is not established here.

On the stylesheet question: scrollbar-size: 0 0 in src/agentgrep/ui/styles.tcss and the terminal-transparent ansi_color = True posture in src/agentgrep/ui/_shell.py do not obstruct selection. Textual's own selection is unaffected, and for the terminal's native drag-select a zero-width scrollbar is a mild benefit because no scrollbar glyph column contaminates the copied cells. The real interaction is that Textual enables mouse tracking, so the terminal's own drag-select is generally reachable only through a terminal-specific modifier (commonly Shift) — terminal behaviour agentgrep does not control and should not try to.

Gaps

  1. A mouse selection cannot be copied outside macOS. ctrl+c is spent on stop/quit and super+c is unreachable on most Linux and WSL terminals. This is the sharpest gap: the selection renders, the user drags it, and no key takes it.
  2. y with a live native selection copies the whole body. Two selection models coexist (visual-mode state and screen.selections) and only one of them is consulted by y.
  3. No range select in the results list. There is no multi-select state to build on — highlighted is a single index.
  4. No yank of a reference. No path, no session id, no canonical id. Everything that leaves the explorer is prose.
  5. No yank of a citation. The provenance header cannot be selected and is not included in either copy command, so a pasted prompt arrives with no indication of which agent, store, or session it came from.
  6. Y truncates silently. y distinguishes copied source from copied source (truncated); Y always says copied rendered text even though _detail_rendered_plain is derived from the already-truncated body.
  7. Visual mode is undiscoverable and unremappable. No Binding, no id, no footer entry, no /keys entry, no docs.
  8. The grep-log layout has no copy contract. If copy is part of the explorer's promise, a peer layout shipping none of the yank commands is a decision that should be made explicitly rather than by omission.

Relationship to other issues

This overlaps three open items conceptually and must not absorb any of their scope.

Key-space contention is real. b (PR #121), e (PR #122), and y/Y/v/space (shipped) all occupy the same bare-letter HUD key space, and PR #122's export review pane binds y to Save and n to No. Any new bare letter proposed here needs to be checked against both open branches before it is chosen.

Constraints

ADR 0011 (docs/dev/adr/0011-non-blocking-tui-invariants.md) is the binding constraint on any bulk yank. NB-1 forbids blocking I/O and unbounded CPU on the pump; NB-5 keeps watchers and render / compose O(1); NB-9 hard-bounds inline fast-path work. The existing code already carries this reasoning: action_copy_detail_source deliberately encodes the already-truncated body rather than record.text because copy_to_clipboard base64-encodes on the calling pump thread, and _yank_detail_visual documents its extract as O(selected) over an already-bounded source.

The direct consequence for a multi-record yank: concatenating N record bodies, casefolding, sorting, or serializing them cannot happen on the pump. Assembly belongs in a thread=True worker (NB-2) with a stable group and exclusive=True where a newer action should cancel it (NB-6), and the pump-side callback may only hand an already-bounded string to copy_to_clipboard. A selection-state watcher that recomputes a preview over the whole selection would violate NB-5. Bulk row repaints go through stream_apply (NB-4).

ADR 0012 applies to the results widget: selection state is typed reactive on the widget (RW-4), leaves as a typed Message rather than a back-reference into siblings (RW-2), and RW-5 binds the widget to NB-1..NB-10.

ADR 0013 makes the grep-log layout a peer, not a lesser surface (PL-1, PL-5), so the copy contract question there is a real decision.

Privacy. The detail header already routes its Path row through format_compact_path (which calls format_display_path) and its origin rows through format_display_path directly. A citation yank must reuse those helpers so a raw absolute path never reaches a clipboard, a notification, or a log — it must not build its own path string.

Proposed scope

Ordered so each step is independently shippable and the blocked item is last.

  1. Make an existing native selection copyable. Bind a key other than ctrl+c to Textual's screen.copy_text so a mouse drag on the detail body is not a dead end, and decide whether y should prefer a live screen.selections entry over the whole body. That second half is a behaviour change to something that shipped in 0.1.0a42, so it needs an explicit call rather than a silent switch.
  2. Give visual mode a real Binding with an id. It then shows in the footer and /keys, and joins REMAPPABLE_BINDING_IDS so keymaps.toml can move it. Document the whole copy surface in docs/tui/, which currently says nothing about it.
  3. Fix the Y truncation notice so both copy commands report truncation the same way.
  4. Yank a citation. One key that copies the record's provenance (agent, store, timestamp, collapsed path) with or without the body, so a pasted prompt says where it came from.
  5. Results-list range select and multi-yank. Add anchor-plus-extend selection state to SearchResultsList, render the marked rows, and yank the selected records with assembly on an offload worker under a named cap. Decide the truncation policy for a selection that exceeds the cap: refuse, truncate with a notice, or copy identifiers only.
  6. Yank a reference — one key that copies the record's canonical ID. Blocked on Deterministic IDs for conversations / prompts #80.
  7. Decide the grep-log layout's copy contract — either give it the same yank vocabulary or state that it is out of scope, in the ADR 0013 sense.

Out of scope

Open questions

  • Does the visual anchor land where the user is looking on a scrolled, rendered body? _visual_top_visible_row walks source lines against a scroll offset produced by the rendered view, and its own docstring calls the result "a close estimate for a markdown/code body". Not tested here; worth a pilot test before building on top of it.
  • Should y change meaning when a native selection is live, given it shipped with a different meaning?
  • Is a single roughly-85-KiB OSC 52 write accepted by every terminal and multiplexer agentgrep targets, and is a chunked or size-gated write needed?
  • Should multi-select reuse PR Add portable record export across surfaces #122's records / observed-thread vocabulary so a later export can consume it, or stay clipboard-local and vocabulary-free until a second consumer exists?
  • Is there any way to give the user feedback that a clipboard write actually landed, or does the notification stay a best-effort claim?

Acceptance criteria

  • A mouse-drag selection on the detail body can be copied with a key that Linux and WSL terminals deliver, without disturbing ctrl+c stop/quit staging.
  • The relationship between y and a live native selection is explicit, tested, and documented.
  • Visual mode is a real binding: it appears in /keys, and a keymaps.toml entry moves it.
  • y and Y report truncation identically.
  • A citation yank reuses format_display_path / format_compact_path and emits no raw absolute path, in the clipboard or in any notification or log.
  • Results-list range selection has typed reactive state on the widget, leaves as a typed Message, and its yank assembles off the pump under a named cap, exercised once with the explicit watchdog setting against a large real store.
  • docs/tui/ documents the full copy and selection surface, which today it does not mention at all.
  • No new format writer, file sink, persisted record, or identity recipe is introduced, so Bookmark prompts / conversations #79, Deterministic IDs for conversations / prompts #80, and Export prompts and conversations #81 keep their scope intact.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions