fix: preserve user input during terminal scrollback replay (#19075)

* fix: preserve user input while terminal scrollback replays

* test: model multiple xterm user-input subscribers

* fix: keep mouse reports suppressed during replay and bind forwarders once

Real keystrokes now survive the replay guard, but xterm flags pointer
reports as user input too, and replayed bytes can leave mouse tracking
armed until the guarded mode reset lands. Keep those suppressed so a
click on restoring scrollback cannot print SGR fragments on the prompt.
Hoist the two provenance-bound forwarders out of the per-keystroke path.

* fix: keep wheel cursor keys off a replayed alt-screen frame

xterm turns a wheel notch into cursor up/down when the active buffer has
no scrollback, and flags it as user input. During a dead-TUI restore that
frame is replayed on the alt buffer and only leaves it when the guarded
?1049l lands, so forwarding those arrows would recall shell history at
the fresh prompt. Suppress them on the alt buffer only; the same bytes on
the normal buffer can only be a keyboard arrow and still survive replay.
Group the pointer-derived predicates in terminal-pointer-input-sequences.
This commit is contained in:
Neil
2026-09-07 19:15:08 -07:00
committed by GitHub
parent de0a91b99f
commit 98b0c329ff
8 changed files with 182 additions and 31 deletions
@@ -455,6 +455,83 @@ describe('connectPanePty', () => {
expect(transport.sendInput).toHaveBeenCalledWith('echo hi\r')
})
it('preserves classified user input during replay while suppressing synthetic replies', async () => {
const { connectPanePty } = await import('./pty-connection')
const pane = createPane(1)
const userInputListeners = new Set<() => void>()
Object.assign(pane.terminal, {
_core: {
coreService: {
onUserInput: (listener: () => void) => {
userInputListeners.add(listener)
return { dispose: () => userInputListeners.delete(listener) }
}
}
}
})
const transport = createMockTransport('ssh:ssh-1@@pty-1')
transportFactoryQueue.push(transport)
const deps = createDeps()
const deferred: (() => void)[] = []
Object.assign(deps, {
deferPtyInput: (_paneId: number, data: string, forward: (data: string) => void) => {
deferred.push(() => forward(data))
}
})
connectPanePty(pane as never, createManager(1, 1) as never, deps as never)
await flushAsyncTicks()
transport.sendInput.mockClear()
deps.replayingPanesRef.current.set(pane.id, 1)
for (const listener of userInputListeners) {
listener()
}
sendTerminalInputThroughPane(pane, 'input_under_flood\r')
sendTerminalInputThroughPane(pane, '\x1b[?1;2c')
// A click on replayed scrollback that still has mouse tracking armed is user input to xterm, but must not reach the shell.
for (const listener of userInputListeners) {
listener()
}
sendTerminalInputThroughPane(pane, '\x1b[<0;12;4M')
for (const forward of deferred.splice(0)) {
forward()
}
expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r')
// A wheel over a replayed alt-screen frame becomes cursor keys; the fresh shell must not recall history from them.
pane.terminal.buffer.active.type = 'alternate'
for (const listener of userInputListeners) {
listener()
}
sendTerminalInputThroughPane(pane, '\x1b[B')
for (const forward of deferred.splice(0)) {
forward()
}
expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r')
// The same bytes on the normal buffer can only be a keyboard arrow, which survives replay.
pane.terminal.buffer.active.type = 'normal'
for (const listener of userInputListeners) {
listener()
}
sendTerminalInputThroughPane(pane, '\x1b[B')
for (const forward of deferred.splice(0)) {
forward()
}
expect(transport.sendInput).toHaveBeenCalledTimes(2)
expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[B')
// Once the guard releases, the same mouse report is ordinary input again.
deps.replayingPanesRef.current.delete(pane.id)
for (const listener of userInputListeners) {
listener()
}
sendTerminalInputThroughPane(pane, '\x1b[<0;12;4M')
for (const forward of deferred.splice(0)) {
forward()
}
expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[<0;12;4M')
})
it('settles a queued startup only after the pane binds its spawned PTY', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-resume')
@@ -556,13 +556,12 @@ describe('connectPanePty', () => {
const manager = createManager(1)
const deps = createDeps()
const pane = createPane(2)
let userInputListener: (() => void) | null = null
const userInputDispose = vi.fn()
const userInputListeners = new Set<() => void>()
;(pane.terminal as unknown as { _core: unknown })._core = {
coreService: {
onUserInput: vi.fn((listener: () => void) => {
userInputListener = listener
return { dispose: userInputDispose }
userInputListeners.add(listener)
return { dispose: () => userInputListeners.delete(listener) }
})
}
}
@@ -571,7 +570,7 @@ describe('connectPanePty', () => {
dispose: () => void
}
await flushAsyncTicks()
expect(userInputListener).toBeTypeOf('function')
expect(userInputListeners.size).toBeGreaterThan(0)
;(mockStoreState.recordTerminalInput as ReturnType<typeof vi.fn>).mockClear()
// A focus-out report forwarded to the PTY must not count as activity.
@@ -581,11 +580,13 @@ describe('connectPanePty', () => {
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O')
// Real user input fires the core signal and records activity.
;(userInputListener as unknown as () => void)()
for (const listener of userInputListeners) {
listener()
}
expect(mockStoreState.recordTerminalInput).toHaveBeenCalledTimes(1)
binding.dispose()
expect(userInputDispose).toHaveBeenCalled()
expect(userInputListeners.size).toBe(0)
})
it('falls back to onData hibernation recording when the core user-input signal is unavailable', async () => {
@@ -1,4 +1,5 @@
import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types'
import { subscribeToTerminalInputData } from '../terminal-user-input-signal'
import { installTerminalImeCompositionRoute } from '../terminal-ime-composition-route'
import { useAppStore } from '@/store'
import { isTerminalQueryReply } from '../../../../../shared/terminal-query-reply'
@@ -9,6 +10,7 @@ import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state'
import { getAppliedSizeReadE2eDelayMs } from '../pty-applied-size-read-e2e-delay'
import { createPtySizeReassertion } from '../pty-size-reassertion'
import { isPaneReplaying } from '../replay-guard'
import { isXtermMouseReport, isXtermWheelCursorKey } from '../terminal-pointer-input-sequences'
import { shouldDropQuarantinedTerminalInput } from '../terminal-input-quarantine'
import {
PANE_PTY_RESIZE_HOLD_FLUSH_EVENT,
@@ -24,15 +26,21 @@ import { isCodexPaneStale } from './codex-pane-stale'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function installPtyInputForward(session: ConnectPanePtySession): void {
session.forwardPtyInput = (data: string): void => {
// Why: xterm auto-replies to embedded query sequences (DA1, DECRQM,
// OSC 10/11, focus, CPR) via onData. When we replay recorded PTY bytes
// into xterm for scrollback/cold-restore/snapshot, those queries would
// otherwise pipe replies into the freshly spawned shell as stray input
// ("?1;2c", "2026;2$y", OSC color fragments, ...). The replay sites
// engage the guard via replayIntoTerminal; here we drop everything
// xterm emits while the guard is active. See replay-guard.ts.
if (isPaneReplaying(session.deps.replayingPanesRef, session.pane.id)) {
session.forwardPtyInput = (data: string, wasUserInput = false): void => {
// Why: replaying recorded PTY bytes makes xterm auto-reply to embedded
// queries (DA1/DECRQM/OSC 10-11/CPR) via onData; those must not leak into
// the shell, but keystrokes typed mid-restore must survive. Pointer input
// stays dropped even though xterm flags it as user input: replayed bytes can
// leave mouse tracking armed until the guarded mode reset lands (a click
// would print SGR fragments on the fresh prompt), and a wheel over a
// replayed alt-screen frame becomes cursor keys that would recall history
// at that prompt once ?1049l lands. See replay-guard.ts.
if (
isPaneReplaying(session.deps.replayingPanesRef, session.pane.id) &&
(!wasUserInput ||
isXtermMouseReport(data) ||
(isXtermWheelCursorKey(data) && session.pane.terminal.buffer.active.type === 'alternate'))
) {
return
}
const currentPtyId = session.transport.getPtyId()
@@ -163,13 +171,21 @@ export function installPtyInputForward(session: ConnectPanePtySession): void {
session.requestRecoveryForUndeliverableInput()
}
}
session.onDataDisposable = session.pane.terminal.onData((data) => {
if (session.deps.deferPtyInput) {
session.deps.deferPtyInput(session.pane.id, data, session.forwardPtyInput)
return
// Why bind once: provenance must survive deferPtyInput's later callback, and
// this is the per-keystroke hot path, so no closure allocation per onData event.
const forwardUserInput = (data: string): void => session.forwardPtyInput(data, true)
const forwardUnclassifiedInput = (data: string): void => session.forwardPtyInput(data, false)
session.onDataDisposable = subscribeToTerminalInputData(
session.pane.terminal,
(data, wasUserInput) => {
const forward = wasUserInput ? forwardUserInput : forwardUnclassifiedInput
if (session.deps.deferPtyInput) {
session.deps.deferPtyInput(session.pane.id, data, forward)
return
}
forward(data)
}
session.forwardPtyInput(data)
})
)
session.imeCompositionRouteDisposable = installTerminalImeCompositionRoute({
terminalElement: session.pane.terminal.element,
terminal: session.pane.terminal,
@@ -12,7 +12,7 @@ import {
import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics'
// Why this guard exists: xterm auto-replies to query sequences (DA1/DECRQM/OSC 10-11/CPR) via onData → shell stdin, so replaying recorded PTY bytes leaks stray replies onto the new shell's prompt.
// No wasUserInput flag distinguishes replay replies from real keystrokes, so a per-pane in-flight counter gates onData; bounded by xterm's parse completion (not a timer), only auto-replies from replayed bytes are dropped.
// The per-pane counter suppresses synthetic onData during replay parsing; xterm's user-input signal keeps real keystrokes flowing.
export type ReplayingPanesRef = React.RefObject<Map<number, number>>
@@ -3,6 +3,7 @@ import {
isTerminalLinkActionActivation,
isTerminalLinkDirectActivation
} from './terminal-link-activation'
import { isXtermMouseReport } from './terminal-pointer-input-sequences'
const CAPTURE_LISTENER_OPTIONS = { capture: true } as const
const MAX_DEFERRED_PTY_INPUT_FRAMES = 64
@@ -17,13 +18,6 @@ export type TerminalLinkPtyMouseSuppression = IDisposable & {
handlePtyInput: (data: string, forward: (data: string) => void) => void
}
function isXtermMouseReport(data: string): boolean {
return (
(data.startsWith('\x1b[M') && data.length === 6) ||
(data.startsWith('\x1b[<') && /^\d+;\d+;\d+[Mm]$/.test(data.slice(3)))
)
}
export function installTerminalLinkPtyMouseSuppression(
terminal: Terminal,
shouldSuppressMouseEvent: (event: MouseEvent) => boolean,
@@ -0,0 +1,15 @@
// Why: xterm flags pointer-derived bytes as user input alongside keystrokes; callers
// that must treat pointer input differently need to recognise it by shape.
/** True for an xterm mouse report (X10 `CSI M` or SGR `CSI <`): pointer input, never a keystroke. */
export function isXtermMouseReport(data: string): boolean {
return (
(data.startsWith('\x1b[M') && data.length === 6) ||
(data.startsWith('\x1b[<') && /^\d+;\d+;\d+[Mm]$/.test(data.slice(3)))
)
}
/** True for the bare cursor up/down xterm synthesises per wheel notch when the active buffer has no scrollback. */
export function isXtermWheelCursorKey(data: string): boolean {
return data === '\x1b[A' || data === '\x1b[B' || data === '\x1bOA' || data === '\x1bOB'
}
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { Terminal } from '@xterm/xterm'
import { subscribeToTerminalUserInput } from './terminal-user-input-signal'
import {
subscribeToTerminalInputData,
subscribeToTerminalUserInput
} from './terminal-user-input-signal'
type CoreServiceAccess = {
_core: {
@@ -78,3 +81,26 @@ describe('subscribeToTerminalUserInput', () => {
expect(listener).not.toHaveBeenCalled()
})
})
describe('subscribeToTerminalInputData', () => {
it('classifies real xterm events independently and disposes both subscriptions', () => {
const terminal = new Terminal({ allowProposedApi: true })
const core = (terminal as unknown as CoreServiceAccess)._core.coreService
const listener = vi.fn()
const subscription = subscribeToTerminalInputData(terminal, listener)
core.triggerDataEvent('keyboard', true)
core.triggerDataEvent('\x1b[?1;2c')
core.triggerDataEvent('\x1b[200~paste\x1b[201~', true)
core.triggerDataEvent('\x1b[O', false)
expect(listener.mock.calls).toEqual([
['keyboard', true],
['\x1b[?1;2c', false],
['\x1b[200~paste\x1b[201~', true],
['\x1b[O', false]
])
subscription.dispose()
core.triggerDataEvent('after-dispose', true)
expect(listener).toHaveBeenCalledTimes(4)
terminal.dispose()
})
})
@@ -43,3 +43,25 @@ export function subscribeToTerminalUserInput(
return null
}
}
/** Preserve xterm's input provenance across deferred PTY forwarding. */
export function subscribeToTerminalInputData(
terminal: Terminal,
listener: (data: string, wasUserInput: boolean) => void
): { dispose: () => void } {
let pendingUserInput = false
const userInput = subscribeToTerminalUserInput(terminal, () => {
pendingUserInput = true
})
const dataInput = terminal.onData((data) => {
const wasUserInput = pendingUserInput
pendingUserInput = false
listener(data, wasUserInput)
})
return {
dispose: () => {
dataInput.dispose()
userInput?.dispose()
}
}
}