mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
9fa49206dc
commit
c275423d0d
17 changed files with 181 additions and 17 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
1
ui-tui/packages/hermes-ink/index.d.ts
vendored
1
ui-tui/packages/hermes-ink/index.d.ts
vendored
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 detach→attach, ssh reconnect, and laptop
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
if (isCtrl(key, ch, 'c')) {
|
||||
cancelOverlayFromCtrlC()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export interface ConfigDisplayConfig {
|
|||
streaming?: boolean
|
||||
thinking_mode?: string
|
||||
tui_compact?: boolean
|
||||
tui_mouse?: boolean
|
||||
tui_statusbar?: boolean
|
||||
}
|
||||
|
||||
|
|
|
|||
2
ui-tui/src/types/hermes-ink.d.ts
vendored
2
ui-tui/src/types/hermes-ink.d.ts
vendored
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue