diff --git a/src/main/daemon/terminal-mode-rehydrate-sequences.ts b/src/main/daemon/terminal-mode-rehydrate-sequences.ts index 77e3603d4f5..6d57965051a 100644 --- a/src/main/daemon/terminal-mode-rehydrate-sequences.ts +++ b/src/main/daemon/terminal-mode-rehydrate-sequences.ts @@ -1,3 +1,4 @@ +import { buildMouseModeRearmSequence } from '../../shared/terminal-mouse-mode-sequences' import type { TerminalModes } from './types' // Why no kitty flags here: rehydrateSequences feeds renderer xterms, and @@ -20,28 +21,13 @@ export function buildRehydrateSequences(modes: TerminalModes): string { } // Why: mobile alt-screen scroll gestures need xterm's mouse mode restored // from cold snapshots; OpenCode/OpenTUI enables scrollable panes this way. - switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') { - case 'x10': - seqs.push('\x1b[?9h') - break - case 'vt200': - seqs.push('\x1b[?1000h') - break - case 'drag': - seqs.push('\x1b[?1002h') - break - case 'any': - seqs.push('\x1b[?1003h') - break - case 'none': - break - } - // Why: xterm tracks the mouse protocol and SGR encoding as independent - // modes, so snapshots must preserve the encoding even when reporting is off. - if (modes.sgrMousePixelsMode) { - seqs.push('\x1b[?1016h') - } else if (modes.sgrMouseMode) { - seqs.push('\x1b[?1006h') - } + // Why shared: the renderer re-arms the same bytes after a reattach reset. + seqs.push( + buildMouseModeRearmSequence({ + mouseTrackingMode: modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none', + sgrMouseMode: modes.sgrMouseMode === true, + sgrMousePixelsMode: modes.sgrMousePixelsMode === true + }) + ) return seqs.join('') } diff --git a/src/main/daemon/terminal-mouse-mode-mirror.ts b/src/main/daemon/terminal-mouse-mode-mirror.ts index 8f8b284f7f4..451e0856aa3 100644 --- a/src/main/daemon/terminal-mouse-mode-mirror.ts +++ b/src/main/daemon/terminal-mouse-mode-mirror.ts @@ -1,119 +1,3 @@ -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)) ? tail : '' - } - if (tail.startsWith('\x9b?')) { - return this.isIncompleteParams(tail.slice(2)) ? tail : '' - } - return '' - } - - private isIncompleteParams(params: string): boolean { - return /^[0-9;]*$/.test(params) - } -} +// Moved to src/shared so the renderer's reattach re-arm scans with the same +// implementation the daemon mirrors from (see terminal-mouse-mode-sequences). +export { TerminalMouseModeMirror } from '../../shared/terminal-mouse-mode-sequences' diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 8bb448ed11a..f23fd142618 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -9231,6 +9231,44 @@ describe('connectPanePty', () => { }) }) + it('re-arms mouse reporting a reattach snapshot restored for a live third-party TUI', async () => { + // Why: issue #8291 — the reattach reset wiped the modes the daemon snapshot just + // rehydrated, so xterm re-enabled its row-wise selection over a live TUI. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1002h\x1b[?1006hthird-party tui session' } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('zsh') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + `${POST_REPLAY_REATTACH_RESET}\x1b[?1002h\x1b[?1006h`, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + it('does not treat persisted tab launchAgent metadata as a live agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('tab-pty') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index fdace14f0d6..483c15d793e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -117,6 +117,7 @@ import { } from '@/lib/pane-manager/pane-pty-resize-hold' import { buildPostReplayLiveAgentReattachReset, + buildReattachMouseModeRearm, POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, POST_REPLAY_LIVE_SNAPSHOT_RESET, POST_REPLAY_MODE_RESET, @@ -5501,9 +5502,14 @@ export function connectPanePty( if (ownerProcessEnded) { return POST_REPLAY_MODE_RESET } - return shouldPreserveAgentReattachModes() - ? buildPostReplayLiveAgentReattachReset(payload) - : POST_REPLAY_REATTACH_RESET + if (shouldPreserveAgentReattachModes()) { + return buildPostReplayLiveAgentReattachReset(payload) + } + // Why the re-arm: a third-party TUI Orca does not recognise as an agent is still live, + // and the daemon snapshot just rehydrated its mouse modes; wiping them re-enables + // xterm's row-wise selection over the TUI (#8291). A dead TUI's stale modes are still + // cleared by the confirmed-shell-foreground reset above. + return `${POST_REPLAY_REATTACH_RESET}${buildReattachMouseModeRearm(payload)}` } const consumeRestoredViewportBlankingMarker = (): boolean => { diff --git a/src/shared/terminal-mode-reset-profiles.ts b/src/shared/terminal-mode-reset-profiles.ts index 70a01fcd4fd..2d4128906bd 100644 --- a/src/shared/terminal-mode-reset-profiles.ts +++ b/src/shared/terminal-mode-reset-profiles.ts @@ -3,6 +3,8 @@ // into an xterm) and the daemon (seeding a cold-restored session) must clear // the same mode bits, and duplicating the literals drifted them apart (#12101). +import { scanReplayedMouseModeRearm } from './terminal-mouse-mode-sequences' + // Why: SerializeAddon replays mode bits assuming reattach to a live TUI, but Orca restores against a fresh shell with none, so stale bits (e.g. focus reporting rings the bell on click) must be reset. export const RESET_TERMINAL_CURSOR_STYLE = '\x1b[0 q' export const RESET_KITTY_KEYBOARD_PROTOCOL = '\x1b[<99u\x1b[=0u' @@ -18,6 +20,15 @@ export const POST_REPLAY_LIVE_SNAPSHOT_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x // Why: daemon reattach hits a live session, so skip the full reset; still clear cursor/focus/mouse/Kitty bits harmful to a plain shell after a bad TUI exit — safe for live TUIs since the post-reattach SIGWINCH repaints the cursor. export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l` +// Why: the reattach reset must not outlive the daemon's authoritative mode mirror — the +// snapshot rehydrates a live TUI's mouse protocol/encoding and RESET_MOUSE_REPORTING would +// disarm it one write later, re-enabling xterm's row-wise selection over the TUI (#8291). +// A TUI that died leaving modes armed is still cleaned up by the confirmed-shell-foreground +// reset in pty-connection, so this only preserves modes the replayed payload really left on. +export function buildReattachMouseModeRearm(payload: string): string { + return scanReplayedMouseModeRearm(payload) +} + // Why: a live agent owns focus reporting; resetting ?1004h suppresses the focus-in it needs to re-anchor its cursor (IME). export const POST_REPLAY_LIVE_AGENT_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h` diff --git a/src/shared/terminal-mouse-mode-sequences.test.ts b/src/shared/terminal-mouse-mode-sequences.test.ts new file mode 100644 index 00000000000..d19c8b04f90 --- /dev/null +++ b/src/shared/terminal-mouse-mode-sequences.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { + buildMouseModeRearmSequence, + scanReplayedMouseModeRearm, + TerminalMouseModeMirror +} from './terminal-mouse-mode-sequences' + +describe('scanReplayedMouseModeRearm', () => { + it('re-arms the protocol and encoding a payload left on', () => { + expect(scanReplayedMouseModeRearm('boot\x1b[?1002h\x1b[?1006hlive tui')).toBe( + '\x1b[?1002h\x1b[?1006h' + ) + }) + + it('re-arms nothing when the payload turned mouse reporting back off', () => { + expect(scanReplayedMouseModeRearm('\x1b[?1002h\x1b[?1006h\x1b[?1002l\x1b[?1006l')).toBe('') + }) + + it('treats RIS as clearing every mouse mode', () => { + expect(scanReplayedMouseModeRearm('\x1b[?1003h\x1b[?1016h\x1bcfresh')).toBe('') + }) + + it('prefers the pixel encoding over SGR', () => { + expect(scanReplayedMouseModeRearm('\x1b[?1000h\x1b[?1016h')).toBe('\x1b[?1000h\x1b[?1016h') + }) +}) + +describe('TerminalMouseModeMirror', () => { + it('detects a DECSET split across two scan calls', () => { + const mirror = new TerminalMouseModeMirror() + mirror.scan('output\x1b[?10') + mirror.scan('02h\x1b[?1006h') + expect(mirror.mouseTrackingMode).toBe('drag') + expect(mirror.sgrMouseMode).toBe(true) + }) +}) + +describe('buildMouseModeRearmSequence', () => { + it('returns nothing when no protocol or encoding is armed', () => { + expect( + buildMouseModeRearmSequence({ + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false + }) + ).toBe('') + }) + + it('keeps the encoding when reporting itself is off', () => { + expect( + buildMouseModeRearmSequence({ + mouseTrackingMode: 'none', + sgrMouseMode: true, + sgrMousePixelsMode: false + }) + ).toBe('\x1b[?1006h') + }) +}) diff --git a/src/shared/terminal-mouse-mode-sequences.ts b/src/shared/terminal-mouse-mode-sequences.ts new file mode 100644 index 00000000000..97c4c7eebab --- /dev/null +++ b/src/shared/terminal-mouse-mode-sequences.ts @@ -0,0 +1,168 @@ +export type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any' + +export type TerminalMouseModeState = { + mouseTrackingMode: TerminalMouseTrackingMode + sgrMouseMode: boolean + sgrMousePixelsMode: boolean +} + +// 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. + * + * Lives in shared so the daemon (authoritative mirror) and the renderer + * (reattach re-arm) can never disagree about what a payload left armed. + */ +export class TerminalMouseModeMirror { + private scanTail = '' + private trackingModeState: TerminalMouseTrackingMode = 'none' + private sgrMouseModeState = false + private sgrMousePixelsModeState = false + + get mouseTrackingMode(): TerminalMouseTrackingMode { + 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)) ? tail : '' + } + if (tail.startsWith('\x9b?')) { + return this.isIncompleteParams(tail.slice(2)) ? tail : '' + } + return '' + } + + private isIncompleteParams(params: string): boolean { + return /^[0-9;]*$/.test(params) + } +} + +/** + * DECSET bytes that re-arm the given mouse protocol + encoding. + * Why the encoding is independent: xterm tracks protocol and SGR encoding as + * separate modes, so the encoding must be preserved even when reporting is off. + */ +export function buildMouseModeRearmSequence(state: TerminalMouseModeState): string { + const seqs: string[] = [] + switch (state.mouseTrackingMode) { + case 'x10': + seqs.push('\x1b[?9h') + break + case 'vt200': + seqs.push('\x1b[?1000h') + break + case 'drag': + seqs.push('\x1b[?1002h') + break + case 'any': + seqs.push('\x1b[?1003h') + break + case 'none': + break + } + if (state.sgrMousePixelsMode) { + seqs.push('\x1b[?1016h') + } else if (state.sgrMouseMode) { + seqs.push('\x1b[?1006h') + } + return seqs.join('') +} + +/** Mouse modes a replayed payload leaves armed, as DECSET bytes ('' when none). */ +export function scanReplayedMouseModeRearm(payload: string): string { + const mirror = new TerminalMouseModeMirror() + mirror.scan(payload) + return buildMouseModeRearmSequence({ + mouseTrackingMode: mirror.mouseTrackingMode, + sgrMouseMode: mirror.sgrMouseMode, + sgrMousePixelsMode: mirror.sgrMousePixelsMode + }) +}