feat(tui): add /mouse [on|off|toggle] runtime slash command

Toggle SGR mouse tracking (DEC 1000/1002/1003/1006) at runtime without
restart or env-var spelunking. Fix path when a terminal doesn't honor raw
mode / no-echo and echoes mouse events as visible escape sequences (e.g.
`<35;111;133M` scrolling up the transcript on every mouse move).

- New `/mouse [on|off|toggle]` slash command (persists via config.set key=mouse
  → display.tui_mouse in ~/.hermes/config.yaml).
- New hermes-ink export `setAltScreenMouseTracking(enabled)` that writes
  ENABLE/DISABLE bytes and updates the instance flag without re-entering
  the alt-screen — so live toggles are flicker-free.
- `<AlternateScreen>` mouseTracking prop is frozen at initial value (from
  `HERMES_TUI_DISABLE_MOUSE` env); runtime state lives in `$uiState` and is
  applied via useEffect. Env-var opt-out wins over config so explicit
  HERMES_TUI_DISABLE_MOUSE=1 stays off regardless of persisted state.
- Server: folds `mouse` into the existing compact/statusbar branch in
  config.set/get, defaulting to on.
This commit is contained in:
Brooklyn Nicholson 2026-04-21 17:31:01 -05:00
parent 9fa49206dc
commit c275423d0d
17 changed files with 181 additions and 17 deletions

View file

@ -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)

View file

@ -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.

View file

@ -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'

View file

@ -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'

View file

@ -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
* <AlternateScreen> 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 detachattach, ssh reconnect, and laptop

View file

@ -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 <AlternateScreen>
* 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)
}

View file

@ -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()

View file

@ -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)

View file

@ -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 }) {
<AppLayout
actions={appActions}
composer={appComposer}
mouseTracking={MOUSE_TRACKING}
progress={appProgress}
status={appStatus}
transcript={appTranscript}

View file

@ -83,6 +83,7 @@ export interface UiState {
detailsMode: DetailsMode
info: null | SessionInfo
inlineDiffs: boolean
mouseTracking: boolean
showCost: boolean
showReasoning: boolean
sid: null | string
@ -321,7 +322,6 @@ export interface AppLayoutTranscriptProps {
export interface AppLayoutProps {
actions: AppLayoutActions
composer: AppLayoutComposerProps
mouseTracking: boolean
progress: AppLayoutProgressProps
status: AppLayoutStatusProps
transcript: AppLayoutTranscriptProps

View file

@ -293,6 +293,23 @@ export const coreCommands: SlashCommand[] = [
}
},
{
help: 'toggle SGR mouse tracking (wheel + click/drag). Turn off if your terminal prints escape codes on mouse move.',
name: 'mouse',
run: (arg, ctx) => {
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<ConfigSetResponse>('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',

View file

@ -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,

View file

@ -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,

View file

@ -180,6 +180,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
if (isCtrl(key, ch, 'c')) {
cancelOverlayFromCtrlC()
}
return
}

View file

@ -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 <AlternateScreen>'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 (
<AlternateScreen mouseTracking={mouseTracking}>
<AlternateScreen mouseTracking={initialMouseTracking}>
<Box flexDirection="column" flexGrow={1}>
<Box flexDirection="row" flexGrow={1}>
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />

View file

@ -60,6 +60,7 @@ export interface ConfigDisplayConfig {
streaming?: boolean
thinking_mode?: string
tui_compact?: boolean
tui_mouse?: boolean
tui_statusbar?: boolean
}

View file

@ -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<void>
export function useExternalProcess(): (run: RunExternalProcess) => Promise<void>