diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 7a7f63284..d12b6185c 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -118,6 +118,53 @@ def test_config_set_yolo_toggles_session_scope(): server._sessions.clear() +def test_config_set_mouse_writes_tui_mouse(monkeypatch): + writes: list[tuple[str, object]] = [] + cfg = {"display": {}} + + monkeypatch.setattr(server, "_load_cfg", lambda: cfg) + monkeypatch.setattr(server, "_write_config_key", lambda path, value: writes.append((path, value))) + + resp_off = server.handle_request({"id": "1", "method": "config.set", "params": {"key": "mouse", "value": "off"}}) + assert resp_off["result"] == {"key": "mouse", "value": "off"} + assert writes[-1] == ("display.tui_mouse", False) + + resp_on = server.handle_request({"id": "2", "method": "config.set", "params": {"key": "mouse", "value": "on"}}) + assert resp_on["result"] == {"key": "mouse", "value": "on"} + assert writes[-1] == ("display.tui_mouse", True) + + +def test_config_set_mouse_toggle_inverts_persisted_value(monkeypatch): + # Persisted off → toggle flips on. + writes: list[tuple[str, object]] = [] + monkeypatch.setattr(server, "_load_cfg", lambda: {"display": {"tui_mouse": False}}) + monkeypatch.setattr(server, "_write_config_key", lambda path, value: writes.append((path, value))) + + resp = server.handle_request({"id": "1", "method": "config.set", "params": {"key": "mouse", "value": "toggle"}}) + assert resp["result"] == {"key": "mouse", "value": "on"} + assert writes[-1] == ("display.tui_mouse", True) + + +def test_config_set_mouse_rejects_unknown_value(monkeypatch): + monkeypatch.setattr(server, "_load_cfg", lambda: {"display": {}}) + monkeypatch.setattr(server, "_write_config_key", lambda path, value: None) + + resp = server.handle_request({"id": "1", "method": "config.set", "params": {"key": "mouse", "value": "sure"}}) + assert "error" in resp + assert "unknown mouse value" in resp["error"]["message"] + + +def test_config_get_mouse_defaults_on(monkeypatch): + monkeypatch.setattr(server, "_load_cfg", lambda: {}) + + resp = server.handle_request({"id": "1", "method": "config.get", "params": {"key": "mouse"}}) + assert resp["result"] == {"value": "on"} + + monkeypatch.setattr(server, "_load_cfg", lambda: {"display": {"tui_mouse": False}}) + resp_off = server.handle_request({"id": "2", "method": "config.get", "params": {"key": "mouse"}}) + assert resp_off["result"] == {"value": "off"} + + def test_enable_gateway_prompts_sets_gateway_env(monkeypatch): monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 73bc39ffb..295e8bd7f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1951,12 +1951,15 @@ def _(rid, params: dict) -> dict: _write_config_key("display.details_mode", "expanded" if nv == "full" else "collapsed") return _ok(rid, {"key": key, "value": nv}) - if key in ("compact", "statusbar"): + if key in ("compact", "statusbar", "mouse"): + # compact defaults off, statusbar + mouse default on. + defaults = {"tui_compact": False, "tui_statusbar": True, "tui_mouse": True} + def_keys = {"compact": "tui_compact", "statusbar": "tui_statusbar", "mouse": "tui_mouse"} + def_key = def_keys[key] raw = str(value or "").strip().lower() cfg0 = _load_cfg() d0 = cfg0.get("display") if isinstance(cfg0.get("display"), dict) else {} - def_key = "tui_compact" if key == "compact" else "tui_statusbar" - cur_b = bool(d0.get(def_key, False if key == "compact" else True)) + cur_b = bool(d0.get(def_key, defaults[def_key])) if raw in ("", "toggle"): nv_b = not cur_b elif raw == "on": @@ -2053,6 +2056,9 @@ def _(rid, params: dict) -> dict: if key == "statusbar": on = bool(_load_cfg().get("display", {}).get("tui_statusbar", True)) return _ok(rid, {"value": "on" if on else "off"}) + if key == "mouse": + on = bool(_load_cfg().get("display", {}).get("tui_mouse", True)) + return _ok(rid, {"value": "on" if on else "off"}) if key == "mtime": cfg_path = _hermes_home / "config.yaml" try: @@ -2106,6 +2112,7 @@ _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 SGR mouse tracking (turn off if your terminal prints escape codes on mouse move)", "TUI"), ] # Commands that queue messages onto _pending_input in the CLI. diff --git a/ui-tui/packages/hermes-ink/index.d.ts b/ui-tui/packages/hermes-ink/index.d.ts index 6536bddb0..3680f0c6c 100644 --- a/ui-tui/packages/hermes-ink/index.d.ts +++ b/ui-tui/packages/hermes-ink/index.d.ts @@ -30,6 +30,7 @@ export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts' export { default as measureElement } from './src/ink/measure-element.ts' export { createRoot, default as render, renderSync } from './src/ink/root.ts' export type { Instance, RenderOptions, Root } from './src/ink/root.ts' +export { setAltScreenMouseTracking } from './src/ink/set-alt-screen-mouse-tracking.ts' export { stringWidth } from './src/ink/stringWidth.ts' export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' export type { Props as TextInputProps } from 'ink-text-input' diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index 6ef1fc5fb..b01ac8661 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -22,5 +22,6 @@ export { useTerminalTitle } from './ink/hooks/use-terminal-title.js' export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js' export { default as measureElement } from './ink/measure-element.js' export { createRoot, default as render, renderSync } from './ink/root.js' +export { setAltScreenMouseTracking } from './ink/set-alt-screen-mouse-tracking.js' export { stringWidth } from './ink/stringWidth.js' export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 1543dc7fc..9d7c0d6ec 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -1094,6 +1094,30 @@ export default class Ink { return this.altScreenActive } + /** + * Toggle SGR mouse tracking (DEC 1000/1002/1003/1006) at runtime without + * re-entering the alt screen. Updates the internal flag so resize/resume/ + * reenterAltScreen respect the new state, and writes ENABLE/DISABLE bytes + * if we're currently in alt-screen + TTY + not paused. + * + * Idempotent. Intended for live `/mouse on|off` toggling — the + * prop controls setup/teardown at mount/unmount, this + * controls in-session switches without a screen flicker. + */ + setAltScreenMouseTracking(enabled: boolean): void { + if (this.altScreenMouseTracking === enabled) { + return + } + + this.altScreenMouseTracking = enabled + + if (!this.altScreenActive || this.isPaused || !this.options.stdout.isTTY) { + return + } + + this.options.stdout.write(enabled ? ENABLE_MOUSE_TRACKING : DISABLE_MOUSE_TRACKING) + } + /** * Re-assert terminal modes after a gap (>5s stdin silence or event-loop * stall). Catches tmux detach→attach, ssh reconnect, and laptop diff --git a/ui-tui/packages/hermes-ink/src/ink/set-alt-screen-mouse-tracking.ts b/ui-tui/packages/hermes-ink/src/ink/set-alt-screen-mouse-tracking.ts new file mode 100644 index 000000000..4ef8a46e0 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/set-alt-screen-mouse-tracking.ts @@ -0,0 +1,22 @@ +import instances from './instances.js' + +/** + * Toggle SGR mouse tracking (DEC 1000/1002/1003/1006) at runtime on the Ink + * instance bound to this stdout. No-op if no Ink instance is attached. + * + * Use this for in-session `/mouse on|off` toggles. The + * prop owns setup/teardown at mount/unmount; this function sidesteps the + * full alt-screen re-entry so the toggle is flicker-free. + * + * Updates the instance's internal `altScreenMouseTracking` flag so the + * resize / SIGCONT-resume / re-enter-alt paths respect the new state. + * + * Defaults to `process.stdout` — pass a specific stream for tests or + * multi-output setups. + */ +export function setAltScreenMouseTracking( + enabled: boolean, + stdout: NodeJS.WriteStream = process.stdout +): void { + instances.get(stdout)?.setAltScreenMouseTracking(enabled) +} diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 1f2f938a9..713fa6891 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -88,6 +88,31 @@ describe('createSlashHandler', () => { expect(ctx.transcript.sys).toHaveBeenCalledWith('details: expanded') }) + it('toggles mouse tracking and persists it', async () => { + const ctx = buildCtx() + + expect(getUiState().mouseTracking).toBe(true) + expect(createSlashHandler(ctx)('/mouse off')).toBe(true) + expect(getUiState().mouseTracking).toBe(false) + expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', { key: 'mouse', value: 'off' }) + + await Promise.resolve() + expect(ctx.transcript.sys).toHaveBeenCalledWith('mouse off') + + expect(createSlashHandler(ctx)('/mouse')).toBe(true) + expect(getUiState().mouseTracking).toBe(true) + expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', { key: 'mouse', value: 'on' }) + }) + + it('rejects unknown /mouse args', () => { + const ctx = buildCtx() + + createSlashHandler(ctx)('/mouse wat') + expect(getUiState().mouseTracking).toBe(true) + expect(ctx.transcript.sys).toHaveBeenCalledWith('usage: /mouse [on|off|toggle]') + expect(ctx.gateway.rpc).not.toHaveBeenCalled() + }) + it('shows tool enable usage when names are missing', () => { const ctx = buildCtx() diff --git a/ui-tui/src/__tests__/useConfigSync.test.ts b/ui-tui/src/__tests__/useConfigSync.test.ts index c14ecff3a..7d8610431 100644 --- a/ui-tui/src/__tests__/useConfigSync.test.ts +++ b/ui-tui/src/__tests__/useConfigSync.test.ts @@ -22,6 +22,7 @@ describe('applyDisplay', () => { show_reasoning: true, streaming: false, tui_compact: true, + tui_mouse: false, tui_statusbar: false } } @@ -34,6 +35,7 @@ describe('applyDisplay', () => { expect(s.compact).toBe(true) expect(s.detailsMode).toBe('expanded') expect(s.inlineDiffs).toBe(false) + expect(s.mouseTracking).toBe(false) expect(s.showCost).toBe(true) expect(s.showReasoning).toBe(true) expect(s.statusBar).toBe(false) @@ -48,6 +50,7 @@ describe('applyDisplay', () => { const s = $uiState.get() expect(setBell).toHaveBeenCalledWith(false) expect(s.inlineDiffs).toBe(true) + expect(s.mouseTracking).toBe(true) expect(s.showCost).toBe(false) expect(s.showReasoning).toBe(false) expect(s.statusBar).toBe(true) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 631bd7a35..89a6c69bf 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1,7 +1,6 @@ import { GatewayProvider } from './app/gatewayContext.js' import { useMainApp } from './app/useMainApp.js' import { AppLayout } from './components/appLayout.js' -import { MOUSE_TRACKING } from './config/env.js' import type { GatewayClient } from './gatewayClient.js' export function App({ gw }: { gw: GatewayClient }) { @@ -12,7 +11,6 @@ export function App({ gw }: { gw: GatewayClient }) { { + const next = flagFromArg(arg, ctx.ui.mouseTracking) + + if (next === null) { + return ctx.transcript.sys('usage: /mouse [on|off|toggle]') + } + + patchUiState({ mouseTracking: next }) + ctx.gateway.rpc('config.set', { key: 'mouse', value: next ? 'on' : 'off' }).catch(() => {}) + + queueMicrotask(() => ctx.transcript.sys(`mouse ${next ? 'on' : 'off'}`)) + } + }, + { help: 'inspect or enqueue a message', name: 'queue', diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index 81089f179..a84520cc0 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -1,5 +1,6 @@ import { atom } from 'nanostores' +import { MOUSE_TRACKING } from '../config/env.js' import { ZERO } from '../domain/usage.js' import { DEFAULT_THEME } from '../theme.js' @@ -12,6 +13,7 @@ const buildUiState = (): UiState => ({ detailsMode: 'collapsed', info: null, inlineDiffs: true, + mouseTracking: MOUSE_TRACKING, showCost: false, showReasoning: false, sid: null, diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 8a3756342..59010b9fb 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react' +import { MOUSE_TRACKING } from '../config/env.js' import { resolveDetailsMode } from '../domain/details.js' import type { GatewayClient } from '../gatewayClient.js' import type { @@ -35,6 +36,9 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea compact: !!d.tui_compact, detailsMode: resolveDetailsMode(d), inlineDiffs: d.inline_diffs !== false, + // HERMES_TUI_DISABLE_MOUSE=1 wins — env-var opt-out must outrank config + // since the user set it specifically because their terminal is broken. + mouseTracking: MOUSE_TRACKING && d.tui_mouse !== false, showCost: !!d.show_cost, showReasoning: !!d.show_reasoning, statusBar: d.tui_statusbar !== false, diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 25243e992..e521790e5 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -180,6 +180,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { if (isCtrl(key, ch, 'c')) { cancelOverlayFromCtrlC() } + return } diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index ad854033a..907dae6ce 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -1,10 +1,11 @@ -import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink' +import { AlternateScreen, Box, NoSelect, ScrollBox, setAltScreenMouseTracking, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' -import { memo } from 'react' +import { memo, useEffect, useRef } from 'react' import type { AppLayoutProgressProps, AppLayoutProps } from '../app/interfaces.js' import { $isBlocked } from '../app/overlayStore.js' import { $uiState } from '../app/uiStore.js' +import { MOUSE_TRACKING } from '../config/env.js' import { PLACEHOLDER } from '../content/placeholders.js' import type { Theme } from '../theme.js' import type { DetailsMode } from '../types.js' @@ -256,16 +257,24 @@ const ComposerPane = memo(function ComposerPane({ ) }) -export const AppLayout = memo(function AppLayout({ - actions, - composer, - mouseTracking, - progress, - status, - transcript -}: AppLayoutProps) { +export const AppLayout = memo(function AppLayout({ actions, composer, progress, status, transcript }: AppLayoutProps) { + const { mouseTracking } = useStore($uiState) + // Freeze 's mouseTracking prop at initial value — runtime + // toggles go through setAltScreenMouseTracking below. Re-running the + // AlternateScreen insertion effect on prop change would re-enter the + // alt-screen (EXIT + ENTER + erase) and flash the frame. Teardown at + // unmount/exit still runs correctly because signal-exit + the final + // useEffect cleanup both emit DISABLE_MOUSE_TRACKING regardless. + const initialMouseTracking = useRef(MOUSE_TRACKING).current + + useEffect(() => { + setAltScreenMouseTracking(mouseTracking) + + return () => setAltScreenMouseTracking(false) + }, [mouseTracking]) + return ( - + diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 6fa1ad92e..a393a7309 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -60,6 +60,7 @@ export interface ConfigDisplayConfig { streaming?: boolean thinking_mode?: string tui_compact?: boolean + tui_mouse?: boolean tui_statusbar?: boolean } diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 507be85a3..4398cba9d 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -77,6 +77,8 @@ declare module '@hermes/ink' { export function render(node: React.ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance + export function setAltScreenMouseTracking(enabled: boolean, stdout?: NodeJS.WriteStream): void + export function useApp(): { readonly exit: (error?: Error) => void } export type RunExternalProcess = () => Promise export function useExternalProcess(): (run: RunExternalProcess) => Promise