mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 16:02:37 +00:00
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.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user