Skip to content

feat: mouse press and wheel from a non-leader client claim leadership#194

Open
TranscendOfSypherus wants to merge 1 commit into
neurosnap:mainfrom
TranscendOfSypherus:mouse-claims-leadership
Open

feat: mouse press and wheel from a non-leader client claim leadership#194
TranscendOfSypherus wants to merge 1 commit into
neurosnap:mainfrom
TranscendOfSypherus:mouse-claims-leadership

Conversation

@TranscendOfSypherus

@TranscendOfSypherus TranscendOfSypherus commented Jul 12, 2026

Copy link
Copy Markdown

Problem

With two clients attached to one session, mouse input only works in the leader. handleInput() routes non-leader input through isUserInput(), which deliberately excludes mouse reports and focus events so a wheel tick or click from the other client is silently dropped, and nothing about it can flip leadership either.
The user-visible symptom: attach a session in two terminals, run something mouse-aware (less --mouse, tmux, any TUI with scroll support), and scrolling is dead in whichever client you didn't type in last. Users learn to "type something first" without knowing why.

Repro:

  1. zmx attach demo in two terminals of different sizes
  2. run an app that enables mouse tracking, type in terminal A
  3. scroll the wheel in terminal B → nothing (bytes dropped in
    handleInput), until any key is pressed in B

Change

isUserInput() becomes classifyInput() with three outcomes:

  • keyboard — printables, CR/LF/tab/backspace, kitty CSI-u/~, modified arrows: claims leadership and is forwarded (behavior unchanged)
  • pointer — SGR mouse (CSI < b;x;y M) button press (base button 0–2, any modifiers) or wheel (button bit 64, any modifiers): claims leadership but the claiming event is swallowed
  • ignore — everything else, unchanged: motion (bit 32), SGR releases (m final), focus in/out, legacy X10 mouse (which also keeps the early return so its raw coordinate bytes can't be misread as printable input), and terminal query responses

The dividing line: events the user aims at the pane (press, wheel) claim leadership; events the terminal emits on the user's behalf (motion, release, focus) don't. Motion in particular must not claim with any-motion tracking (1003) or focus-follows-mouse window managers, merely sweeping the pointer across a pane would steal leadership and cause resize storms.

Swallowing the claiming event is deliberate (click-to-focus semantics): its coordinates were aimed at a screen laid out for the previous leader's size, so forwarding it could act at the wrong position in the app. The user's next event flows normally as leader — and in practice a wheel gesture is many ticks, so scroll feels instant.

Legacy X10 is left non-claiming: its payload is three raw bytes after the CSI, which the streaming classifier would have to special-case, and every modern client negotiates SGR (DECSET 1006).

Testing

  • Unit tests converted to classifyInput expectations plus new cases: SGR press/wheel with and without modifiers classify as pointer; motion, releases, wheel+motion, X10, and focus stay ignored; a graphics-reset CSI 0 m followed by text still counts as keyboard (regression case: only M may stop the scan, for X10's raw bytes). zig fmt --check and zig build test green; full bats suite 30/30.
  • No bats test for this: attaching two PTY clients needs util-linux script, which the alpine CI image doesn't ship — happy to add one plus the apk package if you want the coverage.
  • Two-client integration check (100x30 and 80x24 attached to one session, pty stdin captured via cat -v): the daemon log shows "setting new leader" + resize land at wheel-send time from the non-leader; the wheel bytes never reach the pty; a following key from the new leader forwards without a second claim; keyboard from a non-leader still claims and forwards.

Non-leader input goes through isUserInput(), which excludes all mouse
reports — so scrolling or clicking in a second attached client is
silently dropped until a qualifying keystroke flips leadership. With
two terminals on one session (e.g. a web client and a native terminal),
the mouse is dead in whichever client isn't leader, and users learn to
'type something first' without knowing why.

A button press or a wheel tick is aimed at the pane, making it as
intentional as a keystroke, while motion is ambient (any-motion hover,
focus-follows-mouse sweeps) and a release always follows a press. So:

- isUserInput() becomes classifyInput() returning ignore / keyboard /
  pointer: SGR (CSI < b;x;y M) button press and wheel — including
  modifier variants — classify as pointer; motion (bit 32), releases
  ('m' final), focus reports, and legacy X10 mouse stay ignored (X10
  also keeps the early return so its raw coordinate bytes can't be
  misread as printable input).
- handleInput() claims leadership for pointer input but swallows the
  claiming event: its coordinates were aimed at a screen laid out for
  the previous leader's size, so forwarding could act at the wrong
  position. Click-to-focus semantics — the user's next event flows as
  leader. Keyboard input claims and forwards as before.

Verified with two differently-sized clients attached to one session:
the daemon log shows the leadership switch and resize land at
wheel-send time, the wheel bytes never reach the pty, and keyboard
behavior is unchanged.
@TranscendOfSypherus
TranscendOfSypherus marked this pull request as ready for review July 12, 2026 23:22
@TranscendOfSypherus
TranscendOfSypherus marked this pull request as draft July 12, 2026 23:32
@TranscendOfSypherus
TranscendOfSypherus marked this pull request as ready for review July 12, 2026 23:57
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