feat(tui): Shift+drag text selection while the mouse is on - #278
Merged
Conversation
While mouse reporting (1002/1006) is on, the terminal suppresses its own drag-to-select — no TUI can lift that. Most terminals bypass reporting for a Shift-modified drag natively; Apple Terminal has no bypass and reports the shifted press instead. That report is the trigger: the finished-but-never-wired selection-passthrough module now suspends reporting for 10s with a chat notice, so native selection and copy work uniformly. - MouseTrackingController grows suspend()/resume()/isSuspended(): the same escape sequences, but the controller and its restore-net registration stay live. Both idempotent, both no-ops after disable(), and disable() mid-suspension writes nothing more — the terminal is already clean. - tui-command wires the passthrough between decode and emit: a consumed shift-press never reaches the hit test, and reports still in flight during the window are swallowed. /mouse off during the window stays off; the pending auto-resume finds nothing to resume. - selection-passthrough exported from the mouse barrel; its header no longer claims 1002 is off (it is on since the composer drag). - AGENTS.md and README mouse sections describe the real modes (1002+1006) and the shift+drag path; /mouse off stays documented as the permanent form.
The Shift trigger turned out to be terminal-dependent in the wrong
direction: some terminals bypass reporting natively on Shift (kitty,
WezTerm, Windows Terminal), iTerm2 reserves Option instead, and others
swallow modified clicks entirely — the app never sees the press and the
trigger is dead. Real-world result: Shift+drag over chat did nothing.
A modifier can be a shortcut but not the only path. The terminal-
agnostic trigger is the drag itself: every gesture the app understands
starts on a live target, so a press nobody claims followed by held
motion is the operator dragging across inert text — selecting. Detect
it, suspend for the usual 10s, and say to drag again.
- MouseTargetRegistry.dispatch reports a captured event as consumed
even when the capturer declines it: while captured the event went to
nobody else, and a composer drag must never read as dead content.
- drag-intent.ts arms on an unclaimed left press and fires once, on
the first held-motion report; release or the next press resets it,
wheel reports never participate. Lives behind registry.dispatch in
the TuiApp subscription; a new optional onSelectionDragIntent
callback carries the signal to the host.
- selection-passthrough grows beginWindow(trigger): the same
suspension, timer and auto-resume the modifier path uses, with a
drag-specific notice ("drag again") because the triggering gesture
is already lost to reporting. observe() now also fires on an
alt-modified press — the iTerm2-flavoured analogue of the shifted
one. The tui-command emit gate keeps swallowing in-flight reports
mid-window, which also starves the tracker of stale motion.
- README and AGENTS.md lead with the plain-drag path; Shift/Option
stay documented as the instant paths some terminals provide
natively, /mouse off as the permanent form.
… the first drag Text selection is drag-twice — the first drag pauses reporting, the second selects — and nothing on screen said so. A [drag] select text chip now sits in the idle strip; clicking it opens the pause window directly (a new "chip" trigger with the modifier phrasing, since no drag has been lost yet), so the very next drag selects. Sheds early: the gesture itself works without ever reading the hint.
…tion # Conflicts: # src/tui/components/hotkey-chips.ts # src/tui/components/hotkey-hint.test.tsx
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.
While mouse reporting (1002/1006) is on, the terminal suppresses its own drag-to-select. That is a terminal-level constraint — no TUI can read what the terminal never reports, and plain-drag selection under mouse mode is impossible by design. The fix hands reporting back to the terminal for a 10-second window, on two triggers:
Plain drag (terminal-agnostic, the primary path). A modifier turned out to be the wrong sole trigger: some terminals bypass reporting natively on Shift (kitty, WezTerm, Windows Terminal), iTerm2 reserves Option instead, and others swallow modified clicks outright — the app never sees the gesture. So the drag itself is the trigger now: every gesture the app understands starts on a live mouse target, which means a press nobody claims (message text, panel prose, empty rail) followed by held motion is the operator dragging across inert text — selecting.
MouseTargetRegistry.dispatchreports whether an event was consumed, a small tracker arms on an unclaimed left press and fires once on the first held motion, and the passthrough suspends reporting for 10s with a notice: drag again to select, then copy the way you normally would. Native selection then works in every terminal, no modifier. Drags starting on live targets (composer text, rows, buttons) are claimed presses and never trigger it.Hint chip. The idle strip at the bottom of the screen now carries
[drag] select text, so the gesture is written down where the other commands live. The chip is clickable: a click opens the pause window directly (a third"chip"trigger with the "drag to select" phrasing), so the very next drag selects — no double-drag needed.Modified press (instant, where terminals deliver it). A Shift- or Alt-modified press opens the same window immediately — the terminals that report those get selection on the very drag that asked for it.
MouseTrackingControllergainssuspend()/resume()/isSuspended()— the disable/enable sequences without giving up the controller or its crash-restore registration./mouse offduring it stays off./mouse offremains the permanent way to hand selection to the terminal.Tests: controller suspend/resume semantics, dispatch consumed-ness (claimed / dead-content / captured pointer), drag-intent (fires once per unclaimed press; claimed press never; wheel inert; re-arms after release), wiring (drag suspends + notifies + auto-resumes; alt and shift presses open the window;
/mouse offmid-window stays off).npm run lintclean.