diff --git a/ui-tui/src/__tests__/terminalModes.test.ts b/ui-tui/src/__tests__/terminalModes.test.ts new file mode 100644 index 00000000000..3eacdba55fb --- /dev/null +++ b/ui-tui/src/__tests__/terminalModes.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' + +import { resetTerminalModes, TERMINAL_MODE_RESET } from '../lib/terminalModes.js' + +describe('terminal mode reset', () => { + it('includes the sticky input modes Hermes enables', () => { + expect(TERMINAL_MODE_RESET).toContain('\x1b[?1006l') + expect(TERMINAL_MODE_RESET).toContain('\x1b[?1003l') + expect(TERMINAL_MODE_RESET).toContain('\x1b[?1002l') + expect(TERMINAL_MODE_RESET).toContain('\x1b[?1000l') + expect(TERMINAL_MODE_RESET).toContain('\x1b[?1004l') + expect(TERMINAL_MODE_RESET).toContain('\x1b[?2004l') + expect(TERMINAL_MODE_RESET).toContain('\x1b[?1049l') + }) + + it('writes reset sequence to TTY streams without fds', () => { + const write = vi.fn() + + expect(resetTerminalModes({ isTTY: true, write } as unknown as NodeJS.WriteStream)).toBe(true) + expect(write).toHaveBeenCalledWith(TERMINAL_MODE_RESET) + }) + + it('skips non-TTY streams', () => { + const write = vi.fn() + + expect(resetTerminalModes({ isTTY: false, write } as unknown as NodeJS.WriteStream)).toBe(false) + expect(write).not.toHaveBeenCalled() + }) +}) diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index f1ce52bab5d..c2c9fefe6c4 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -11,12 +11,17 @@ import { GatewayClient } from './gatewayClient.js' import { setupGracefulExit } from './lib/gracefulExit.js' import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js' import { type MemorySnapshot, startMemoryMonitor } from './lib/memoryMonitor.js' +import { resetTerminalModes } from './lib/terminalModes.js' if (!process.stdin.isTTY) { console.log('hermes-tui: no TTY') process.exit(0) } +// Start from a clean slate. If a previous TUI crashed or was kill -9'd, the +// terminal tab can still have mouse/focus/paste modes enabled. +resetTerminalModes() + const gw = new GatewayClient() gw.start() @@ -25,17 +30,27 @@ const dumpNotice = (snap: MemorySnapshot, dump: HeapDumpResult | null) => `hermes-tui: ${snap.level} memory (${formatBytes(snap.heapUsed)}) — auto heap dump → ${dump?.heapPath ?? '(failed)'}\n` setupGracefulExit({ - cleanups: [() => gw.kill()], + cleanups: [ + () => { + resetTerminalModes() + + return gw.kill() + } + ], onError: (scope, err) => { const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err) process.stderr.write(`hermes-tui ${scope}: ${message.slice(0, 2000)}\n`) }, - onSignal: signal => process.stderr.write(`hermes-tui: received ${signal}\n`) + onSignal: signal => { + resetTerminalModes() + process.stderr.write(`hermes-tui: received ${signal}\n`) + } }) const stopMemoryMonitor = startMemoryMonitor({ onCritical: (snap, dump) => { + resetTerminalModes() process.stderr.write(dumpNotice(snap, dump)) process.stderr.write('hermes-tui: exiting to avoid OOM; restart to recover\n') process.exit(137) diff --git a/ui-tui/src/lib/terminalModes.ts b/ui-tui/src/lib/terminalModes.ts new file mode 100644 index 00000000000..bf60b667e40 --- /dev/null +++ b/ui-tui/src/lib/terminalModes.ts @@ -0,0 +1,41 @@ +import { writeSync } from 'node:fs' + +export const TERMINAL_MODE_RESET = + '\x1b[?1006l' + // SGR mouse + '\x1b[?1003l' + // any-motion mouse + '\x1b[?1002l' + // button-motion mouse + '\x1b[?1000l' + // click mouse + '\x1b[?1004l' + // focus events + '\x1b[?2004l' + // bracketed paste + '\x1b[?1049l' + // alternate screen + '\x1b[0m' + // attributes + '\x1b[?25h' // cursor visible + +type ResettableStream = Pick & { + fd?: number +} + +export function resetTerminalModes(stream: ResettableStream = process.stdout): boolean { + if (!stream.isTTY) { + return false + } + + const fd = typeof stream.fd === 'number' ? stream.fd : stream === process.stdout ? 1 : undefined + if (fd !== undefined) { + try { + writeSync(fd, TERMINAL_MODE_RESET) + + return true + } catch { + // Fall through to stream.write for mocked or unusual TTY streams. + } + } + + try { + stream.write(TERMINAL_MODE_RESET) + + return true + } catch { + return false + } +}