From f5ada40afd468b5ac90de991acbfaaf5ccdc618f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:14:36 -0700 Subject: [PATCH] fix(terminal): suppress xterm auto-replies during session restore (#960) --- .../components/terminal-pane/TerminalPane.tsx | 7 ++ .../terminal-pane/layout-serialization.ts | 13 +- .../terminal-pane/pty-connection-types.ts | 2 + .../terminal-pane/pty-connection.test.ts | 42 +++++++ .../terminal-pane/pty-connection.ts | 49 +++++++- .../terminal-pane/pty-dispatcher.ts | 7 ++ .../terminal-pane/pty-transport.test.ts | 77 ++++++++++++ .../components/terminal-pane/pty-transport.ts | 27 +++- .../terminal-pane/replay-guard.test.ts | 118 ++++++++++++++++++ .../components/terminal-pane/replay-guard.ts | 55 ++++++++ .../use-terminal-pane-lifecycle.ts | 8 +- 11 files changed, 394 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/replay-guard.test.ts create mode 100644 src/renderer/src/components/terminal-pane/replay-guard.ts diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 00721eaae8a..abadfea7e31 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -67,6 +67,11 @@ export default function TerminalPane({ const paneLastThemeModeRef = useRef>(new Map()) const panePtyBindingsRef = useRef>(new Map()) const pendingWritesRef = useRef>(new Map()) + // Why: tracks panes currently replaying recorded PTY bytes into xterm + // (cold-restore, daemon snapshot, scrollback restore, eager-buffer flush). + // While non-zero, pty-connection.ts drops xterm onData so auto-replies to + // embedded query sequences don't leak to the shell. See replay-guard.ts. + const replayingPanesRef = useRef>(new Map()) const isActiveRef = useRef(isActive) isActiveRef.current = isActive const isVisibleRef = useRef(isVisible) @@ -347,6 +352,7 @@ export default function TerminalPane({ paneLastThemeModeRef, panePtyBindingsRef, pendingWritesRef, + replayingPanesRef, isActiveRef, isVisibleRef, onPtyExitRef, @@ -408,6 +414,7 @@ export default function TerminalPane({ startup: { command: 'codex' }, paneTransportsRef, pendingWritesRef, + replayingPanesRef, isActiveRef, isVisibleRef, onPtyExitRef, diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index 4afa19524b4..db10a259bb0 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -4,6 +4,7 @@ import type { TerminalPaneSplitDirection } from '../../../../shared/types' import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' export const EMPTY_LAYOUT: TerminalLayoutSnapshot = { root: null, @@ -198,7 +199,8 @@ function collectLeafIds( export function restoreScrollbackBuffers( manager: PaneManager, savedBuffers: Record | undefined, - restoredPaneByLeafId: Map + restoredPaneByLeafId: Map, + replayingPanesRef: ReplayingPanesRef ): void { if (!savedBuffers) { return @@ -224,10 +226,15 @@ export function restoreScrollbackBuffers( buf = buf.slice(0, lastOn) } if (buf.length > 0) { - pane.terminal.write(buf) + // Why replayIntoTerminal: the serialized buffer can contain query + // sequences that leaked in via the pendingWritesRef flush before + // serialization (see TerminalPane capture hook). Writing those + // through xterm would trigger auto-replies that land in the new + // shell's stdin. See replay-guard.ts. + replayIntoTerminal(pane, replayingPanesRef, buf) // Ensure cursor is on a new line so the new shell prompt // doesn't trigger zsh's PROMPT_EOL_MARK (%) indicator. - pane.terminal.write('\r\n') + replayIntoTerminal(pane, replayingPanesRef, '\r\n') } } catch { // If restore fails, continue with blank terminal. diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index e40d5b5f6a6..6478ecaff0b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -1,4 +1,5 @@ import type { PtyTransport } from './pty-transport' +import type { ReplayingPanesRef } from './replay-guard' export type PtyConnectionDeps = { tabId: string @@ -9,6 +10,7 @@ export type PtyConnectionDeps = { restoredPtyIdByLeafId?: Record paneTransportsRef: React.RefObject> pendingWritesRef: React.RefObject> + replayingPanesRef: ReplayingPanesRef isActiveRef: React.RefObject isVisibleRef: React.RefObject onPtyExitRef: React.RefObject<(ptyId: string) => void> diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index a414510ecbf..552fc78ff85 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -125,6 +125,7 @@ function createDeps(overrides: Record = {}) { restoredPtyIdByLeafId: {}, paneTransportsRef: { current: new Map() }, pendingWritesRef: { current: new Map() }, + replayingPanesRef: { current: new Map() }, isActiveRef: { current: true }, isVisibleRef: { current: true }, onPtyExitRef: { current: vi.fn() }, @@ -229,6 +230,47 @@ describe('connectPanePty', () => { ) }) + it('drops xterm onData while pane is replaying restored bytes', async () => { + // Regression: during cold-restore / snapshot replay, xterm auto-replies + // to embedded query sequences (DA1, DECRQM, OSC 10/11, focus, CPR) via + // onData. Those replies must not pipe through to transport.sendInput, or + // they land as stray characters ("?1;2c", "2026;2$y", ...) on the new + // shell's prompt. See replay-guard.ts. + const { connectPanePty } = await import('./pty-connection') + + const transport = createMockTransport('pty-live') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + const replayingPanesRef = { current: new Map([[1, 1]]) } + const deps = createDeps({ replayingPanesRef }) + + connectPanePty(pane as never, manager as never, deps as never) + + expect(onDataHandler).toBeDefined() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + // Simulate xterm emitting a DA1 auto-reply during replay parse. + ;(onDataHandler as (data: string) => void)('\x1b[?1;2c') + expect(transport.sendInput).not.toHaveBeenCalled() + + // Once replay completes (guard cleared), real keystrokes flow through. + replayingPanesRef.current.delete(1) + ;(onDataHandler as (data: string) => void)('a') + expect(transport.sendInput).toHaveBeenCalledWith('a') + }) + it('blocks input to stale Codex panes until they restart', async () => { const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 30010b94b99..d599f482857 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -8,6 +8,7 @@ import type { PtyConnectResult } from './pty-transport' import { createIpcPtyTransport } from './pty-transport' import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding' import type { PtyConnectionDeps } from './pty-connection-types' +import { isPaneReplaying, replayIntoTerminal } from './replay-guard' const pendingSpawnByTabId = new Map>() @@ -190,6 +191,16 @@ export function connectPanePty( deps.paneTransportsRef.current.set(pane.id, transport) const onDataDisposable = pane.terminal.onData((data) => { + // 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(deps.replayingPanesRef, pane.id)) { + return + } const currentPtyId = transport.getPtyId() // Why: after a Codex account switch, the runtime auth has already moved to // the newly selected account. Stale panes must not keep sending input until @@ -259,6 +270,7 @@ export function connectPanePty( rows, callbacks: { onData: dataCallback, + onReplayData: replayDataCallback, onError: reportError } }) @@ -275,6 +287,21 @@ export function connectPanePty( pendingSpawnByTabId.set(deps.tabId, spawnPromise) } + // Why: replay bytes (eager-buffer flush, attach-time screen clear) must + // always go through the replay guard so xterm's auto-replies to embedded + // query sequences don't leak to the shell. Unlike dataCallback we do not + // honor isVisibleRef — the visibility branch is a perf batching strategy + // for live output that defers parsing until the worktree is foregrounded, + // but deferring replay parsing drops the bytes into pendingWritesRef, + // which later flushes through plain pane.terminal.write (in + // use-terminal-pane-global-effects) with no guard engaged. xterm's + // write() buffers internally regardless of DOM visibility, and the guard + // stays engaged via the write-completion callback until xterm finishes + // parsing — so writing directly here is both correct and safe. + const replayDataCallback = (data: string): void => { + replayIntoTerminal(pane, deps.replayingPanesRef, data) + } + const dataCallback = (data: string): void => { if (deps.isVisibleRef.current) { pane.terminal.write(data) @@ -371,6 +398,7 @@ export function connectPanePty( sessionId: deferredReattachSessionId, callbacks: { onData: dataCallback, + onReplayData: replayDataCallback, onError: reportError } }) @@ -397,9 +425,17 @@ export function connectPanePty( // buffer before this rAF ran. The cold-restore scrollback from // disk history overlaps with that content. Without clearing first, // the terminal shows duplicated output. - pane.terminal.write('\x1b[2J\x1b[3J\x1b[H') - pane.terminal.write(connectResult.coldRestore.scrollback) - pane.terminal.write('\r\n\x1b[2m--- session restored ---\x1b[0m\r\n\r\n') + // Why replayIntoTerminal: the recorded scrollback is raw PTY output + // that may contain query sequences the previous agent CLI emitted; + // writing them through xterm.write would trigger auto-replies that + // land in the new shell's stdin. See replay-guard.ts. + replayIntoTerminal(pane, deps.replayingPanesRef, '\x1b[2J\x1b[3J\x1b[H') + replayIntoTerminal(pane, deps.replayingPanesRef, connectResult.coldRestore.scrollback) + replayIntoTerminal( + pane, + deps.replayingPanesRef, + '\r\n\x1b[2m--- session restored ---\x1b[0m\r\n\r\n' + ) window.api.pty.ackColdRestore(ptyId!) } else if (connectResult?.snapshot) { // Why: always clear before writing the daemon snapshot to prevent @@ -407,8 +443,9 @@ export function connectPanePty( // wrote earlier. The alt-screen case previously skipped this, // leaving stale scrollback in the normal buffer that reappeared // when the user exited the TUI (e.g. Claude Code). - pane.terminal.write('\x1b[2J\x1b[3J\x1b[H') - pane.terminal.write(connectResult.snapshot) + // Why replayIntoTerminal: same rationale as the cold-restore path. + replayIntoTerminal(pane, deps.replayingPanesRef, '\x1b[2J\x1b[3J\x1b[H') + replayIntoTerminal(pane, deps.replayingPanesRef, connectResult.snapshot) } if (ptyId) { @@ -446,6 +483,7 @@ export function connectPanePty( rows, callbacks: { onData: dataCallback, + onReplayData: replayDataCallback, onError: reportError } }) @@ -493,6 +531,7 @@ export function connectPanePty( rows, callbacks: { onData: dataCallback, + onReplayData: replayDataCallback, onError: reportError } }) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 4e1210bf626..ea568cadc02 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -150,6 +150,11 @@ export type PtyTransport = { onConnect?: () => void onDisconnect?: () => void onData?: (data: string) => void + /** Replay bytes from a prior session (eager buffers, attach-time screen + * clears). Routed separately from onData so the renderer can engage + * the replay guard — otherwise xterm auto-replies to embedded query + * sequences leak into the shell. See replay-guard.ts. */ + onReplayData?: (data: string) => void onStatus?: (shell: string) => void onError?: (message: string, errors?: string[]) => void onExit?: (code: number) => void @@ -169,6 +174,8 @@ export type PtyTransport = { onConnect?: () => void onDisconnect?: () => void onData?: (data: string) => void + /** See note on connect.callbacks.onReplayData. */ + onReplayData?: (data: string) => void onStatus?: (shell: string) => void onError?: (message: string, errors?: string[]) => void onExit?: (code: number) => void diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 67737b9bf81..0adc240ad69 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -118,6 +118,83 @@ describe('createIpcPtyTransport', () => { expect(onBell).not.toHaveBeenCalled() }) + it('routes eager-buffered bytes through onReplayData so the renderer can engage the replay guard', async () => { + const { createIpcPtyTransport, registerEagerPtyBuffer } = await import('./pty-transport') + + // Why: eager-buffered bytes often contain query sequences (e.g. DA1 `\x1b[c`) + // left over from a previous session. Routing them through onData instead of + // onReplayData would bypass pty-connection's replay guard and xterm would + // auto-reply to those queries, leaking stray input into the shell. + const bufferedPayload = 'helloworld' + + const handle = registerEagerPtyBuffer('pty-restored', vi.fn()) + onData?.({ + id: 'pty-restored', + data: bufferedPayload + }) + + const transport = createIpcPtyTransport() + const onDataCallback = vi.fn() + const onReplayData = vi.fn() + + transport.attach({ + existingPtyId: 'pty-restored', + callbacks: { + onData: onDataCallback, + onReplayData + } + }) + + expect(handle.flush()).toBe('') + expect(onReplayData).toHaveBeenCalledWith(bufferedPayload) + expect(onDataCallback).not.toHaveBeenCalledWith(bufferedPayload) + }) + + it('routes the attach-time clear sequence through onReplayData for non-alternate-screen sessions', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + + const transport = createIpcPtyTransport() + const onDataCallback = vi.fn() + const onReplayData = vi.fn() + + transport.attach({ + existingPtyId: 'pty-attached', + callbacks: { + onData: onDataCallback, + onReplayData + } + }) + + // Why: the clear preamble must travel the replay path so any subsequent + // snapshot bytes sit under the same replay guard in pty-connection.ts. + const clear = '\x1b[2J\x1b[3J\x1b[H' + expect(onReplayData).toHaveBeenCalledWith(clear) + expect(onDataCallback).not.toHaveBeenCalledWith(clear) + }) + + it('skips the attach-time clear sequence for alternate-screen sessions', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + + const transport = createIpcPtyTransport() + const onDataCallback = vi.fn() + const onReplayData = vi.fn() + + transport.attach({ + existingPtyId: 'pty-alt-screen', + isAlternateScreen: true, + callbacks: { + onData: onDataCallback, + onReplayData + } + }) + + // Why: alternate-screen snapshots already fill the viewport; emitting the + // clear would erase the restored content. Neither path should see it. + const clear = '\x1b[2J\x1b[3J\x1b[H' + expect(onReplayData).not.toHaveBeenCalledWith(clear) + expect(onDataCallback).not.toHaveBeenCalledWith(clear) + }) + it('passes startup commands through PTY spawn instead of writing them after connect', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawnMock = vi.fn().mockResolvedValue({ id: 'pty-1' }) diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 27d45d31d02..b65e6cb8bad 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -128,10 +128,20 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra agentTracker?.handleTitle(title) } + // Why: true while we're replaying buffered/attach-time bytes into the + // terminal. Routes those bytes through onReplayData so the renderer can + // engage the replay guard — otherwise xterm auto-replies to embedded + // query sequences leak into the shell as stray input. + let replayingBufferedData = false + // Why: shared by connect() and attach() to avoid duplicating title/bell/exit logic. function registerPtyDataHandler(id: string): void { ptyDataHandlers.set(id, (data) => { - storedCallbacks.onData?.(data) + if (replayingBufferedData && storedCallbacks.onReplayData) { + storedCallbacks.onReplayData(data) + } else { + storedCallbacks.onData?.(data) + } if (onTitleChange) { const title = extractLastOscTitle(data) if (title !== null) { @@ -283,10 +293,16 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra // data so titles and scrollback restore correctly, but it must not // generate fresh unread badges or notifications for unrelated // worktrees just because Orca is reconnecting background terminals. + // Why replayingBufferedData: those buffered bytes are raw PTY output + // that may contain query sequences from the previous session; route + // them through onReplayData so the renderer engages the replay guard + // and xterm's auto-replies do not leak into the shell. suppressAttentionEvents = true + replayingBufferedData = true try { ptyDataHandlers.get(id)?.(buffered) } finally { + replayingBufferedData = false suppressAttentionEvents = false } } @@ -297,8 +313,15 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra // content doesn't layer on top of stale output. Skip the clear for // alternate-screen sessions — the snapshot already fills the screen // and clearing would erase it. + // Why onReplayData: treat this clear as replay-path too so any data + // that immediately follows from the renderer sits under the same guard. if (!options.isAlternateScreen) { - storedCallbacks.onData?.('\x1b[2J\x1b[3J\x1b[H') + const clear = '\x1b[2J\x1b[3J\x1b[H' + if (storedCallbacks.onReplayData) { + storedCallbacks.onReplayData(clear) + } else { + storedCallbacks.onData?.(clear) + } } if (options.cols && options.rows) { diff --git a/src/renderer/src/components/terminal-pane/replay-guard.test.ts b/src/renderer/src/components/terminal-pane/replay-guard.test.ts new file mode 100644 index 00000000000..976aab001e0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/replay-guard.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import type { ManagedPane } from '@/lib/pane-manager/pane-manager' +import { isPaneReplaying, replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' + +function makeRef(): ReplayingPanesRef { + return { current: new Map() } as ReplayingPanesRef +} + +type FakeTerminal = { + write: (data: string, cb?: () => void) => void + lastData: string[] + pendingCallbacks: (() => void)[] + /** Flush all pending xterm write callbacks, simulating parse completion. */ + flush: () => void +} + +function makeFakePane(paneId: number): { pane: ManagedPane; terminal: FakeTerminal } { + const pendingCallbacks: (() => void)[] = [] + const terminal: FakeTerminal = { + lastData: [], + pendingCallbacks, + write(data: string, cb?: () => void) { + terminal.lastData.push(data) + if (cb) { + pendingCallbacks.push(cb) + } + }, + flush() { + while (pendingCallbacks.length > 0) { + pendingCallbacks.shift()!() + } + } + } + // Only `id` and `terminal` are exercised by replayIntoTerminal. + const pane = { id: paneId, terminal } as unknown as ManagedPane + return { pane, terminal } +} + +describe('replay-guard', () => { + it('reports no replay for untouched pane', () => { + const ref = makeRef() + expect(isPaneReplaying(ref, 1)).toBe(false) + }) + + it('is replaying between write dispatch and xterm parse completion', () => { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'hello') + + // Before xterm fires its write-completion callback, the guard is engaged — + // this is the window during which xterm could emit auto-replies for any + // query sequences embedded in the replayed data. + expect(isPaneReplaying(ref, 1)).toBe(true) + + terminal.flush() + expect(isPaneReplaying(ref, 1)).toBe(false) + }) + + it('composes nested replays via a counter', () => { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + + // Simulates the cold-restore path: clear preamble + scrollback + banner + // dispatched back-to-back before xterm completes any of them. + replayIntoTerminal(pane, ref, '\x1b[2J\x1b[3J\x1b[H') + replayIntoTerminal(pane, ref, 'scrollback bytes') + replayIntoTerminal(pane, ref, '--- session restored ---') + expect(isPaneReplaying(ref, 1)).toBe(true) + + // Completion of the first write must not clear the guard — the later + // writes are still in xterm's queue and may still auto-reply. + terminal.pendingCallbacks.shift()!() + expect(isPaneReplaying(ref, 1)).toBe(true) + + terminal.pendingCallbacks.shift()!() + expect(isPaneReplaying(ref, 1)).toBe(true) + + terminal.pendingCallbacks.shift()!() + expect(isPaneReplaying(ref, 1)).toBe(false) + }) + + it('keeps each pane independent', () => { + const ref = makeRef() + const a = makeFakePane(1) + const b = makeFakePane(2) + + replayIntoTerminal(a.pane, ref, 'a') + expect(isPaneReplaying(ref, 1)).toBe(true) + expect(isPaneReplaying(ref, 2)).toBe(false) + + replayIntoTerminal(b.pane, ref, 'b') + expect(isPaneReplaying(ref, 2)).toBe(true) + + a.terminal.flush() + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(isPaneReplaying(ref, 2)).toBe(true) + + b.terminal.flush() + expect(isPaneReplaying(ref, 2)).toBe(false) + }) + + it('skips empty data without touching the guard or xterm', () => { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + replayIntoTerminal(pane, ref, '') + expect(terminal.lastData).toEqual([]) + expect(isPaneReplaying(ref, 1)).toBe(false) + }) + + it('removes the counter entry when the last replay completes', () => { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + replayIntoTerminal(pane, ref, 'x') + terminal.flush() + expect(ref.current.has(1)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/replay-guard.ts b/src/renderer/src/components/terminal-pane/replay-guard.ts new file mode 100644 index 00000000000..e949fae3efd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/replay-guard.ts @@ -0,0 +1,55 @@ +import type { ManagedPane } from '@/lib/pane-manager/pane-manager' + +// Why: xterm.js auto-responds to terminal query sequences (DA1 `CSI c`, +// DECRQM `CSI ? Ps $ p`, OSC 10/11 color queries, focus events, CPR) by +// emitting the reply through its onData callback. In pty-connection.ts that +// callback is wired directly to `transport.sendInput`, which pipes the reply +// to the shell's stdin. When we restore terminal state at startup or on +// reattach we write recorded PTY bytes back into xterm — including any +// queries the previous agent CLI emitted — and the auto-replies end up as +// stray characters on the new shell's prompt (e.g. `?1;2c`, `2026;2$y`, +// OSC 10/11 color fragments). +// +// xterm does not expose a `wasUserInput` flag on its public onData, so we +// cannot distinguish replay-induced replies from real keystrokes after the +// fact. Instead, we track an in-flight replay counter per pane: callers +// replay into xterm via `replayIntoTerminal`, which increments the counter, +// writes, and decrements in xterm's write-completion callback. The onData +// handler in pty-connection.ts drops data while the counter is non-zero. +// +// The guard window is bounded by xterm's own parse completion, not a +// wall-clock timer, so only replies generated while parsing the replayed +// bytes are suppressed. User keystrokes typed after the replay completes +// are unaffected. In practice replay finishes within milliseconds — before +// the user could meaningfully type — so the few-ms window where real input +// would also be dropped is acceptable relative to correctness. + +export type ReplayingPanesRef = React.RefObject> + +export function isPaneReplaying(ref: ReplayingPanesRef, paneId: number): boolean { + return (ref.current.get(paneId) ?? 0) > 0 +} + +/** Writes `data` into the pane's terminal with the replay guard engaged, + * so xterm's auto-replies to embedded query sequences do not leak to the + * shell as input. The counter increments/decrements so nested replays + * (e.g. clear-screen preamble + snapshot body) compose correctly. */ +export function replayIntoTerminal( + pane: ManagedPane, + replayingPanesRef: ReplayingPanesRef, + data: string +): void { + if (!data) { + return + } + const map = replayingPanesRef.current + map.set(pane.id, (map.get(pane.id) ?? 0) + 1) + pane.terminal.write(data, () => { + const remaining = (map.get(pane.id) ?? 1) - 1 + if (remaining <= 0) { + map.delete(pane.id) + } else { + map.set(pane.id, remaining) + } + }) +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 1b75ba45fc3..d0b903fd026 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -29,6 +29,7 @@ import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-optio import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { connectPanePty } from './pty-connection' import type { PtyTransport } from './pty-transport' +import type { ReplayingPanesRef } from './replay-guard' import { fitAndFocusPanes, fitPanes } from './pane-helpers' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { e2eConfig } from '@/lib/e2e-config' @@ -76,6 +77,7 @@ type UseTerminalPaneLifecycleDeps = { paneLastThemeModeRef: React.RefObject> panePtyBindingsRef: React.RefObject> pendingWritesRef: React.RefObject> + replayingPanesRef: ReplayingPanesRef isActiveRef: React.RefObject isVisibleRef: React.RefObject onPtyExitRef: React.RefObject<(ptyId: string) => void> @@ -154,6 +156,7 @@ export function useTerminalPaneLifecycle({ paneLastThemeModeRef, panePtyBindingsRef, pendingWritesRef, + replayingPanesRef, isActiveRef, isVisibleRef, onPtyExitRef, @@ -286,6 +289,7 @@ export function useTerminalPaneLifecycle({ startup, paneTransportsRef, pendingWritesRef, + replayingPanesRef, isActiveRef, isVisibleRef, onPtyExitRef, @@ -467,6 +471,7 @@ export function useTerminalPaneLifecycle({ clearRuntimePaneTitle(tabId, paneId) paneFontSizesRef.current.delete(paneId) pendingWritesRef.current.delete(paneId) + replayingPanesRef.current.delete(paneId) // Clean up pane title state so closed panes don't leave stale entries. setPaneTitles((prev) => { if (!(paneId in prev)) { @@ -567,7 +572,8 @@ export function useTerminalPaneLifecycle({ restoreScrollbackBuffers( manager, initialLayoutRef.current.buffersByLeafId, - restoredPaneByLeafId + restoredPaneByLeafId, + replayingPanesRef ) // Seed pane titles from the persisted snapshot using the same