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:
Brennan Benson
2026-09-29 19:23:25 -07:00
committed by GitHub
parent 6b36a2c3fb
commit ad2e1b5efa
15 changed files with 274 additions and 190 deletions
-1
View File
@@ -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
+6 -8
View File
@@ -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)
+2 -1
View File
@@ -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')
+1 -6
View File
@@ -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()
})
})
+3 -3
View File
@@ -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')
})
})
+40
View File
@@ -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 ''
}
}
+2 -2
View File
@@ -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. */