mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
fix(tui): recover mouse tracking without a resize via DECRQM watchdog (#66080)
When the terminal's own 'disable mouse reporting' toggle (or an external app / tmux) clears the DEC mouse modes, a mouse-only user is deadlocked: every existing recovery trigger (resize, >5s stdin gap + keypress, raw-mode bounce) needs stdin — and mouse reporting being off is exactly why no stdin arrives. Users had to resize the window to scroll again. Fix: a mouse-mode watchdog in App that DECRQM-probes mode 1000 every 2s while tracking is expected on. If the terminal reports the mode RESET, reassertTerminalModes() re-arms tracking — the same recovery a resize performs, without the resize. Probes are skipped whenever a mouse/wheel event arrived within the interval (tracking provably alive → zero query chatter during normal use), never fired while paused for an editor handoff or when /mouse off was chosen, and the watchdog permanently disables itself on terminals that don't answer DECRQM (DA1-sentinel resolution via the existing timeout-free TerminalQuerier). Terminals whose toggle merely gates event delivery report SET, so an active user toggle is never fought; terminals whose toggle clears the modes report RESET after re-enable and recover within ~2s.
This commit is contained in:
parent
b170f522a4
commit
46d16f4c28
3 changed files with 367 additions and 2 deletions
242
ui-tui/packages/hermes-ink/src/ink/app-mouse-watchdog.test.ts
Normal file
242
ui-tui/packages/hermes-ink/src/ink/app-mouse-watchdog.test.ts
Normal file
|
|
@ -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<void>
|
||||
/** Feed a terminal response and let promise resolution settle. */
|
||||
answer: (data: string) => Promise<void>
|
||||
}
|
||||
|
||||
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<void>(resolve => setImmediate(resolve))
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
}
|
||||
|
||||
async function mount(mouseTracking: 'all' | 'off' = 'all'): Promise<Harness> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<Props, State> {
|
|||
// 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<Props, State> {
|
|||
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<Props, State> {
|
|||
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<Props, State> {
|
|||
|
||||
// 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<Props, State> {
|
|||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue