Files
orca/src/shared/terminal-output-side-effects.ts
NeilandOrca 5ea7df1a5b fix(terminal): make DECSET 2031 subscriptions silent (#13904)
fish arms `CSI ?2031h` before painting each prompt and withdraws it when it
hands the tty to a child — a ~1ms window. Orca answered that subscribe with
`CSI ?997;Nn` across a 1-3ms renderer hop, so the reply landed after the
withdrawal and was read as stdin by the next child, corrupting `brew`/`npx`
`[y/N]` prompts.

The reply is not stale by Orca's own view when written (measured
staleReplies: 0), so no suppress-the-stale-reply scheme can close this — the
information needed to suppress does not exist yet. Nothing asked for the reply
either. The Contour spec says a terminal "should only send out the DSR when the
palette has been updated"; Ghostty (Termio.zig:729 — force=true reachable only
from the ?996n DSR), iTerm2 (VT100Terminal.m:995 — flag only) and xterm.js
(InputHandler.ts:2035 — flag only) all emit nothing on the DECSET. So stop
entering the race: record the subscription, answer nothing.

Of 17 real programs measured under a pty, only fish, tmux, claude and opencode
subscribe; none block on a reply, and answering produces one redundant palette
re-query and zero rendering difference. tmux is the only one that sends `?996n`,
which Orca still answers.

- Subscribes are record-only at all four emitters (live scan, hidden-gate fact,
  parked byte watcher, parked responder — the last is deleted, it only replied).
- `?996n` answers, the subscription registry, and the theme-flip push are
  unchanged. `paneLastThemeMode` is still seeded at subscribe so the next
  appearance re-apply is not read as a flip.
- Replay grammar carries `?2031l` alongside `?2031h`, so a late-attaching remote
  client no longer registers a subscription the TUI already retired.

Also closes fish-integration gaps found alongside: `unset` (which fish lacks)
becomes `set -e` on paths parsed by the client's login shell, `config.fish` is
parsed for agent-home detection, and bracketed-paste startup delivery is made
consistent across local/daemon/relay.

Regression test drives real fish 4.7.1 under node-pty and asserts on what the
child process reads; it fails against pre-fix code with the exact payload from
the issue. CI installs fish 4 and fails loudly rather than skipping.

Closes #9993

Co-authored-by: Orca <help@stably.ai>
2026-08-11 21:16:36 -07:00

