feat(tui): mouse_tracking DEC mode presets (salvage of #26681) (#30084)

* feat(tui): make display.mouse_tracking pick which DEC modes to enable

Previously the boolean flag was all-or-nothing across modes 1000+1002+1003+1006.
Inside tmux, mode 1003 (any-motion) makes every mouse cross of the prompt row
fire a clipboard probe that surfaces as "No image in clipboard" — sometimes
dozens in a row. Disabling tracking entirely killed scroll-wheel scrolling too,
since tmux's own scrollback is preempted by the alt-screen TUI.

`display.mouse_tracking` (and `/mouse <preset>`) now accepts `off | wheel |
buttons | all` in addition to the legacy booleans. `wheel` is 1000+1006:
scroll wheel + click only, no drag, no hover — the tmux-friendly subset.
`buttons` adds 1002 for drag-to-select. `all` (= legacy `true`) keeps the
hover-driven UI (scrollbar paginate-on-hover, link mouseenter, etc.).

* fix(tui): repaint + sync mouse mode when display.mouse_tracking changes

Two interacting bugs left the TUI blank when `display.mouse_tracking`
switched at runtime (config edit, /mouse <preset>):

1. AlternateScreen's effect re-runs on every `mouseTracking` change,
   tearing down and re-entering the alt screen. After re-entry, ink's
   frame buffers are reset by `resetFramesForAltScreen()` but nothing
   schedules the follow-up render — the alt screen sits blank until
   some other state change happens to trigger one. Add a
   `scheduleRender()` in `setAltScreenActive`'s active=true branch so
   the freshly-entered alt screen gets a full repaint immediately.

2. `setAltScreenActive` early-returns when `active` hasn't changed,
   which silently drops a `mouseTracking` change if the cleanup→setup
   pair somehow leaves `altScreenActive` already true. Call
   `setAltScreenMouseTracking` explicitly from the AlternateScreen
   effect so the in-memory mode and terminal DECSET sequence stay in
   sync regardless of how `setAltScreenActive` resolved (the call is a
   no-op when the mode is unchanged).

* fix(tui): address copilot review #4341269705

- tui_gateway/server.py: drop the never-referenced _MOUSE_TRACKING_MODES
  frozenset (comment #3284802434). _MOUSE_TRACKING_ALIASES already
  centralizes the canonical preset set via its values; the separate
  constant added no behavior.
- tests/test_tui_gateway_server.py: update the existing
  test_config_mouse_uses_documented_key_with_legacy_fallback to assert
  the new preset strings ('all'/'off' instead of 'on'/'off',
  display.mouse_tracking persisted as 'all' instead of True) and add
  test_config_mouse_accepts_preset_strings_and_aliases covering /mouse
  set with wheel/click/unknown (comment #3284802453). The on/off legacy
  config.set return shape was an implementation detail of the boolean
  flag, not a stable API — the slash command, gateway help text, and
  docs all advertise the preset values now.
- ui-tui/packages/hermes-ink/src/ink/ink.tsx: schedule a render at the
  end of reenterAltScreen() (comment #3284802461). Mirrors the same fix
  in setAltScreenActive() from ece0a2f4c — without it, SIGCONT/resize
  self-heal/stdin-gap re-entry leaves the alt screen blank because
  every caller returns early after invoking us.

* fix(tui): address copilot review #4341308478 round 2

- ui-tui/src/config/env.ts (comment #3284837577): the precedence
  comment was misleading. Actual behavior on origin/main is
  HERMES_TUI_MOUSE_TRACKING (explicit override) > Termux default >
  HERMES_TUI_DISABLE_MOUSE legacy kill-switch. This is preserved from
  main; the only change here was the wrong comment that claimed
  DISABLE_MOUSE kept kill-switch semantics. Rewrote the comment block
  to document the actual precedence ladder.
- tui_gateway/server.py /mouse set (comment #3284837607): replaced
  'str(value or "").strip().lower()' with the explicit None idiom
  already used for /indicator, so programmatic callers can pass 0 /
  False and have them route through _MOUSE_TRACKING_ALIASES → 'off'
  instead of collapsing to '' and triggering the toggle path.
- ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx
  (comment #3284837620): always prepend DISABLE_MOUSE_TRACKING before
  enableMouseTrackingFor(...) on mount. Otherwise selecting
  'wheel'/'buttons' from a state where DEC 1003 was already asserted
  (crash, another app, debugger) would silently leave hover on. Also
  unconditionally DISABLE on unmount so a crash mid-mount can't leak
  DEC modes back to the host shell.

* chore(release): map nat@nthrow.io to @nthrow for #26681 salvage

* fix(tui): drop redundant setAltScreenMouseTracking in AlternateScreen

Copilot review #4341356637 (comment #3284880417). The explicit
setAltScreenMouseTracking(mouseTracking) after setAltScreenActive(true,
mouseTracking) was defensive paranoia added in the previous fix commit
that's not actually reachable in practice:

- React's cleanup always runs before the next setup, so on any prop
  change (mouseTracking or writeRaw) the cleanup sets active=false
  first. Setup then sees active was false and applies the new mode
  via setAltScreenActive without early-returning.
- On the impossible 'active stayed true' path, the writeRaw above has
  already sent DISABLE_MOUSE_TRACKING + enableMouseTrackingFor(newMode)
  to the terminal, so the in-memory mode would lag but the visible
  state is already correct.

Removing the redundant call means a single DEC sequence per mount.
If the 'active stayed true' path ever manifests in practice, the
right fix is in setAltScreenActive (track mode regardless of the
active early-return), not here.

* fix(tui): always DISABLE before enableMouseTrackingFor in ink.tsx

Copilot review #4341379994 (comments #3284900825, #3284900840,
#3284900852). Three remaining call sites in ink.tsx still re-enabled
mouse tracking without first sending DISABLE_MOUSE_TRACKING:

- handleResize alt-screen recovery (line ~577)
- reassertTerminalModes stdin-gap re-assertion (line ~1351)
- reenterAltScreen SIGCONT/resize/stdin-gap self-heal (line ~1408)

For 'wheel'/'buttons' presets, omitting DISABLE leaves any externally-
asserted DEC 1003 (other apps, prior crash, tmux state) still active
and the hover-free preset silently has hover on. DISABLE_MOUSE_TRACKING
is idempotent and safe to send unconditionally — it resets all four
modes. Matches the pattern already in setAltScreenMouseTracking and
the AlternateScreen mount path.

* fix(tui): always DISABLE before enableMouseTrackingFor in exitAlternateScreen

Copilot review #4341452823 (comment #3284959762). exitAlternateScreen()
was the last call site in ink.tsx still re-enabling mouse tracking
without DISABLE first. Editors (vim/nvim/less) and tmux can leave
DEC 1003 hover asserted across the handoff back; without DISABLE,
'wheel'/'buttons' presets silently kept hover on after the editor
quit. Now all five enableMouseTrackingFor() call sites in ink.tsx
prepend DISABLE_MOUSE_TRACKING — handleResize, reassertTerminalModes,
reenterAltScreen, setAltScreenMouseTracking, exitAlternateScreen.

* fix(tui): add defensive default to enableMouseTrackingFor switch

Copilot review #4341485231 (comment #3284979323). TS exhaustive switch
returns string per the type system, but a JS caller / corrupted config
/ hot-reload-in-dev could reach the function with an unknown value at
runtime. Without a default, that path returns undefined which then
concatenates as the literal string 'undefined' into the terminal byte
stream — visibly garbling output. Treat unknown as 'off' (no DEC
sequences) so the worst case is silent input loss rather than a
wrecked screen.

---------

Co-authored-by: Nat Thrower <nat@nthrow.io>
This commit is contained in:
brooklyn!
2026-05-21 20:25:52 -05:00
committed by GitHub
parent 4d58e48cdb
commit a7cd254c29
14 changed files with 399 additions and 88 deletions

View File

@ -845,19 +845,50 @@ def _coerce_statusbar(raw) -> str:
return "top"
def _display_mouse_tracking(display: dict) -> bool:
"""Return canonical display.mouse_tracking with legacy tui_mouse fallback."""
_MOUSE_TRACKING_ALIASES = {
"0": "off",
"1": "all",
"all": "all",
"any": "all",
"button": "buttons",
"buttons": "buttons",
"click": "buttons",
"false": "off",
"full": "all",
"no": "off",
"off": "off",
"on": "all",
"scroll": "wheel",
"true": "all",
"wheel": "wheel",
"yes": "all",
}
def _display_mouse_tracking(display: dict) -> str:
"""Resolve display.mouse_tracking to one of ``off|wheel|buttons|all``.
Boolean values keep their legacy meaning (``True`` → ``all``, ``False`` →
``off``). The ``wheel`` preset (DEC 1000+1006) is the tmux-friendly
subset — wheel + click only, no hover events to trigger prompt-row
clipboard probes. Legacy ``tui_mouse`` is honored only when
``mouse_tracking`` is absent.
"""
if not isinstance(display, dict):
return True
return "all"
if "mouse_tracking" in display:
raw = display.get("mouse_tracking")
else:
raw = display.get("tui_mouse", True)
if raw is False or raw == 0:
return False
return "off"
if raw is True or raw is None:
return "all"
if isinstance(raw, (int, float)):
return "all"
if isinstance(raw, str):
return raw.strip().lower() not in {"0", "false", "no", "off"}
return True
return _MOUSE_TRACKING_ALIASES.get(raw.strip().lower(), "all")
return "all"
def _load_reasoning_config() -> dict | None:
@ -4078,22 +4109,25 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"key": key, "value": nv})
if key == "mouse":
raw = str(value or "").strip().lower()
# Explicit None check rather than `value or ""` so falsy non-string
# inputs (0, False) reach the alias map as themselves — both map to
# 'off' via _MOUSE_TRACKING_ALIASES — instead of being collapsed to
# '' and triggering the toggle path. The slash command always passes
# a string, but programmatic JSON-RPC callers may send booleans.
raw = ("" if value is None else str(value)).strip().lower()
cfg = _load_cfg()
display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
current = _display_mouse_tracking(display)
if raw in {"", "toggle"}:
nv = not current
elif raw == "on":
nv = True
elif raw == "off":
nv = False
nv = "all" if current == "off" else "off"
elif raw in _MOUSE_TRACKING_ALIASES:
nv = _MOUSE_TRACKING_ALIASES[raw]
else:
return _err(rid, 4002, f"unknown mouse value: {value}")
_write_config_key("display.mouse_tracking", nv)
return _ok(rid, {"key": key, "value": "on" if nv else "off"})
return _ok(rid, {"key": key, "value": nv})
if key == "indicator":
# Use an explicit None check rather than `value or ""` so falsy
@ -4266,8 +4300,7 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"value": _coerce_statusbar(raw)})
if key == "mouse":
display = _load_cfg().get("display")
on = _display_mouse_tracking(display)
return _ok(rid, {"value": "on" if on else "off"})
return _ok(rid, {"value": _display_mouse_tracking(display)})
if key == "mtime":
cfg_path = _hermes_home / "config.yaml"
try:
@ -4402,7 +4435,11 @@ _TUI_HIDDEN: frozenset[str] = frozenset(
_TUI_EXTRA: list[tuple[str, str, str]] = [
("/compact", "Toggle compact display mode", "TUI"),
("/logs", "Show recent gateway log lines", "TUI"),
("/mouse", "Toggle mouse/wheel tracking [on|off|toggle]", "TUI"),
(
"/mouse",
"Set mouse tracking preset [on|off|toggle|wheel|buttons|all]",
"TUI",
),
]
# Commands that queue messages onto _pending_input in the CLI.
@ -5280,7 +5317,7 @@ def _(rid, params: dict) -> dict:
{
"text": "/mouse",
"display": "/mouse",
"meta": "Toggle mouse/wheel tracking [on|off|toggle]",
"meta": "Set mouse tracking preset [on|off|toggle|wheel|buttons|all]",
},
]
for extra in extras: