diff --git a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts index acbced67212..d6d7246f017 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts @@ -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') diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts index 8577c08ea1e..340d174db9d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts @@ -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).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 () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts index 1c53e318dab..3c8166b2410 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/replay-guard.ts b/src/renderer/src/components/terminal-pane/replay-guard.ts index 5b3d5d984d9..41d9f86db49 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.ts @@ -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> diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts index 70cd81cf1e4..6dccb30340c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/terminal-pointer-input-sequences.ts b/src/renderer/src/components/terminal-pane/terminal-pointer-input-sequences.ts new file mode 100644 index 00000000000..61ebbf9b022 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pointer-input-sequences.ts @@ -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' +} diff --git a/src/renderer/src/components/terminal-pane/terminal-user-input-signal.test.ts b/src/renderer/src/components/terminal-pane/terminal-user-input-signal.test.ts index 6e76135ead0..231fe2dc588 100644 --- a/src/renderer/src/components/terminal-pane/terminal-user-input-signal.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-user-input-signal.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-user-input-signal.ts b/src/renderer/src/components/terminal-pane/terminal-user-input-signal.ts index 714426b52f4..3ea31ec1b08 100644 --- a/src/renderer/src/components/terminal-pane/terminal-user-input-signal.ts +++ b/src/renderer/src/components/terminal-pane/terminal-user-input-signal.ts @@ -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() + } + } +}