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:
Teknium 2026-07-16 22:25:50 -07:00 committed by GitHub
parent b170f522a4
commit 46d16f4c28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 367 additions and 2 deletions

View 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()
})
})

View file

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

View file

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