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 4ed6df14bbb..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 @@ -497,6 +497,29 @@ describe('connectPanePty', () => { } 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) { 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 b2ff742edc2..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 @@ -10,7 +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 } from '../terminal-mouse-report-sequence' +import { isXtermMouseReport, isXtermWheelCursorKey } from '../terminal-pointer-input-sequences' import { shouldDropQuarantinedTerminalInput } from '../terminal-input-quarantine' import { PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, @@ -29,13 +29,17 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { 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. Mouse reports - // stay dropped even though xterm flags them as user input: replayed bytes - // can leave mouse tracking armed until the guarded mode reset lands, and a - // click would otherwise print SGR fragments on the fresh prompt. See replay-guard.ts. + // 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)) + (!wasUserInput || + isXtermMouseReport(data) || + (isXtermWheelCursorKey(data) && session.pane.terminal.buffer.active.type === 'alternate')) ) { return } 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 c34b716c361..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,7 +3,7 @@ import { isTerminalLinkActionActivation, isTerminalLinkDirectActivation } from './terminal-link-activation' -import { isXtermMouseReport } from './terminal-mouse-report-sequence' +import { isXtermMouseReport } from './terminal-pointer-input-sequences' const CAPTURE_LISTENER_OPTIONS = { capture: true } as const const MAX_DEFERRED_PTY_INPUT_FRAMES = 64 diff --git a/src/renderer/src/components/terminal-pane/terminal-mouse-report-sequence.ts b/src/renderer/src/components/terminal-pane/terminal-mouse-report-sequence.ts deleted file mode 100644 index 7ce25bebccb..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-mouse-report-sequence.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** 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))) - ) -} 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' +}