fix(terminal): keep a live TUI's mouse modes across daemon reattach

On daemon reattach Orca replayed the snapshot and then immediately wrote a mode reset that wiped the mouse modes the snapshot had just rehydrated. xterm no longer knew the still-running TUI had mouse tracking on, so it fell back to row-wise selection over the TUI — the reporter saw drag selection appear after an in-app update restart.

Re-arm the modes the replayed payload actually left on, instead of resetting them. A TUI that died leaving modes armed is still cleaned up by the confirmed-shell-foreground reset, so this only preserves modes that were genuinely on. Composed with the newer cold-restore branch from #12101: when the owner process ended, the full reset still wins.

Fixes #8291

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-04 00:34:36 -07:00
co-authored by Orca
parent c498d2d405
commit 0f7aa33b31
7 changed files with 296 additions and 145 deletions
@@ -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('')
}
+3 -119
View File
@@ -1,119 +1,3 @@
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)) ? 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'
@@ -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')
@@ -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 => {
@@ -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`
@@ -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')
})
})
+168
View File
@@ -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
})
}