Skip to content

Send mouse input to programs that track the mouse - #683

Merged
coneilen merged 5 commits into
mainfrom
coneilen-terminal-mouse-reporting
Oct 10, 2026
Merged

coneilen merged 5 commits into
mainfrom
coneilen-terminal-mouse-reporting

Conversation

@coneilen

@coneilen coneilen commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

#679 turned every left press, drag and release in a terminal surface into a local text selection, and nothing in the terminal sources forwards mouse input to the pty. Mouse-aware programs (vim, tmux, htop, the Copilot CLI TUI in each loop's agent tab) therefore never received any, and Shift only extended the selection. This makes the mouse follow the program's own request: while the running program tracks the mouse, the shell sends it the events; otherwise nothing changes. One of a small series following the review of #676, #677 and #679; independent of the AltGr change in #681 and does not touch paste or focus routing; the one focus-related change is that the focus-lost callback now closes a held forwarded button (below).

Verified in code first: TerminalSurface.onMouse only ever fed selectionMouse, there was no query of the VT's mouse mode, and the winghostty provider only reports mouse events to the shell (it cannot write to the shell's pty). The wheel never did anything in the shell (scrollViewport was not wired), so there is no existing scrollback behavior to preserve.

Changes

  • TerminalVt.State gains mouseTrackingEnabled() (DECSET 9/1000/1002/1003 via GHOSTTY_TERMINAL_DATA_MOUSE_TRACKING) and encodeMouse(), which uses the pinned libghostty-vt mouse encoder, so the tracking mode and the format (X10, UTF-8 1005, SGR 1006, URXVT 1015, SGR-pixels 1016) come from the program's own requests. Motion within the cell of the last report is dropped by the shell (the encoder's own cell memory resets on every option refresh); positions are clamped to the surface.
  • TerminalSurface.onMouse routes each event: while the program tracks the mouse the left, middle and right buttons, motion and wheel are encoded and queued to the shell; with Shift held the gesture is a local selection instead (as in xterm and Windows Terminal), and with tracking off behavior is exactly as before. A gesture keeps the route it started on until every button is up, so a mode change mid-drag neither sends half a drag nor turns a forwarded press into a selection. A forwarded press clears any local selection.
  • A gesture the program owns is closed when it cannot end normally: if the mouse capture is lost (checked in the provider's selection callback, where a normal release still holds the capture), the surface loses focus, the surface is torn down (a recreate re-attaches the same session, so the release is written straight to the attach pipe before the attach is killed, since the queue is gone by then; only when the press was actually delivered: if input for the surface is still queued or being written the buttons are just forgotten, so there is no release for a press the program never saw; and the direct write is skipped when the attach pipe did not take non-blocking mode, which is now recorded), or the next pointer event shows a button the program was told is down is up (a release outside the window), the shell releases that button for the program at the pointer's last position. The user's own later release of a button whose gesture was cancelled is swallowed: it neither reaches the program nor opens the context menu. A program that stopped tracking forgets the gesture and is told nothing, so the next click is not stuck on the program route and Shift+drag selects. A mode change that leaves tracking on keeps the gesture, so the program still gets the release in its new mode.
  • Shift is the shell's override and is never reported to the program, even if pressed part-way through a gesture. A pointer leave resets the same-cell motion filter, so re-entering a cell is reported again.
  • The right button opens the context menu only when the event is the shell's (tracking off, or Shift held); Menu key and Shift+F10 are unchanged.
  • A wheel that sends less than a notch (120 units) is accumulated; a turn the other way drops the leftover; at most 8 reports per event.
  • README and the parity-matrix Clipboard/selection row describe the behavior and its limits. The row stays Partial.

What each test proves

  • TerminalVt.zig (3 tests, no windows): DECSET 9/1000/1002/1003 each turn mouseTrackingEnabled on and off and 1006 alone does not; encodeMouse returns the exact hand-derived bytes for press, release, right/middle/Ctrl, wheel up and down, drag motion once per cell, hover under 1003, and the X10, SGR, URXVT, SGR-pixels, UTF-8 (a column above 95) formats and legacy mode 9 (press only); clamping at the edges; an empty geometry is refused.
  • TerminalSurface.zig (1 test): routeMouse decisions without a parser, with tracking on/off, with Shift, for the wheel, a side button, and a tracking change mid-gesture.
  • App.zig live tests (10), each using a real Winghostty surface window inside the shell window with native mouse messages (WM_LBUTTONDOWN, WM_MOUSEMOVE, WM_MOUSEWHEEL, and so on) delivered to its window procedure and the bytes read from the shell's terminal input queue: all button, motion, wheel, format and mode combinations above arrive as exact bytes and no selection appears; Shift+drag still selects GC-COPY-2 and sends nothing, including hover and wheel with Shift; gestures keep their route and the right button's context-menu route; a forwarded drag that loses mouse capture (ReleaseCapture, delivering a real WM_CAPTURECHANGED) gets its release, then Shift selects and a new click is tracked; a right button released outside (seen as a later motion without it) is released at that position; WM_KILLFOCUS releases a held button; destroySurface (the first half of a recreate) releases a held button exactly once into a test sink standing in for the attach pipe when the press was delivered; a press still queued at teardown (nothing drained it) produces no release at all (RED against a version that always released: expected 0 sink calls, found 1); a pipe not in non-blocking mode skips the write; right button down then DECSET off then right-up opens no context menu and sends nothing, likewise after lost focus, and a release that never comes does not swallow the next gesture's; a program that stops tracking mid-press leaves the next gesture a normal selection; Shift pressed mid-drag adds no modifier bit; WM_MOUSELEAVE then re-entering the same cell in any-event mode is reported again; 60-unit wheel turns accumulate to one report and a reversal discards the leftover.

RED before the fix, review round 2: the right-down/DECSET-off/right-up test failed against the previous commit (�xpected 0, found 1: the context menu opened); the teardown test could only fail to compile there, since it uses the new test sink (not behavioral RED), and the direct write to the real attach pipe is not tested. RED before the fix, round 1: the four live tests, and after review the two added for stuck gestures, Shift during a gesture and leave/re-enter (run against the previous commit's code: the release was missing at byte 19 and a Shift bit appeared at byte 13) (the VT and routing tests exercise the new API and were not run against the old code). They failed because nothing was sent: each tracking assertion expected bytes such as ESC [ < 64 ; 4 ; 3 M (10 bytes) and found none. The parts of those tests that describe the old behavior (selecting with no tracking, Shift+drag selecting) passed before the fix and are guards, not RED. One expectation was corrected while writing the GREEN run: disabling DECSET 1002 or 1003 turns tracking off (as in real terminals), so the test re-enables 1000 before checking the next format; this was a mistake in my test, not in the code.

What is NOT tested

  • No real vim, tmux, htop, Copilot CLI, pwsh, conhost, ConPTY or Dev Box: the input queue is read directly and the terminal's attached process is the repository's fake zmx stand-in. Nothing was observed from a program's side of the pty.
  • No physical mouse or pointer capture across windows; the messages are synthetic, in-process, and delivered with SendMessageW. DPI scaling of pixel positions and cell metrics other than the fixture's are not tested.
  • Not implemented: Alt reported with mouse events, the side buttons (not forwarded), a horizontal wheel (the provider's event cannot tell it from a vertical one, so it is reported as vertical). WM_CANCELMODE is not handled separately, and the loss of capture was observed through the provider's selection callback and GetCapture, not through every way Windows can take capture.
  • Known limitation at teardown: the gate that skips the release asks whether any input is still queued or being written for the surface, not whether the press itself was delivered. A press that was delivered followed by other queued bytes (a key, a motion report) when the surface is recreated also skips the release, and the program keeps the button down until its next click. Rare (a recreate during a held button with unrelated input queued); not fixed here, and not tested.
  • Without tracking the wheel still does nothing (scrollback is not wired to it), as before. Alternate-scroll mode (1007) is not implemented.
  • Isolation: tests ran with process-only USERPROFILE, APPDATA, LOCALAPPDATA, TEMP, TMP and GRAPHCODE_SUPPORT_DIR in a scratch profile; no installed daemon, per-user zmx or real clipboard.

Test plan

RED: zig test src\App.zig --test-filter "live terminal mouse" (pinned target and link flags, unfixed TerminalSurface) -> 3 passed, 4 failed: each tracking assertion expected 10 bytes such as ESC [ < 64 ; 4 ; 3 M and found len 0
GREEN: zig test src\App.zig --test-filter "live terminal mouse" (pinned flags, with the change) -> All 9 tests passed; zig test src\TerminalVt.zig --test-filter "mouse" -> All 3 tests passed
REGRESSION: zig test for TerminalKeys, TerminalVt, TerminalKeyEncoding, TerminalSelection, Clipboard, TerminalSurface and App roots (pinned flags) -> 10, 26, 43, 39, 15, 241 and 1052 tests (after rebasing on main with #684/#685) (after rebasing on main with the F6/F10 change) passed, 0 failed (baseline on main after the AltGr change: TerminalVt 22, TerminalSurface 223, App 1001)

Checklist

  • I have read the Contributing Guidelines
  • I have signed off my commits (git commit -s) per the DCO
  • Tests pass locally (make test) - macOS target, not run: Windows-only change; the Windows roots above were run instead
  • Code follows the existing style (make check) - macOS lint target, not run: Windows-only change in Zig
  • I added the test/contract before the implementation and observed the intended RED failure

@coneilen
coneilen force-pushed the coneilen-terminal-mouse-reporting branch 3 times, most recently from a341384 to 5bb9ac7 Compare October 10, 2026 07:40
coneilen and others added 5 commits October 10, 2026 01:04
Every left press, drag and release in a terminal surface became a local text
selection, and nothing forwarded mouse input to the pty, so mouse-aware
programs (vim, tmux, htop, the Copilot CLI TUI) never received any, and
Shift was not a way around it.

Query the shell's VT state for the program's mouse tracking (DECSET 9, 1000,
1002, 1003). While it is on, encode press, release, motion and wheel with the
pinned libghostty-vt mouse encoder in the format the program asked for
(1005, 1006, 1015, 1016 or X10) and queue the bytes to the shell. Shift hands
a gesture back to the local selection, and the right button opens the context
menu only for the shell (or with Shift). A gesture keeps the route it began
on until every button is up. A finer-than-notch wheel is accumulated to whole
notches. With tracking off, behavior is unchanged.

Not covered: Alt with the mouse, the side buttons, horizontal wheel (the
provider does not distinguish it), and a drag that loses mouse capture is not
reported to the program as released.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Colin Neilens <coneilen@microsoft.com>
A gesture owned by the program stayed on the program route until a later
button-up, so a lost capture, a release outside the window, lost focus or a
program that stopped tracking left the next click stuck on the program and
the program with a held button. Track the buttons the program was told are
down and where it last saw the pointer, release them when the capture or focus
is lost or the next pointer event shows them up, and forget the gesture when
tracking stops. Shift is the shell's override and is no longer reported, and a
pointer leave now resets the same-cell motion filter.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Colin Neilens <coneilen@microsoft.com>
…releases

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Colin Neilens <coneilen@microsoft.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Colin Neilens <coneilen@microsoft.com>
…put skips the release

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Colin Neilens <coneilen@microsoft.com>
@coneilen
coneilen force-pushed the coneilen-terminal-mouse-reporting branch from 5bb9ac7 to 79791ec Compare October 10, 2026 08:09
@coneilen
coneilen merged commit 1e36a01 into main Oct 10, 2026
24 checks passed
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