Skip to content

Give the user a copy gesture over mouse-grabbing panes - #1181

Merged
edwin-zvs merged 3 commits into
mainfrom
tui-copy-mouse-grab
Aug 3, 2026
Merged

Give the user a copy gesture over mouse-grabbing panes#1181
edwin-zvs merged 3 commits into
mainfrom
tui-copy-mouse-grab

Conversation

@edwin-zvs

@edwin-zvs edwin-zvs commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Why

Copy to clipboard doesn't work over a Claude pane. The clipboard bridge is fine — I confirmed a full round trip over the live SSH session's socket (copyok, paste → same 22 bytes back), and the previously merged paste fix (#1179) works end to end.

The gesture is what's missing. The only copy path in the TUI is drag-to-select. A child that turns on mouse tracking and never turns it off owns every mouse event over its pane, so the client never builds a selection and the copy branch is unreachable. Session pty.log shows the Claude child emitting ESC[?1000h ?1002h ?1003h ?1006h ten times in one session and never the matching l disables — so mouse_protocol_mode() is permanently non-None and forward_mouse_to_child swallows the press, the drag, and the release.

This is not SSH-specific, and not machine-specific. Reproduced on a second host by changing only the renderer: /tui default (inline, no mouse tracking) selects fine, /tui fullscreen on the same binary does not. It fails locally too, over exactly the panes worth copying from.

What

Shift-drag reaches the client's own selection. Shift-held left press/drag/release now bypasses the forward-to-child gate. Only the opening press needs the modifier — once a selection is in flight the existing in-progress-gesture rule keeps the rest of the drag with the client, so releasing Shift mid-drag is fine.

Shift is the modifier worth spending here because it degrades well in both directions. Terminals disagree about whether Shift-drag even reaches an application while mouse reporting is on: one that intercepts it runs its own native selection (copy with the terminal's shortcut), one that forwards it gets the client's selection. Either way the user ends up with selectable text.

The gesture tells the user it exists. A reserved gesture nobody can discover isn't a fix — and the failure mode here is silent: you drag, the child eats it, no selection appears, and nothing says why the gesture that works everywhere else in the TUI did nothing. So when a left-drag completes having been forwarded to the child — the one signal that separates an attempted selection from ordinary clicking — the status line says the pane's harness handles the mouse and that Shift-drag selects.

Shown once per run. The signal is necessarily ambiguous: a child that uses drag itself (vim visual mode, a draggable scrollbar) produces byte-identical events from a user who wanted that drag forwarded. Repeating the hint would nag exactly those users.

An empty selection no longer reaches the clipboard. A drag across blank chrome selects nothing, and writing empty does not leave the clipboard alone — pbcopy with empty input clears the pasteboard. A stray drag silently destroyed whatever the user had copied earlier. This is very likely why the earlier manual test read back an empty clipboard rather than the value that was actually in it.

C-x c (toggle mouse capture off) remains the escape hatch for full native terminal selection.

Two unrelated clippy errors, fixed here

cargo clippy --all-targets was failing on two deny-level lints in construct-adapter-smith, both pre-existing and untouched by this diff (last modified by 5e80ac4, #1057). They're fixed here rather than in a separate PR so CI isn't red for reasons that have nothing to do with the change:

  • never_loop in tasks.rssupervise's loop { tokio::select! { ... } } had a break in every arm, so it never iterated. Now a plain select! that waits exactly once, with the deadline hoisted above it. All four outcomes (completion, control message, background deadline, join error) stay covered by the existing tests.
  • reversed_empty_ranges in interactive.rsfor _ in 0..PAD_BOTTOM with PAD_BOTTOM = 0, a loop that could never run. Removed along with the constant; the comment above PAD_TOP already explained why bottom padding is zero (the TUI's editor pane supplies that separator), and that explanation is preserved at the removal site.

Specs

Both touched specs recorded the old behavior and would otherwise conflict with this change:

  • 0040 stated that drag-to-select no longer originates in a grabbing pane and that native selection requires the terminal's own modifier. That's now true only for unmodified drags. The Shift carve-out, the rule that a reachable selection gesture must always exist, and the discoverability rule — any gesture the client reserves over a grabbing pane must come with a way to find it, because the failure mode is silent — are recorded there.
  • 0127 gains the rule that a selection with no text must not reach any clipboard transport, ahead of every transport rather than in each one.

Tests

Three new tests in crates/cli/src/app.rs:

  • shift_left_drag_is_a_selection_gesture_but_the_wheel_is_not — the whole press/drag/release trio is recognized; the wheel, the right button, and an unmodified press are not (Shift+wheel stays the lineage pane's horizontal scroll).
  • a_drag_over_blank_cells_selects_no_text — a drag over padding yields "" (which the copy path refuses), while the same drag over rendered text still yields that text.
  • a_swallowed_drag_hints_once_at_the_release — press→drag→release fires the hint at the release and clears the gesture; a plain click stays silent; an already-shown hint stays silent; a wheel tick mid-drag doesn't cancel the pending hint.

Selection-range extraction, selection-text extraction, and the hint's state machine are free functions so they're testable without a live App (whose constructor needs a running daemon), matching the existing selection_bounds_for_layout pattern.

construct-cli:           3 passed; 0 failed
construct-adapter-smith: 220 passed; 0 failed

cargo clippy -p construct-cli --bins --all-targets and cargo clippy -p construct-adapter-smith --all-targets both report 0 errors with no suppressions. cargo build is clean (the one warning is pre-existing dead code in token_meter.rs).

No recording

This is a mouse-gesture change with no new rendered surface, and vhs has no shift-drag primitive to reproduce it faithfully — a tape would show the selection highlight the TUI already draws today, not the thing that changed. Happy to record if you'd rather see it.

Binary

The workspace produces a single binary, construct (from crates/cli, which depends on crates/adapter-smith) — so both changed crates land in it:

/Users/moon/construct/.claude/worktrees/tui-copy-mouse-grab/target/debug/construct

Copy has been dead over exactly the panes worth copying from. A child
that enables mouse tracking and never disables it — Claude Code emits
?1000h ?1002h ?1003h ?1006h and no matching disables — permanently owns
every press, drag and release over its pane, so the client never builds
a text selection and its only copy gesture (drag-to-select) is
unreachable. Nothing about this is SSH-specific; the clipboard bridge
works, and the same gap exists locally.

Shift-held left press/drag/release now bypasses the forward-to-child
gate and reaches the client's own selection. Only the opening press
needs the modifier — once a selection is in flight the existing
in-progress-gesture rule keeps the rest of the drag with the client.
Shift is the right modifier to spend here because it degrades well:
terminals disagree about whether Shift-drag even reaches an application
while mouse reporting is on, and either outcome leaves the user with
selectable text — a terminal that intercepts it runs its own native
selection, one that forwards it gets ours.

Second, an empty selection no longer reaches the clipboard. A drag
across blank chrome selects nothing, and writing empty does not leave
the clipboard alone — pbcopy with empty input clears the pasteboard, so
a stray drag silently destroyed whatever the user had copied earlier.

Selection-range and selection-text extraction move to free functions so
the copy path can be tested without standing up a live App, matching
the existing selection_bounds_for_layout pattern.

Specs 0040 and 0127 recorded the old behavior and are updated: 0040
said drag-to-select no longer originates in a grabbing pane, which is
now true only for unmodified drags.
The Shift-drag override added in the previous commit works but is
undiscoverable: when a harness grabs the mouse, a plain drag is
forwarded to the child, no selection appears, and nothing explains why
the gesture that works everywhere else in the TUI did nothing here.

Surface it at the moment it is needed. A left-drag that completes having
been forwarded is the one signal that separates an attempted selection
from ordinary clicking, so the hint rides the release rather than firing
on every motion event.

The signal is necessarily ambiguous — a child that uses drag itself
(vim visual mode, a draggable scrollbar) produces identical events from
a user who wanted the drag forwarded — so the hint is shown once per run
and then stays quiet.

The transition is a pure function so the state machine is testable
without standing up a mock daemon, matching the split already used for
the copy path.
Both are pre-existing and unrelated to this branch's TUI work, but they
are `deny` by default, so clippy fails on the smith crate before it ever
reaches construct-cli.

`never_loop` in the tool supervisor: every arm of the select! settles
the call, so the enclosing `loop` never ran twice and the `break`s were
only there to yield a value. Wait once and let select! be the
expression. No behavior change — the deadline was already computed from
`started_at` on the single pass. All four supervisor outcomes (done,
killed, manual background, deadline background) stay covered by tests.

`reversed_empty_ranges` in the interactive renderer: PAD_BOTTOM is a
documented constant zero, so the loop padding by it could never emit a
byte. Drop the dead loop and the constant; the comment explaining why
there is no bottom padding stays with PAD_TOP.
@edwin-zvs
edwin-zvs merged commit aa1d618 into main Aug 3, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the tui-copy-mouse-grab branch August 3, 2026 03:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant