mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
fix(terminal): restore the mouse format with mouse tracking, so phone swipes don't type into Codex (#23946)
* fix(terminal): restore the mouse encoding with mouse tracking in every snapshot Swiping to scroll Codex from the phone on a Windows host typed legacy `ESC [ M` mouse reports into the Codex composer (#23818). SerializeAddon re-arms mouse tracking (?1000h/?1002h/?1003h) but never the SGR encoding (?1006h/?1016h). Any snapshot taken from a desktop pane's xterm (the runtime seeds its headless model from it after a reattach, and serves it to remote viewers when no model exists) therefore restored "tracking on, legacy encoding", and the phone encoded wheel events as X10 bytes, which ConPTY hands to Codex as keystrokes. serializeWithAbsoluteCursor, the one wrapper every Orca snapshot producer uses, now appends the encoding xterm itself parsed, read from xterm's mouse state service. The daemon/runtime headless model reads tracking and encoding from xterm too, so its regex mirror of the DECSET stream is deleted (one source of truth; one less regex pass per PTY chunk). Mixed versions: no wire field changes. A new host's snapshot carries an extra DECSET that old desktop and phone clients already parse; an old host's snapshot restores exactly as before. With tracking off the encoding alone sends no reports, so the wheel still scrolls scrollback. * test(terminal): pin the mouse-encoding read against the renderer xterm build * fix(terminal): type the xterm mouse-state read behind named shapes
This commit is contained in:
@@ -1608,7 +1608,6 @@
|
||||
"src/main/daemon/terminal-host-teardown-recreate.test.ts": 484,
|
||||
"src/main/daemon/terminal-host-wsl-context.test.ts": 249,
|
||||
"src/main/daemon/terminal-host.test.ts": 691,
|
||||
"src/main/daemon/terminal-mouse-tail-retention.test.ts": 171,
|
||||
"src/main/daemon/terminal-session-teardown.test.ts": 95,
|
||||
"src/main/daemon/terminal-shell-armed-input-mode-recovery.test.ts": 215,
|
||||
"src/main/daemon/terminal-shell-lifecycle-scanner.test.ts": 56,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// CSI strip against the shipped shape (256KB tail + shared CSI_SEQUENCE_PATTERN). Every
|
||||
// variant is asserted to agree with the baseline before it is timed.
|
||||
// 2. TerminalKittyKeyboardModeTracker.scanReplay — measured to justify leaving it alone, and
|
||||
// to record that porting the daemon mouse mirror's includes() pre-filter makes it slower.
|
||||
// to record that an includes() introducer pre-filter makes it slower.
|
||||
//
|
||||
// Payloads are generated deterministically (LCG, no Math.random) so runs compare.
|
||||
//
|
||||
@@ -214,7 +214,7 @@ const kittyScan = (data) => {
|
||||
tracker.scanReplay(data)
|
||||
return tracker.flags
|
||||
}
|
||||
// Mirrors src/main/daemon/terminal-mouse-mode-mirror.ts:41-47.
|
||||
// The includes() introducer pre-filter a daemon-side mode scan would use.
|
||||
const kittyGated = (data) => {
|
||||
if (!data.includes('\x1b[?') && !data.includes('\x1bc') && !data.includes('\x9b')) {
|
||||
return 0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Terminal } from '@xterm/headless'
|
||||
import type { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror'
|
||||
import { readTerminalMouseEncoding } from '../../shared/terminal-mouse-encoding'
|
||||
import type { TerminalModes } from './types'
|
||||
|
||||
type TerminalWithKittyKeyboard = Terminal & {
|
||||
@@ -13,18 +13,16 @@ export function readKittyKeyboardFlags(terminal: Terminal): number {
|
||||
}
|
||||
|
||||
/** Mode state a snapshot must carry so a restored pane behaves like the live one. */
|
||||
export function readTerminalModes(
|
||||
terminal: Terminal,
|
||||
mouseModes: TerminalMouseModeMirror
|
||||
): TerminalModes {
|
||||
export function readTerminalModes(terminal: Terminal): TerminalModes {
|
||||
const buffer = terminal.buffer.active
|
||||
const mouseTrackingMode = mouseModes.mouseTrackingMode
|
||||
const mouseTrackingMode = terminal.modes.mouseTrackingMode
|
||||
const mouseEncoding = readTerminalMouseEncoding(terminal)
|
||||
return {
|
||||
bracketedPaste: terminal.modes.bracketedPasteMode,
|
||||
mouseTracking: mouseTrackingMode !== 'none',
|
||||
mouseTrackingMode,
|
||||
sgrMouseMode: mouseModes.sgrMouseMode,
|
||||
sgrMousePixelsMode: mouseModes.sgrMousePixelsMode,
|
||||
sgrMouseMode: mouseEncoding === 'sgr',
|
||||
sgrMousePixelsMode: mouseEncoding === 'sgr-pixels',
|
||||
applicationCursor: buffer.type === 'normal' ? terminal.modes.applicationCursorKeysMode : false,
|
||||
alternateScreen: buffer.type === 'alternate',
|
||||
kittyKeyboardFlags: readKittyKeyboardFlags(terminal)
|
||||
|
||||
@@ -455,7 +455,8 @@ describe('HeadlessEmulator', () => {
|
||||
|
||||
it('tracks long split private mouse mode sequences', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
const fillerModes = Array.from({ length: 40 }, (_, i) => String(3000 + i)).join(';')
|
||||
// Within xterm's 32-param cap: modes follow what xterm itself parsed, as every live viewer does.
|
||||
const fillerModes = Array.from({ length: 29 }, (_, i) => String(3000 + i)).join(';')
|
||||
|
||||
await emulator.write(`\x1b[?1002;${fillerModes};100`)
|
||||
await emulator.write('6h')
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { TerminalViewAttributes } from '../../shared/terminal-view-attribut
|
||||
import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges'
|
||||
import { readTerminalModes } from './headless-emulator-modes'
|
||||
import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences'
|
||||
import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror'
|
||||
import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner'
|
||||
import { buildFrameRestoreSnapshotFields } from './terminal-frame-restore-sequences'
|
||||
import { splitTerminalSnapshotAnsi } from './terminal-snapshot-ansi-buffers'
|
||||
@@ -60,7 +59,6 @@ export class HeadlessEmulator {
|
||||
protected terminal: Terminal
|
||||
protected serializer: SerializeAddon
|
||||
private oscText: TerminalOscCwdTitleScanner
|
||||
private mouseModes = new TerminalMouseModeMirror()
|
||||
private readonly pathFlavor?: 'posix' | 'win32'
|
||||
private readonly remotePosixFileUriAuthority: boolean
|
||||
private restoredOscLinks: TerminalOscLinkRange[] = []
|
||||
@@ -188,8 +186,6 @@ export class HeadlessEmulator {
|
||||
if (forwardQueryReplies) {
|
||||
this.queryReplyForwardingDepth -= 1
|
||||
}
|
||||
// Why: commit the mouse-mode mirror only after xterm has parsed the same bytes (snapshots combine both).
|
||||
this.mouseModes.scan(data)
|
||||
this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data)
|
||||
resolve()
|
||||
})
|
||||
@@ -222,7 +218,6 @@ export class HeadlessEmulator {
|
||||
this.queryReplyForwardingDepth -= 1
|
||||
}
|
||||
}
|
||||
this.mouseModes.scan(data)
|
||||
this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data)
|
||||
return true
|
||||
}
|
||||
@@ -364,6 +359,6 @@ export class HeadlessEmulator {
|
||||
}
|
||||
|
||||
private getModes(): TerminalModes {
|
||||
return readTerminalModes(this.terminal, this.mouseModes)
|
||||
return readTerminalModes(this.terminal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ import { iterateTerminalHistorySeedChunks } from './terminal-history-seed-chunks
|
||||
// never armed mouse reporting, yet its snapshot re-arms it, so pointer motion
|
||||
// echoes literal SGR reports (^[[<35;col;rowM) into the prompt.
|
||||
//
|
||||
// This drives the REAL Session / HeadlessEmulator / TerminalMouseModeMirror /
|
||||
// buildRehydrateSequences / HistoryManager / HistoryReader, with a fake
|
||||
// This drives the REAL Session / HeadlessEmulator / buildRehydrateSequences /
|
||||
// HistoryManager / HistoryReader, with a fake
|
||||
// subprocess standing in for node-pty (a real PTY can't be SIGKILLed
|
||||
// deterministically mid-DECSET in vitest). The kill goes through Session.kill()'s
|
||||
// real teardown; only killWithDescendantSweep is stubbed so the test never
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { ownRetainedString } from '../../shared/own-retained-string'
|
||||
import type { TerminalModes } from './types'
|
||||
|
||||
type MouseTrackingMode = NonNullable<TerminalModes['mouseTrackingMode']>
|
||||
|
||||
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
|
||||
// Keep parser state far beyond normal mode lists while still bounding memory.
|
||||
const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096
|
||||
|
||||
/**
|
||||
* Mirrors DECSET mouse-protocol/encoding state from the raw byte stream.
|
||||
* xterm's public modes API does not expose which mouse protocol is active,
|
||||
* so snapshots track it independently of the headless terminal; callers
|
||||
* must feed `scan()` the same bytes the terminal parsed, in order.
|
||||
*/
|
||||
export class TerminalMouseModeMirror {
|
||||
private scanTail = ''
|
||||
private trackingModeState: MouseTrackingMode = 'none'
|
||||
private sgrMouseModeState = false
|
||||
private sgrMousePixelsModeState = false
|
||||
|
||||
get mouseTrackingMode(): MouseTrackingMode {
|
||||
return this.trackingModeState
|
||||
}
|
||||
|
||||
get sgrMouseMode(): boolean {
|
||||
return this.sgrMouseModeState
|
||||
}
|
||||
|
||||
get sgrMousePixelsMode(): boolean {
|
||||
return this.sgrMousePixelsModeState
|
||||
}
|
||||
|
||||
scan(data: string): void {
|
||||
// Why the pre-filter: this runs on the daemon's per-chunk hot path for
|
||||
// every session; a flood chunk with no private-mode/reset introducer
|
||||
// must not pay the regex pass (measured share of a 2.2x ingest
|
||||
// regression — findings log 2026-07-03). Split sequences stay correct:
|
||||
// an introducer split across chunks either left a non-empty scanTail
|
||||
// (previous partial) or ends this chunk, which extractScanTail retains.
|
||||
if (
|
||||
this.scanTail.length === 0 &&
|
||||
!data.includes('\x1b[?') &&
|
||||
!data.includes('\x1bc') &&
|
||||
!data.includes('\x9b')
|
||||
) {
|
||||
this.scanTail = this.extractScanTail(data)
|
||||
return
|
||||
}
|
||||
const input = this.scanTail.length === 0 ? data : this.scanTail + data
|
||||
this.scanTail = this.extractScanTail(input)
|
||||
// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars
|
||||
const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = privateModeRe.exec(input)) !== null) {
|
||||
if (match[0] === '\x1bc') {
|
||||
this.trackingModeState = 'none'
|
||||
this.sgrMouseModeState = false
|
||||
this.sgrMousePixelsModeState = false
|
||||
continue
|
||||
}
|
||||
const params = match[1] ?? match[3]
|
||||
const enabled = (match[2] ?? match[4]) === 'h'
|
||||
for (const rawParam of params.split(';')) {
|
||||
if (rawParam === '') {
|
||||
continue
|
||||
}
|
||||
const param = Number(rawParam)
|
||||
if (!Number.isInteger(param)) {
|
||||
continue
|
||||
}
|
||||
if (param === 9) {
|
||||
this.trackingModeState = enabled ? 'x10' : 'none'
|
||||
}
|
||||
if (param === 1000) {
|
||||
this.trackingModeState = enabled ? 'vt200' : 'none'
|
||||
}
|
||||
if (param === 1002) {
|
||||
this.trackingModeState = enabled ? 'drag' : 'none'
|
||||
}
|
||||
if (param === 1003) {
|
||||
this.trackingModeState = enabled ? 'any' : 'none'
|
||||
}
|
||||
if (param === 1006) {
|
||||
this.sgrMouseModeState = enabled
|
||||
this.sgrMousePixelsModeState = false
|
||||
}
|
||||
if (param === 1016) {
|
||||
this.sgrMouseModeState = false
|
||||
this.sgrMousePixelsModeState = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractScanTail(input: string): string {
|
||||
const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b'))
|
||||
if (start === -1) {
|
||||
return ''
|
||||
}
|
||||
const tail = input.slice(start)
|
||||
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) {
|
||||
return ''
|
||||
}
|
||||
if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') {
|
||||
return tail
|
||||
}
|
||||
if (tail.startsWith('\x1b[?')) {
|
||||
return this.isIncompleteParams(tail.slice(3)) ? ownRetainedString(tail) : ''
|
||||
}
|
||||
if (tail.startsWith('\x9b?')) {
|
||||
return this.isIncompleteParams(tail.slice(2)) ? ownRetainedString(tail) : ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private isIncompleteParams(params: string): boolean {
|
||||
return /^[0-9;]*$/.test(params)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror'
|
||||
|
||||
function heapAfterGc(): number {
|
||||
if (!('gc' in globalThis) || typeof globalThis.gc !== 'function') {
|
||||
throw new Error('The test runner must enable --expose-gc')
|
||||
}
|
||||
// Isolate mirror ownership from V8's process-wide last successful regexp input.
|
||||
void /reset/.test('reset')
|
||||
globalThis.gc()
|
||||
globalThis.gc()
|
||||
return process.memoryUsage().heapUsed
|
||||
}
|
||||
|
||||
describe('mouse mode scan tail retention', () => {
|
||||
it.each(['\x1b[', '\x9b'])(
|
||||
'retains a split %j mode sequence without retaining consumed output',
|
||||
(introducer) => {
|
||||
const before = heapAfterGc()
|
||||
const mirrors = Array.from({ length: 8 }, (_value, index) => {
|
||||
const mirror = new TerminalMouseModeMirror()
|
||||
mirror.scan(`${index}:${'x'.repeat(4 * 1024 * 1024)}${introducer}?1049;2004;1000;`)
|
||||
return mirror
|
||||
})
|
||||
|
||||
expect(heapAfterGc() - before).toBeLessThan(2 * 1024 * 1024)
|
||||
for (const mirror of mirrors) {
|
||||
expect(mirror.mouseTrackingMode).toBe('none')
|
||||
mirror.scan('1006h')
|
||||
expect(mirror.mouseTrackingMode).toBe('vt200')
|
||||
expect(mirror.sgrMouseMode).toBe(true)
|
||||
mirror.scan('\x1b[?1016h')
|
||||
expect(mirror.sgrMouseMode).toBe(false)
|
||||
expect(mirror.sgrMousePixelsMode).toBe(true)
|
||||
mirror.scan('\x1bc')
|
||||
expect(mirror.mouseTrackingMode).toBe('none')
|
||||
expect(mirror.sgrMousePixelsMode).toBe(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
// #23818: a restored pane must keep the mouse encoding (DECSET 1006/1016) with
|
||||
// the tracking mode, or it reports wheel events as legacy `ESC [ M` bytes that a
|
||||
// ConPTY host delivers to the app as typed text.
|
||||
import './xterm-env-polyfill'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import { SerializeAddon } from '@xterm/addon-serialize'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
import { serializeWithAbsoluteCursor } from '../../shared/terminal-serialize-absolute-cursor'
|
||||
import { readTerminalMouseEncoding } from '../../shared/terminal-mouse-encoding'
|
||||
|
||||
const CODEX_FULLSCREEN_MODES = '\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h'
|
||||
const X10_WHEEL_UP_AT_10_5 = `\x1b[M${String.fromCharCode(96, 42, 37)}`
|
||||
const SGR_WHEEL_UP_AT_10_5 = '\x1b[<64;10;5M'
|
||||
|
||||
function createTerminal(): { terminal: Terminal; addon: SerializeAddon } {
|
||||
const terminal = new Terminal({ cols: 40, rows: 10, scrollback: 100, allowProposedApi: true })
|
||||
const addon = new SerializeAddon()
|
||||
terminal.loadAddon(addon)
|
||||
return { terminal, addon }
|
||||
}
|
||||
|
||||
function write(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise((resolve) => terminal.write(data, () => resolve()))
|
||||
}
|
||||
|
||||
async function replay(data: string): Promise<Terminal> {
|
||||
const { terminal } = createTerminal()
|
||||
await write(terminal, data)
|
||||
return terminal
|
||||
}
|
||||
|
||||
type XtermCoreMouseEvent = {
|
||||
col: number
|
||||
row: number
|
||||
x: number
|
||||
y: number
|
||||
button: number
|
||||
action: number
|
||||
}
|
||||
|
||||
/** xterm's private mouse state service: the encoder desktop viewers send reports through. */
|
||||
type XtermMouseStateService = {
|
||||
areMouseEventsActive: boolean
|
||||
encodeMouseEvent: (event: XtermCoreMouseEvent) => string
|
||||
}
|
||||
|
||||
function isXtermMouseStateService(value: unknown): value is XtermMouseStateService {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'areMouseEventsActive' in value &&
|
||||
typeof value.areMouseEventsActive === 'boolean' &&
|
||||
'encodeMouseEvent' in value &&
|
||||
typeof value.encodeMouseEvent === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
/** The report xterm's own encoder sends for a wheel-up, or null when tracking is off. */
|
||||
function wheelUpReport(terminal: Terminal): string | null {
|
||||
const core = '_core' in terminal ? terminal._core : undefined
|
||||
const service =
|
||||
typeof core === 'object' && core !== null && 'mouseStateService' in core
|
||||
? core.mouseStateService
|
||||
: undefined
|
||||
if (!isXtermMouseStateService(service)) {
|
||||
throw new Error('xterm mouse state service is unavailable')
|
||||
}
|
||||
if (!service.areMouseEventsActive) {
|
||||
return null
|
||||
}
|
||||
return service.encodeMouseEvent({ col: 10, row: 5, x: 0, y: 0, button: 4, action: 0 })
|
||||
}
|
||||
|
||||
describe('snapshot mouse encoding', () => {
|
||||
it('the bare addon drops SGR encoding, so its restore reports X10 (the #23818 shape)', async () => {
|
||||
const { terminal, addon } = createTerminal()
|
||||
await write(terminal, `shell$ ${CODEX_FULLSCREEN_MODES}codex`)
|
||||
|
||||
const restored = await replay(addon.serialize())
|
||||
|
||||
expect(restored.modes.mouseTrackingMode).toBe('any')
|
||||
expect(wheelUpReport(restored)).toBe(X10_WHEEL_UP_AT_10_5)
|
||||
})
|
||||
|
||||
it('round-trips SGR encoding with the tracking mode, so the restore reports SGR', async () => {
|
||||
const { terminal, addon } = createTerminal()
|
||||
await write(terminal, `shell$ ${CODEX_FULLSCREEN_MODES}codex`)
|
||||
|
||||
const restored = await replay(serializeWithAbsoluteCursor(addon, terminal))
|
||||
|
||||
expect(restored.buffer.active.type).toBe('alternate')
|
||||
expect(restored.modes.mouseTrackingMode).toBe('any')
|
||||
expect(readTerminalMouseEncoding(restored)).toBe('sgr')
|
||||
expect(wheelUpReport(restored)).toBe(SGR_WHEEL_UP_AT_10_5)
|
||||
})
|
||||
|
||||
it('round-trips SGR-pixels encoding', async () => {
|
||||
const { terminal, addon } = createTerminal()
|
||||
await write(terminal, 'x\x1b[?1002h\x1b[?1016h')
|
||||
|
||||
const restored = await replay(serializeWithAbsoluteCursor(addon, terminal))
|
||||
|
||||
expect(restored.modes.mouseTrackingMode).toBe('drag')
|
||||
expect(readTerminalMouseEncoding(restored)).toBe('sgr-pixels')
|
||||
})
|
||||
|
||||
it('restores no mouse reports when tracking is off, even with SGR encoding armed', async () => {
|
||||
const { terminal, addon } = createTerminal()
|
||||
await write(terminal, 'x\x1b[?1003h\x1b[?1006h\x1b[?1003l')
|
||||
|
||||
const restored = await replay(serializeWithAbsoluteCursor(addon, terminal))
|
||||
|
||||
expect(restored.modes.mouseTrackingMode).toBe('none')
|
||||
expect(wheelUpReport(restored)).toBeNull()
|
||||
})
|
||||
|
||||
it('adds nothing for the default encoding or when modes are excluded', async () => {
|
||||
const { terminal, addon } = createTerminal()
|
||||
await write(terminal, 'x\x1b[?1000h')
|
||||
const legacy = serializeWithAbsoluteCursor(addon, terminal)
|
||||
expect(legacy).toContain('\x1b[?1000h')
|
||||
expect(legacy).not.toContain('\x1b[?1006h')
|
||||
expect(legacy).not.toContain('\x1b[?1016h')
|
||||
|
||||
await write(terminal, '\x1b[?1006h')
|
||||
const excluded = serializeWithAbsoluteCursor(addon, terminal, { excludeModes: true })
|
||||
expect(excluded).not.toContain('\x1b[?1006h')
|
||||
})
|
||||
|
||||
it('carries the encoding from a renderer snapshot through a headless seed to its snapshot', async () => {
|
||||
// Why: the runtime seeds its headless model from the desktop pane's snapshot, and
|
||||
// mobile keeps only what follows the last `?1049h` of that model's snapshot.
|
||||
const { terminal, addon } = createTerminal()
|
||||
await write(terminal, `shell$ ${CODEX_FULLSCREEN_MODES}codex`)
|
||||
const emulator = new HeadlessEmulator({ cols: 40, rows: 10 })
|
||||
await emulator.write(serializeWithAbsoluteCursor(addon, terminal))
|
||||
|
||||
const snapshot = emulator.getSnapshot()
|
||||
const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi
|
||||
const mobileReplay = data.slice(data.lastIndexOf('\x1b[?1049h'))
|
||||
|
||||
expect(snapshot.modes.mouseTrackingMode).toBe('any')
|
||||
expect(snapshot.modes.sgrMouseMode).toBe(true)
|
||||
expect(mobileReplay).toContain('\x1b[?1003h')
|
||||
expect(mobileReplay).toContain('\x1b[?1006h')
|
||||
expect(wheelUpReport(await replay(data))).toBe(SGR_WHEEL_UP_AT_10_5)
|
||||
emulator.dispose()
|
||||
})
|
||||
|
||||
it('a snapshot from an older host without the encoding restores as before', async () => {
|
||||
const emulator = new HeadlessEmulator({ cols: 40, rows: 10 })
|
||||
await emulator.write('\x1b[?1049h\x1b[?1003hcodex')
|
||||
|
||||
const snapshot = emulator.getSnapshot()
|
||||
|
||||
expect(snapshot.modes.mouseTrackingMode).toBe('any')
|
||||
expect(snapshot.modes.sgrMouseMode).toBe(false)
|
||||
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1006h')
|
||||
emulator.dispose()
|
||||
})
|
||||
})
|
||||
@@ -29,9 +29,9 @@ export function mulberry32(seed: number): () => number {
|
||||
export type AgentTuiStreamDims = { cols: number; rows: number }
|
||||
|
||||
export type AgentTuiStreamProfile = {
|
||||
/** Mouse-mode toggles require mirroring TerminalMouseModeMirror to build
|
||||
* rehydrate parity; the renderer-side fuzz cannot import that main-only
|
||||
* module (tsconfig.tc.web.json excludes src/main/daemon), so it opts out. */
|
||||
/** Mouse-mode toggles need the daemon's rehydrate for parity; the
|
||||
* renderer-side fuzz cannot import that main-only module
|
||||
* (tsconfig.tc.web.json excludes src/main/daemon), so it opts out. */
|
||||
includeMouseModes: boolean
|
||||
/** OSC 8 hyperlinks mark their cells underlined in the xterm buffer, but
|
||||
* SerializeAddon never re-emits OSC 8 — production restores link ranges
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { readTerminalMouseEncoding } from './terminal-mouse-encoding'
|
||||
|
||||
function write(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise((resolve) => terminal.write(data, () => resolve()))
|
||||
}
|
||||
|
||||
describe('readTerminalMouseEncoding', () => {
|
||||
it('reads the encoding from the real vendored renderer xterm build', async () => {
|
||||
// Pinned on purpose: if an upgrade moves the private service, this must fail
|
||||
// loudly — a silent 'default' would bring back X10 reports (#23818).
|
||||
const terminal = new Terminal({ allowProposedApi: true })
|
||||
expect(readTerminalMouseEncoding(terminal)).toBe('default')
|
||||
|
||||
await write(terminal, '\x1b[?1003h\x1b[?1006h')
|
||||
expect(readTerminalMouseEncoding(terminal)).toBe('sgr')
|
||||
|
||||
await write(terminal, '\x1b[?1016h')
|
||||
expect(readTerminalMouseEncoding(terminal)).toBe('sgr-pixels')
|
||||
|
||||
await write(terminal, '\x1bc')
|
||||
expect(readTerminalMouseEncoding(terminal)).toBe('default')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('answers default when the private internals are missing', () => {
|
||||
expect(readTerminalMouseEncoding({ cols: 80, rows: 24 })).toBe('default')
|
||||
expect(readTerminalMouseEncoding({ cols: 80, rows: 24, _core: {} })).toBe('default')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
/** Mouse report encoding armed by DECSET 1006 (SGR) or 1016 (SGR pixels); independent of the tracking protocol. */
|
||||
export type TerminalMouseEncoding = 'default' | 'sgr' | 'sgr-pixels'
|
||||
|
||||
/** Private xterm surface read below; optional because an xterm upgrade can move or drop it. */
|
||||
type TerminalWithMouseStateCore = {
|
||||
// Why: required public members keep this from being a weak type, so xterm Terminals assign uncast.
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
_core?: { mouseStateService?: { activeEncoding?: unknown } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the encoding xterm itself parsed. Its public `modes` exposes the
|
||||
* tracking protocol but not the encoding, and SerializeAddon omits it, so a
|
||||
* restored pane otherwise emits legacy `ESC [ M` reports that a ConPTY host
|
||||
* types into the app as text (#23818).
|
||||
*/
|
||||
export function readTerminalMouseEncoding(
|
||||
terminal: TerminalWithMouseStateCore
|
||||
): TerminalMouseEncoding {
|
||||
const encoding = terminal._core?.mouseStateService?.activeEncoding
|
||||
if (encoding === 'SGR') {
|
||||
return 'sgr'
|
||||
}
|
||||
if (encoding === 'SGR_PIXELS') {
|
||||
return 'sgr-pixels'
|
||||
}
|
||||
return 'default'
|
||||
}
|
||||
|
||||
export function buildMouseEncodingRestoreSequence(encoding: TerminalMouseEncoding): string {
|
||||
switch (encoding) {
|
||||
case 'sgr':
|
||||
return '\x1b[?1006h'
|
||||
case 'sgr-pixels':
|
||||
return '\x1b[?1016h'
|
||||
case 'default':
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -155,8 +155,8 @@ export function advancePartialEscapeTail(pendingTail: string, chunk: string): st
|
||||
// Why the pre-filter: `extractPartialEscapeTail` only leaves `ground` on an ESC byte, so with
|
||||
// no pending tail and no ESC in the chunk the answer is always ''. Taking it here skips both
|
||||
// the full-chunk concat and the per-code-unit walk on ESC-free output (build logs, `cat`,
|
||||
// piped tool output) — the same gate `TerminalOscCwdTitleScanner.scan` and
|
||||
// `TerminalMouseModeMirror.scan` already apply on the very same ingest path.
|
||||
// piped tool output) — the same gate `TerminalOscCwdTitleScanner.scan` already
|
||||
// applies on the very same ingest path.
|
||||
if (pendingTail.length === 0 && !chunk.includes('\x1b')) {
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -255,8 +255,8 @@ export function buildParityMainBufferSnapshot(
|
||||
if (!alternateScreen && terminal.modes.applicationCursorKeysMode) {
|
||||
seqs.push('\x1b[?1h')
|
||||
}
|
||||
// Mouse-mode rehydrate omitted: TerminalMouseModeMirror is main-only and
|
||||
// mouse reporting is input encoding — it cannot alter rendered output.
|
||||
// Mouse-mode rehydrate omitted: mouse reporting is input encoding — it
|
||||
// cannot alter rendered output.
|
||||
const snapshot: ParityMainSnapshot = {
|
||||
data: seqs.join('') + snapshotAnsi,
|
||||
cols: terminal.cols,
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
// snapshot that will be replayed into another terminal must therefore end
|
||||
// with an absolute CUP derived from the SOURCE terminal's authoritative
|
||||
// cursor position. Snapshot producers that also need the VT100 DECSC
|
||||
// saved-cursor register carried across the restore compose it here too.
|
||||
// saved-cursor register carried across the restore compose it here too, and
|
||||
// the mouse encoding the addon's mode trailer omits rides beside it.
|
||||
|
||||
import {
|
||||
buildMouseEncodingRestoreSequence,
|
||||
readTerminalMouseEncoding
|
||||
} from './terminal-mouse-encoding'
|
||||
|
||||
type SerializeCursorTerminal = {
|
||||
cols: number
|
||||
@@ -81,7 +87,20 @@ export function serializeWithAbsoluteCursor<TOpts>(
|
||||
if (serialized.length === 0) {
|
||||
return serialized
|
||||
}
|
||||
return `${serialized}${buildAbsoluteCursorRestoreSequence(terminal, savedCursor)}`
|
||||
// Why: the addon re-arms mouse tracking but not its encoding; restoring one without the other makes X10 reports.
|
||||
const mouseEncodingRestore = serializesModes(opts)
|
||||
? buildMouseEncodingRestoreSequence(readTerminalMouseEncoding(terminal))
|
||||
: ''
|
||||
return `${serialized}${mouseEncodingRestore}${buildAbsoluteCursorRestoreSequence(terminal, savedCursor)}`
|
||||
}
|
||||
|
||||
function serializesModes(opts: unknown): boolean {
|
||||
return !(
|
||||
typeof opts === 'object' &&
|
||||
opts !== null &&
|
||||
'excludeModes' in opts &&
|
||||
opts.excludeModes === true
|
||||
)
|
||||
}
|
||||
|
||||
/** Cursor state appended after serialized modes; safe to replay without the frame body. */
|
||||
|
||||
Reference in New Issue
Block a user