321 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shared per-PTY terminal title side-effect tracking — the parser core behind both the renderer
* transport (`createPtyOutputProcessor`) and main's per-PTY tracker (`OrcaRuntimeService.onPtyData`).
* Title semantics must not drift between the two paths.
*/
import {
type AgentStatus,
clearWorkingIndicators,
createAgentStatusTracker,
detectAgentStatusFromTitle,
extractAllOscTitles,
isCursorNativeAgentTitle,
normalizeTerminalTitle,
shouldSuppressCursorNativeTitle
} from './agent-detection'
import { createBellDetector } from './terminal-bell-detector'
import {
INITIAL_MODE_2031_REPLY_SCAN_STATE,
scanMode2031ReplyDecision
} from './terminal-color-scheme-protocol'
import {
createTerminalGitHubPRLinkDetector,
type TerminalGitHubPRLink
} from './terminal-github-pr-link-detector'
import { createOsc133CommandFinishedScanner } from './terminal-osc133-command-finished'
/** Ms of title-less output after a working title before it is cleared. */
export const STALE_WORKING_TITLE_TIMEOUT_MS = 3000
// Braille spinner glyphs (U+2800–U+28FF); mirrors the range clearWorkingIndicators strips in agent-detection.ts.
// eslint-disable-next-line no-control-regex -- intentional unicode range
const BRAILLE_SPINNER_RE = /[\u2800-\u28FF]/g
/**
* Strip decorative braille spinner frame glyphs so titles differing only by the animation frame
* compare equal — the gate consumers use to avoid fan-out churn on spinner ticks.
*/
export function stripBrailleSpinnerGlyphs(title: string): string {
return title.replace(BRAILLE_SPINNER_RE, '').trim()
}
/** Provenance for title/idle facts; `staleWorkingTitleClear` marks facts synthesized by the 3s stale timer — not genuine task completions. */
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
onAgentBecameIdle?: (title: string, meta?: TerminalTitleFactMeta) => void
onAgentBecameWorking?: () => void
onAgentExited?: () => void
/** Fired once per chunk containing a real BEL (OSC-aware, cross-chunk escape state), after the chunk's titles (renderer drain order). */
onBell?: () => void
/**
* Fired per complete OSC 133;D (chunk-boundary-safe) with the sequence's best-effort exit code;
* mirrors renderer command-lifecycle semantics so the fact path drops stale agent rows like byte mode.
*/
onCommandFinished?: (bestEffortExitCode: number | null) => void
/** Fired once per newly observed GitHub PR URL (chunk-boundary-safe, deduplicated per tracker). */
onPrLink?: (link: TerminalGitHubPRLink) => void
/**
* Fired per chunk containing a DECSET 2031 subscribe (chunk-boundary-safe): lets
* hidden-delivery-gated renderer views track the subscription 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?: 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.
*/
applySyntheticTitleFrame: (frame: string) => void
/**
* Seed the last-known title for a mid-session tracker (app relaunch with persisted titles).
* No-ops once any title has been observed or seeded (live state wins); fires no callbacks.
*/
seedInitialTitle: (rawTitle: string) => void
/** Restore the status consumed by the latest exit candidate when process evidence disproves it. */
restoreLastAgentExit: () => AgentStatus | null
/** Last title surfaced through onTitle, after normalization. */
getLastNormalizedTitle: () => string | null
/**
* While suppressed, handleChunk skips the transient-fact scanners (bell/133/pr-link/2031)
* because a thinning transport owns scan authority and the delivered bytes may be gapped.
* Titles are unaffected; un-suppressing resets the scanners' cross-chunk carry.
*/
setTransientFactScanningSuppressed: (suppressed: boolean) => void
/** Enable consumer-only bell, PR-link, and mode-2031 scans without resetting title state. */
setTransientSideEffectScanningEnabled: (enabled: boolean) => void
/** Cancel the stale-title timer and clear accumulated tracker state. */
dispose: () => void
}
export function createTerminalTitleTracker(
callbacks: TerminalTitleTrackerCallbacks,
options: { initialTitle?: string } = {}
): TerminalTitleTracker {
const {
onTitle,
onAgentBecameIdle,
onAgentBecameWorking,
onAgentExited,
onBell,
onCommandFinished,
onPrLink,
onMode2031Subscribe,
onMode2031Unsubscribe
} = callbacks
let bellDetector = onBell ? createBellDetector() : null
// Why: created only when a consumer exists so headless serve never pays the per-chunk 133/URL scans.
const commandFinishedScanner = onCommandFinished
? createOsc133CommandFinishedScanner(onCommandFinished)
: null
let prLinkDetector = onPrLink ? createTerminalGitHubPRLinkDetector() : null
let transientSideEffectScanningEnabled = true
let transientFactScanningSuppressed = false
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
let staleTitleTimer: ReturnType<typeof setTimeout> | null = null
// Why: flags the stale-timer clear so its idle callback carries timer provenance, not a genuine task-complete.
let applyingStaleWorkingTitleClear = false
const initialAgentStatusTitle =
options.initialTitle !== undefined && !isCursorNativeAgentTitle(options.initialTitle)
? options.initialTitle
: undefined
const agentTracker =
onAgentBecameIdle || onAgentBecameWorking || onAgentExited
? createAgentStatusTracker(
(title) => {
onAgentBecameIdle?.(
title,
applyingStaleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined
)
},
onAgentBecameWorking,
onAgentExited,
initialAgentStatusTitle
)
: null
function clearStaleTitleTimer(): void {
if (staleTitleTimer) {
clearTimeout(staleTitleTimer)
staleTitleTimer = null
}
}
function applyObservedTitle(rawTitle: string): void {
// Why: cursor-agent re-emits its bare native title mid-turn; passing it through would stomp Orca's synthesized spinner state.
if (isCursorNativeAgentTitle(rawTitle)) {
if (shouldSuppressCursorNativeTitle(lastEmittedTitle)) {
return
}
// Why: a hookless Cursor pane needs the literal once so it has an identity (#10258),
// but never as activity — its null status would read as an exit in the status tracker.
lastEmittedTitle = normalizeTerminalTitle(rawTitle)
onTitle?.(lastEmittedTitle, rawTitle)
return
}
lastEmittedTitle = normalizeTerminalTitle(rawTitle)
onTitle?.(lastEmittedTitle, rawTitle)
agentTracker?.handleTitle(rawTitle)
}
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]')
// Why: consume every chunk so cross-chunk OSC escape state survives; but none while suppressed, since delivered bytes may be gapped.
const containsBell =
bellDetector && !transientFactScanningSuppressed
? bellDetector.chunkContainsBell(data, { containsOscIntroducer })
: false
// Why: feed every OSC title in byte order; a last-title reader drops intra-chunk working→idle transitions in coalesced payloads (issue #1083).
const titles = titleScanData.includes('\x1b]') ? extractAllOscTitles(titleScanData) : []
if (titles.length > 0) {
clearStaleTitleTimer()
for (const title of titles) {
applyObservedTitle(title)
}
} else if (
// Why: an agent exiting without resetting its title leaves a stale spinner; title-less output while working arms the 3s clear timer.
data.length > 0 &&
lastEmittedTitle !== null &&
detectAgentStatusFromTitle(lastEmittedTitle) === 'working'
) {
clearStaleTitleTimer()
staleTitleTimer = setTimeout(() => {
staleTitleTimer = null
if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') {
const cleared = clearWorkingIndicators(lastEmittedTitle)
lastEmittedTitle = cleared
// Why: tag timer-synthesized facts so downstream distinguishes a merely-paused agent from a genuine working→idle completion.
applyingStaleWorkingTitleClear = true
try {
onTitle?.(cleared, cleared, { staleWorkingTitleClear: true })
agentTracker?.handleTitle(cleared)
} finally {
applyingStaleWorkingTitleClear = false
}
}
}, STALE_WORKING_TITLE_TIMEOUT_MS)
}
// Fact order (matches renderer drain): titles → command-finished → pr-link → 2031-subscribe → bell; bell last.
if (!transientFactScanningSuppressed) {
commandFinishedScanner?.scan(data)
if (prLinkDetector) {
for (const link of prLinkDetector(data)) {
onPrLink?.(link)
}
}
if (transientSideEffectScanningEnabled && (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?.()
}
}
}
if (containsBell) {
onBell?.()
}
}
function applySyntheticTitleFrame(frame: string): void {
// Why: parse statelessly — the stateful chunk bell detector could mint or swallow bells around a real cross-chunk OSC split.
const titles = extractAllOscTitles(frame)
if (titles.length > 0) {
clearStaleTitleTimer()
for (const title of titles) {
applyObservedTitle(title)
}
}
// The permission BEL rides outside the OSC title; a FRESH detector avoids touching the chunk detector's cross-chunk escape state.
if (
transientSideEffectScanningEnabled &&
onBell &&
createBellDetector().chunkContainsBell(frame)
) {
onBell()
}
// Why: deliberately skip the 133/PR-link/2031 scanners — fabricated bytes contain none and must not perturb their cross-chunk carry.
}
return {
handleChunk,
applySyntheticTitleFrame,
seedInitialTitle(rawTitle: string): void {
if (lastEmittedTitle !== null || !rawTitle) {
return
}
lastEmittedTitle = normalizeTerminalTitle(rawTitle)
// Why: the cursor-agent literal seeds identity only — feeding its null status to the
// tracker would make the next real frame look like an agent exit.
if (!isCursorNativeAgentTitle(rawTitle)) {
agentTracker?.seedTitle(rawTitle)
}
},
restoreLastAgentExit(): AgentStatus | null {
return agentTracker?.restoreLastExit() ?? null
},
getLastNormalizedTitle: () => lastEmittedTitle,
setTransientFactScanningSuppressed(suppressed: boolean): void {
if (suppressed === transientFactScanningSuppressed) {
return
}
transientFactScanningSuppressed = suppressed
if (!suppressed) {
// 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()
mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
if (prLinkDetector) {
prLinkDetector = createTerminalGitHubPRLinkDetector()
}
}
},
setTransientSideEffectScanningEnabled(enabled: boolean): void {
if (enabled === transientSideEffectScanningEnabled) {
return
}
transientSideEffectScanningEnabled = enabled
bellDetector?.reset()
bellDetector = enabled && onBell ? createBellDetector() : null
prLinkDetector = enabled && onPrLink ? createTerminalGitHubPRLinkDetector() : null
mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
},
dispose(): void {
clearStaleTitleTimer()
agentTracker?.reset()
bellDetector?.reset()
commandFinishedScanner?.reset()
mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
}
}
}