diff --git a/ui-tui/packages/hermes-ink/src/ink/app-mouse-watchdog.test.ts b/ui-tui/packages/hermes-ink/src/ink/app-mouse-watchdog.test.ts new file mode 100644 index 000000000000..ef0469424cf2 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/app-mouse-watchdog.test.ts @@ -0,0 +1,242 @@ +import { EventEmitter } from 'events' + +import React, { useContext, useEffect } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import StdinContext from './components/StdinContext.js' +import Text from './components/Text.js' +import Ink from './ink.js' +import instances from './instances.js' +import { csi } from './termio/csi.js' +import { DEC, DISABLE_MOUSE_TRACKING, enableMouseTrackingFor } from './termio/dec.js' + +// DECRQM request for mode 1000 (what the watchdog writes). +const DECRQM_1000 = csi(`?${DEC.MOUSE_NORMAL}$p`) +// DA1 sentinel (what querier.flush() writes). +const DA1_REQUEST = csi('c') +// DECRPM replies (what the terminal answers). +const DECRPM_1000_SET = csi(`?${DEC.MOUSE_NORMAL};1$y`) +const DECRPM_1000_RESET = csi(`?${DEC.MOUSE_NORMAL};2$y`) +const DA1_REPLY = csi('?62c') + +// Watchdog cadence (mirrors MOUSE_WATCHDOG_INTERVAL_MS in App.tsx). +const TICK_MS = 2000 + +class FakeStdout extends EventEmitter { + chunks: string[] = [] + columns = 80 + rows = 24 + isTTY = true + + write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean { + this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + cb?.() + + return true + } +} + +// Stdin fake with a real readable-style buffer so tests can feed terminal +// responses (DECRPM / DA1) the way a live pty would deliver them. +class FakeStdin extends EventEmitter { + isTTY = true + isRaw = false + private buffer: string[] = [] + + get readableLength(): number { + return this.buffer.reduce((n, c) => n + c.length, 0) + } + + ref(): void {} + unref(): void {} + setEncoding(): this { + return this + } + setRawMode(mode: boolean): this { + this.isRaw = mode + + return this + } + read(): string | null { + return this.buffer.shift() ?? null + } + feed(data: string): void { + this.buffer.push(data) + this.emit('readable') + } +} + +function RawModeConsumer() { + const { setRawMode, isRawModeSupported } = useContext(StdinContext) + + useEffect(() => { + if (!isRawModeSupported) { + return + } + + setRawMode(true) + + return () => setRawMode(false) + }, [isRawModeSupported, setRawMode]) + + return React.createElement(Text, null, 'x') +} + +type Harness = { + ink: Ink + stdout: FakeStdout + stdin: FakeStdin + /** Advance one watchdog tick and let the probe write settle. */ + tickWatchdog: () => Promise + /** Feed a terminal response and let promise resolution settle. */ + answer: (data: string) => Promise +} + +const flushMicrotasks = async () => { + // Real setImmediate turns: lets React flush effects (raw-mode enable), + // the deferred init writes fire, and querier promise chains settle. + // Two rounds cover promise → setImmediate interleave. + await new Promise(resolve => setImmediate(resolve)) + await new Promise(resolve => setImmediate(resolve)) +} + +async function mount(mouseTracking: 'all' | 'off' = 'all'): Promise { + const stdout = new FakeStdout() + const stdin = new FakeStdin() + const stderr = new FakeStdout() + + const ink = new Ink({ + exitOnCtrlC: false, + patchConsole: false, + stderr: stderr as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + stdout: stdout as unknown as NodeJS.WriteStream + }) + + // Production instances are registered by render.ts; direct construction + // skips that. The watchdog (like the raw-mode re-assert) resolves its + // Ink through this map, so mirror production here. + instances.set(stdout as unknown as NodeJS.WriteStream, ink) + + ink.setAltScreenActive(true, mouseTracking) + ink.render(React.createElement(RawModeConsumer)) + ink.onRender() + await flushMicrotasks() + + // The XTVERSION probe from raw-mode entry has its own DA1 sentinel + // pending. Answer it so the querier queue is empty before tests start. + stdin.feed(DA1_REPLY) + await flushMicrotasks() + + stdout.chunks = [] + + return { + ink, + stdout, + stdin, + tickWatchdog: async () => { + await vi.advanceTimersByTimeAsync(TICK_MS) + }, + answer: async (data: string) => { + stdin.feed(data) + await flushMicrotasks() + } + } +} + +describe('App mouse-mode watchdog', () => { + beforeEach(() => { + // Fake only the interval + Date clock. setImmediate/setTimeout stay + // real so React effect flushing and Ink's internal scheduling work. + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('probes DECRQM on the interval and re-asserts tracking when the terminal reports RESET', async () => { + const h = await mount('all') + + await h.tickWatchdog() + + // Probe went out: DECRQM for mode 1000 + DA1 sentinel. + const probed = h.stdout.chunks.join('') + + expect(probed).toContain(DECRQM_1000) + expect(probed).toContain(DA1_REQUEST) + + h.stdout.chunks = [] + + // Terminal says mode 1000 is RESET (someone cleared our modes), then + // answers the sentinel. + await h.answer(DECRPM_1000_RESET + DA1_REPLY) + + const out = h.stdout.chunks.join('') + + // reassertTerminalModes: DISABLE first, then the full 'all' preset. + expect(out).toContain(DISABLE_MOUSE_TRACKING) + expect(out).toContain(enableMouseTrackingFor('all')) + + h.ink.unmount() + }) + + it('does nothing when the terminal reports the mode still SET', async () => { + const h = await mount('all') + + await h.tickWatchdog() + h.stdout.chunks = [] + + await h.answer(DECRPM_1000_SET + DA1_REPLY) + + expect(h.stdout.chunks.join('')).not.toContain(enableMouseTrackingFor('all')) + + h.ink.unmount() + }) + + it('disables itself permanently when the terminal ignores DECRQM', async () => { + const h = await mount('all') + + await h.tickWatchdog() + expect(h.stdout.chunks.join('')).toContain(DECRQM_1000) + h.stdout.chunks = [] + + // Terminal answers only the DA1 sentinel — DECRQM unsupported. + await h.answer(DA1_REPLY) + + // No re-assert... + expect(h.stdout.chunks.join('')).not.toContain(enableMouseTrackingFor('all')) + + // ...and no further probes on subsequent ticks. + await h.tickWatchdog() + await h.tickWatchdog() + expect(h.stdout.chunks.join('')).not.toContain(DECRQM_1000) + + h.ink.unmount() + }) + + it('does not probe when mouse tracking is off', async () => { + const h = await mount('off') + + await h.tickWatchdog() + await h.tickWatchdog() + + expect(h.stdout.chunks.join('')).not.toContain(DECRQM_1000) + + h.ink.unmount() + }) + + it('skips the probe when a mouse event arrived within the interval', async () => { + const h = await mount('all') + + // Half a tick in, a live SGR mouse event (wheel-up at 10;5) proves + // tracking works; the interval fires half a tick later → gap < interval. + await vi.advanceTimersByTimeAsync(TICK_MS / 2) + await h.answer(csi('<64;10;5M')) + await vi.advanceTimersByTimeAsync(TICK_MS / 2) + + expect(h.stdout.chunks.join('')).not.toContain(DECRQM_1000) + + h.ink.unmount() + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx index a1ebf83b575f..68af579b6e0e 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx @@ -11,6 +11,7 @@ import { InputEvent } from '../events/input-event.js' import { TerminalFocusEvent } from '../events/terminal-focus-event.js' import instances from '../instances.js' import { + DECRPM_STATUS, INITIAL_STATE, type ParsedInput, type ParsedKey, @@ -20,7 +21,7 @@ import { import reconciler from '../reconciler.js' import { clearSelection, finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js' import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js' -import { TerminalQuerier, xtversion } from '../terminal-querier.js' +import { decrqm, TerminalQuerier, xtversion } from '../terminal-querier.js' import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js' import { DISABLE_KITTY_KEYBOARD, @@ -30,7 +31,7 @@ import { FOCUS_IN, FOCUS_OUT } from '../termio/csi.js' -import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, SHOW_CURSOR } from '../termio/dec.js' +import { DBP, DEC, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, SHOW_CURSOR } from '../termio/dec.js' import AppContext from './AppContext.js' import { ClockProvider } from './ClockContext.js' @@ -50,6 +51,20 @@ const SUPPORTS_SUSPEND = false // but no signal reaches us. 5s is well above normal inter-keystroke gaps // but short enough that the first scroll after reattach works. const STDIN_RESUME_GAP_MS = 5000 + +// Mouse-mode watchdog cadence. The stdin-gap re-assert above needs a +// KEYSTROKE to fire — a mouse-only user whose terminal dropped the DEC +// mouse modes (e.g. the terminal's own "disable mouse reporting" toggle, +// which some emulators implement by clearing the modes) produces no stdin +// at all: mouse reporting is exactly what's off. Recovery would need +// input, input needs recovery — historically only a window resize broke +// the deadlock. The watchdog closes it out-of-band: every tick it asks +// the terminal whether mode 1000 is still set (DECRQM — answered on +// stdin, no user action needed) and re-asserts tracking when the terminal +// reports it lost. Terminals that gate delivery without clearing the mode +// report SET, so an *active* user toggle is never fought; terminals that +// don't answer DECRQM at all disable the watchdog permanently. +const MOUSE_WATCHDOG_INTERVAL_MS = 2000 type Props = { readonly children: ReactNode readonly stdin: NodeJS.ReadStream @@ -184,6 +199,21 @@ export default class App extends PureComponent { // Initialized to now so startup doesn't false-trigger. lastStdinTime = Date.now() + // Mouse-mode watchdog (see MOUSE_WATCHDOG_INTERVAL_MS). Runs while raw + // mode is on; each tick DECRQM-probes mode 1000 unless a mouse event + // arrived within the last interval (tracking provably alive → zero + // probe chatter during normal mouse use). + mouseWatchdogTimer: NodeJS.Timeout | null = null + // A probe round-trip is outstanding. Also latches permanently when the + // terminal never answers the DA1 sentinel (non-conforming) so a dead + // querier can't accumulate pending promises. + mouseWatchdogProbeInFlight = false + // Terminal ignored DECRQM (or reported the mode permanently reset) — + // probing is useless; the watchdog disables itself for the session. + mouseWatchdogUnsupported = false + // Timestamp of the last parsed mouse event (any kind). + lastMouseEventTime = 0 + // Determines if TTY is supported on the provided stdin isRawModeSupported(): boolean { return this.props.stdin.isTTY @@ -236,6 +266,11 @@ export default class App extends PureComponent { this.incompleteEscapeTimer = null } + if (this.mouseWatchdogTimer) { + clearInterval(this.mouseWatchdogTimer) + this.mouseWatchdogTimer = null + } + if (this.pendingHyperlinkTimer) { clearTimeout(this.pendingHyperlinkTimer) this.pendingHyperlinkTimer = null @@ -325,6 +360,13 @@ export default class App extends PureComponent { setImmediate(() => { instances.get(this.props.stdout)?.reassertTerminalModes() }) + + // Mouse-mode watchdog: recovers tracking when the terminal drops + // the DEC mouse modes with no stdin and no resize to tell us (see + // MOUSE_WATCHDOG_INTERVAL_MS). Interval, not per-event: the whole + // point is firing when NO events arrive. + this.mouseWatchdogTimer = setInterval(this.probeMouseTracking, MOUSE_WATCHDOG_INTERVAL_MS) + this.mouseWatchdogTimer.unref?.() } this.rawModeEnabledCount++ @@ -334,6 +376,11 @@ export default class App extends PureComponent { // Disable raw mode only when no components left that are using it if (--this.rawModeEnabledCount === 0) { + if (this.mouseWatchdogTimer) { + clearInterval(this.mouseWatchdogTimer) + this.mouseWatchdogTimer = null + } + this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS) this.props.stdout.write(DISABLE_KITTY_KEYBOARD) // Disable terminal focus reporting (DECSET 1004) @@ -355,6 +402,60 @@ export default class App extends PureComponent { } } + // Mouse-mode watchdog tick. Asks the terminal whether DEC mode 1000 is + // still set (DECRQM); if the terminal reports RESET while we expect + // tracking on, someone cleared our modes out from under us (terminal + // "disable mouse reporting" toggle, an external app, tmux) — re-assert. + // See MOUSE_WATCHDOG_INTERVAL_MS for the full rationale. Timeout-free: + // the querier's DA1 sentinel resolves the probe with `undefined` when + // the terminal ignores DECRQM, which permanently disables the watchdog. + probeMouseTracking = (): void => { + if (this.mouseWatchdogUnsupported || this.mouseWatchdogProbeInFlight) { + return + } + + const ink = instances.get(this.props.stdout) + + // Only probe when tracking is supposed to be armed (alt screen active, + // not paused, preset ≠ 'off'). Covers /mouse off, editor handoffs, and + // inline mode without extra wiring. + if (!ink?.expectsMouseTracking || !this.props.stdout.isTTY) { + return + } + + // A recent mouse event proves tracking is alive — skip the probe so + // normal mouse use generates zero query chatter. + if (Date.now() - this.lastMouseEventTime < MOUSE_WATCHDOG_INTERVAL_MS) { + return + } + + this.mouseWatchdogProbeInFlight = true + + void Promise.all([this.querier.send(decrqm(DEC.MOUSE_NORMAL)), this.querier.flush()]) + .then(([r]) => { + this.mouseWatchdogProbeInFlight = false + + if (!r || r.status === DECRPM_STATUS.NOT_RECOGNIZED || r.status === DECRPM_STATUS.PERMANENTLY_RESET) { + // Terminal ignored DECRQM or can't set the mode at all — probing + // is useless for the rest of the session. + this.mouseWatchdogUnsupported = true + logForDebugging('mouse watchdog: DECRQM unsupported, disabling') + + return + } + + // Re-check expectation: state may have legitimately changed (e.g. + // /mouse off, pause) while the probe was in flight. + if (r.status === DECRPM_STATUS.RESET && instances.get(this.props.stdout)?.expectsMouseTracking) { + logForDebugging('mouse watchdog: terminal lost mouse tracking, re-asserting') + instances.get(this.props.stdout)?.reassertTerminalModes() + } + }) + .catch(() => { + this.mouseWatchdogProbeInFlight = false + }) + } + // Helper to flush incomplete escape sequences flushIncomplete = (): void => { // Clear the timer reference @@ -568,11 +669,21 @@ function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined, // Terminal sends 1-indexed col/row; convert to 0-indexed for the // screen buffer. Button bit 0x20 = drag (motion while button held). if (item.kind === 'mouse') { + // Proof-of-life for the mouse-mode watchdog: any parsed mouse event + // means tracking is armed, so the next probe tick can skip. + app.lastMouseEventTime = Date.now() handleMouseEvent(app, item) continue } + // Wheel events stay ParsedKey (routed through the keybinding system), + // but they're mouse-tracking traffic all the same — stamp proof-of-life + // so a wheel-only user doesn't trigger redundant watchdog probes. + if (item.kind === 'key' && (item.name === 'wheelup' || item.name === 'wheeldown')) { + app.lastMouseEventTime = Date.now() + } + const sequence = item.sequence // Handle terminal focus events (DECSET 1004) diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 4c175721466d..609bcad54b74 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -1342,6 +1342,18 @@ export default class Ink { return this.altScreenActive } + /** + * True while the terminal is expected to have DEC mouse tracking armed: + * alt screen active, not paused for an editor handoff, and the current + * preset isn't 'off'. Gates App's mouse-mode watchdog (DECRQM probe) so + * it never probes when tracking is intentionally disabled (/mouse off), + * during pause (probe bytes would leak into the external editor's + * session), or after unmount. + */ + get expectsMouseTracking(): boolean { + return this.altScreenActive && !this.isPaused && !this.isUnmounted && this.altScreenMouseTracking !== 'off' + } + /** * Re-assert terminal modes after a gap (>5s stdin silence or event-loop * stall). Catches tmux detach→attach, ssh reconnect, and laptop