mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
fix(terminal): suppress xterm auto-replies during session restore (#960)
This commit is contained in:
@@ -67,6 +67,11 @@ export default function TerminalPane({
|
||||
const paneLastThemeModeRef = useRef<Map<number, 'dark' | 'light'>>(new Map())
|
||||
const panePtyBindingsRef = useRef<Map<number, IDisposable>>(new Map())
|
||||
const pendingWritesRef = useRef<Map<number, string>>(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<Map<number, number>>(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,
|
||||
|
||||
@@ -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<string, string> | undefined,
|
||||
restoredPaneByLeafId: Map<string, number>
|
||||
restoredPaneByLeafId: Map<string, number>,
|
||||
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.
|
||||
|
||||
@@ -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<string, string>
|
||||
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
|
||||
pendingWritesRef: React.RefObject<Map<number, string>>
|
||||
replayingPanesRef: ReplayingPanesRef
|
||||
isActiveRef: React.RefObject<boolean>
|
||||
isVisibleRef: React.RefObject<boolean>
|
||||
onPtyExitRef: React.RefObject<(ptyId: string) => void>
|
||||
|
||||
@@ -125,6 +125,7 @@ function createDeps(overrides: Record<string, unknown> = {}) {
|
||||
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<number, number>([[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')
|
||||
|
||||
|
||||
@@ -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<string, Promise<string | null>>()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = 'hello[cworld'
|
||||
|
||||
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' })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<Map<number, number>>
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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<Map<number, 'dark' | 'light'>>
|
||||
panePtyBindingsRef: React.RefObject<Map<number, IDisposable>>
|
||||
pendingWritesRef: React.RefObject<Map<number, string>>
|
||||
replayingPanesRef: ReplayingPanesRef
|
||||
isActiveRef: React.RefObject<boolean>
|
||||
isVisibleRef: React.RefObject<boolean>
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user