diff --git a/scripts/release.py b/scripts/release.py index e2eafd060e1..24e3fd92fc7 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -59,6 +59,7 @@ AUTHOR_MAP = { "mgongzai@gmail.com": "vKongv", "0x.badfriend@gmail.com": "discodirector", "altriatree@gmail.com": "TruaShamu", + "nat@nthrow.io": "nthrow", "m@mobrienv.dev": "mikeyobrien", "saeed919@pm.me": "falasi", "omar@techdeveloper.site": "nycomar", diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index fe8e189091c..2205cb8df64 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1476,8 +1476,10 @@ def test_config_mouse_uses_documented_key_with_legacy_fallback(monkeypatch): set_toggle = server.handle_request( {"id": "2", "method": "config.set", "params": {"key": "mouse"}} ) - assert set_toggle["result"] == {"key": "mouse", "value": "on"} - assert writes == [("display.mouse_tracking", True)] + # /mouse (no arg) toggles between 'all' and 'off'. Starting from + # tui_mouse: False (→ 'off'), the toggle flips to 'all'. + assert set_toggle["result"] == {"key": "mouse", "value": "all"} + assert writes == [("display.mouse_tracking", "all")] cfg["display"] = {"mouse_tracking": 0, "tui_mouse": True} get_canonical = server.handle_request( @@ -1489,7 +1491,51 @@ def test_config_mouse_uses_documented_key_with_legacy_fallback(monkeypatch): get_null = server.handle_request( {"id": "4", "method": "config.get", "params": {"key": "mouse"}} ) - assert get_null["result"]["value"] == "on" + # mouse_tracking present-but-None defers neither to tui_mouse nor to + # the legacy off bucket: it falls through to the 'all' default. + assert get_null["result"]["value"] == "all" + + +def test_config_mouse_accepts_preset_strings_and_aliases(monkeypatch): + cfg = {"display": {"mouse_tracking": "all"}} + writes = [] + + monkeypatch.setattr(server, "_load_cfg", lambda: cfg) + monkeypatch.setattr( + server, "_write_config_key", lambda path, value: writes.append((path, value)) + ) + + # Direct preset. + set_wheel = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"key": "mouse", "value": "wheel"}, + } + ) + assert set_wheel["result"] == {"key": "mouse", "value": "wheel"} + assert writes[-1] == ("display.mouse_tracking", "wheel") + + # Alias for buttons. + set_click = server.handle_request( + { + "id": "2", + "method": "config.set", + "params": {"key": "mouse", "value": "click"}, + } + ) + assert set_click["result"] == {"key": "mouse", "value": "buttons"} + assert writes[-1] == ("display.mouse_tracking", "buttons") + + # Unknown value → 4002. + bad = server.handle_request( + { + "id": "3", + "method": "config.set", + "params": {"key": "mouse", "value": "rainbows"}, + } + ) + assert bad["error"]["code"] == 4002 def test_enable_gateway_prompts_sets_gateway_env(monkeypatch): diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 71a5d6f9417..921853a34c5 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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: diff --git a/ui-tui/packages/hermes-ink/index.d.ts b/ui-tui/packages/hermes-ink/index.d.ts index 66fed32ae60..14fc27dfc95 100644 --- a/ui-tui/packages/hermes-ink/index.d.ts +++ b/ui-tui/packages/hermes-ink/index.d.ts @@ -7,6 +7,7 @@ export { Ansi } from './src/ink/Ansi.tsx' export { evictInkCaches } from './src/ink/cache-eviction.ts' export type { EvictLevel, InkCacheSizes } from './src/ink/cache-eviction.ts' export { AlternateScreen } from './src/ink/components/AlternateScreen.tsx' +export type { MouseTrackingMode } from './src/ink/termio/dec.ts' export { default as Box } from './src/ink/components/Box.tsx' export type { Props as BoxProps } from './src/ink/components/Box.tsx' export { default as Link } from './src/ink/components/Link.tsx' diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index a113660385f..c279a892391 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -28,4 +28,5 @@ export { createRoot, forceRedraw, default as render, renderSync } from './ink/ro export { stringWidth } from './ink/stringWidth.js' export { wrapAnsi } from './ink/wrapAnsi.js' export { isXtermJs } from './ink/terminal.js' +export type { MouseTrackingMode } from './ink/termio/dec.js' export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' diff --git a/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx index 6bf9f513aa9..f05487437bb 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx @@ -3,14 +3,26 @@ import { c as _c } from 'react/compiler-runtime' import instances from '../instances.js' import { CURSOR_HOME, ERASE_SCREEN, ERASE_SCROLLBACK } from '../termio/csi.js' -import { DISABLE_MOUSE_TRACKING, ENABLE_MOUSE_TRACKING, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN } from '../termio/dec.js' +import { + DISABLE_MOUSE_TRACKING, + enableMouseTrackingFor, + ENTER_ALT_SCREEN, + EXIT_ALT_SCREEN, + type MouseTrackingMode +} from '../termio/dec.js' import { TerminalWriteContext } from '../useTerminalNotification.js' import Box from './Box.js' import { TerminalSizeContext } from './TerminalSizeContext.js' type Props = PropsWithChildren<{ - /** Enable SGR mouse tracking (wheel + click/drag). Default true. */ - mouseTracking?: boolean + /** + * Which SGR mouse-tracking preset to enable. Default `'all'` — wheel + + * click + drag + hover (1000 + 1002 + 1003 + 1006). Set to `'wheel'` + * (1000 + 1006) to silence the noisy hover events that tmux turns into + * "No image in clipboard" spam over the prompt row, while keeping + * scroll-wheel scrolling. `'off'` disables tracking entirely. + */ + mouseTracking?: MouseTrackingMode }> /** @@ -20,9 +32,10 @@ type Props = PropsWithChildren<{ * - Enters the alt screen (DEC 1049), clears it, homes the cursor * - Constrains its own height to the terminal row count, so overflow must * be handled via `overflow: scroll` / flexbox (no native scrollback) - * - Optionally enables SGR mouse tracking (wheel + click/drag) — events - * surface as `ParsedKey` (wheel) and update the Ink instance's - * selection state (click/drag) + * - Optionally enables a subset of SGR mouse tracking (wheel-only, + * wheel+drag, or wheel+drag+hover) — events surface as `ParsedKey` + * (wheel) and update the Ink instance's selection state (click/drag). + * See `MouseTrackingMode` for the available presets. * * On unmount, disables mouse tracking and exits the alt screen, restoring * the main screen's content. Safe for use in ctrl-o transcript overlays @@ -38,7 +51,7 @@ export function AlternateScreen(t0: Props) { const { children, mouseTracking: t1 } = t0 - const mouseTracking = t1 === undefined ? true : t1 + const mouseTracking: MouseTrackingMode = t1 === undefined ? 'all' : t1 const size = useContext(TerminalSizeContext) const writeRaw = useContext(TerminalWriteContext) let t2 @@ -52,19 +65,40 @@ export function AlternateScreen(t0: Props) { return } + const enableMouse = enableMouseTrackingFor(mouseTracking) + + // Always reset every mouse mode before enabling the requested preset + // so the terminal lands in an exact state. If a previous instance + // (crash, another app, lingering DECSET from a debugger) left DEC + // 1003 hover events asserted, picking 'wheel' or 'buttons' without + // an unconditional DISABLE would silently leave hover on and defeat + // the point of the preset. writeRaw( ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + - (mouseTracking ? ENABLE_MOUSE_TRACKING : DISABLE_MOUSE_TRACKING) + DISABLE_MOUSE_TRACKING + + enableMouse ) ink?.setAltScreenActive(true, mouseTracking) + // setAltScreenActive(true, mouseTracking) above stores the mode for + // SIGCONT/resize/stdin-gap re-assertion. We don't also call + // setAltScreenMouseTracking(mouseTracking) here: it would early-return + // in the happy mode-change path (active flipped false→true with the + // new mode), and on any path where setAltScreenActive saw active was + // already true (so it didn't store mode), the writeRaw above has + // already DISABLE'd + enabled the new mode. A second + // setAltScreenMouseTracking would just duplicate the same DEC bytes. return () => { ink?.setAltScreenActive(false) ink?.clearTextSelection() - writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN) + // DISABLE_MOUSE_TRACKING is safe to send even when we never enabled + // tracking (it unconditionally resets all four modes). Sending it + // on every teardown means a crash mid-mount can't leak DEC modes + // back to the host shell. + writeRaw(DISABLE_MOUSE_TRACKING + EXIT_ALT_SCREEN) } } @@ -97,4 +131,3 @@ export function AlternateScreen(t0: Props) { return t5 } -//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzV2l0aENoaWxkcmVuIiwidXNlQ29udGV4dCIsInVzZUluc2VydGlvbkVmZmVjdCIsImluc3RhbmNlcyIsIkRJU0FCTEVfTU9VU0VfVFJBQ0tJTkciLCJFTkFCTEVfTU9VU0VfVFJBQ0tJTkciLCJFTlRFUl9BTFRfU0NSRUVOIiwiRVhJVF9BTFRfU0NSRUVOIiwiVGVybWluYWxXcml0ZUNvbnRleHQiLCJCb3giLCJUZXJtaW5hbFNpemVDb250ZXh0IiwiUHJvcHMiLCJtb3VzZVRyYWNraW5nIiwiQWx0ZXJuYXRlU2NyZWVuIiwidDAiLCIkIiwiX2MiLCJjaGlsZHJlbiIsInQxIiwidW5kZWZpbmVkIiwic2l6ZSIsIndyaXRlUmF3IiwidDIiLCJ0MyIsImluayIsImdldCIsInByb2Nlc3MiLCJzdGRvdXQiLCJzZXRBbHRTY3JlZW5BY3RpdmUiLCJjbGVhclRleHRTZWxlY3Rpb24iLCJ0NCIsInJvd3MiLCJ0NSJdLCJzb3VyY2VzIjpbIkFsdGVybmF0ZVNjcmVlbi50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0LCB7XG4gIHR5cGUgUHJvcHNXaXRoQ2hpbGRyZW4sXG4gIHVzZUNvbnRleHQsXG4gIHVzZUluc2VydGlvbkVmZmVjdCxcbn0gZnJvbSAncmVhY3QnXG5pbXBvcnQgaW5zdGFuY2VzIGZyb20gJy4uL2luc3RhbmNlcy5qcydcbmltcG9ydCB7XG4gIERJU0FCTEVfTU9VU0VfVFJBQ0tJTkcsXG4gIEVOQUJMRV9NT1VTRV9UUkFDS0lORyxcbiAgRU5URVJfQUxUX1NDUkVFTixcbiAgRVhJVF9BTFRfU0NSRUVOLFxufSBmcm9tICcuLi90ZXJtaW8vZGVjLmpzJ1xuaW1wb3J0IHsgVGVybWluYWxXcml0ZUNvbnRleHQgfSBmcm9tICcuLi91c2VUZXJtaW5hbE5vdGlmaWNhdGlvbi5qcydcbmltcG9ydCBCb3ggZnJvbSAnLi9Cb3guanMnXG5pbXBvcnQgeyBUZXJtaW5hbFNpemVDb250ZXh0IH0gZnJvbSAnLi9UZXJtaW5hbFNpemVDb250ZXh0LmpzJ1xuXG50eXBlIFByb3BzID0gUHJvcHNXaXRoQ2hpbGRyZW48e1xuICAvKiogRW5hYmxlIFNHUiBtb3VzZSB0cmFja2luZyAod2hlZWwgKyBjbGljay9kcmFnKS4gRGVmYXVsdCB0cnVlLiAqL1xuICBtb3VzZVRyYWNraW5nPzogYm9vbGVhblxufT5cblxuLyoqXG4gKiBSdW4gY2hpbGRyZW4gaW4gdGhlIHRlcm1pbmFsJ3MgYWx0ZXJuYXRlIHNjcmVlbiBidWZmZXIsIGNvbnN0cmFpbmVkIHRvXG4gKiB0aGUgdmlld3BvcnQgaGVpZ2h0LiBXaGlsZSBtb3VudGVkOlxuICpcbiAqIC0gRW50ZXJzIHRoZSBhbHQgc2NyZWVuIChERUMgMTA0OSksIGNsZWFycyBpdCwgaG9tZXMgdGhlIGN1cnNvclxuICogLSBDb25zdHJhaW5zIGl0cyBvd24gaGVpZ2h0IHRvIHRoZSB0ZXJtaW5hbCByb3cgY291bnQsIHNvIG92ZXJmbG93IG11c3RcbiAqICAgYmUgaGFuZGxlZCB2aWEgYG92ZXJmbG93OiBzY3JvbGxgIC8gZmxleGJveCAobm8gbmF0aXZlIHNjcm9sbGJhY2spXG4gKiAtIE9wdGlvbmFsbHkgZW5hYmxlcyBTR1IgbW91c2UgdHJhY2tpbmcgKHdoZWVsICsgY2xpY2svZHJhZykg4oCUIGV2ZW50c1xuICogICBzdXJmYWNlIGFzIGBQYXJzZWRLZXlgICh3aGVlbCkgYW5kIHVwZGF0ZSB0aGUgSW5rIGluc3RhbmNlJ3NcbiAqICAgc2VsZWN0aW9uIHN0YXRlIChjbGljay9kcmFnKVxuICpcbiAqIE9uIHVubW91bnQsIGRpc2FibGVzIG1vdXNlIHRyYWNraW5nIGFuZCBleGl0cyB0aGUgYWx0IHNjcmVlbiwgcmVzdG9yaW5nXG4gKiB0aGUgbWFpbiBzY3JlZW4ncyBjb250ZW50LiBTYWZlIGZvciB1c2UgaW4gY3RybC1vIHRyYW5zY3JpcHQgb3ZlcmxheXNcbiAqIGFuZCBzaW1pbGFyIHRlbXBvcmFyeSBmdWxsc2NyZWVuIHZpZXdzIOKAlCB0aGUgbWFpbiBzY3JlZW4gaXMgcHJlc2VydmVkLlxuICpcbiAqIE5vdGlmaWVzIHRoZSBJbmsgaW5zdGFuY2UgdmlhIGBzZXRBbHRTY3JlZW5BY3RpdmUoKWAgc28gdGhlIHJlbmRlcmVyXG4gKiBrZWVwcyB0aGUgY3Vyc29yIGluc2lkZSB0aGUgdmlld3BvcnQgKHByZXZlbnRpbmcgdGhlIGN1cnNvci1yZXN0b3JlIExGXG4gKiBmcm9tIHNjcm9sbGluZyBjb250ZW50KSBhbmQgc28gc2lnbmFsLWV4aXQgY2xlYW51cCBjYW4gZXhpdCB0aGUgYWx0XG4gKiBzY3JlZW4gaWYgdGhlIGNvbXBvbmVudCdzIG93biB1bm1vdW50IGRvZXNuJ3QgcnVuLlxuICovXG5leHBvcnQgZnVuY3Rpb24gQWx0ZXJuYXRlU2NyZWVuKHtcbiAgY2hpbGRyZW4sXG4gIG1vdXNlVHJhY2tpbmcgPSB0cnVlLFxufTogUHJvcHMpOiBSZWFjdC5SZWFjdE5vZGUge1xuICBjb25zdCBzaXplID0gdXNlQ29udGV4dChUZXJtaW5hbFNpemVDb250ZXh0KVxuICBjb25zdCB3cml0ZVJhdyA9IHVzZUNvbnRleHQoVGVybWluYWxXcml0ZUNvbnRleHQpXG5cbiAgLy8gdXNlSW5zZXJ0aW9uRWZmZWN0IChub3QgdXNlTGF5b3V0RWZmZWN0KTogcmVhY3QtcmVjb25jaWxlciBjYWxsc1xuICAvLyByZXNldEFmdGVyQ29tbWl0IGJldHdlZW4gdGhlIG11dGF0aW9uIGFuZCBsYXlvdXQgY29tbWl0IHBoYXNlcywgYW5kXG4gIC8vIEluaydzIHJlc2V0QWZ0ZXJDb21taXQgdHJpZ2dlcnMgb25SZW5kZXIuIFdpdGggdXNlTGF5b3V0RWZmZWN0LCB0aGF0XG4gIC8vIGZpcnN0IG9uUmVuZGVyIGZpcmVzIEJFRk9SRSB0aGlzIGVmZmVjdCDigJQgd3JpdGluZyBhIGZ1bGwgZnJhbWUgdG8gdGhlXG4gIC8vIG1haW4gc2NyZWVuIHdpdGggYWx0U2NyZWVuPWZhbHNlLiBUaGF0IGZyYW1lIGlzIHByZXNlcnZlZCB3aGVuIHdlXG4gIC8vIGVudGVyIGFsdCBzY3JlZW4gYW5kIHJldmVhbGVkIG9uIGV4aXQgYXMgYSBicm9rZW4gdmlldy4gSW5zZXJ0aW9uXG4gIC8vIGVmZmVjdHMgZmlyZSBkdXJpbmcgdGhlIG11dGF0aW9uIHBoYXNlLCBiZWZvcmUgcmVzZXRBZnRlckNvbW1pdCwgc29cbiAgLy8gRU5URVJfQUxUX1NDUkVFTiByZWFjaGVzIHRoZSB0ZXJtaW5hbCBiZWZvcmUgdGhlIGZpcnN0IGZyYW1lIGRvZXMuXG4gIC8vIENsZWFudXAgdGltaW5nIGlzIHVuY2hhbmdlZDogYm90aCBpbnNlcnRpb24gYW5kIGxheW91dCBlZmZlY3QgY2xlYW51cFxuICAvLyBydW4gaW4gdGhlIG11dGF0aW9uIHBoYXNlIG9uIHVubW91bnQsIGJlZm9yZSByZXNldEFmdGVyQ29tbWl0LlxuICB1c2VJbnNlcnRpb25FZmZlY3QoKCkgPT4ge1xuICAgIGNvbnN0IGluayA9IGluc3RhbmNlcy5nZXQocHJvY2Vzcy5zdGRvdXQpXG4gICAgaWYgKCF3cml0ZVJhdykgcmV0dXJuXG5cbiAgICB3cml0ZVJhdyhcbiAgICAgIEVOVEVSX0FMVF9TQ1JFRU4gK1xuICAgICAgICAnXFx4MWJbMkpcXHgxYltIJyArXG4gICAgICAgIChtb3VzZVRyYWNraW5nID8gRU5BQkxFX01PVVNFX1RSQUNLSU5HIDogJycpLFxuICAgIClcbiAgICBpbms/LnNldEFsdFNjcmVlbkFjdGl2ZSh0cnVlLCBtb3VzZVRyYWNraW5nKVxuXG4gICAgcmV0dXJuICgpID0+IHtcbiAgICAgIGluaz8uc2V0QWx0U2NyZWVuQWN0aXZlKGZhbHNlKVxuICAgICAgaW5rPy5jbGVhclRleHRTZWxlY3Rpb24oKVxuICAgICAgd3JpdGVSYXcoKG1vdXNlVHJhY2tpbmcgPyBESVNBQkxFX01PVVNFX1RSQUNLSU5HIDogJycpICsgRVhJVF9BTFRfU0NSRUVOKVxuICAgIH1cbiAgfSwgW3dyaXRlUmF3LCBtb3VzZVRyYWNraW5nXSlcblxuICByZXR1cm4gKFxuICAgIDxCb3hcbiAgICAgIGZsZXhEaXJlY3Rpb249XCJjb2x1bW5cIlxuICAgICAgaGVpZ2h0PXtzaXplPy5yb3dzID8/IDI0fVxuICAgICAgd2lkdGg9XCIxMDAlXCJcbiAgICAgIGZsZXhTaHJpbms9ezB9XG4gICAgPlxuICAgICAge2NoaWxkcmVufVxuICAgIDwvQm94PlxuICApXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLElBQ1YsS0FBS0MsaUJBQWlCLEVBQ3RCQyxVQUFVLEVBQ1ZDLGtCQUFrQixRQUNiLE9BQU87QUFDZCxPQUFPQyxTQUFTLE1BQU0saUJBQWlCO0FBQ3ZDLFNBQ0VDLHNCQUFzQixFQUN0QkMscUJBQXFCLEVBQ3JCQyxnQkFBZ0IsRUFDaEJDLGVBQWUsUUFDVixrQkFBa0I7QUFDekIsU0FBU0Msb0JBQW9CLFFBQVEsK0JBQStCO0FBQ3BFLE9BQU9DLEdBQUcsTUFBTSxVQUFVO0FBQzFCLFNBQVNDLG1CQUFtQixRQUFRLDBCQUEwQjtBQUU5RCxLQUFLQyxLQUFLLEdBQUdYLGlCQUFpQixDQUFDO0VBQzdCO0VBQ0FZLGFBQWEsQ0FBQyxFQUFFLE9BQU87QUFDekIsQ0FBQyxDQUFDOztBQUVGO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQSxPQUFPLFNBQUFDLGdCQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQXlCO0lBQUFDLFFBQUE7SUFBQUwsYUFBQSxFQUFBTTtFQUFBLElBQUFKLEVBR3hCO0VBRE4sTUFBQUYsYUFBQSxHQUFBTSxFQUFvQixLQUFwQkMsU0FBb0IsR0FBcEIsSUFBb0IsR0FBcEJELEVBQW9CO0VBRXBCLE1BQUFFLElBQUEsR0FBYW5CLFVBQVUsQ0FBQ1MsbUJBQW1CLENBQUM7RUFDNUMsTUFBQVcsUUFBQSxHQUFpQnBCLFVBQVUsQ0FBQ08sb0JBQW9CLENBQUM7RUFBQSxJQUFBYyxFQUFBO0VBQUEsSUFBQUMsRUFBQTtFQUFBLElBQUFSLENBQUEsUUFBQUgsYUFBQSxJQUFBRyxDQUFBLFFBQUFNLFFBQUE7SUFZOUJDLEVBQUEsR0FBQUEsQ0FBQTtNQUNqQixNQUFBRSxHQUFBLEdBQVlyQixTQUFTLENBQUFzQixHQUFJLENBQUNDLE9BQU8sQ0FBQUMsTUFBTyxDQUFDO01BQ3pDLElBQUksQ0FBQ04sUUFBUTtRQUFBO01BQUE7TUFFYkEsUUFBUSxDQUNOZixnQkFBZ0IsR0FDZCxlQUFlLElBQ2RNLGFBQWEsR0FBYlAscUJBQTBDLEdBQTFDLEVBQTBDLENBQy9DLENBQUM7TUFDRG1CLEdBQUcsRUFBQUksa0JBQXlDLENBQXBCLElBQUksRUFBRWhCLGFBQWEsQ0FBQztNQUFBLE9BRXJDO1FBQ0xZLEdBQUcsRUFBQUksa0JBQTJCLENBQU4sS0FBSyxDQUFDO1FBQzlCSixHQUFHLEVBQUFLLGtCQUFzQixDQUFELENBQUM7UUFDekJSLFFBQVEsQ0FBQyxDQUFDVCxhQUFhLEdBQWJSLHNCQUEyQyxHQUEzQyxFQUEyQyxJQUFJRyxlQUFlLENBQUM7TUFBQSxDQUMxRTtJQUFBLENBQ0Y7SUFBRWdCLEVBQUEsSUFBQ0YsUUFBUSxFQUFFVCxhQUFhLENBQUM7SUFBQUcsQ0FBQSxNQUFBSCxhQUFBO0lBQUFHLENBQUEsTUFBQU0sUUFBQTtJQUFBTixDQUFBLE1BQUFPLEVBQUE7SUFBQVAsQ0FBQSxNQUFBUSxFQUFBO0VBQUE7SUFBQUQsRUFBQSxHQUFBUCxDQUFBO0lBQUFRLEVBQUEsR0FBQVIsQ0FBQTtFQUFBO0VBaEI1QmIsa0JBQWtCLENBQUNvQixFQWdCbEIsRUFBRUMsRUFBeUIsQ0FBQztFQUtqQixNQUFBTyxFQUFBLEdBQUFWLElBQUksRUFBQVcsSUFBWSxJQUFoQixFQUFnQjtFQUFBLElBQUFDLEVBQUE7RUFBQSxJQUFBakIsQ0FBQSxRQUFBRSxRQUFBLElBQUFGLENBQUEsUUFBQWUsRUFBQTtJQUYxQkUsRUFBQSxJQUFDLEdBQUcsQ0FDWSxhQUFRLENBQVIsUUFBUSxDQUNkLE1BQWdCLENBQWhCLENBQUFGLEVBQWUsQ0FBQyxDQUNsQixLQUFNLENBQU4sTUFBTSxDQUNBLFVBQUMsQ0FBRCxHQUFDLENBRVpiLFNBQU8sQ0FDVixFQVBDLEdBQUcsQ0FPRTtJQUFBRixDQUFBLE1BQUFFLFFBQUE7SUFBQUYsQ0FBQSxNQUFBZSxFQUFBO0lBQUFmLENBQUEsTUFBQWlCLEVBQUE7RUFBQTtJQUFBQSxFQUFBLEdBQUFqQixDQUFBO0VBQUE7RUFBQSxPQVBOaUIsRUFPTTtBQUFBIiwiaWdub3JlTGlzdCI6W119 diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 5723cdd84ee..485ef5ffca9 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -97,9 +97,10 @@ import { DBP, DFE, DISABLE_MOUSE_TRACKING, - ENABLE_MOUSE_TRACKING, + enableMouseTrackingFor, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN, + type MouseTrackingMode, SHOW_CURSOR } from './termio/dec.js' import { @@ -267,9 +268,11 @@ export default class Ink { // LF-induced scroll when screen.height === terminalRows) and gates // alt-screen-aware SIGCONT/resize/unmount handling. private altScreenActive = false - // Set alongside altScreenActive so SIGCONT resume knows whether to - // re-enable mouse tracking (not all uses want it). - private altScreenMouseTracking = false + // Set alongside altScreenActive so SIGCONT resume knows which mouse + // tracking preset to re-enable (not all uses want + // tracking, and tmux users routinely opt into the hover-free 'wheel' + // subset to silence prompt-row clipboard probes). + private altScreenMouseTracking: MouseTrackingMode = 'off' // True when the previous frame's screen buffer cannot be trusted for // blit — selection overlay mutated it, resetFramesForAltScreen() // replaced it with blanks, or forceRedraw() reset it to 0×0. Forces @@ -570,9 +573,11 @@ export default class Ink { this.resizeSettleTimer = null } - if (this.altScreenMouseTracking) { - this.options.stdout.write(ENABLE_MOUSE_TRACKING) - } + // Mouse tracking — DISABLE first so we land in the exact preset state + // even if an external app/terminal/tmux left DEC 1003 hover asserted. + // DISABLE_MOUSE_TRACKING is idempotent (resets all four modes + // unconditionally), safe to send even when current preset is 'off'. + this.options.stdout.write(DISABLE_MOUSE_TRACKING + enableMouseTrackingFor(this.altScreenMouseTracking)) this.resetFramesForAltScreen() this.needsEraseBeforePaint = true @@ -609,7 +614,7 @@ export default class Ink { // kitty/modifyOtherKeys stays active. exitAlternateScreen re-enables. DISABLE_KITTY_KEYBOARD + DISABLE_MODIFY_OTHER_KEYS + - (this.altScreenMouseTracking ? DISABLE_MOUSE_TRACKING : '') + + (this.altScreenMouseTracking !== 'off' ? DISABLE_MOUSE_TRACKING : '') + // disable mouse (no-op if off) (this.altScreenActive ? '' : '\x1b[?1049h') + // enter alt (already in alt if fullscreen) @@ -645,7 +650,11 @@ export default class Ink { // clear screen (now alt if fullscreen) '\x1b[H' + // cursor home - (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '') + + // DISABLE first so external editors/tmux that left DEC 1003 hover + // on can't survive the handoff back — same pattern as + // setAltScreenMouseTracking / reenterAltScreen. + DISABLE_MOUSE_TRACKING + + enableMouseTrackingFor(this.altScreenMouseTracking) + (this.altScreenActive ? '' : '\x1b[?1049l') + // exit alt (non-fullscreen only) '\x1b[?25l' // hide cursor (Ink manages) @@ -1249,13 +1258,13 @@ export default class Ink { * the first alt-screen frame (and first main-screen frame on exit) is * a full redraw with no stale diff state. */ - setAltScreenActive(active: boolean, mouseTracking = false): void { + setAltScreenActive(active: boolean, mouseTracking: MouseTrackingMode = 'off'): void { if (this.altScreenActive === active) { return } this.altScreenActive = active - this.altScreenMouseTracking = active && mouseTracking + this.altScreenMouseTracking = active ? mouseTracking : 'off' // Hover state is alt-screen-scoped: dispatchHover is gated on // altScreenActive, so once we leave the alt screen there's no path to @@ -1269,25 +1278,29 @@ export default class Ink { if (active) { this.resetFramesForAltScreen() + this.scheduleRender() } else { this.repaint() } } /** - * Toggle mouse tracking at runtime while the alt screen is active. - * Writes the appropriate DEC reset/set sequences so the terminal - * (and ConPTY on Windows WSL2) reflects the change immediately. + * Switch mouse tracking preset at runtime while the alt screen is + * active. Always issues DISABLE first so switching between subsets (e.g. + * 'all' → 'wheel') clears mode 1003 instead of leaving it asserted — + * DEC private modes have no "set this exact bitmask" form, only + * individual set/reset, and tmux's mouse-mode bookkeeping does honor the + * reset so the prompt-row "No image in clipboard" spam stops. */ - setAltScreenMouseTracking(enabled: boolean): void { - if (this.altScreenMouseTracking === enabled) { + setAltScreenMouseTracking(mode: MouseTrackingMode): void { + if (this.altScreenMouseTracking === mode) { return } - this.altScreenMouseTracking = enabled + this.altScreenMouseTracking = mode if (this.altScreenActive) { - this.options.stdout.write(enabled ? ENABLE_MOUSE_TRACKING : DISABLE_MOUSE_TRACKING) + this.options.stdout.write(DISABLE_MOUSE_TRACKING + enableMouseTrackingFor(mode)) } } get isAltScreenActive(): boolean { @@ -1340,9 +1353,10 @@ export default class Ink { } // Mouse tracking — idempotent, safe to re-assert on every stdin gap. - if (this.altScreenMouseTracking) { - this.options.stdout.write(ENABLE_MOUSE_TRACKING) - } + // DISABLE first so we land in the exact preset state even if an + // external app or tmux left DEC 1003 hover asserted out from under us + // since the last assertion. + this.options.stdout.write(DISABLE_MOUSE_TRACKING + enableMouseTrackingFor(this.altScreenMouseTracking)) // Alt-screen re-entry — destructive (ERASE_SCREEN). Only for callers that // have a strong signal the terminal actually dropped mode 1049. @@ -1398,10 +1412,28 @@ export default class Ink { * stays true. ENTER_ALT_SCREEN is a terminal-side no-op if already in alt. */ private reenterAltScreen(): void { + // DISABLE_MOUSE_TRACKING before enableMouseTrackingFor — same as + // setAltScreenMouseTracking / AlternateScreen mount / handleResize. + // DEC private modes have no atomic "set this bitmask" sequence, only + // per-mode set/reset, so for 'wheel'/'buttons' presets we must reset + // first to drop any lingering DEC 1003 hover from before re-entry. this.options.stdout.write( - ENTER_ALT_SCREEN + ERASE_SCREEN + CURSOR_HOME + (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '') + ENTER_ALT_SCREEN + + ERASE_SCREEN + + CURSOR_HOME + + DISABLE_MOUSE_TRACKING + + enableMouseTrackingFor(this.altScreenMouseTracking) ) this.resetFramesForAltScreen() + // ERASE_SCREEN above leaves the physical alt screen blank, and + // resetFramesForAltScreen() seeds prev/back as blank rows×cols, so + // nothing on the front frame survives the re-entry. Callers + // (handleResume on SIGCONT, the resize self-heal, the stdin-gap + // re-assertion) all return early after invoking us, so without an + // explicit render schedule the alt screen sits blank until some + // unrelated state change fires the next commit. queueing one + // microtask matches scheduleRender's normal cadence. + this.scheduleRender() } /** diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/dec.ts b/ui-tui/packages/hermes-ink/src/ink/termio/dec.ts index 4548b923ffa..f5b89995d05 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/dec.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/dec.ts @@ -47,8 +47,53 @@ export const EXIT_ALT_SCREEN = decreset(DEC.ALT_SCREEN_CLEAR) // Mouse tracking: 1000 reports button press/release/wheel, 1002 adds drag // events (button-motion), 1003 adds all-motion (no button held — for // hover), 1006 uses SGR format (CSI < btn;col;row M/m) instead of legacy -// X10 bytes. Combined: wheel + click/drag for selection + hover. -export const ENABLE_MOUSE_TRACKING = - decset(DEC.MOUSE_NORMAL) + decset(DEC.MOUSE_BUTTON) + decset(DEC.MOUSE_ANY) + decset(DEC.MOUSE_SGR) +// X10 bytes. +// +// Modes are addressable as a preset so users can opt out of 1003 (hover), +// which is the noisy one inside tmux — every cursor cross of the prompt +// row triggers a clipboard probe that surfaces as "No image in clipboard". +// Presets: +// - 'off' — no DECSET, terminal/tmux native selection + scroll work +// - 'wheel' — 1000 + 1006: click + wheel only, no drag, no hover +// - 'buttons' — 1000 + 1002 + 1006: adds drag (text selection), no hover +// - 'all' — 1000 + 1002 + 1003 + 1006: legacy behavior, hover-driven +// UI (scrollbar paginate-on-hover, link mouseenter, etc.) +export type MouseTrackingMode = 'all' | 'buttons' | 'off' | 'wheel' + +const MOUSE_NORMAL = decset(DEC.MOUSE_NORMAL) +const MOUSE_BUTTON = decset(DEC.MOUSE_BUTTON) +const MOUSE_ANY = decset(DEC.MOUSE_ANY) +const MOUSE_SGR = decset(DEC.MOUSE_SGR) + +/** Sequence to enable the requested mouse tracking preset, or '' for 'off'. */ +export function enableMouseTrackingFor(mode: MouseTrackingMode): string { + switch (mode) { + case 'all': + return MOUSE_NORMAL + MOUSE_BUTTON + MOUSE_ANY + MOUSE_SGR + + case 'buttons': + return MOUSE_NORMAL + MOUSE_BUTTON + MOUSE_SGR + + case 'wheel': + return MOUSE_NORMAL + MOUSE_SGR + + case 'off': + return '' + + default: + // Defensive fallback: the type system guarantees exhaustiveness, but + // JS callers / corrupted config / hot-reloads in dev could reach this + // with an unknown value. Without a default, an unmatched mode 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. + return '' + } +} + +/** Legacy alias for the maximal preset (1000 + 1002 + 1003 + 1006). */ +export const ENABLE_MOUSE_TRACKING = enableMouseTrackingFor('all') +/** Reset every mouse mode unconditionally — safe to send when any subset is on. */ export const DISABLE_MOUSE_TRACKING = decreset(DEC.MOUSE_SGR) + decreset(DEC.MOUSE_ANY) + decreset(DEC.MOUSE_BUTTON) + decreset(DEC.MOUSE_NORMAL) diff --git a/ui-tui/src/__tests__/useConfigSync.test.ts b/ui-tui/src/__tests__/useConfigSync.test.ts index 39020d27633..2a6f7262456 100644 --- a/ui-tui/src/__tests__/useConfigSync.test.ts +++ b/ui-tui/src/__tests__/useConfigSync.test.ts @@ -77,13 +77,26 @@ describe('applyDisplay', () => { const setBell = vi.fn() applyDisplay({ config: { display: { mouse_tracking: false } } }, setBell) - expect($uiState.get().mouseTracking).toBe(false) + expect($uiState.get().mouseTracking).toBe('off') applyDisplay({ config: { display: { mouse_tracking: true, tui_mouse: false } } }, setBell) - expect($uiState.get().mouseTracking).toBe(true) + expect($uiState.get().mouseTracking).toBe('all') applyDisplay({ config: { display: { tui_mouse: false } } }, setBell) - expect($uiState.get().mouseTracking).toBe(false) + expect($uiState.get().mouseTracking).toBe('off') + }) + + it('threads mouse_tracking presets through to $uiState', () => { + const setBell = vi.fn() + + applyDisplay({ config: { display: { mouse_tracking: 'wheel' } } }, setBell) + expect($uiState.get().mouseTracking).toBe('wheel') + + applyDisplay({ config: { display: { mouse_tracking: 'buttons' } } }, setBell) + expect($uiState.get().mouseTracking).toBe('buttons') + + applyDisplay({ config: { display: { mouse_tracking: 'all' } } }, setBell) + expect($uiState.get().mouseTracking).toBe('all') }) it('parses display.sections into per-section overrides', () => { @@ -183,15 +196,30 @@ describe('normalizeStatusBar', () => { }) describe('normalizeMouseTracking', () => { - it('defaults on and prefers canonical mouse_tracking over legacy tui_mouse', () => { - expect(normalizeMouseTracking({})).toBe(true) - expect(normalizeMouseTracking({ mouse_tracking: false })).toBe(false) - expect(normalizeMouseTracking({ mouse_tracking: 0 })).toBe(false) - expect(normalizeMouseTracking({ mouse_tracking: 'off' })).toBe(false) - expect(normalizeMouseTracking({ mouse_tracking: 'false' })).toBe(false) - expect(normalizeMouseTracking({ mouse_tracking: null, tui_mouse: false })).toBe(true) - expect(normalizeMouseTracking({ mouse_tracking: true, tui_mouse: false })).toBe(true) - expect(normalizeMouseTracking({ tui_mouse: false })).toBe(false) + it('defaults to all and prefers canonical mouse_tracking over legacy tui_mouse', () => { + expect(normalizeMouseTracking({})).toBe('all') + expect(normalizeMouseTracking({ mouse_tracking: false })).toBe('off') + expect(normalizeMouseTracking({ mouse_tracking: 0 })).toBe('off') + expect(normalizeMouseTracking({ mouse_tracking: 'off' })).toBe('off') + expect(normalizeMouseTracking({ mouse_tracking: 'false' })).toBe('off') + expect(normalizeMouseTracking({ mouse_tracking: null, tui_mouse: false })).toBe('all') + expect(normalizeMouseTracking({ mouse_tracking: true, tui_mouse: false })).toBe('all') + expect(normalizeMouseTracking({ tui_mouse: false })).toBe('off') + }) + + it('accepts preset strings (wheel/buttons/all) and their aliases', () => { + expect(normalizeMouseTracking({ mouse_tracking: 'wheel' })).toBe('wheel') + expect(normalizeMouseTracking({ mouse_tracking: 'scroll' })).toBe('wheel') + expect(normalizeMouseTracking({ mouse_tracking: 'buttons' })).toBe('buttons') + expect(normalizeMouseTracking({ mouse_tracking: 'click' })).toBe('buttons') + expect(normalizeMouseTracking({ mouse_tracking: 'all' })).toBe('all') + expect(normalizeMouseTracking({ mouse_tracking: 'full' })).toBe('all') + expect(normalizeMouseTracking({ mouse_tracking: 'on' })).toBe('all') + expect(normalizeMouseTracking({ mouse_tracking: ' WHEEL ' })).toBe('wheel') + }) + + it('falls back to all for unknown strings', () => { + expect(normalizeMouseTracking({ mouse_tracking: 'rainbows' })).toBe('all') }) }) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index b5ad2c0f3d3..b71e34188ef 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -1,4 +1,4 @@ -import type { ScrollBoxHandle } from '@hermes/ink' +import type { MouseTrackingMode, ScrollBoxHandle } from '@hermes/ink' import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'react' import type { PasteEvent } from '../components/textInput.js' @@ -104,7 +104,7 @@ export interface UiState { detailsModeCommandOverride: boolean info: null | SessionInfo inlineDiffs: boolean - mouseTracking: boolean + mouseTracking: MouseTrackingMode sections: SectionVisibility showCost: boolean showReasoning: boolean @@ -351,7 +351,7 @@ export interface AppLayoutTranscriptProps { export interface AppLayoutProps { actions: AppLayoutActions composer: AppLayoutComposerProps - mouseTracking: boolean + mouseTracking: MouseTrackingMode progress: AppLayoutProgressProps status: AppLayoutStatusProps transcript: AppLayoutTranscriptProps diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index ae2387da61d..58b84f27b49 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -1,9 +1,9 @@ -import { forceRedraw } from '@hermes/ink' +import { forceRedraw, type MouseTrackingMode } from '@hermes/ink' import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js' import { dailyFortune, randomFortune } from '../../../content/fortunes.js' import { HOTKEYS } from '../../../content/hotkeys.js' -import { SECTION_NAMES, isSectionName, nextDetailsMode, parseDetailsMode } from '../../../domain/details.js' +import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js' import type { ConfigGetValueResponse, ConfigSetResponse, @@ -44,6 +44,30 @@ const flagFromArg = (arg: string, current: boolean): boolean | null => { return null } +// `/mouse` toggles between full tracking and off when called bare so the +// old binary muscle-memory still works. Explicit presets (wheel / buttons / +// all) target the tmux-friendly hover-free subsets. +const MOUSE_MODE_ALIASES: Record = { + all: 'all', + any: 'all', + button: 'buttons', + buttons: 'buttons', + click: 'buttons', + full: 'all', + off: 'off', + on: 'all', + scroll: 'wheel', + wheel: 'wheel' +} + +const mouseModeFromArg = (arg: string, current: MouseTrackingMode): MouseTrackingMode | null => { + if (!arg || arg.trim().toLowerCase() === 'toggle') { + return current === 'off' ? 'all' : 'off' + } + + return MOUSE_MODE_ALIASES[arg.trim().toLowerCase()] ?? null +} + const RESET_WORDS = new Set(['reset', 'clear', 'default']) const CYCLE_WORDS = new Set(['cycle', 'toggle']) @@ -105,20 +129,20 @@ export const coreCommands: SlashCommand[] = [ { aliases: ['scroll'], - help: 'toggle mouse/wheel tracking [on|off|toggle]', + help: 'set mouse tracking preset [on|off|toggle|wheel|buttons|all]', name: 'mouse', run: (arg, ctx) => { const current = ctx.ui.mouseTracking - const next = flagFromArg(arg, current) + const next = mouseModeFromArg(arg, current) if (next === null) { - return ctx.transcript.sys('usage: /mouse [on|off|toggle]') + return ctx.transcript.sys('usage: /mouse [on|off|toggle|wheel|buttons|all]') } patchUiState({ mouseTracking: next }) - ctx.gateway.rpc('config.set', { key: 'mouse', value: next ? 'on' : 'off' }).catch(() => {}) + ctx.gateway.rpc('config.set', { key: 'mouse', value: next }).catch(() => {}) - queueMicrotask(() => ctx.transcript.sys(`mouse tracking ${next ? 'on' : 'off'}`)) + queueMicrotask(() => ctx.transcript.sys(`mouse tracking ${next}`)) } }, diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index b0e590ee2c2..35694dbec6a 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -1,3 +1,4 @@ +import type { MouseTrackingMode } from '@hermes/ink' import { useEffect, useRef } from 'react' import { resolveDetailsMode, resolveSections } from '../domain/details.js' @@ -9,8 +10,8 @@ import type { } from '../gatewayTypes.js' import { DEFAULT_VOICE_RECORD_KEY, - parseVoiceRecordKey, - type ParsedVoiceRecordKey + type ParsedVoiceRecordKey, + parseVoiceRecordKey } from '../lib/platform.js' import { asRpcResult } from '../lib/rpc.js' @@ -68,16 +69,57 @@ export const normalizeIndicatorStyle = (raw: unknown): IndicatorStyle => { } const FALSEY_MOUSE = new Set(['0', 'false', 'no', 'off']) +const TRUTHY_MOUSE_ALL = new Set(['1', 'true', 'yes', 'on', 'all', 'full', 'any']) const hasOwn = (obj: object, key: PropertyKey) => Object.prototype.hasOwnProperty.call(obj, key) -export const normalizeMouseTracking = (display: { mouse_tracking?: unknown; tui_mouse?: unknown }): boolean => { +// `display.mouse_tracking` accepts boolean (`true` ⇒ all modes, `false` ⇒ off) +// for back-compat, plus the string presets `off|wheel|buttons|all` (aliases: +// `on`/`full`/`any`/`1`/`true`/... → `all`; `0`/`false`/`no`/`off` → `off`). +// `wheel` enables 1000+1006 — scroll wheel + click only, no drag or hover, +// which silences tmux's "No image in clipboard" spam over the prompt row. +// `buttons` adds 1002 so terminal-side text selection drags still register. +// Legacy `tui_mouse` is honored only if `mouse_tracking` is absent. +export const normalizeMouseTracking = (display: { + mouse_tracking?: unknown + tui_mouse?: unknown +}): MouseTrackingMode => { const raw = hasOwn(display, 'mouse_tracking') ? display.mouse_tracking : display.tui_mouse if (raw === false || raw === 0) { - return false + return 'off' } - return typeof raw === 'string' ? !FALSEY_MOUSE.has(raw.trim().toLowerCase()) : true + if (raw === true || raw === undefined || raw === null) { + return 'all' + } + + if (typeof raw === 'number') { + return 'all' + } + + if (typeof raw !== 'string') { + return 'all' + } + + const v = raw.trim().toLowerCase() + + if (FALSEY_MOUSE.has(v)) { + return 'off' + } + + if (TRUTHY_MOUSE_ALL.has(v)) { + return 'all' + } + + if (v === 'wheel' || v === 'scroll') { + return 'wheel' + } + + if (v === 'buttons' || v === 'button' || v === 'click') { + return 'buttons' + } + + return 'all' } const MTIME_POLL_MS = 5000 @@ -114,6 +156,7 @@ export async function hydrateFullConfig( ): Promise { const cfg = await quietRpc(gw, 'config.get', { key: 'full' }) applyDisplay(cfg, setBell, setVoiceRecordKey) + return cfg } @@ -125,6 +168,7 @@ export const applyDisplay = ( const d = cfg?.config?.display ?? {} setBell(!!d.bell_on_complete) + // Only push the voice record key when the RPC actually returned a // config payload. ``quietRpc()`` collapses failures to ``null``; if we // reset the cached shortcut on every null we would clobber a custom @@ -135,6 +179,7 @@ export const applyDisplay = ( if (setVoiceRecordKey && cfg) { setVoiceRecordKey(_voiceRecordKeyFromConfig(cfg)) } + patchUiState({ busyInputMode: normalizeBusyInputMode(d.busy_input_mode), compact: !!d.tui_compact, diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts index 35cc6878279..88d1f4eb3a9 100644 --- a/ui-tui/src/config/env.ts +++ b/ui-tui/src/config/env.ts @@ -1,3 +1,4 @@ +import type { MouseTrackingMode } from '@hermes/ink' import { isTermuxTuiMode } from '../lib/termux.js' const truthy = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim()) @@ -27,13 +28,24 @@ export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() export const STARTUP_QUERY = (process.env.HERMES_TUI_QUERY ?? '').trim() export const STARTUP_IMAGE = (process.env.HERMES_TUI_IMAGE ?? '').trim() +// Mouse tracking mode resolution at startup. Per-mode selection (off|wheel| +// buttons|all) lives in display.mouse_tracking in config.yaml — these env +// vars only set the boot-time default before that config is applied. +// +// Precedence (highest first): +// +// - HERMES_TUI_MOUSE_TRACKING (truthy/falsy) explicitly overrides everything. +// This is the "force a value" knob and intentionally beats the legacy +// kill-switch and the Termux default. +// - HERMES_TUI_DISABLE_MOUSE=1 forces mouse off — the legacy kill switch. +// - On Termux the default is mouse off so touch selection isn't intercepted +// by terminal mouse protocols. Desktop defaults to 'all' to preserve prior +// behavior. const mouseTrackingOverride = parseToggle(process.env.HERMES_TUI_MOUSE_TRACKING) const mouseTrackingDisabledLegacy = truthy(process.env.HERMES_TUI_DISABLE_MOUSE) -// Mobile selection UX: on Termux default mouse tracking OFF so touch selection -// is less likely to be intercepted by terminal mouse protocols. Desktop keeps -// prior behavior unless explicitly overridden. -export const MOUSE_TRACKING = +const resolvedBootMouseEnabled = mouseTrackingOverride ?? (TERMUX_TUI_MODE ? false : !mouseTrackingDisabledLegacy) +export const MOUSE_TRACKING: MouseTrackingMode = resolvedBootMouseEnabled ? 'all' : 'off' export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.HERMES_TUI_NO_CONFIRM) diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index 8a673b76efe..533a661258b 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -106,7 +106,7 @@ All slash commands work unchanged. A few are TUI-owned — they produce richer o | `/usage` | Rich token / cost / context panel | | `/agents` (alias `/tasks`) | Observability overlay — live subagent tree with kill/pause controls, per-branch cost / token / file rollups, turn-by-turn history | | `/reload` | Re-reads `~/.hermes/.env` into the running TUI process so newly added API keys take effect without a restart | -| `/mouse` | Toggle mouse tracking on/off at runtime (also persists to `display.mouse_tracking` in `config.yaml`) | +| `/mouse [on\|off\|toggle\|wheel\|buttons\|all]` | Pick a mouse tracking preset at runtime (also persists to `display.mouse_tracking` in `config.yaml`). `wheel` (1000+1006) keeps scroll-wheel scrolling without the hover events that make tmux spam "No image in clipboard" over the prompt row; `buttons` adds drag-to-select; `all` is the default with hover-driven UI. | Every other slash command (including installed skills, quick commands, and personality toggles) works identically to the classic CLI. See [Slash Commands Reference](../reference/slash-commands.md). @@ -190,7 +190,13 @@ display: thinking: expanded # always open tools: expanded # always open activity: collapsed # opt back IN to the activity panel (hidden by default) - mouse_tracking: true # disable if your terminal conflicts with mouse reporting + mouse_tracking: all # off | wheel | buttons | all (or true/false for back-compat). + # wheel — 1000+1006 (scroll + click; no drag, no hover — + # recommended inside tmux to silence the prompt-row + # "No image in clipboard" spam from hover events) + # buttons — adds 1002 for terminal-side drag selection + # all — adds 1003 for hover (scrollbar paginate-on-hover, + # link mouseenter, etc.) ``` Runtime toggles: