Give the user a copy gesture over mouse-grabbing panes - #1181
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 (
copy→ok,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.logshows the Claude child emittingESC[?1000h ?1002h ?1003h ?1006hten times in one session and never the matchingldisables — somouse_protocol_mode()is permanently non-Noneandforward_mouse_to_childswallows 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 fullscreenon 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 —
pbcopywith 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-targetswas failing on twodeny-level lints inconstruct-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_loopintasks.rs—supervise'sloop { tokio::select! { ... } }had abreakin every arm, so it never iterated. Now a plainselect!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_rangesininteractive.rs—for _ in 0..PAD_BOTTOMwithPAD_BOTTOM = 0, a loop that could never run. Removed along with the constant; the comment abovePAD_TOPalready 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:
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 existingselection_bounds_for_layoutpattern.cargo clippy -p construct-cli --bins --all-targetsandcargo clippy -p construct-adapter-smith --all-targetsboth report 0 errors with no suppressions.cargo buildis clean (the one warning is pre-existing dead code intoken_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(fromcrates/cli, which depends oncrates/adapter-smith) — so both changed crates land in it:/Users/moon/construct/.claude/worktrees/tui-copy-mouse-grab/target/debug/construct