diff --git a/src/main/daemon/daemon-background-transient-facts.test.ts b/src/main/daemon/daemon-background-transient-facts.test.ts index acfcdb959f4..0d1aa12a94c 100644 --- a/src/main/daemon/daemon-background-transient-facts.test.ts +++ b/src/main/daemon/daemon-background-transient-facts.test.ts @@ -39,6 +39,29 @@ describe('BackgroundTransientFactRelay', () => { expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: 'command-finished', exitCode: 0 } }]) }) + it('preserves a provisional 2031 subscribe when scan authority moves to the daemon', () => { + const { relay, emitted } = createRelay() + relay.onSessionData('s1', '\x1b[?2031h\x1b[?') + relay.setSessionBackground('s1', true) + relay.seedSessionScanState('s1', '\x1b[?') + relay.onSessionData('s1', '25h') + + expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: '2031-subscribe' } }]) + }) + + it('exposes a provisional 2031 subscribe when scan authority returns to main', () => { + const { relay } = createRelay() + relay.setSessionBackground('s1', true) + relay.seedSessionScanState('s1', '') + relay.onSessionData('s1', '\x1b[?2031h\x1b[?') + relay.setSessionBackground('s1', false) + + expect(relay.getMode2031ReplyScanState('s1')).toEqual({ + tail: '\x1b[?', + pendingSubscribe: true + }) + }) + it('stops emitting after un-background and reports the toggle as a state change', () => { const { relay, emitted } = createRelay() expect(relay.setSessionBackground('s1', true)).toBe(true) diff --git a/src/main/daemon/daemon-background-transient-facts.ts b/src/main/daemon/daemon-background-transient-facts.ts index 3867ab0eddd..3e1c31c1369 100644 --- a/src/main/daemon/daemon-background-transient-facts.ts +++ b/src/main/daemon/daemon-background-transient-facts.ts @@ -19,6 +19,11 @@ import { createTerminalTitleTracker, type TerminalTitleTracker } from '../../shared/terminal-output-side-effects' +import { + INITIAL_MODE_2031_REPLY_SCAN_STATE, + scanMode2031ReplyDecision, + type Mode2031ReplyScanState +} from '../../shared/terminal-color-scheme-protocol' import type { DaemonTransientFact } from './types' // Kill switch for the whole background keep-tail mechanism (thinning + @@ -27,6 +32,8 @@ export const BACKGROUND_STREAM_DROP_ENABLED = process.env.ORCA_DAEMON_BACKGROUND export class BackgroundTransientFactRelay { private trackersBySessionId = new Map() + // Why: shadow foreground bytes so a provisional subscribe survives either scan-authority handoff. + private mode2031ReplyScanStateBySessionId = new Map() private emitFact: (sessionId: string, fact: DaemonTransientFact) => void constructor(emitFact: (sessionId: string, fact: DaemonTransientFact) => void) { @@ -59,7 +66,8 @@ export class BackgroundTransientFactRelay { // PR-link dedup memory, so a link re-printed across toggles can // re-fire — consumers treat pr-link as a latest-association update. onPrLink: (link) => this.emitFact(sessionId, { kind: 'pr-link', link }), - onMode2031Subscribe: () => this.emitFact(sessionId, { kind: '2031-subscribe' }) + onMode2031Subscribe: () => this.emitFact(sessionId, { kind: '2031-subscribe' }), + onMode2031Unsubscribe: () => this.emitFact(sessionId, { kind: '2031-unsubscribe' }) }) ) } else { @@ -73,30 +81,63 @@ export class BackgroundTransientFactRelay { * background toggle neither mints a phantom bell nor loses its fact. A * partial tail contains no complete sequence, so this can never fire. */ seedSessionScanState(sessionId: string, partialEscapeTailAnsi: string): void { - if (partialEscapeTailAnsi.length > 0) { - this.trackersBySessionId - .get(sessionId) - ?.handleChunk(partialEscapeTailAnsi, { titleScanData: '' }) + let mode2031State = this.mode2031ReplyScanStateBySessionId.get(sessionId) + if (!mode2031State && partialEscapeTailAnsi.length > 0) { + mode2031State = scanMode2031ReplyDecision( + INITIAL_MODE_2031_REPLY_SCAN_STATE, + partialEscapeTailAnsi + ).state + if (mode2031State.tail.length > 0) { + this.mode2031ReplyScanStateBySessionId.set(sessionId, mode2031State) + } + } + mode2031State ??= INITIAL_MODE_2031_REPLY_SCAN_STATE + const scanSeedAnsi = mode2031State.tail || partialEscapeTailAnsi + if (scanSeedAnsi.length > 0) { + this.trackersBySessionId.get(sessionId)?.handleChunk(scanSeedAnsi, { + titleScanData: '', + mode2031PendingSubscribe: mode2031State.pendingSubscribe + }) } } /** Feed one raw chunk, in byte order, BEFORE it is enqueued for delivery — * facts must be captured even when the chunk is later keep-tail dropped. */ onSessionData(sessionId: string, data: string): void { + const previousMode2031State = this.mode2031ReplyScanStateBySessionId.get(sessionId) + if (previousMode2031State || data.includes('\x1b') || data.includes('\x9b')) { + const mode2031Result = scanMode2031ReplyDecision( + previousMode2031State ?? INITIAL_MODE_2031_REPLY_SCAN_STATE, + data + ) + if (mode2031Result.state.tail.length > 0 || mode2031Result.state.pendingSubscribe) { + this.mode2031ReplyScanStateBySessionId.set(sessionId, mode2031Result.state) + } else { + this.mode2031ReplyScanStateBySessionId.delete(sessionId) + } + } // titleScanData:'' skips title extraction (titles stay main-authoritative) // and keeps the stale-working-title timer permanently unarmed — only the // four transient scanners consume the chunk. this.trackersBySessionId.get(sessionId)?.handleChunk(data, { titleScanData: '' }) } + getMode2031ReplyScanState(sessionId: string): Mode2031ReplyScanState { + return ( + this.mode2031ReplyScanStateBySessionId.get(sessionId) ?? INITIAL_MODE_2031_REPLY_SCAN_STATE + ) + } + onSessionExit(sessionId: string): void { this.disposeTracker(sessionId) + this.mode2031ReplyScanStateBySessionId.delete(sessionId) } dispose(): void { for (const sessionId of Array.from(this.trackersBySessionId.keys())) { this.disposeTracker(sessionId) } + this.mode2031ReplyScanStateBySessionId.clear() } private disposeTracker(sessionId: string): void { diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index edefd8fa02b..790042c840a 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,12 +3,14 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(28) + expect(PROTOCOL_VERSION).toBe(29) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(24) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(25) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(26) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(27) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(28) }) }) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index 025d7838e7b..e01a4f58053 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -4,19 +4,33 @@ import { AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, + MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, - PROTOCOL_VERSION + PROTOCOL_VERSION, + supportsMode2031UnsubscribeFact } from './daemon-protocol-version' describe('daemon protocol version', () => { - it('ships preflight-cache replacement after completion inspection', () => { - expect(PROTOCOL_VERSION).toBe(28) + it('ships the 2031-unsubscribe fact after preflight-cache replacement', () => { + expect(PROTOCOL_VERSION).toBe(29) + expect(MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION).toBe(29) expect(COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION).toBe(27) expect(GET_FOREGROUND_PROCESS_PROTOCOL_VERSION).toBe(11) expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 27 }, (_, index) => index + 1) + Array.from({ length: 28 }, (_, index) => index + 1) ) }) + + it('withholds 2031-unsubscribe support from every preserved older daemon', () => { + // Why (#9993): v28 is what ships today, so a v28 daemon preserved across an app + // update is the live hazard — it emits '2031-subscribe' with no way to retract it. + // The boundary must sit at 29, not merely "recent enough". + expect(supportsMode2031UnsubscribeFact(PROTOCOL_VERSION)).toBe(true) + expect(supportsMode2031UnsubscribeFact(28)).toBe(false) + for (const version of PREVIOUS_DAEMON_PROTOCOL_VERSIONS) { + expect(supportsMode2031UnsubscribeFact(version)).toBe(false) + } + }) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index f2a890aac09..f999b6c22b4 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,6 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v28 replaces v26/v27 daemons that can retain permanent macOS preflight rejections (#9756). -export const PROTOCOL_VERSION = 28 +// v29 emits '2031-unsubscribe' transient facts; v20-28 emit only '2031-subscribe' (#9993). +export const PROTOCOL_VERSION = 29 export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27 export const GET_FOREGROUND_PROCESS_PROTOCOL_VERSION = 11 export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25 @@ -8,10 +8,26 @@ export const AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION = 26 export const AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION = 26 export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22 export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 +// Why (#9993): a gate-managed pane's bytes never reach the renderer, so main's +// transient facts are the only thing that can retire a 2031 subscription for it. +// Daemons before this version emit '2031-subscribe' but have no unsubscribe fact +// at all, so a TUI exiting while hidden would leave the subscription registered +// forever and the next theme flip would inject CSI 997 into whatever replaced it. +// Scan authority moves to the daemon only while a session is backgrounded, so the +// gate lives on backgrounding itself (setPtyBackgrounded): a pre-v29 daemon is +// never asked to thin, and main's scanner — which emits BOTH facts — stays +// authoritative over the whole stream. Filtering the subscribe fact alone would +// not help, because the visible-era subscription is registered by main. +export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, + 28 ] as const export function supportsPtyStartupIngress(protocolVersion: number): boolean { return protocolVersion >= PTY_STARTUP_INGRESS_PROTOCOL_VERSION } + +export function supportsMode2031UnsubscribeFact(protocolVersion: number): boolean { + return protocolVersion >= MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION +} diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index cec5bd880aa..b37feba878e 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -765,6 +765,177 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) }) + describe('mode 2031 fact compatibility (#9993)', () => { + let onEventSpy: ReturnType + // Why these tests exist: daemons survive app updates, so a NEW desktop can be + // driving a PRESERVED older daemon. Those daemons emit '2031-subscribe' but have + // no unsubscribe fact at all. For a gate-managed pane the renderer never sees the + // bytes, so main's facts are the only thing that can retire the subscription — + // trusting a subscribe that can never be retracted leaves it live forever, and the + // next theme flip injects CSI 997 into whatever shell replaced the exited TUI. + function captureForwardedFacts(target: DaemonPtyAdapter): { + kinds: () => string[] + emit: (fact: { kind: string }) => void + } { + const forwarded: string[] = [] + target.onBackgroundStreamEvent((payload) => { + if (payload.kind === 'transientFact') { + forwarded.push((payload.fact as { kind: string }).kind) + } + }) + const listeners: ((event: unknown) => void)[] = [] + onEventSpy = vi.spyOn(DaemonClient.prototype, 'onEvent').mockImplementation((listener) => { + listeners.push(listener) + return () => {} + }) + return { + kinds: () => forwarded, + emit: (fact) => { + expect(listeners.length).toBeGreaterThan(0) + for (const listener of listeners) { + // `type: 'event'` is the envelope the routing switch requires. + listener({ + type: 'event', + event: 'transientFact', + sessionId: 'session-1', + payload: fact + }) + } + } + } + } + + it('drops a pre-v29 daemon 2031-subscribe it could never retract', () => { + // v28 is the version shipping today, so this is the live upgrade hazard: a v28 + // daemon preserved across an app update, still holding real sessions. + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 28 }) + try { + const captured = captureForwardedFacts(legacy) + // Force a fresh wire-up so the spy above is the listener the adapter installs. + legacy['removeEventListener'] = null + legacy['setupEventRouting']() + captured.emit({ kind: '2031-subscribe' }) + captured.emit({ kind: 'bell' }) + + // The unretractable subscribe is withheld; unrelated facts still flow, so the + // gate is narrow rather than "ignore this daemon's facts". + expect(captured.kinds()).toEqual(['bell']) + } finally { + legacy.dispose() + onEventSpy.mockRestore() + } + }) + + it('never delegates scan authority to a v28 daemon, so a hidden withdrawal is still seen', () => { + // The scenario the fact filter alone does NOT cover, and the reason the gate sits + // on backgrounding: while the pane is VISIBLE, main's own scanner registers the + // 2031 subscribe (bytes transit main either way). Only backgrounding hands scan + // authority to the daemon. If a v28 daemon were allowed to take it, the TUI could + // exit while hidden with no party able to emit the withdrawal — #9993 via upgrade. + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 28 }) + try { + legacy.setPtyBackgrounded('v28-session', true) + expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { + sessionId: 'v28-session', + background: false + }) + } finally { + legacy.dispose() + notifySpy.mockRestore() + } + }) + + it('delegates scan authority to a v29 daemon, which can retract', () => { + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 }) + try { + current.setPtyBackgrounded('v29-session', true) + expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { + sessionId: 'v29-session', + background: true + }) + } finally { + current.dispose() + notifySpy.mockRestore() + } + }) + + it('forwards a provisional subscribe with the foreground handoff marker', () => { + const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 }) + const forwarded: unknown[] = [] + current.onBackgroundStreamEvent((payload) => forwarded.push(payload)) + const listeners: ((event: unknown) => void)[] = [] + onEventSpy = vi.spyOn(DaemonClient.prototype, 'onEvent').mockImplementation((listener) => { + listeners.push(listener) + return () => {} + }) + try { + current['removeEventListener'] = null + current['setupEventRouting']() + for (const listener of listeners) { + listener({ + type: 'event', + event: 'sessionBackgroundMarker', + sessionId: 'session-1', + payload: { + background: false, + scanSeedAnsi: '\x1b[?', + mode2031PendingSubscribe: true + } + }) + } + + expect(forwarded).toEqual([ + { + id: 'session-1', + kind: 'backgroundMarker', + background: false, + scanSeedAnsi: '\x1b[?', + mode2031PendingSubscribe: true + } + ]) + } finally { + current.dispose() + onEventSpy.mockRestore() + } + }) + + it('forwards a v28 unsubscribe, which can only retire state main registered', () => { + // Asymmetric on purpose: an unretractable subscribe is the hazard, a withdrawal + // never is. A stale relay tracker on a preserved daemon must still be able to + // clear a subscription rather than be silenced into stranding it. + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 28 }) + try { + const captured = captureForwardedFacts(legacy) + legacy['removeEventListener'] = null + legacy['setupEventRouting']() + captured.emit({ kind: '2031-unsubscribe' }) + + expect(captured.kinds()).toEqual(['2031-unsubscribe']) + } finally { + legacy.dispose() + onEventSpy.mockRestore() + } + }) + + it('forwards 2031 facts from a v29 daemon that can retract them', () => { + const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 }) + try { + const captured = captureForwardedFacts(current) + current['removeEventListener'] = null + current['setupEventRouting']() + captured.emit({ kind: '2031-subscribe' }) + captured.emit({ kind: '2031-unsubscribe' }) + + expect(captured.kinds()).toEqual(['2031-subscribe', '2031-unsubscribe']) + } finally { + current.dispose() + onEventSpy.mockRestore() + } + }) + }) + describe('background stream thinning compatibility', () => { it('reports authoritative snapshot support only for protocol v20 and newer', () => { const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 }) @@ -834,6 +1005,111 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) + it.each([ + { protocolVersion: 28, clearsHint: true }, + { protocolVersion: 29, clearsHint: false } + ])( + 'uses protocol v$protocolVersion background authority before spawn', + async ({ protocolVersion, clearsHint }) => { + const ensureConnectedSpy = vi + .spyOn(DaemonClient.prototype, 'ensureConnected') + .mockResolvedValue() + const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({ + isNew: true, + pid: null, + shellState: 'unsupported', + snapshot: null + } as never) + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const target = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion }) + const sessionId = `spawn-v${protocolVersion}-session` + try { + await target.spawn({ sessionId, cols: 80, rows: 24 }) + + if (clearsHint) { + expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { + sessionId, + background: false + }) + expect(notifySpy.mock.invocationCallOrder[0]).toBeLessThan( + requestSpy.mock.invocationCallOrder[0] + ) + } else { + expect(notifySpy).not.toHaveBeenCalledWith( + 'setSessionBackground', + expect.objectContaining({ sessionId }) + ) + } + } finally { + target.dispose() + notifySpy.mockRestore() + requestSpy.mockRestore() + ensureConnectedSpy.mockRestore() + } + } + ) + + it('clears a preserved v28 background hint before attaching, so scan authority comes home', async () => { + // The gate on setPtyBackgrounded only binds THIS process. Daemons outlive the desktop: + // a v28 that a previous desktop backgrounded is still scanning when a new desktop + // attaches, and this process never called setPtyBackgrounded for it — so without the + // pre-attach clear the daemon keeps authority it can never retract (#9993). + const ensureConnectedSpy = vi + .spyOn(DaemonClient.prototype, 'ensureConnected') + .mockResolvedValue() + const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({ + isNew: false, + pid: 4242, + shellState: 'unsupported', + snapshot: null + } as never) + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 28 }) + try { + await legacy.attach('preserved-v28-session') + + expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { + sessionId: 'preserved-v28-session', + background: false + }) + expect(notifySpy.mock.invocationCallOrder[0]).toBeLessThan( + requestSpy.mock.invocationCallOrder[0] + ) + } finally { + legacy.dispose() + notifySpy.mockRestore() + requestSpy.mockRestore() + ensureConnectedSpy.mockRestore() + } + }) + + it('leaves a v29 background hint alone on attach, because it can retract on its own', async () => { + const ensureConnectedSpy = vi + .spyOn(DaemonClient.prototype, 'ensureConnected') + .mockResolvedValue() + const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({ + isNew: false, + pid: 4242, + shellState: 'unsupported', + snapshot: null + } as never) + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 }) + try { + await current.attach('preserved-v29-session') + + expect(notifySpy).not.toHaveBeenCalledWith( + 'setSessionBackground', + expect.objectContaining({ sessionId: 'preserved-v29-session' }) + ) + } finally { + current.dispose() + notifySpy.mockRestore() + requestSpy.mockRestore() + ensureConnectedSpy.mockRestore() + } + }) + it('still returns the v19 attach snapshot for a desktop renderer replay', async () => { const ensureConnectedSpy = vi .spyOn(DaemonClient.prototype, 'ensureConnected') diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 02c2c9aa775..57cc9d55e54 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -16,6 +16,7 @@ import { AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, PROTOCOL_VERSION, + supportsMode2031UnsubscribeFact, supportsPtyStartupIngress, type CreateOrAttachResult, type DaemonEvent, @@ -200,6 +201,16 @@ export class DaemonPtyAdapter implements IPtyProvider { return this.supportsAuthoritativeBufferSnapshots } + // Why one predicate (#9993): the attach-time clear and setPtyBackgrounded must agree on + // which daemons may hold a background hint. Daemons outlive the desktop that set it, so + // if these two drift a preserved daemon keeps a hint this process would never grant. + private get canDelegateBackgroundToDaemon(): boolean { + return ( + this.supportsAuthoritativeBufferSnapshots && + supportsMode2031UnsubscribeFact(this.protocolVersion) + ) + } + constructor(opts: DaemonPtyAdapterOptions) { this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION this.socketPath = opts.socketPath @@ -360,8 +371,10 @@ export class DaemonPtyAdapter implements IPtyProvider { } await this.ensureConnected() - // Why before createOrAttach: a preserved v19 daemon may still think this session is backgrounded; clear it before attached bytes get thinned without a recoverable seq. - if (!this.supportsAuthoritativeBufferSnapshots) { + // Why before createOrAttach: a preserved daemon may still think this session is backgrounded — from + // a v19 that thins without a recoverable seq, or (#9993) from a pre-v29 that a previous desktop + // handed 2031 scan authority to and can never retract it. Clear it before any bytes are attached. + if (!this.canDelegateBackgroundToDaemon) { this.setPtyBackgrounded(sessionId, false) } @@ -717,7 +730,7 @@ export class DaemonPtyAdapter implements IPtyProvider { async attach(id: string): Promise { await this.ensureConnected() - if (!this.supportsAuthoritativeBufferSnapshots) { + if (!this.canDelegateBackgroundToDaemon) { this.setPtyBackgrounded(id, false) } @@ -785,7 +798,12 @@ export class DaemonPtyAdapter implements IPtyProvider { return } // Why: preserved v19 daemons can thin but can't return the absolute snapshot sequence to recover a gap; clear their stale hint too. - const safeBackground = this.supportsAuthoritativeBufferSnapshots && background + // Why also gate on 2031 (#9993): backgrounding is what hands transient-fact scan + // authority to the daemon. A pre-v29 daemon can announce a 2031 subscribe but never + // retract it, so a TUI exiting while hidden would strand the subscription and the + // next theme flip would inject CSI 997 into its replacement shell. Declining to + // background keeps main's scanner — which emits both facts — authoritative. + const safeBackground = this.canDelegateBackgroundToDaemon && background if (safeBackground) { this.backgroundedSessionIds.add(id) } else { @@ -1913,6 +1931,9 @@ export class DaemonPtyAdapter implements IPtyProvider { background: event.payload.background, ...(event.payload.scanSeedAnsi !== undefined ? { scanSeedAnsi: event.payload.scanSeedAnsi } + : {}), + ...(event.payload.mode2031PendingSubscribe + ? { mode2031PendingSubscribe: true as const } : {}) }) } else if (event.event === 'dataGap') { @@ -1925,6 +1946,18 @@ export class DaemonPtyAdapter implements IPtyProvider { : { sequenceChars: event.payload.sequenceChars }) }) } else if (event.event === 'transientFact') { + // Why (#9993): belt-and-braces behind the setPtyBackgrounded gate. A pre-v29 + // daemon is never asked to background, so it should emit no transient facts at + // all — but one preserved across a reconnect could still have a stale relay + // tracker. An unretractable subscribe is the harmful direction, so drop it. + // An unsubscribe is always forwarded: retiring a subscription main registered + // can only ever help, never strand one. + if ( + event.payload.kind === '2031-subscribe' && + !supportsMode2031UnsubscribeFact(this.protocolVersion) + ) { + return + } this.emitBackgroundStreamEvent({ id: event.sessionId, kind: 'transientFact', diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index f55809a131a..0ac8d158b27 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -868,14 +868,20 @@ export class DaemonServer { return {} } // Reveal intentionally keeps the queued tail: main needs those bytes, and the normal flush/drain delivers them in order ahead of the marker. - const scanSeedAnsi = background ? '' : this.host.getPartialEscapeTailAnsi(sessionId) + const mode2031State = this.transientFactRelay.getMode2031ReplyScanState(sessionId) + const scanSeedAnsi = background + ? '' + : mode2031State.pendingSubscribe + ? mode2031State.tail + : this.host.getPartialEscapeTailAnsi(sessionId) this.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, { type: 'event', event: 'sessionBackgroundMarker', sessionId, payload: { background, - ...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}) + ...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}), + ...(mode2031State.pendingSubscribe ? { mode2031PendingSubscribe: true as const } : {}) } }) return {} diff --git a/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts b/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts index 76cdc72a990..b41876604d5 100644 --- a/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts +++ b/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts @@ -175,6 +175,37 @@ describe('daemon stream droppability lifecycle', () => { expect(lifecycle).toEqual(['refresh:false', 'marker:false']) }) + it('returns a provisional 2031 subscribe with the foreground handoff marker', async () => { + const harness = createServerHarness() + server = harness.server + const { daemon } = harness + addClient(daemon) + daemon.streamClientIdBySessionId.set('session-toggle', 'client-1') + daemon.transientFactRelay.setSessionBackground('session-toggle', true) + daemon.transientFactRelay.onSessionData('session-toggle', '\x1b[?2031h\x1b[?') + vi.spyOn(daemon.host, 'getPartialEscapeTailAnsi').mockReturnValue('\x1b[?') + const enqueue = vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent') + + await daemon.routeRequest('client-1', { + id: 'foreground', + type: 'setSessionBackground', + payload: { sessionId: 'session-toggle', background: false } + }) + + expect(enqueue).toHaveBeenCalledWith( + 'client-1', + 'session-toggle', + expect.objectContaining({ + event: 'sessionBackgroundMarker', + payload: { + background: false, + scanSeedAnsi: '\x1b[?', + mode2031PendingSubscribe: true + } + }) + ) + }) + it('routes an attached session before refresh and emits its background marker after', async () => { const harness = createServerHarness() server = harness.server diff --git a/src/main/daemon/daemon-stream-events.ts b/src/main/daemon/daemon-stream-events.ts index 1d2caa2c77a..62519016ca9 100644 --- a/src/main/daemon/daemon-stream-events.ts +++ b/src/main/daemon/daemon-stream-events.ts @@ -41,12 +41,17 @@ export type TerminalErrorEvent = { * exactly this position so no fact double-fires or goes missing. * scanSeedAnsi (un-background only) carries the emulator's dangling * incomplete escape so main can prime its fresh scanner carry — a sequence - * split across the handoff must not mint a phantom bell or lose its fact. */ + * split across the handoff must not mint a phantom bell or lose its fact. + * mode2031PendingSubscribe preserves a subscribe deferred behind that tail. */ export type SessionBackgroundMarkerEvent = { type: 'event' event: 'sessionBackgroundMarker' sessionId: string - payload: { background: boolean; scanSeedAnsi?: string } + payload: { + background: boolean + scanSeedAnsi?: string + mode2031PendingSubscribe?: true + } } /** A backgrounded session's oldest undelivered output was dropped at the @@ -69,6 +74,7 @@ export type DaemonTransientFact = | { kind: 'command-finished'; exitCode: number | null } | { kind: 'pr-link'; link: TerminalGitHubPRLink } | { kind: '2031-subscribe' } + | { kind: '2031-unsubscribe' } export type TransientFactEvent = { type: 'event' diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index aaa65028c60..53fc791bb05 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -32,6 +32,8 @@ export { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION, PTY_STARTUP_INGRESS_PROTOCOL_VERSION, + MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION, + supportsMode2031UnsubscribeFact, supportsPtyStartupIngress } from './daemon-protocol-version' diff --git a/src/main/ipc/pty-pending-data-drain-contract.ts b/src/main/ipc/pty-pending-data-drain-contract.ts index 1768fb8c9d4..05fbd2ff77e 100644 --- a/src/main/ipc/pty-pending-data-drain-contract.ts +++ b/src/main/ipc/pty-pending-data-drain-contract.ts @@ -1,3 +1,5 @@ +import type { Mode2031ReplyScanState } from '../../shared/terminal-color-scheme-protocol' + export type PendingPtyData = { data: string startSeq?: number @@ -5,6 +7,8 @@ export type PendingPtyData = { transformed?: true containsBackgroundOutput?: boolean droppedOutput?: true + droppedMode2031Data?: string + droppedMode2031ScanState?: Mode2031ReplyScanState } export type PtyPendingDataDrainDisposition = 'active' | 'background' | 'blocked' diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 763085c0699..c7d282c52e4 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -10995,17 +10995,19 @@ describe('registerPtyHandlers', () => { vi.advanceTimersByTime(1) } - // Flood past the pending cap WITH an embedded DSR probe — the writing program blocks on the reply (bench DSR timeout). - mockProc.emitData(`${'y'.repeat(2 * 1024 * 1024)}\x1b[6n${'y'.repeat(1024 * 1024)}`) - // While latched, a later probe must also be carved out (bounded). - mockProc.emitData(`${'z'.repeat(32 * 1024)}\x1b[0c${'z'.repeat(32 * 1024)}`) + // Flood past the cap with a DSR probe and a mode-2031 withdrawal split at the chunk edge. + mockProc.emitData( + `${'y'.repeat(2 * 1024 * 1024)}\x1b[6n${'y'.repeat(1024 * 1024)}\x1b[?2031h prompt \x1b[?20` + ) + // While latched, later queries and the withdrawal continuation must still be carved out. + mockProc.emitData(`31l${'z'.repeat(32 * 1024)}\x1b[0c${'z'.repeat(32 * 1024)}`) mainWindow.webContents.send.mockClear() ackData(null, { id: spawn.id, charCount: 512 * 1024 }) vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawn.id, - data: '\x1b[6n\x1b[0c', + data: '\x1b[6n\x1b[0c\x1b[?2031l', droppedOutput: true }) } finally { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index b1a5927247d..135345f7d32 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -24,6 +24,11 @@ import type { PtyRendererDeliveryStateReport } from '../../shared/pty-renderer-delivery-health' import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' +import { + INITIAL_MODE_2031_REPLY_SCAN_STATE, + scanMode2031ReplyDecision, + type Mode2031ReplyScanState +} from '../../shared/terminal-color-scheme-protocol' import { type PtyMainDeliveryDiagnostics, type PtyPerPtyDeliveryDiagnostics, @@ -2418,6 +2423,29 @@ export function registerPtyHandlers( return extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData } + function scanDroppedMode2031Data( + data: string, + previous: Mode2031ReplyScanState + ): { data: string; state: Mode2031ReplyScanState } { + const result = scanMode2031ReplyDecision(previous, data) + const decisionData = + result.decision === 'subscribed' + ? '\x1b[?2031h' + : result.decision === 'unsubscribed' + ? '\x1b[?2031l' + : '' + return { data: decisionData, state: result.state } + } + + function getDroppedMode2031RendererData(pending: PendingPtyData): string { + const state = pending.droppedMode2031ScanState + if (!state) { + return pending.droppedMode2031Data ?? '' + } + const pendingSubscribe = state.pendingSubscribe ? '\x1b[?2031h' : '' + return (pending.droppedMode2031Data ?? '') + pendingSubscribe + state.tail + } + function dropOversizedPendingPtyData(id: string, pending: PendingPtyData): PendingPtyData { const capChars = pendingDataCapChars() if (pending.droppedOutput === true || pending.data.length <= capChars) { @@ -2438,10 +2466,13 @@ export function registerPtyHandlers( pendingOverflowMarkedPtys.add(id) } pendingDroppedChars += pending.data.length + const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE) // Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along). return { data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS), - droppedOutput: true + droppedOutput: true, + droppedMode2031Data: mode2031.data, + droppedMode2031ScanState: mode2031.state } } @@ -2457,11 +2488,21 @@ export function registerPtyHandlers( ): PendingPtyData { // Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode. if (existing?.droppedOutput === true) { - if (existing.data.length >= DROPPED_QUERY_SALVAGE_MAX_CHARS) { - return existing + const mode2031 = scanDroppedMode2031Data( + data, + existing.droppedMode2031ScanState ?? INITIAL_MODE_2031_REPLY_SCAN_STATE + ) + const remainingQueryCapacity = Math.max( + 0, + DROPPED_QUERY_SALVAGE_MAX_CHARS - existing.data.length + ) + const salvaged = extractDroppedPtyQueryBytes(data).slice(0, remainingQueryCapacity) + return { + ...existing, + data: existing.data + salvaged, + droppedMode2031Data: mode2031.data || existing.droppedMode2031Data, + droppedMode2031ScanState: mode2031.state } - const salvaged = extractDroppedPtyQueryBytes(data) - return salvaged ? { ...existing, data: existing.data + salvaged } : existing } const nextContainsBackgroundOutput = existing?.containsBackgroundOutput === true || containsBackgroundOutput @@ -2579,7 +2620,13 @@ export function registerPtyHandlers( pendingData.remove(selection) updateProducerFlowControl(id) // Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only). - if (!sendPtyDataToRenderer(id, { id, data: pending.data, droppedOutput: true })) { + if ( + !sendPtyDataToRenderer(id, { + id, + data: pending.data + getDroppedMode2031RendererData(pending), + droppedOutput: true + }) + ) { sendFailed = true break } @@ -2805,7 +2852,8 @@ export function registerPtyHandlers( runtime?.setPtyTransientFactDelegation( payload.id, payload.background, - payload.scanSeedAnsi + payload.scanSeedAnsi, + payload.mode2031PendingSubscribe ) return } @@ -2886,7 +2934,7 @@ export function registerPtyHandlers( pending.droppedOutput === true && !overflowMarkedBeforeAppend && pendingOverflowMarkedPtys.has(payload.id) - const nextData = pending.data + const nextData = pending.data + getDroppedMode2031RendererData(pending) const isInteractiveOutput = shouldSendInteractiveOutputNow( payload.id, nextData, diff --git a/src/main/providers/pty-provider-events.ts b/src/main/providers/pty-provider-events.ts index 6de435cb23f..643aecac655 100644 --- a/src/main/providers/pty-provider-events.ts +++ b/src/main/providers/pty-provider-events.ts @@ -15,8 +15,15 @@ export type PtyTransientFact = | { kind: 'command-finished'; exitCode: number | null } | { kind: 'pr-link'; link: TerminalGitHubPRLink } | { kind: '2031-subscribe' } + | { kind: '2031-unsubscribe' } export type PtyBackgroundStreamEvent = - | { id: string; kind: 'backgroundMarker'; background: boolean; scanSeedAnsi?: string } + | { + id: string + kind: 'backgroundMarker' + background: boolean + scanSeedAnsi?: string + mode2031PendingSubscribe?: true + } | { id: string; kind: 'dataGap'; droppedChars: number; sequenceChars?: number } | { id: string; kind: 'transientFact'; fact: PtyTransientFact } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 8486af362eb..e61b64df170 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -8357,6 +8357,17 @@ describe('OrcaRuntimeService', () => { expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: '2031-subscribe' }]) }) + it('restores a provisional 2031 subscribe when daemon scan authority returns', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.setPtyTransientFactDelegation('pty-1', true) + runtime.setPtyTransientFactDelegation('pty-1', false, '\x1b[?', true) + runtime.onPtyData('pty-1', '25h', 100) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: '2031-subscribe' }]) + }) + it('prefers the tracked title over the renderer snapshot lastTitle', async () => { const { runtime } = createSideEffectRuntime() const serializeBuffer = vi.fn().mockResolvedValue({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 03df14bebf6..8d94b771481 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -8075,7 +8075,12 @@ export class OrcaRuntimeService { * facts itself and the delivered bytes may be gapped — feeding them to * main's transient scanners would mint phantom or duplicate facts. Title * processing stays main-side either way. */ - setPtyTransientFactDelegation(ptyId: string, delegated: boolean, scanSeedAnsi?: string): void { + setPtyTransientFactDelegation( + ptyId: string, + delegated: boolean, + scanSeedAnsi?: string, + mode2031PendingSubscribe?: true + ): void { const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) entry.tracker.setTransientFactScanningSuppressed(delegated) if (!delegated && scanSeedAnsi) { @@ -8083,7 +8088,10 @@ export class OrcaRuntimeService { // incomplete escape at the handoff position — a sequence split across // the un-background toggle must not mint a phantom bell or lose its // fact. titleScanData:'' keeps titles out (they were never suppressed). - entry.tracker.handleChunk(scanSeedAnsi, { titleScanData: '' }) + entry.tracker.handleChunk(scanSeedAnsi, { + titleScanData: '', + mode2031PendingSubscribe + }) } } @@ -8106,6 +8114,9 @@ export class OrcaRuntimeService { return case '2031-subscribe': this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) + return + case '2031-unsubscribe': + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }) } } @@ -8353,6 +8364,12 @@ export class OrcaRuntimeService { // still sent by the renderer (query authority stays with the view). onMode2031Subscribe: () => { this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) + }, + // Why: the gated view never sees the withdrawal bytes either, so the + // subscription registry it keeps for theme flips needs this fact to + // stay truthful. + onMode2031Unsubscribe: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }) } } : {}) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.test.ts new file mode 100644 index 00000000000..c619790fdb1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.test.ts @@ -0,0 +1,124 @@ +// Why: a parked tab has no xterm, so this byte sidecar is the only thing that +// answers DECSET 2031. fish toggles 2031 on and off around every prompt, so +// answering the sticky "an h appeared" flag writes `?997;1n` into a shell that +// already handed the tty to a child (#9993). +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const ESC = '\x1b' +const PTY_ID = 'pty-parked-2031' + +// One fish prompt cycle: subscribe, paint, hand off the tty. +const FISH_PROMPT_HANDOFF = `${ESC}[?2031h${ESC}[0m~/orca ${ESC}[32m❯${ESC}[0m ${ESC}[?2031l` + +let sidecarWatcher: ((data: string) => void) | null = null +const unsubscribe = vi.fn() + +vi.mock('./pty-data-sidecar-subscriptions', () => ({ + subscribeToPtyData: (_ptyId: string, watcher: (data: string) => void) => { + sidecarWatcher = watcher + return unsubscribe + } +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ settings: { theme: 'dark' as const } }) + } +})) + +vi.mock('@/lib/terminal-theme', () => ({ + getSystemPrefersDark: () => true +})) + +const { startParkedTerminalMode2031Responder } = + await import('./parked-terminal-mode2031-responder') + +function startResponder(): { sendInput: ReturnType; feed: (data: string) => void } { + const sendInput = vi.fn() + startParkedTerminalMode2031Responder({ ptyId: PTY_ID, sendInput }) + return { + sendInput, + feed: (data: string) => sidecarWatcher?.(data) + } +} + +beforeEach(() => { + sidecarWatcher = null + vi.clearAllMocks() +}) + +describe('parked-tab DECSET 2031 responder honors the chunk-final state (#9993)', () => { + it('does not reply to a subscribe the same chunk withdrew', () => { + const { sendInput, feed } = startResponder() + + feed(FISH_PROMPT_HANDOFF) + + expect(sendInput).not.toHaveBeenCalled() + }) + + it('replies once to a TUI that subscribes and keeps listening', () => { + const { sendInput, feed } = startResponder() + + feed(`${ESC}[?2031h`) + + expect(sendInput).toHaveBeenCalledTimes(1) + expect(sendInput).toHaveBeenCalledWith(`${ESC}[?997;1n`) + }) + + it('stays silent across a run of fish prompts', () => { + const { sendInput, feed } = startResponder() + + feed(FISH_PROMPT_HANDOFF) + feed(FISH_PROMPT_HANDOFF) + feed(FISH_PROMPT_HANDOFF) + + expect(sendInput).not.toHaveBeenCalled() + }) + + it('replies when a TUI subscribes after a prompt cycle in the same chunk', () => { + const { sendInput, feed } = startResponder() + + feed(`${FISH_PROMPT_HANDOFF}${ESC}[?2031h`) + + expect(sendInput).toHaveBeenCalledTimes(1) + }) + + it('still answers a subscribe split across two chunks', () => { + const { sendInput, feed } = startResponder() + + feed(`${ESC}[?20`) + feed('31h') + + expect(sendInput).toHaveBeenCalledTimes(1) + }) + + it('does not reply when the withdrawal is split across two chunks', () => { + const { sendInput, feed } = startResponder() + + feed(`${ESC}[?2031h prompt ${ESC}[?20`) + expect(sendInput).not.toHaveBeenCalled() + feed('31l') + + expect(sendInput).not.toHaveBeenCalled() + }) + + it('does not reply when an unrelated private mode appends a split withdrawal', () => { + const { sendInput, feed } = startResponder() + + feed(`${ESC}[?2031h prompt ${ESC}[?25`) + expect(sendInput).not.toHaveBeenCalled() + feed(';2031l') + + expect(sendInput).not.toHaveBeenCalled() + }) + + it('replies after an ambiguous tail resolves to another mode', () => { + const { sendInput, feed } = startResponder() + + feed(`${ESC}[?2031h drawing ${ESC}[?20`) + expect(sendInput).not.toHaveBeenCalled() + feed('25h') + + expect(sendInput).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts index da804026de6..4c2ca568de9 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts @@ -15,9 +15,10 @@ * consumed while the gate is ON — this sidecar stays the only answerer here. */ import { + INITIAL_MODE_2031_REPLY_SCAN_STATE, mode2031SequenceFor, resolveTerminalColorSchemeMode, - scanMode2031Sequences + scanMode2031ReplyDecision } from '../../../../shared/terminal-color-scheme-protocol' import { useAppStore } from '@/store' import { getSystemPrefersDark } from '@/lib/terminal-theme' @@ -33,13 +34,11 @@ export function startParkedTerminalMode2031Responder( options: ParkedTerminalMode2031ResponderOptions ): () => void { const { ptyId, sendInput } = options - // Why: a DECSET 2031 subscribe can be split across PTY chunks; the scan - // carries a bounded tail between chunks so split sequences still match. - let scanTail = '' + let scanState = INITIAL_MODE_2031_REPLY_SCAN_STATE return subscribeToPtyData(ptyId, (data) => { - const scan = scanMode2031Sequences(scanTail, data) - scanTail = scan.tail - if (!scan.subscribe) { + const result = scanMode2031ReplyDecision(scanState, data) + scanState = result.state + if (result.decision !== 'subscribed') { return } // Why: reply with the resolved theme so TUIs that subscribe while parked 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 ec51ba00102..65cc6493de9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -9728,14 +9728,62 @@ describe('connectPanePty', () => { expect(recordPaneMode2031Subscription).toHaveBeenCalledWith(1, 'dark') }) - it('reports the gate-managed predicate on the binding for the xterm 2031 observer', async () => { + it('retires the fact-registered subscription when the TUI withdraws it', async () => { + // The counterpart to the test above. A gated pane never sees the withdrawal bytes + // (main drops them) and both the chunk scanner and xterm's CSI handler are disabled + // for it, so this fact is the only observer that can retire the subscription. Left + // registered, the next theme flip pushes CSI 997 at the shell that replaced the TUI. + enableMainAuthority() + const paneMode2031Ref = { current: new Map() } + const paneLastThemeModeRef = { current: new Map() } + const deps = createDeps({ + isVisibleRef: { current: false }, + paneMode2031Ref, + paneLastThemeModeRef, + // Exactly what use-terminal-pane-lifecycle wires up for this callback. + recordPaneMode2031Subscription: (paneId: number, repliedMode: 'dark' | 'light') => { + paneMode2031Ref.current.set(paneId, true) + paneLastThemeModeRef.current.set(paneId, repliedMode) + } + }) + await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] + }) + expect(paneMode2031Ref.current.get(1)).toBe(true) + + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 24, + facts: [{ kind: '2031-unsubscribe' }] + }) + + expect(paneMode2031Ref.current.get(1)).toBeUndefined() + expect(paneLastThemeModeRef.current.get(1)).toBeUndefined() + }) + + it('leaves the chunk scanner silent on a gate-managed PTY', async () => { + // Main's '2031-subscribe' fact already answers these; a chunk-boundary reply here + // would answer the same subscribe a second time. enableMainAuthority() const deps = createDeps({ isVisibleRef: { current: false } }) - const { binding } = await connectHiddenPane(deps) - const bindingWithPredicate = binding as typeof binding & { - isHiddenDeliveryGateManagedPty: () => boolean - } - expect(bindingWithPredicate.isHiddenDeliveryGateManagedPty()).toBe(true) + const { transport, dataCallback } = await connectHiddenPane(deps) + const before = transport.sendInput.mock.calls.length + + dataCallback('\x1b[?2031h') + + const replies = transport.sendInput.mock.calls + .slice(before) + .flat() + .filter((arg) => String(arg).includes('997')) + expect(replies).toEqual([]) }) it('declares hidden-at-spawn on connect for hidden panes', async () => { @@ -9775,13 +9823,7 @@ describe('connectPanePty', () => { terminalHiddenDeliveryGate: false } as StoreState['settings'] const deps = createDeps({ isVisibleRef: { current: false } }) - const { transport, dataCallback, binding } = await connectHiddenPane(deps) - // Why: the lifecycle's xterm CSI observer consults this predicate — kill switch off keeps the legacy xterm reply path. - expect( - ( - binding as typeof binding & { isHiddenDeliveryGateManagedPty: () => boolean } - ).isHiddenDeliveryGateManagedPty() - ).toBe(false) + const { transport, dataCallback } = await connectHiddenPane(deps) const transportOptions = createdTransportOptions.at(-1) as { onPtySpawn?: (ptyId: string) => void } @@ -10049,10 +10091,11 @@ describe('connectPanePty', () => { pane: ReturnType dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void getMainBufferSnapshot: ReturnType + transport: MockTransport }> { enableMainAuthority() const deps = createDeps({ isVisibleRef: { current: true } }) - const { pane, dataCallback } = await connectHiddenPane(deps) + const { pane, dataCallback, transport } = await connectHiddenPane(deps) const transportOptions = createdTransportOptions.at(-1) as { onPtySpawn?: (ptyId: string) => void } @@ -10080,7 +10123,7 @@ describe('connectPanePty', () => { expect.any(Function) ) pane.terminal.write.mockClear() - return { pane, dataCallback, getMainBufferSnapshot } + return { pane, dataCallback, getMainBufferSnapshot, transport } } function writtenData(pane: ReturnType): string { @@ -10101,6 +10144,26 @@ describe('connectPanePty', () => { expect(writtenData(pane)).toContain('NEW') }) + it('answers a 2031 subscribe on a chunk the restored snapshot drops as duplicate', async () => { + // Why the scan runs before reconciliation: the snapshot restore replays bytes + // into xterm without answering queries, so this live delivery is the only + // chance to reply. Dropping the chunk as a duplicate must not drop the query. + // Gate off so the chunk scanner (not main's fact) owns this pane's reply. + enableMainAuthority() + mockStoreState.settings = { + ...mockStoreState.settings, + terminalHiddenDeliveryGate: false + } as StoreState['settings'] + const { pane, dataCallback, transport } = await restoreVisiblePaneToBaseline() + transport.sendInput.mockClear() + + dataCallback(`SUB\x1b[?2031h`, { seq: 60, rawLength: 11 }) + await flushAsyncTicks(8) + + expect(writtenData(pane)).not.toContain('SUB') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + }) + it('slices a partial overlap when raw and clean lengths match', async () => { const { pane, dataCallback } = await restoreVisiblePaneToBaseline() @@ -10792,7 +10855,7 @@ describe('connectPanePty', () => { binding.dispose() }) - it('writes mode 2031 through hidden xterm instead of side-channel answering it', async () => { + it('answers a mode 2031 subscribe once, from the raw chunk boundary', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -10820,7 +10883,11 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('\x1b[?2031h') vi.advanceTimersByTime(50) - expect(transport.sendInput).not.toHaveBeenCalled() + // Why the scanner and not xterm's CSI handler: xterm batches PTY chunks into one + // parse, so only this layer knows the chunk ended still subscribed (#9993). + expect(transport.sendInput).toHaveBeenCalledTimes(1) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;2n') + // The bytes still reach xterm so the emulator tracks the mode itself. expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?2031h') } finally { vi.useRealTimers() @@ -10829,6 +10896,168 @@ describe('connectPanePty', () => { binding.dispose() }) + // Why these three: fish 4.7.1 enables and disables 2031 around *every* prompt with no + // opt-out, so back-to-back chunks each carrying a toggle are the normal case, not an edge + // case. Each chunk owes exactly one decision, and xterm cannot make it — it parses several + // chunks in one synchronous batch, so a reply deferred to the parser is either dropped or + // answered against a subscription a later chunk already withdrew (#9993). + describe('mode 2031 replies are decided per raw PTY chunk', () => { + async function connectVisiblePane(): Promise<{ + transport: ReturnType + deps: ReturnType + emit: (data: string, meta?: { droppedOutput?: boolean }) => void + dispose: () => void + }> { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const captured: { + current: ((data: string, meta?: { droppedOutput?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + captured.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + settings: { ...mockStoreState.settings, theme: 'light' } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ isVisibleRef: { current: true } }) + const binding = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + return { + transport, + deps, + emit: (data: string, meta?: { droppedOutput?: boolean }) => captured.current?.(data, meta), + dispose: () => binding.dispose() + } + } + + const replies = (transport: { sendInput: { mock: { calls: unknown[][] } } }): unknown[] => + transport.sendInput.mock.calls.flat().filter((arg) => String(arg).includes('997')) + + it('answers a chunk that ends subscribed even when the next chunk withdraws', async () => { + const { transport, emit, dispose } = await connectVisiblePane() + // Two separate PTY chunks. xterm would parse both in one batch and see only the net + // result; the first chunk still owes a reply. + emit('\x1b[?2031h') + emit('\x1b[?2031l') + expect(replies(transport)).toEqual(['\x1b[?997;2n']) + dispose() + }) + + it('answers each chunk that ends subscribed', async () => { + const { transport, emit, dispose } = await connectVisiblePane() + emit('\x1b[?2031h') + emit('\x1b[?2031l\x1b[?2031h') + expect(replies(transport)).toHaveLength(2) + dispose() + }) + + it('stays silent when one chunk both subscribes and withdraws', async () => { + const { transport, emit, dispose } = await connectVisiblePane() + // The fish prompt case: the subscription is gone before the program could read a reply. + emit('\x1b[?2031h prompt \x1b[?2031l') + expect(replies(transport)).toEqual([]) + dispose() + }) + + it('stays silent when the withdrawal is split across two chunks', async () => { + const { transport, emit, dispose } = await connectVisiblePane() + emit('\x1b[?2031h prompt \x1b[?20') + expect(replies(transport)).toEqual([]) + emit('31l') + expect(replies(transport)).toEqual([]) + dispose() + }) + + it('stays silent when an unrelated private mode appends a split withdrawal', async () => { + const { transport, emit, dispose } = await connectVisiblePane() + emit('\x1b[?2031h prompt \x1b[?25') + expect(replies(transport)).toEqual([]) + emit(';2031l') + expect(replies(transport)).toEqual([]) + dispose() + }) + + it('answers after an ambiguous tail resolves to another mode', async () => { + const { transport, emit, dispose } = await connectVisiblePane() + emit('\x1b[?2031h drawing \x1b[?20') + expect(replies(transport)).toEqual([]) + emit('25h') + expect(replies(transport)).toEqual(['\x1b[?997;2n']) + dispose() + }) + + // One fish prompt cycle, exactly as fish's tty_handoff.rs emits it. + const FISH_PROMPT_HANDOFF = '\x1b[?2031h\x1b[0m~/orca \x1b[32m❯\x1b[0m \x1b[?2031l' + + it('stays silent across three fish prompts', async () => { + const { transport, emit, dispose } = await connectVisiblePane() + emit(FISH_PROMPT_HANDOFF) + emit(FISH_PROMPT_HANDOFF) + emit(FISH_PROMPT_HANDOFF) + expect(replies(transport)).toEqual([]) + dispose() + }) + + it('leaves no stale subscription behind after a fish prompt cycle', async () => { + const { deps, emit, dispose } = await connectVisiblePane() + emit(FISH_PROMPT_HANDOFF) + // A later theme flip must not push CSI 997 at a shell that unsubscribed. + expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() + dispose() + }) + + it('answers once when a TUI subscribes at the end of a fish prompt chunk', async () => { + const { transport, emit, dispose } = await connectVisiblePane() + emit(`${FISH_PROMPT_HANDOFF}\x1b[?2031h`) + expect(replies(transport)).toHaveLength(1) + dispose() + }) + + it('answers a subscribe whose withdrawal never arrives', async () => { + const { transport, emit, dispose } = await connectVisiblePane() + // A real TUI: subscribe now, unsubscribe minutes later on exit. + emit('\x1b[?2031h') + emit('painting the ui') + expect(replies(transport)).toHaveLength(1) + dispose() + }) + + it('drops a live subscription when a later chunk withdraws it', async () => { + const { deps, emit, dispose } = await connectVisiblePane() + // A TUI that exits: the earlier chunk really did register, so the withdrawal has + // state to undo — otherwise a theme flip pushes CSI 997 at the shell that replaced it. + emit('\x1b[?2031h') + expect(deps.paneMode2031Ref.current.get(1)).toBe(true) + emit('\x1b[?2031l') + expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() + expect(deps.paneLastThemeModeRef.current.get(1)).toBeUndefined() + dispose() + }) + + it('discards a half-read escape prefix when the pane swaps PTYs', async () => { + const { transport, emit, dispose } = await connectVisiblePane() + // Why: the tail is a byte range from the old stream. Splicing it onto the first + // chunk of a replacement PTY fabricates a subscribe no program ever sent. + transport.serializeBuffer = vi.fn().mockResolvedValue(null) + emit('\x1b[?20') + // droppedOutput latches the hidden-restore PTY id, which is what detects the swap. + emit('', { droppedOutput: true }) + transport.getPtyId.mockReturnValue('pty-id-2') + + emit('31h') + + expect(replies(transport)).toEqual([]) + dispose() + }) + }) + it('answers hidden Codex mode 2031 subscribes split across becoming visible', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -11410,10 +11639,11 @@ describe('connectPanePty', () => { getMainBufferSnapshot.mockClear() // Main hit the per-PTY pending cap and sent the droppedOutput sentinel: the stream has a gap, so repaint from the authoritative main-owned buffer. - capturedDataCallback.current?.('', { droppedOutput: true }) + capturedDataCallback.current?.('\x1b[?2031h', { droppedOutput: true }) await flushAsyncTicks(20) expect(getMainBufferSnapshot).toHaveBeenCalled() + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') expect(pane.terminal.write).toHaveBeenCalledWith( expect.stringContaining('healed from snapshot'), expect.any(Function) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 4fbc140940a..b7dc9d18ff2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -121,9 +121,10 @@ import { shouldUseShellReadyStartupDelivery } from '../../../../shared/codex-sta import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' import { getSystemPrefersDark } from '@/lib/terminal-theme' import { + INITIAL_MODE_2031_REPLY_SCAN_STATE, mode2031SequenceFor, resolveTerminalColorSchemeMode, - scanMode2031Sequences + scanMode2031ReplyDecision } from '../../../../shared/terminal-color-scheme-protocol' import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics' import { subscribeToTerminalUserInput } from './terminal-user-input-signal' @@ -660,11 +661,6 @@ type PanePtyBinding = IDisposable & { requestDroidReconfirmation: () => void reconcileIfSessionDead: (liveSessionIds: Set, snapshotRequestedAt?: number) => void reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void - /** True when the hidden-delivery gate structurally manages the pane's - * current PTY. The lifecycle's xterm CSI ?2031h observer consults this to - * stay silent — main's '2031-subscribe' fact is the sole responder for - * gate-managed PTYs. */ - isHiddenDeliveryGateManagedPty: () => boolean } function isAgentTaskCompleteNotificationEnabled(): boolean { @@ -2239,7 +2235,10 @@ export function connectPanePty( // Why: gated hidden panes never see the subscribe bytes; the fact // replaces the byte scan (and the old post-latch subscribe drop). ...(hiddenDeliveryGateActive - ? { onMode2031Subscribe: handleHiddenMode2031SubscribeFact } + ? { + onMode2031Subscribe: handleHiddenMode2031SubscribeFact, + onMode2031Unsubscribe: handleHiddenMode2031UnsubscribeFact + } : {}) }, restoreTitleOnRegister: true @@ -3590,6 +3589,20 @@ export function connectPanePty( deps.recordPaneMode2031Subscription?.(pane.id, mode) recordHiddenMode2031Reply() } + // Why (gate mode only): the counterpart to the subscribe fact. These panes never + // receive the withdrawal bytes — main drops them before delivery — and both the + // chunk scanner and the xterm CSI handler are disabled for them, so this fact is + // the ONLY observer that can retire the subscription. Without it a TUI that exits + // while hidden leaves paneMode2031 set, and the next theme flip pushes CSI 997 + // into the shell that replaced it (#9993 via maybePushMode2031Flip). No reply is + // sent: a withdrawal is not a query. + const handleHiddenMode2031UnsubscribeFact = (): void => { + if (disposed || !isHiddenDeliveryGateManagedPty(transport.getPtyId())) { + return + } + deps.paneMode2031Ref.current.delete(pane.id) + deps.paneLastThemeModeRef.current.delete(pane.id) + } deps.paneTransportsRef.current.set(pane.id, transport) const terminalCapabilityRepliesDisposable = installTerminalCapabilityReplyHandlers({ terminal: pane.terminal, @@ -5480,7 +5493,7 @@ export function connectPanePty( let foregroundImmediateBudgetWindowStart = 0 let foregroundRewriteChunkEndedWithCarriageReturn = false let foregroundRewriteCsiScanTail = '' - let hiddenMode2031ScanTail = '' + let mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE const shouldSnapshotHiddenCodexOutput = shouldKeepHiddenStartupRendererQueriesLive(paneStartup) let hiddenStartupRendererQueryPending = '' let hiddenRendererStateDirty = false @@ -5802,19 +5815,30 @@ export function connectPanePty( } } - function respondToSkippedMode2031Subscribe(data: string): void { - const scan = scanMode2031Sequences(hiddenMode2031ScanTail, data) - hiddenMode2031ScanTail = scan.tail - if (scan.finalState === 'unsubscribed') { + // Why here and not in xterm's CSI handler: xterm batches several PTY chunks into one + // synchronous parse, so a handler cannot tell where a chunk ended. fish enables and + // disables 2031 around every prompt, so answering a subscribe the same chunk withdraws + // pushes `?997;1n` into the prompt or a child's stdin as literal text (#9993). One raw + // chunk in, one order-aware decision out. + function observeLiveMode2031Chunk(data: string): void { + // Main's '2031-subscribe' fact is the sole responder for gate-managed PTYs; a second + // reply from here would answer one subscribe twice. + if (isHiddenDeliveryGateManagedPty(transport.getPtyId())) { + return + } + const result = scanMode2031ReplyDecision(mode2031ReplyScanState, data) + mode2031ReplyScanState = result.state + if (result.decision === 'unsubscribed') { deps.paneMode2031Ref.current.delete(pane.id) deps.paneLastThemeModeRef.current.delete(pane.id) } - if (scan.finalState !== 'subscribed') { + if (result.decision !== 'subscribed') { return } const settings = useAppStore.getState().settings const mode = resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()) - // Why: hidden snapshot-backed panes skip xterm.write for PTY bytes; answer immediately so the reply can't outlive the program's read window. + // Why immediate: the reply must land inside the program's read window, and hidden + // snapshot-backed panes skip xterm.write for PTY bytes entirely. deps.paneMode2031Ref.current.set(pane.id, true) sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode)) deps.paneLastThemeModeRef.current.set(pane.id, mode) @@ -5892,9 +5916,6 @@ export function connectPanePty( const synchronizedFrameLatencySensitive = synchronizedForegroundOutput && synchronizedForegroundFrameInteractive synchronizedForegroundOutputActive = nextSynchronizedForegroundOutputActive - if (!foreground && hiddenMode2031ScanTail) { - respondToSkippedMode2031Subscribe(data) - } writeTerminalOutput(pane.terminal, data, { foreground: foregroundOutput, beforeWrite: beforeTerminalOutputWrite, @@ -6064,7 +6085,6 @@ export function connectPanePty( function skipHiddenRendererOutput(data: string): void { writeHiddenStartupRendererQueries(data) - respondToSkippedMode2031Subscribe(data) markHiddenOutputRestoreNeeded() hiddenRendererStateDirty = true if (hiddenOutputRestoreInFlight) { @@ -6551,6 +6571,9 @@ export function connectPanePty( function clearPaneMode2031State(): void { deps.paneMode2031Ref.current.delete(pane.id) deps.paneLastThemeModeRef.current.delete(pane.id) + // A partial CSI prefix belongs to the stream that produced it; carrying it into a + // replacement PTY would splice two unrelated byte ranges into one sequence. + mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE } function pulseVisibleLocalPtySizeForTuiRepaint(ptyId: string): void { @@ -6573,7 +6596,6 @@ export function connectPanePty( function skipBackgroundAlternateScreenOutput(data: string): void { writeHiddenStartupRendererQueries(data) - respondToSkippedMode2031Subscribe(data) resetSkippedHiddenRendererRiskState() hiddenRendererStateDirty = true recordHiddenRendererSkip(data.length) @@ -7050,6 +7072,7 @@ export function connectPanePty( } observeStartupDraftPasteReadiness(data) resetHiddenOutputRestoreIfPtyChanged() + observeLiveMode2031Chunk(data) if (meta?.droppedOutput === true) { // Why gated (rc.7.perf loop): a visible pane's cap-drop during its own restore is self-caused backpressure; defer to one post-flood repaint instead of re-arming per sentinel. if (meta?.background !== true && isForegroundRestoreBackpressureContext()) { @@ -7081,9 +7104,6 @@ export function connectPanePty( if (!foreground) { syncHiddenRendererPtyDelivery() } - if (foreground && hiddenMode2031ScanTail) { - respondToSkippedMode2031Subscribe(data) - } // Post-restore reconciliation: drop chunks the snapshot covers, force a fresh restore for unmappable seq gaps; runs after byte observers, before any xterm write. const reconciliation = reconcileChunkAgainstRestoredSnapshot(data, meta) if (reconciliation.action === 'drop-duplicate') { @@ -8247,9 +8267,6 @@ export function connectPanePty( // Why: the hidden-delivery gate must follow every pane visibility flip. syncHiddenRendererPtyDelivery() }, - isHiddenDeliveryGateManagedPty() { - return isHiddenDeliveryGateManagedPty(transport.getPtyId()) - }, // Why: visible-resume size readback repairs dropped hidden resizes without refitting against xterm's transient hidden DOM fallback. noteVisibilityResume() { ptySizeReassertion.request({ fit: false }) diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index 8a0e8842a32..62b43f0b158 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -1,16 +1,13 @@ import { describe, expect, it, vi } from 'vitest' -import { Terminal } from '@xterm/headless' import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import { getDefaultSettings } from '../../../../shared/constants' import { applyTerminalAppearance, hexToRgba, - installMode2031Handlers, publishTerminalViewAttributesAtAppStart } from './terminal-appearance' import { maybePushMode2031Flip } from './terminal-mode-2031-replies' import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-protocol' -import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' import { _resetTerminalViewAttributesPublisherForTest } from './terminal-view-attributes-publisher' import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' @@ -138,219 +135,6 @@ describe('maybePushMode2031Flip', () => { expect(last.get(2)).toBe('dark') }) }) -describe('installMode2031Handlers', () => { - // Regression coverage for the "random characters on restart" bug: a replayed `CSI ?2031h` pushed `CSI ?997;1n` into the fresh shell. - - function writeSync(term: Terminal, data: string): Promise { - return new Promise((resolve) => term.write(data, resolve)) - } - - function makeReplayingRef(): ReplayingPanesRef { - return { current: new Map() } as ReplayingPanesRef - } - - function setup(paneId = 1): { - term: Terminal - pane: ManagedPane - replayingPanesRef: ReplayingPanesRef - onSubscribe: ReturnType - paneMode2031: Map - paneLastThemeMode: Map - dispose: () => void - } { - const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) - const pane = { id: paneId, terminal: term } as unknown as ManagedPane - const replayingPanesRef = makeReplayingRef() - const paneMode2031 = new Map() - const paneLastThemeMode = new Map() - const onSubscribe = vi.fn() - const disposables = installMode2031Handlers({ - paneId, - parser: term.parser, - onSubscribe, - isReplaying: () => (replayingPanesRef.current.get(paneId) ?? 0) > 0, - paneMode2031, - paneLastThemeMode - }) - return { - term, - pane, - replayingPanesRef, - onSubscribe, - paneMode2031, - paneLastThemeMode, - dispose: () => { - for (const d of disposables) { - d.dispose() - } - term.dispose() - } - } - } - - it('records subscribe and fires onSubscribe on a live `CSI ?2031h`', async () => { - const h = setup() - try { - await writeSync(h.term, '\x1b[?2031h') - expect(h.paneMode2031.get(1)).toBe(true) - expect(h.onSubscribe).toHaveBeenCalledTimes(1) - } finally { - h.dispose() - } - }) - - it('does NOT fire onSubscribe or record state when the sequence arrives during replay', async () => { - // On cold restore the replay guard is set before xterm parses, so the handler must skip both the push and the bookkeeping. - const h = setup() - try { - replayIntoTerminal(h.pane, h.replayingPanesRef, '\x1b[?2031h') - // write() is async: the replay guard stays engaged until the write-completion callback fires. - await new Promise((resolve) => h.term.write('', resolve)) - - expect(h.onSubscribe).not.toHaveBeenCalled() - expect(h.paneMode2031.has(1)).toBe(false) - expect(h.paneLastThemeMode.has(1)).toBe(false) - // Once the replay window closes, the pane is not marked replaying. - expect(h.replayingPanesRef.current.get(1) ?? 0).toBe(0) - } finally { - h.dispose() - } - }) - - it('still honors a real `CSI ?2031h` received after a replay window closes', async () => { - // A real `?2031h` from a TUI relaunched after cold restore must take effect normally. - const h = setup() - try { - replayIntoTerminal(h.pane, h.replayingPanesRef, '\x1b[?2031h') - await new Promise((resolve) => h.term.write('', resolve)) - expect(h.onSubscribe).not.toHaveBeenCalled() - - await writeSync(h.term, '\x1b[?2031h') - expect(h.paneMode2031.get(1)).toBe(true) - expect(h.onSubscribe).toHaveBeenCalledTimes(1) - } finally { - h.dispose() - } - }) - - it('clears subscribe state on `CSI ?2031l` regardless of replay state', async () => { - // The `l` (unsubscribe) branch is intentionally not replay-guarded: clearing is harmless since we only send on subscribe. - const h = setup() - try { - // Non-replay path: subscribe then unsubscribe clears state. - await writeSync(h.term, '\x1b[?2031h') - h.paneLastThemeMode.set(1, 'dark') - expect(h.paneMode2031.get(1)).toBe(true) - - await writeSync(h.term, '\x1b[?2031l') - expect(h.paneMode2031.has(1)).toBe(false) - expect(h.paneLastThemeMode.has(1)).toBe(false) - - // Replay path: the `l` handler must still clear even during a replay window. - await writeSync(h.term, '\x1b[?2031h') - h.paneLastThemeMode.set(1, 'dark') - expect(h.paneMode2031.get(1)).toBe(true) - - replayIntoTerminal(h.pane, h.replayingPanesRef, '\x1b[?2031l') - await new Promise((resolve) => h.term.write('', resolve)) - expect(h.paneMode2031.has(1)).toBe(false) - expect(h.paneLastThemeMode.has(1)).toBe(false) - } finally { - h.dispose() - } - }) - - it('returns `false` so compound DEC private modes still reach xterm', async () => { - // Why: handlers return `false` so compound sequences like `CSI ?25;2031h` still reach xterm's built-in DEC private mode handler. - const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) - const paneMode2031 = new Map() - const paneLastThemeMode = new Map() - const onSubscribe = vi.fn() - const returnValues: boolean[] = [] - // Cast: parser cb returns plain `boolean` but `Mode2031Parser` reflects xterm's `boolean | Promise` (handlers here are sync). - const spyParser: Parameters[0]['parser'] = { - registerCsiHandler: (id, cb) => - term.parser.registerCsiHandler(id, (params) => { - const r = cb(params) as boolean - returnValues.push(r) - return r - }) - } - const disposables = installMode2031Handlers({ - paneId: 1, - parser: spyParser, - onSubscribe, - isReplaying: () => false, - paneMode2031, - paneLastThemeMode - }) - try { - // Compound: ?25 (cursor show) + ?2031 (color-scheme subscribe). - await writeSync(term, '\x1b[?25;2031h') - // Our 2031 recording fired: - expect(paneMode2031.get(1)).toBe(true) - expect(onSubscribe).toHaveBeenCalledTimes(1) - // Every handler invocation returned `false`, so xterm's built-in DEC private mode handler still processes the sequence. - expect(returnValues.length).toBeGreaterThan(0) - expect(returnValues.every((v) => v === false)).toBe(true) - } finally { - for (const d of disposables) { - d.dispose() - } - term.dispose() - } - }) - - it('keeps per-pane state isolated when two panes share the parser API', async () => { - // The subscribe bookkeeping map is shared across panes, so a replay on pane 1 must not leak into pane 2's live subscribe. - const shared2031 = new Map() - const sharedLast = new Map() - const replayingPanesRef = makeReplayingRef() - - const term1 = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) - const term2 = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) - const pane1 = { id: 1, terminal: term1 } as unknown as ManagedPane - const onSub1 = vi.fn() - const onSub2 = vi.fn() - - const d1 = installMode2031Handlers({ - paneId: 1, - parser: term1.parser, - onSubscribe: onSub1, - isReplaying: () => (replayingPanesRef.current.get(1) ?? 0) > 0, - paneMode2031: shared2031, - paneLastThemeMode: sharedLast - }) - const d2 = installMode2031Handlers({ - paneId: 2, - parser: term2.parser, - onSubscribe: onSub2, - isReplaying: () => (replayingPanesRef.current.get(2) ?? 0) > 0, - paneMode2031: shared2031, - paneLastThemeMode: sharedLast - }) - - try { - // Replay on pane 1 must not subscribe. - replayIntoTerminal(pane1, replayingPanesRef, '\x1b[?2031h') - await new Promise((resolve) => term1.write('', resolve)) - expect(onSub1).not.toHaveBeenCalled() - expect(shared2031.has(1)).toBe(false) - - // Live on pane 2 must subscribe normally. - await writeSync(term2, '\x1b[?2031h') - expect(onSub2).toHaveBeenCalledTimes(1) - expect(shared2031.get(2)).toBe(true) - } finally { - for (const d of [...d1, ...d2]) { - d.dispose() - } - term1.dispose() - term2.dispose() - } - }) -}) - describe('applyTerminalAppearance theme assignment', () => { // xterm rebuilds the palette on any new theme-object identity (wiping OSC color mutations), so the assignment must be value-gated. function makePane(id: number): ManagedPane { diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index fa93552388a..c1ff2c24f1a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -1,4 +1,4 @@ -import type { IDisposable, IParser, ITheme } from '@xterm/xterm' +import type { ITheme } from '@xterm/xterm' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import type { GlobalSettings } from '../../../../shared/types' import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' @@ -9,7 +9,6 @@ import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { buildFontFamily } from './layout-serialization' -import { guardParserHandler } from './terminal-parser-handler-guard' import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' import { normalizeTerminalFastScrollSensitivity, @@ -26,57 +25,6 @@ import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-he import { maybePushMode2031Flip } from './terminal-mode-2031-replies' import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction' -// Why Pick over a hand-rolled type: stays tied to xterm's canonical signature so upstream tightening surfaces here. -type Mode2031Parser = Pick - -type Mode2031HandlerDeps = { - paneId: number - parser: Mode2031Parser - /** Called when a real (non-replayed) `CSI ?2031h` arrives, after the subscribe flag is set. - * A callback so the lifecycle hook keeps its transport-aware `pushMode2031ForPane` closure. */ - onSubscribe: () => void - isReplaying: () => boolean - paneMode2031: Map - paneLastThemeMode: Map -} - -// Why a pure function: lets tests drive a real xterm parser end-to-end against the "random characters on restart" guard. -export function installMode2031Handlers(deps: Mode2031HandlerDeps): IDisposable[] { - const hasMode2031 = (params: (number | number[])[]): boolean => - params.some((p) => (Array.isArray(p) ? p.includes(2031) : p === 2031)) - - // Why return false: we only observe mode 2031; false lets xterm's built-in DEC handler still process compound sequences. - return [ - deps.parser.registerCsiHandler( - { prefix: '?', final: 'h' }, - guardParserHandler('csi-mode2031-subscribe', (params) => { - if (hasMode2031(params)) { - // Why gate on isReplaying: a restored buffer's replayed `?2031h` would push `?997;1n` into a fresh shell with no - // TUI, which echoes it as literal text; pty-connection's guard covers only xterm auto-replies, not handler sends. - // Return early (before recording the subscribe bit) so a later theme flip won't push into a shell that isn't subscribed. - if (deps.isReplaying()) { - return false - } - deps.paneMode2031.set(deps.paneId, true) - deps.onSubscribe() - } - return false - }) - ), - // Why no replay guard here: we only push CSI 997 on subscribe; unsubscribe just clears map entries, so replay is harmless. - deps.parser.registerCsiHandler( - { prefix: '?', final: 'l' }, - guardParserHandler('csi-mode2031-unsubscribe', (params) => { - if (hasMode2031(params)) { - deps.paneMode2031.delete(deps.paneId) - deps.paneLastThemeMode.delete(deps.paneId) - } - return false - }) - ) - ] -} - export function hexToRgba(hex: string, alpha: number): string { let clean = hex.replace('#', '') if (clean.length === 3) { diff --git a/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts deleted file mode 100644 index f26cd149894..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { pushMode2031SeedReply } from './terminal-mode-2031-replies' - -describe('pushMode2031SeedReply', () => { - function createHarness(connected: boolean): { - connected: { current: boolean } - subscribed: { current: boolean } - sendInput: ReturnType boolean>> - sendInputImmediate: ReturnType boolean>> - scheduled: (() => void)[] - recordMode: ReturnType - push: () => void - } { - const connectedState = { current: connected } - const subscribed = { current: true } - const sendInput = vi.fn<(data: string) => boolean>(() => true) - const sendInputImmediate = vi.fn<(data: string) => boolean>(() => true) - const scheduled: (() => void)[] = [] - const recordMode = vi.fn() - const currentAttempt = { current: Symbol() } - const transport = { - isConnected: () => connectedState.current, - sendInput, - sendInputImmediate - } - return { - connected: connectedState, - subscribed, - sendInput, - sendInputImmediate, - scheduled, - recordMode, - push: () => { - const attempt = Symbol() - currentAttempt.current = attempt - pushMode2031SeedReply(1, { - hasPane: () => true, - isSubscribed: () => subscribed.current, - isCurrentAttempt: () => currentAttempt.current === attempt, - getTransport: () => transport, - getMode: () => 'dark', - recordMode, - schedule: (callback) => scheduled.push(callback) - }) - } - } - } - - it('routes the color-scheme response through latency-critical input', () => { - const harness = createHarness(true) - - harness.push() - - expect(harness.sendInputImmediate).toHaveBeenCalledWith('\x1b[?997;1n') - expect(harness.sendInput).not.toHaveBeenCalled() - expect(harness.recordMode).toHaveBeenCalledWith(1, 'dark') - }) - - it('cancels a pre-connect retry after the program unsubscribes', () => { - const harness = createHarness(false) - - harness.push() - expect(harness.scheduled).toHaveLength(1) - - harness.subscribed.current = false - harness.connected.current = true - harness.scheduled.shift()?.() - - expect(harness.sendInputImmediate).not.toHaveBeenCalled() - expect(harness.sendInput).not.toHaveBeenCalled() - expect(harness.scheduled).toHaveLength(0) - }) - - it('does not let an old retry answer a later subscription', () => { - const harness = createHarness(false) - - harness.push() - harness.subscribed.current = false - harness.subscribed.current = true - harness.push() - expect(harness.scheduled).toHaveLength(2) - - harness.connected.current = true - harness.scheduled.shift()?.() - harness.scheduled.shift()?.() - - expect(harness.sendInputImmediate).toHaveBeenCalledTimes(1) - expect(harness.recordMode).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts index 66df12517b1..7e709dc51e9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts +++ b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts @@ -2,21 +2,8 @@ import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-pr import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' import type { PtyTransport } from './pty-transport' -const MODE_2031_CONNECT_RETRY_MS = 25 -const MODE_2031_CONNECT_ATTEMPTS = 8 - type Mode2031ReplyTransport = Pick -type Mode2031SeedReplyDeps = { - hasPane: (paneId: number) => boolean - isSubscribed: (paneId: number) => boolean - isCurrentAttempt: (paneId: number) => boolean - getTransport: (paneId: number) => Mode2031ReplyTransport | undefined - getMode: () => TerminalColorSchemeMode | null - recordMode: (paneId: number, mode: TerminalColorSchemeMode) => void - schedule: (callback: () => void, delayMs: number) => void -} - function sendMode2031Reply( transport: Mode2031ReplyTransport, mode: TerminalColorSchemeMode @@ -26,30 +13,6 @@ function sendMode2031Reply( return transport.sendInputImmediate(mode2031SequenceFor(mode)) } -export function pushMode2031SeedReply(paneId: number, deps: Mode2031SeedReplyDeps): void { - let attempts = 0 - const send = (): void => { - // Why: a TUI can unsubscribe while the PTY is connecting; every delayed - // attempt must revalidate intent or its color reply can land at a shell prompt. - if (!deps.hasPane(paneId) || !deps.isSubscribed(paneId) || !deps.isCurrentAttempt(paneId)) { - return - } - const transport = deps.getTransport(paneId) - if (!transport?.isConnected()) { - attempts += 1 - if (attempts < MODE_2031_CONNECT_ATTEMPTS) { - deps.schedule(send, MODE_2031_CONNECT_RETRY_MS) - } - return - } - const mode = deps.getMode() - if (mode && sendMode2031Reply(transport, mode)) { - deps.recordMode(paneId, mode) - } - } - send() -} - // Appearance updates include font and opacity changes, so only report actual // color-mode flips to programs that still have mode 2031 enabled. export function maybePushMode2031Flip( diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index d9f3c8d7d31..892e8977d72 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -86,6 +86,9 @@ export type TerminalSideEffectFactConsumerCallbacks = { * hidden-delivery-gated consumers (their bytes never arrive); the theme * reply is sent renderer-side — query authority stays with the view. */ onMode2031Subscribe?: () => void + /** DECSET 2031 withdrawal observed by main's tracker. Clears the pane's + * subscription registry so later theme flips stop pushing CSI 997. */ + onMode2031Unsubscribe?: () => void } type ConsumerEntry = { @@ -137,6 +140,9 @@ function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: return case '2031-subscribe': entry.callbacks.onMode2031Subscribe?.() + return + case '2031-unsubscribe': + entry.callbacks.onMode2031Unsubscribe?.() } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index bdc8ea6300b..ff49cc46800 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -66,8 +66,7 @@ import { scheduleTerminalInitialRenderSettled } from './terminal-initial-render- import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' import { makePaneKey } from '../../../../shared/stable-pane-id' import { applyExpandedLayoutTo, restoreExpandedLayoutFrom } from './expand-collapse' -import { applyTerminalAppearance, installMode2031Handlers } from './terminal-appearance' -import { pushMode2031SeedReply } from './terminal-mode-2031-replies' +import { applyTerminalAppearance } from './terminal-appearance' import { createOsc52OscHandler } from './osc52-clipboard' import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast' import { parseOsc7 } from './parse-osc7' @@ -98,7 +97,6 @@ import { import type { PaneCwdMap } from './resolve-split-cwd' import { installMouseHideWhileTyping } from './mouse-hide-while-typing' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' -import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { connectPanePty } from './pty-connection' import type { PtyTransport } from './pty-transport' import type { PtyTransportRecoveryState } from './pty-transport-types' @@ -574,8 +572,6 @@ export function useTerminalPaneLifecycle({ // Why: read settingsRef at fire time so toggling "copy on select" applies without recreating panes. const selectionDisposablesRef = useRef(new Map()) const selectionCaptureTimersRef = useRef(new Map()) - const mode2031DisposablesRef = useRef(new Map()) - const mode2031SeedAttemptTokensRef = useRef(new Map()) const osc52DisposablesRef = useRef(new Map()) const osc7DisposablesRef = useRef(new Map()) const mouseHideDisposablesRef = useRef(new Map()) @@ -601,30 +597,6 @@ export function useTerminalPaneLifecycle({ ) } - const pushMode2031ForPane = (paneId: number): void => { - const attemptToken = Symbol() - mode2031SeedAttemptTokensRef.current.set(paneId, attemptToken) - pushMode2031SeedReply(paneId, { - hasPane: (candidateId) => - managerRef.current?.getPanes().some((pane) => pane.id === candidateId) === true, - isSubscribed: (candidateId) => paneMode2031Ref.current.get(candidateId) === true, - // Why: an older connect retry must not answer a later resubscription. - isCurrentAttempt: (candidateId) => - mode2031SeedAttemptTokensRef.current.get(candidateId) === attemptToken, - getTransport: (candidateId) => paneTransportsRef.current.get(candidateId), - getMode: () => { - const currentSettings = settingsRef.current - return currentSettings - ? resolveEffectiveTerminalAppearance(currentSettings, systemPrefersDarkRef.current).mode - : null - }, - recordMode: (candidateId, mode) => paneLastThemeModeRef.current.set(candidateId, mode), - schedule: (callback, delayMs) => { - window.setTimeout(callback, delayMs) - } - }) - } - // Initialize PaneManager instance once useEffect(() => { const container = containerRef.current @@ -786,26 +758,6 @@ export function useTerminalPaneLifecycle({ const manager = new PaneManager(container, { // `spawnHints.cwd` (from Split actions) lets the new PTY inherit the source pane's cwd — see docs/ssh-split-pane-inherit-cwd.md. onPaneCreated: (pane, spawnHints) => { - // Install mode 2031 handlers before PTY attach so the child's initial CSI ?2031h is captured. - const mode2031Disposables = installMode2031Handlers({ - paneId: pane.id, - parser: pane.terminal.parser, - onSubscribe: () => { - // Why: for hidden-delivery-gate PTYs, main's '2031-subscribe' fact is the sole responder — don't send a second reply. - const binding = panePtyBindings.get(pane.id) as - | (IDisposable & { isHiddenDeliveryGateManagedPty?: () => boolean }) - | undefined - if (binding?.isHiddenDeliveryGateManagedPty?.()) { - return - } - pushMode2031ForPane(pane.id) - }, - isReplaying: () => isPaneReplaying(replayingPanesRef, pane.id), - paneMode2031: paneMode2031Ref.current, - paneLastThemeMode: paneLastThemeModeRef.current - }) - mode2031DisposablesRef.current.set(pane.id, mode2031Disposables) - // OSC 52 — TUI-initiated clipboard writes (Zellij/tmux/nvim/fzf/ssh). // Why: read settingsRef at fire time so mid-session gate toggles apply; return true in both paths so xterm doesn't fall through. const osc52Disposable = pane.terminal.parser.registerOscHandler( @@ -1185,15 +1137,7 @@ export function useTerminalPaneLifecycle({ window.clearTimeout(selectionCaptureTimer) selectionCaptureTimersRef.current.delete(paneId) } - const mode2031Disposables = mode2031DisposablesRef.current.get(paneId) - if (mode2031Disposables) { - for (const d of mode2031Disposables) { - d.dispose() - } - mode2031DisposablesRef.current.delete(paneId) - } paneMode2031Ref.current.delete(paneId) - mode2031SeedAttemptTokensRef.current.delete(paneId) paneKittyKeyboardModesRef.current.delete(paneId) paneLastThemeModeRef.current.delete(paneId) const osc52Disposable = osc52DisposablesRef.current.get(paneId) diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json index 8c2bb743b44..f4b1a07abfe 100644 --- a/src/shared/local-build-compatibility-contract.json +++ b/src/shared/local-build-compatibility-contract.json @@ -3,9 +3,9 @@ "appId": "com.stablyai.orca", "stateSchemaVersion": 1, "readableStateSchemaVersions": [1], - "daemonProtocolVersion": 28, + "daemonProtocolVersion": 29, "attachableDaemonProtocolVersions": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28 + 27, 28, 29 ] } diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts index f05be487dd2..ad80dd85808 100644 --- a/src/shared/local-build-compatibility-contract.ts +++ b/src/shared/local-build-compatibility-contract.ts @@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = { appId: 'com.stablyai.orca', stateSchemaVersion: 1, readableStateSchemaVersions: [1], - daemonProtocolVersion: 28, + daemonProtocolVersion: 29, attachableDaemonProtocolVersions: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28 + 27, 28, 29 ] } as const diff --git a/src/shared/terminal-color-scheme-protocol.ts b/src/shared/terminal-color-scheme-protocol.ts index 22c8c426fdb..1b6d680c2a1 100644 --- a/src/shared/terminal-color-scheme-protocol.ts +++ b/src/shared/terminal-color-scheme-protocol.ts @@ -24,13 +24,41 @@ export type Mode2031ScanResult = { unsubscribe: boolean finalState: 'subscribed' | 'unsubscribed' | null tail: string + /** + * The retained tail is a private-mode sequence still capable of resolving to + * 2031 once the next chunk arrives — so `finalState` is provisional, not final. + */ + tailMayResolveToMode2031: boolean +} + +export type Mode2031ReplyScanState = { + tail: string + pendingSubscribe: boolean +} + +export type Mode2031ReplyDecision = 'subscribed' | 'unsubscribed' | null + +export type Mode2031ReplyScanResult = { + decision: Mode2031ReplyDecision + state: Mode2031ReplyScanState +} + +export const INITIAL_MODE_2031_REPLY_SCAN_STATE: Mode2031ReplyScanState = { + tail: '', + pendingSubscribe: false +} + +const NO_MODE_2031_REPLY_DECISION: Mode2031ReplyScanResult = { + decision: null, + state: INITIAL_MODE_2031_REPLY_SCAN_STATE } const NO_MODE_2031_SEQUENCE: Mode2031ScanResult = { subscribe: false, unsubscribe: false, finalState: null, - tail: '' + tail: '', + tailMayResolveToMode2031: false } export function scanMode2031Sequences(previousTail: string, data: string): Mode2031ScanResult { @@ -38,11 +66,13 @@ export function scanMode2031Sequences(previousTail: string, data: string): Mode2 return NO_MODE_2031_SEQUENCE } const input = `${previousTail}${data}` + const tail = extractPrivateModeScanTail(input) const result: Mode2031ScanResult = { subscribe: false, unsubscribe: false, finalState: null, - tail: extractPrivateModeScanTail(input) + tail, + tailMayResolveToMode2031: tailCouldStillBeMode2031(tail) } // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars const privateModeRe = /\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g @@ -63,6 +93,40 @@ export function scanMode2031Sequences(previousTail: string, data: string): Mode2 return result } +export function scanMode2031ReplyDecision( + previous: Mode2031ReplyScanState, + data: string +): Mode2031ReplyScanResult { + if ( + !previous.pendingSubscribe && + !previous.tail && + !data.includes('\x1b') && + !data.includes('\x9b') + ) { + return NO_MODE_2031_REPLY_DECISION + } + const scan = scanMode2031Sequences(previous.tail, data) + let decision = scan.finalState + let pendingSubscribe = previous.pendingSubscribe + + if (scan.finalState === 'unsubscribed') { + pendingSubscribe = false + } else if (scan.finalState === 'subscribed' || pendingSubscribe) { + if (scan.tailMayResolveToMode2031) { + decision = null + pendingSubscribe = true + } else { + decision = 'subscribed' + pendingSubscribe = false + } + } + + return { + decision, + state: { tail: scan.tail, pendingSubscribe } + } +} + function hasMode2031(params: string): boolean { return params.split(';').some((param) => Number(param) === 2031) } @@ -91,3 +155,13 @@ function extractPrivateModeScanTail(input: string): string { function isIncompletePrivateModeParams(params: string): boolean { return /^[0-9;]*$/.test(params) } + +/** + * Whether a retained (incomplete) private-mode tail could still turn out to be a + * 2031 toggle. Lets the caller hold a provisional decision for one chunk instead + * of answering a subscribe that the very next bytes withdraw (#9993). + */ +function tailCouldStillBeMode2031(tail: string): boolean { + // Any retained private-mode prefix can still append `;2031` before its final byte. + return tail.length > 0 +} diff --git a/src/shared/terminal-mode-2031-final-state.test.ts b/src/shared/terminal-mode-2031-final-state.test.ts new file mode 100644 index 00000000000..b8e04d9f1c0 --- /dev/null +++ b/src/shared/terminal-mode-2031-final-state.test.ts @@ -0,0 +1,133 @@ +// Why: fish enables and disables DEC mode 2031 around every prompt +// (`src/tty_handoff.rs`), so a single PTY chunk routinely carries +// `?2031h ... ?2031l`. Answering the sticky "an h appeared" flag replies to a +// subscription the shell has already dropped, and the reply lands as literal +// text at the prompt or in a child's stdin (#9993). +import { describe, expect, it } from 'vitest' +import { scanMode2031Sequences } from './terminal-color-scheme-protocol' +import { + createTerminalTitleTracker, + type TerminalTitleTrackerCallbacks +} from './terminal-output-side-effects' + +const ESC = '\x1b' + +// A fish prompt cycle: subscribe, paint the prompt, hand the tty to the child. +const FISH_PROMPT_HANDOFF = `${ESC}[?2031h${ESC}[0m~/orca ${ESC}[32m❯${ESC}[0m ${ESC}[?2031l` + +function trackerRecording(overrides: TerminalTitleTrackerCallbacks = {}): { + subscribes: number + tracker: ReturnType +} { + const state = { subscribes: 0 } + const tracker = createTerminalTitleTracker({ + onMode2031Subscribe: () => { + state.subscribes += 1 + }, + ...overrides + }) + return { + get subscribes() { + return state.subscribes + }, + tracker + } +} + +describe('DECSET 2031 replies follow the chunk-final state (#9993)', () => { + it('reports a subscribe-then-unsubscribe chunk as unsubscribed', () => { + const scan = scanMode2031Sequences('', FISH_PROMPT_HANDOFF) + + // The sticky flags stay true — both toggles really did occur. + expect(scan.subscribe).toBe(true) + expect(scan.unsubscribe).toBe(true) + // But the shell is NOT listening by the end of the chunk. + expect(scan.finalState).toBe('unsubscribed') + }) + + it('does not emit a 2031-subscribe fact when the shell unsubscribed in the same chunk', () => { + const recorded = trackerRecording() + + recorded.tracker.handleChunk(FISH_PROMPT_HANDOFF) + + expect(recorded.subscribes).toBe(0) + }) + + it('still emits a fact when the chunk ends subscribed', () => { + const recorded = trackerRecording() + + recorded.tracker.handleChunk(`${ESC}[?2031l${ESC}[?2031h`) + + expect(recorded.subscribes).toBe(1) + }) + + it('emits once per chunk that ends subscribed, across a fish prompt loop', () => { + const recorded = trackerRecording() + + // Three prompt cycles, then a TUI that subscribes and keeps listening. + recorded.tracker.handleChunk(FISH_PROMPT_HANDOFF) + recorded.tracker.handleChunk(FISH_PROMPT_HANDOFF) + recorded.tracker.handleChunk(FISH_PROMPT_HANDOFF) + recorded.tracker.handleChunk(`${ESC}[?2031h`) + + expect(recorded.subscribes).toBe(1) + }) + + it('keeps answering a subscribe split across chunk boundaries', () => { + const recorded = trackerRecording() + + recorded.tracker.handleChunk(`${ESC}[?20`) + recorded.tracker.handleChunk('31h') + + expect(recorded.subscribes).toBe(1) + }) + + it('does not answer a subscribe whose withdrawal straddles the chunk boundary', () => { + const recorded = trackerRecording() + + // Same bytes as FISH_PROMPT_HANDOFF, just cut mid-withdrawal by the kernel. + // A reply cannot be recalled, so chunk 1 must hold rather than answer-then-regret. + recorded.tracker.handleChunk(`${ESC}[?2031h prompt ${ESC}[?20`) + recorded.tracker.handleChunk('31l') + + expect(recorded.subscribes).toBe(0) + }) + + it('does not defer for a partial sequence that cannot become a private mode', () => { + const recorded = trackerRecording() + + recorded.tracker.handleChunk(`${ESC}[?2031h drawing ${ESC}[25`) + + expect(recorded.subscribes).toBe(1) + }) + + it('defers an unrelated private-mode prefix that can append 2031', () => { + const recorded = trackerRecording() + + recorded.tracker.handleChunk(`${ESC}[?2031h drawing ${ESC}[?25`) + expect(recorded.subscribes).toBe(0) + recorded.tracker.handleChunk(';2031l') + + expect(recorded.subscribes).toBe(0) + }) + + it('answers a deferred subscribe when the ambiguous tail resolves to another mode', () => { + const recorded = trackerRecording() + + recorded.tracker.handleChunk(`${ESC}[?2031h drawing ${ESC}[?20`) + expect(recorded.subscribes).toBe(0) + recorded.tracker.handleChunk('25h') + + expect(recorded.subscribes).toBe(1) + }) + + it('answers the re-subscribe once when a split toggle resolves back to h', () => { + const recorded = trackerRecording() + + recorded.tracker.handleChunk(`${ESC}[?2031h p ${ESC}[?2031l${ESC}[?20`) + recorded.tracker.handleChunk('31h') + + // Chunk 1 ends unsubscribed-then-pending, chunk 2 resolves to subscribed. + expect(recorded.subscribes).toBe(1) + }) +}) diff --git a/src/shared/terminal-output-side-effects.test.ts b/src/shared/terminal-output-side-effects.test.ts index d9da1b23928..876e4dc12cd 100644 --- a/src/shared/terminal-output-side-effects.test.ts +++ b/src/shared/terminal-output-side-effects.test.ts @@ -18,6 +18,7 @@ type RecordedEvent = | ['finished', number | null] | ['pr', string, number] | ['2031-subscribe'] + | ['2031-unsubscribe'] function createRecordingTracker(overrides: TerminalTitleTrackerCallbacks = {}): { events: RecordedEvent[] @@ -30,6 +31,7 @@ function createRecordingTracker(overrides: TerminalTitleTrackerCallbacks = {}): onCommandFinished: (exitCode) => events.push(['finished', exitCode]), onPrLink: (link) => events.push(['pr', link.url, link.number]), onMode2031Subscribe: () => events.push(['2031-subscribe']), + onMode2031Unsubscribe: () => events.push(['2031-unsubscribe']), ...overrides }) return { events, tracker } @@ -134,16 +136,40 @@ describe('createTerminalTitleTracker 2031-subscribe facts', () => { expect(events).toEqual([['2031-subscribe']]) }) - it('ignores DECSET 2031 unsubscribes', () => { + it('emits an unsubscribe fact so gated views can retire the subscription', () => { + // Gated views never see these bytes; without the fact their registry goes stale + // and a later theme flip pushes CSI 997 at a shell that already withdrew (#9993). const { events, tracker } = createRecordingTracker() tracker.handleChunk(`${ESC}[?2031l`) + expect(events).toEqual([['2031-unsubscribe']]) + }) + + it('emits nothing for chunks that carry no 2031 bytes at all', () => { + // finalState is null here, not 'unsubscribed' — ordinary output must stay silent. + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk('plain build output\r\n') + expect(events).toEqual([]) }) + it('reports only the chunk-final state when a chunk toggles 2031 both ways', () => { + // fish enables and disables 2031 around every prompt; one chunk, one decision. + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}[?2031hprompt${ESC}[?2031l`) + tracker.handleChunk(`${ESC}[?2031lprompt${ESC}[?2031h`) + + expect(events).toEqual([['2031-unsubscribe'], ['2031-subscribe']]) + }) + it('skips the 2031 scan entirely when no consumer is registered', () => { - const { events, tracker } = createRecordingTracker({ onMode2031Subscribe: undefined }) + const { events, tracker } = createRecordingTracker({ + onMode2031Subscribe: undefined, + onMode2031Unsubscribe: undefined + }) tracker.handleChunk(`${ESC}[?2031h`) @@ -219,4 +245,18 @@ describe('createTerminalTitleTracker transient-fact scanning suppression', () => expect(events).toEqual([['finished', 130]]) }) + + it('a handoff scan seed preserves a provisional 2031 subscribe', () => { + const { events, tracker } = createRecordingTracker() + + tracker.setTransientFactScanningSuppressed(true) + tracker.setTransientFactScanningSuppressed(false) + tracker.handleChunk(`${ESC}[?`, { + titleScanData: '', + mode2031PendingSubscribe: true + }) + tracker.handleChunk('25h') + + expect(events).toEqual([['2031-subscribe']]) + }) }) diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts index 2b4b3ea81c5..cde876882c6 100644 --- a/src/shared/terminal-output-side-effects.ts +++ b/src/shared/terminal-output-side-effects.ts @@ -13,7 +13,10 @@ import { normalizeTerminalTitle } from './agent-detection' import { createBellDetector } from './terminal-bell-detector' -import { scanMode2031Sequences } from './terminal-color-scheme-protocol' +import { + INITIAL_MODE_2031_REPLY_SCAN_STATE, + scanMode2031ReplyDecision +} from './terminal-color-scheme-protocol' import { createTerminalGitHubPRLinkDetector, type TerminalGitHubPRLink @@ -40,6 +43,11 @@ export type TerminalTitleFactMeta = { staleWorkingTitleClear?: boolean } +type TerminalTitleTrackerChunkOptions = { + titleScanData?: string + mode2031PendingSubscribe?: boolean +} + export type TerminalTitleTrackerCallbacks = { /** Fired once per observed OSC title, in byte order — including the synthesized cleared title when the stale-working timer fires. */ onTitle?: (normalizedTitle: string, rawTitle: string, meta?: TerminalTitleFactMeta) => void @@ -60,11 +68,18 @@ export type TerminalTitleTrackerCallbacks = { * hidden-delivery-gated renderer views answer the color-scheme query without byte access. */ onMode2031Subscribe?: () => void + /** + * Fired per chunk that ends *unsubscribed* after having carried 2031 bytes. Gated + * views never see the withdrawal (main drops their bytes), so without this fact + * their subscription registry goes stale and a later theme flip pushes CSI 997 + * into a shell that already withdrew — #9993 through the theme-change door. + */ + onMode2031Unsubscribe?: () => void } export type TerminalTitleTracker = { /** Feed one raw PTY chunk; titles are applied synchronously in byte order. */ - handleChunk: (data: string, options?: { titleScanData?: string }) => void + handleChunk: (data: string, options?: TerminalTitleTrackerChunkOptions) => void /** * Apply a main-fabricated OSC title/BEL frame (agent hook spinner frames). Parsed statelessly, * never through the chunk bell detector, so a synthetic tick can't corrupt cross-chunk escape state. @@ -99,7 +114,8 @@ export function createTerminalTitleTracker( onBell, onCommandFinished, onPrLink, - onMode2031Subscribe + onMode2031Subscribe, + onMode2031Unsubscribe } = callbacks const bellDetector = onBell ? createBellDetector() : null // Why: created only when a consumer exists so headless serve never pays the per-chunk 133/URL scans. @@ -108,8 +124,7 @@ export function createTerminalTitleTracker( : null let prLinkDetector = onPrLink ? createTerminalGitHubPRLinkDetector() : null let transientFactScanningSuppressed = false - // Why: a DECSET 2031 subscribe can split across chunks; carry a bounded tail so split sequences still match. - let mode2031ScanTail = '' + let mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE // Why: seed both so a mid-session tracker behaves as if it had observed the pane's last live title (renderer parity). let lastEmittedTitle: string | null = options.initialTitle !== undefined ? normalizeTerminalTitle(options.initialTitle) : null @@ -148,7 +163,7 @@ export function createTerminalTitleTracker( agentTracker?.handleTitle(rawTitle) } - function handleChunk(data: string, options: { titleScanData?: string } = {}): void { + function handleChunk(data: string, options: TerminalTitleTrackerChunkOptions = {}): void { const titleScanData = options.titleScanData ?? data // Why: hot path — scan for the OSC introducer once and share it with the bell detector's fast-path gate. const containsOscIntroducer = data.includes('\x1b]') @@ -195,11 +210,16 @@ export function createTerminalTitleTracker( onPrLink?.(link) } } - if (onMode2031Subscribe) { - const mode2031Scan = scanMode2031Sequences(mode2031ScanTail, data) - mode2031ScanTail = mode2031Scan.tail - if (mode2031Scan.subscribe) { - onMode2031Subscribe() + if (onMode2031Subscribe || onMode2031Unsubscribe) { + const previousMode2031ReplyScanState = options.mode2031PendingSubscribe + ? { ...mode2031ReplyScanState, pendingSubscribe: true } + : mode2031ReplyScanState + const result = scanMode2031ReplyDecision(previousMode2031ReplyScanState, data) + mode2031ReplyScanState = result.state + if (result.decision === 'subscribed') { + onMode2031Subscribe?.() + } else if (result.decision === 'unsubscribed') { + onMode2031Unsubscribe?.() } } } @@ -245,7 +265,7 @@ export function createTerminalTitleTracker( // Cross-chunk carry predates the gapped span; reset it so stale state can't swallow real bells or mint phantom facts. bellDetector?.reset() commandFinishedScanner?.reset() - mode2031ScanTail = '' + mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE if (prLinkDetector) { prLinkDetector = createTerminalGitHubPRLinkDetector() } @@ -256,7 +276,7 @@ export function createTerminalTitleTracker( agentTracker?.reset() bellDetector?.reset() commandFinishedScanner?.reset() - mode2031ScanTail = '' + mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE } } } diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts index 8dee0e6242c..ce388f730fc 100644 --- a/src/shared/terminal-side-effect-facts.ts +++ b/src/shared/terminal-side-effect-facts.ts @@ -32,6 +32,10 @@ export type TerminalSideEffectFact = * the theme reply — the reply stays renderer-side because query authority * belongs to the view (model/view contract invariant 6). */ | { kind: '2031-subscribe' } + /** DECSET 2031 withdrawal observed in the byte stream. Gated views never see + * these bytes, so without this fact their subscription registry goes stale + * and a later theme flip pushes CSI 997 at a shell that already withdrew. */ + | { kind: '2031-unsubscribe' } export type TerminalSideEffectBatch = { ptyId: string