diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 9e2fbaedf3f..113739dde4f 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -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, diff --git a/config/scripts/terminal-reattach-payload-scan-benchmark.mjs b/config/scripts/terminal-reattach-payload-scan-benchmark.mjs index fedfdc2c699..3deb4d1fdbc 100644 --- a/config/scripts/terminal-reattach-payload-scan-benchmark.mjs +++ b/config/scripts/terminal-reattach-payload-scan-benchmark.mjs @@ -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 diff --git a/src/main/daemon/headless-emulator-modes.ts b/src/main/daemon/headless-emulator-modes.ts index bf65d527a3c..5ab7e2398ad 100644 --- a/src/main/daemon/headless-emulator-modes.ts +++ b/src/main/daemon/headless-emulator-modes.ts @@ -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) diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 849f78b8308..326bba56fb5 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -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') diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index a74502e251f..fa1cb054ee3 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -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) } } diff --git a/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts b/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts index f58536f36b8..91658df7d8a 100644 --- a/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts +++ b/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts @@ -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 diff --git a/src/main/daemon/terminal-mouse-mode-mirror.ts b/src/main/daemon/terminal-mouse-mode-mirror.ts deleted file mode 100644 index 7e2ea703cac..00000000000 --- a/src/main/daemon/terminal-mouse-mode-mirror.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { ownRetainedString } from '../../shared/own-retained-string' -import type { TerminalModes } from './types' - -type MouseTrackingMode = NonNullable - -// 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) - } -} diff --git a/src/main/daemon/terminal-mouse-tail-retention.test.ts b/src/main/daemon/terminal-mouse-tail-retention.test.ts deleted file mode 100644 index 7a266b4b23d..00000000000 --- a/src/main/daemon/terminal-mouse-tail-retention.test.ts +++ /dev/null @@ -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) - } - } - ) -}) diff --git a/src/main/daemon/terminal-snapshot-mouse-encoding.test.ts b/src/main/daemon/terminal-snapshot-mouse-encoding.test.ts new file mode 100644 index 00000000000..0822aada4e1 --- /dev/null +++ b/src/main/daemon/terminal-snapshot-mouse-encoding.test.ts @@ -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 { + return new Promise((resolve) => terminal.write(data, () => resolve())) +} + +async function replay(data: string): Promise { + 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() + }) +}) diff --git a/src/shared/agent-tui-ansi-fuzz-stream.ts b/src/shared/agent-tui-ansi-fuzz-stream.ts index afffcc0a411..885dd9c0d50 100644 --- a/src/shared/agent-tui-ansi-fuzz-stream.ts +++ b/src/shared/agent-tui-ansi-fuzz-stream.ts @@ -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 diff --git a/src/shared/terminal-mouse-encoding.test.ts b/src/shared/terminal-mouse-encoding.test.ts new file mode 100644 index 00000000000..e9cf475e56e --- /dev/null +++ b/src/shared/terminal-mouse-encoding.test.ts @@ -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 { + 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') + }) +}) diff --git a/src/shared/terminal-mouse-encoding.ts b/src/shared/terminal-mouse-encoding.ts new file mode 100644 index 00000000000..289b88ec1d2 --- /dev/null +++ b/src/shared/terminal-mouse-encoding.ts @@ -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 '' + } +} diff --git a/src/shared/terminal-partial-escape-tail.ts b/src/shared/terminal-partial-escape-tail.ts index 53cfd3c17b4..5c735dd86ec 100644 --- a/src/shared/terminal-partial-escape-tail.ts +++ b/src/shared/terminal-partial-escape-tail.ts @@ -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 '' } diff --git a/src/shared/terminal-restore-parity-fixture.ts b/src/shared/terminal-restore-parity-fixture.ts index d3d4e0bf2b0..10fb0cd6e58 100644 --- a/src/shared/terminal-restore-parity-fixture.ts +++ b/src/shared/terminal-restore-parity-fixture.ts @@ -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, diff --git a/src/shared/terminal-serialize-absolute-cursor.ts b/src/shared/terminal-serialize-absolute-cursor.ts index ee2ac371296..fae85b32293 100644 --- a/src/shared/terminal-serialize-absolute-cursor.ts +++ b/src/shared/terminal-serialize-absolute-cursor.ts @@ -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( 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. */