mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(terminal): stop answering mode-2031 toggles that the same chunk withdrew (#10817)
* fix(terminal): stop answering DECSET 2031 subscriptions fish already withdrew fish enables and disables mode 2031 around every prompt (tty_handoff.rs), so a single PTY chunk routinely carries `?2031h ... ?2031l`. All three responders answered the sticky "an h appeared anywhere" flag, so each prompt cycle wrote `?997;1n` into a shell that had already handed the tty to a child — it lands as literal text, or as stdin for whatever is reading. pty-connection.ts's hidden-pane responder already had the right shape (`finalState !== 'subscribed'`); this brings the other three in line: - shared tracker: gate the '2031-subscribe' fact on the chunk-final state - parked-tab byte sidecar: same guard - visible-pane xterm CSI handler: xterm dispatches mid-parse, so there is no chunk-final state to read. Defer the reply to a microtask and re-check the subscription, letting a same-chunk `?2031l` cancel it. Refs #9993 Co-authored-by: Orca <help@stably.ai> * fix(terminal): decide 2031 replies per PTY chunk, not per xterm parse The previous commit deferred the visible-pane reply to a microtask so a same-chunk `?2031l` could cancel it. That cannot work: xterm's WriteBuffer parses every queued `terminal.write()` synchronously in one batch before any microtask runs, so the microtask sees the net state of N PTY chunks, not of the one that carried the subscribe. A TUI that subscribes in chunk N gets no reply when chunk N+1 happens to withdraw, and a fish prompt straddling two writes still gets answered. Move the decision to where chunk boundaries actually exist — pty-connection's dataCallback, which receives one PTY chunk per call. It scans raw bytes with `scanMode2031Sequences`, carrying a tail across chunks so a CSI split mid- sequence still resolves, and replies only when that chunk *ends* subscribed. Ownership stays single: gate-managed PTYs are answered by main's '2031-subscribe' fact, so the chunk scanner returns early for them, and the xterm CSI handler now observes only panes the scanner does not own. The tail is dropped on PTY replacement — a partial prefix belongs to the stream that produced it. Removes the microtask responder and the seed-reply retry path it needed. Mutation-tested: 6 mutations applied, 6 killed. * fix(terminal): carry DECSET 2031 withdrawals as a side-effect fact The previous commit moved 2031 reply decisions to the PTY chunk boundary and gave gate-managed panes a single owner: main's '2031-subscribe' fact. But the fact union is subscribe-only, and that left the withdrawal unobserved. For a gate-managed pane, main drops renderer-bound bytes after model ingestion, the chunk scanner early-returns, and xterm's CSI handler is disabled. So when a TUI emits `?2031l` while hidden, nothing retires the subscription: paneMode2031 stays set, and the next theme flip has maybePushMode2031Flip push `CSI ?997;2n` into the shell that replaced the TUI — #9993 again, through the theme-change door. Before this branch, skipHiddenRendererOutput observed those withheld bytes; consolidating ownership removed that observer without replacing it. No renderer-side observer can close this: the bytes are gone before the renderer sees them. The state protocol has to carry the withdrawal, so add a '2031-unsubscribe' fact alongside the subscribe across the three fact unions (shared, provider, daemon). It fires only on a real chunk-final withdrawal — a chunk with no 2031 bytes scans to null and stays silent. The renderer handler clears both maps and sends nothing: a withdrawal is not a query. Also closes two gaps an adversarial review found by mutation, both previously resting on comments rather than tests: the lifecycle parser-ownership predicate (extracted as isPaneParserOwnedMode2031Observer so it is directly testable) and the scan-before-reconciliation ordering that lets a chunk the snapshot drops as a duplicate still answer its query. Mutation-tested: 12 mutations applied, 12 killed (6 from the prior round re-run, 6 new covering this fix and the two survivors). * fix(daemon): refuse 2031 authority from a daemon that cannot retract it Round-2 review found a wire-compatibility hole in the original #9993 fix. Daemons survive app updates, so a new desktop can drive a daemon that was started by the previous build. Pre-v29 daemons emit '2031-subscribe' but have no '2031-unsubscribe' fact at all. For a gate-managed pane, main drops the renderer-bound bytes before the renderer sees them, so main's transient facts are the ONLY thing that can retire a subscription. Against such a daemon a TUI exiting while its pane is hidden leaves the subscription registered forever, and the next theme flip injects CSI 997 into whatever shell replaced it -- #9993 all over again, reached through the upgrade path. Gate it: bump PROTOCOL_VERSION 28 -> 29, add MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION with supportsMode2031UnsubscribeFact(), and drop '2031-subscribe' from any daemon below that floor. Trade-off: a gate-managed pane on a preserved v28 daemon keeps renderer-scanner authority instead of daemon-fact authority. That is exactly the pre-fact behaviour -- correct for visible panes, no worse than today for hidden ones -- and it resolves on the daemon's next restart. Non-2031 transient facts (bell, etc.) are unaffected at every version. Tests: two adapter regression tests (v28 drops subscribe, v29 forwards it), plus a version-pin test asserting the floor sits above every entry in PREVIOUS_DAEMON_PROTOCOL_VERSIONS -- so adding a new preserved version cannot silently re-open the hole. Mutation-verified in both directions: `false &&` (under-block) and `true` (over-block) each fail the new tests. * fix(daemon): gate background delegation, not just the fact stream A pre-v29 daemon can announce a 2031 subscribe but never retract it. Filtering that fact is not enough: while a pane is visible main's own scanner registers the subscription, and scan authority only moves to the daemon when the session is backgrounded. So the gate belongs on setPtyBackgrounded — decline to hand a non-retracting daemon authority at all, and main stays authoritative over the whole stream. Co-authored-by: Orca <help@stably.ai> * fix(daemon): clear a preserved pre-v29 background hint at attach, not just at background Co-authored-by: Orca <help@stably.ai> * fix(terminal): don't answer a 2031 subscribe whose withdrawal straddles a chunk Review found the chunk-final-state fix left one hole open. When the kernel cuts fish's toggle pair mid-withdrawal — chunk 1 ends "...?2031h prompt ESC[?20", chunk 2 is "31l" — chunk 1 genuinely ends subscribed, so it answers, and the reply lands as literal text at the prompt. Chunk 2 then recognizes the withdrawal but cannot recall bytes already written. The same byte stream is safe or corrupting purely by where the kernel split it. The scanner already retains an incomplete private-mode tail; it just didn't tell the caller whether that tail could still resolve to 2031. It now does, and a subscribe is held one chunk while the answer is still in doubt. Only subscribes defer — retiring a subscription writes nothing to the pty, so withdrawals stay eager. Deferral is narrow: a trailing "ESC[?25" (cursor hide) can never become 2031, so a subscribe already seen in that chunk is still answered immediately. This case predates the branch — the old sticky-flag policy replied here too — so it is a residual this fix now closes rather than a regression it introduced. Tests: three cases pinned (split withdrawal, non-2031 partial must not defer, split re-subscribe answers once). Removing the deferral fails only the first. * fix(terminal): preserve mode 2031 reply decisions * fix(build): record daemon protocol v29 compatibility --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<string, TerminalTitleTracker>()
|
||||
// Why: shadow foreground bytes so a provisional subscribe survives either scan-authority handoff.
|
||||
private mode2031ReplyScanStateBySessionId = new Map<string, Mode2031ReplyScanState>()
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -765,6 +765,177 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('mode 2031 fact compatibility (#9993)', () => {
|
||||
let onEventSpy: ReturnType<typeof vi.spyOn>
|
||||
// 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')
|
||||
|
||||
@@ -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<void> {
|
||||
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',
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+56
-8
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
: {})
|
||||
|
||||
@@ -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<typeof vi.fn>; 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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<number, boolean>() }
|
||||
const paneLastThemeModeRef = { current: new Map<number, 'dark' | 'light'>() }
|
||||
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<typeof createPane>
|
||||
dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
getMainBufferSnapshot: ReturnType<typeof vi.fn>
|
||||
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<typeof createPane>): 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<typeof createMockTransport>
|
||||
deps: ReturnType<typeof createDeps>
|
||||
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)
|
||||
|
||||
@@ -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<string>, 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 })
|
||||
|
||||
@@ -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<void> {
|
||||
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<typeof vi.fn>
|
||||
paneMode2031: Map<number, boolean>
|
||||
paneLastThemeMode: Map<number, 'dark' | 'light'>
|
||||
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<number, boolean>()
|
||||
const paneLastThemeMode = new Map<number, 'dark' | 'light'>()
|
||||
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<void>((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<void>((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<void>((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<number, boolean>()
|
||||
const paneLastThemeMode = new Map<number, 'dark' | 'light'>()
|
||||
const onSubscribe = vi.fn()
|
||||
const returnValues: boolean[] = []
|
||||
// Cast: parser cb returns plain `boolean` but `Mode2031Parser` reflects xterm's `boolean | Promise<boolean>` (handlers here are sync).
|
||||
const spyParser: Parameters<typeof installMode2031Handlers>[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<number, boolean>()
|
||||
const sharedLast = new Map<number, 'dark' | 'light'>()
|
||||
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<void>((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 {
|
||||
|
||||
@@ -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<IParser, 'registerCsiHandler'>
|
||||
|
||||
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<number, boolean>
|
||||
paneLastThemeMode: Map<number, 'dark' | 'light'>
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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<typeof vi.fn<(data: string) => boolean>>
|
||||
sendInputImmediate: ReturnType<typeof vi.fn<(data: string) => boolean>>
|
||||
scheduled: (() => void)[]
|
||||
recordMode: ReturnType<typeof vi.fn>
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<PtyTransport, 'isConnected' | 'sendInputImmediate'>
|
||||
|
||||
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(
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<number, IDisposable>())
|
||||
const selectionCaptureTimersRef = useRef(new Map<number, number>())
|
||||
const mode2031DisposablesRef = useRef(new Map<number, IDisposable[]>())
|
||||
const mode2031SeedAttemptTokensRef = useRef(new Map<number, symbol>())
|
||||
const osc52DisposablesRef = useRef(new Map<number, IDisposable>())
|
||||
const osc7DisposablesRef = useRef(new Map<number, IDisposable>())
|
||||
const mouseHideDisposablesRef = useRef(new Map<number, IDisposable>())
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<typeof createTerminalTitleTracker>
|
||||
} {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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']])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user