From 471ba09db3bdd3ea3b8a0a72a484797565ac7fbc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:57:42 -0700 Subject: [PATCH] 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. --- .../pty-connection-fresh-spawn-guards.test.ts | 23 +++++++++++++++++++ .../pty-connection/pty-input-forward.ts | 16 ++++++++----- .../terminal-link-pty-mouse-suppression.ts | 2 +- .../terminal-mouse-report-sequence.ts | 7 ------ .../terminal-pointer-input-sequences.ts | 15 ++++++++++++ 5 files changed, 49 insertions(+), 14 deletions(-) delete mode 100644 src/renderer/src/components/terminal-pane/terminal-mouse-report-sequence.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pointer-input-sequences.ts 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' +}