Files
orca/src/shared/terminal-reply-query-scan.ts
T
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

140 lines
4.8 KiB
TypeScript

import { findCsiFinalByteIndex } from './terminal-reply-query-extraction'
import { parseTerminalOscColorQuery } from './terminal-osc-color-reply'
const ESC = '\x1b'
const MAX_PENDING_QUERY_CHARS = 4096
/* oxlint-disable no-control-regex -- terminal query grammars contain ESC by definition */
const DEVICE_ATTRIBUTES_QUERY_RE = new RegExp('^\\u001b\\[[?>=]?[0-9;]*c$')
const MODE_QUERY_RE = new RegExp('^\\u001b\\[\\??[0-9;]+\\$p$')
/* oxlint-enable no-control-regex */
export type TerminalReplyQuerySequence = {
data: string
startSeq: number
endSeq: number
}
export type TerminalReplyQueryScanState = {
pending: string
pendingStartSeq: number | null
}
export const EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE: TerminalReplyQueryScanState = {
pending: '',
pendingStartSeq: null
}
/**
* Sequences worth replaying to a late-attaching view, because a scrollback snapshot
* carries only rendered cells and drops the control bytes that produced them.
*
* Most entries are reply-eliciting queries the attaching emulator must answer once.
* The DECSET/DECRST 2031 pair is different: since #9993 nothing answers the subscribe,
* but a remote renderer derives its color-scheme subscription registry from these very
* bytes (pty-connection observeLiveMode2031Chunk), so both toggles must be carried —
* an arm without its withdraw would leave the client subscribed for a TUI that already
* retired the subscription, and fish rearms/withdraws on every prompt.
*/
function isReplayableCsi(sequence: string): boolean {
if (DEVICE_ATTRIBUTES_QUERY_RE.test(sequence)) {
return true
}
if (MODE_QUERY_RE.test(sequence)) {
return true
}
return (
sequence === '\x1b[5n' ||
sequence === '\x1b[6n' ||
sequence === '\x1b[?6n' ||
sequence === '\x1b[?996n' ||
sequence === '\x1b[>q' ||
sequence === '\x1b[14t' ||
sequence === '\x1b[16t' ||
sequence === '\x1b[18t' ||
sequence === '\x1b[?u' ||
sequence === '\x1b[?2031h' ||
// Why paired with the arm above, exact-form only: replaying a combined DECRST such as
// `CSI ?2004;2031l` would also toggle unrelated modes in the attaching emulator.
sequence === '\x1b[?2031l'
)
}
function boundedPending(input: string, startIndex: number): string {
return input.slice(startIndex, startIndex + MAX_PENDING_QUERY_CHARS)
}
export function scanTerminalReplyQuerySequences(
data: string,
chunkStartSeq: number,
previous: TerminalReplyQueryScanState
): { queries: TerminalReplyQuerySequence[]; state: TerminalReplyQueryScanState } {
const continuesPending =
previous.pendingStartSeq !== null &&
previous.pendingStartSeq + previous.pending.length === chunkStartSeq
const pending = continuesPending ? previous.pending : ''
const input = pending + data
const inputStartSeq = chunkStartSeq - pending.length
const queries: TerminalReplyQuerySequence[] = []
let offset = 0
while (offset < input.length) {
const candidateIndex = input.indexOf(ESC, offset)
if (candidateIndex === -1) {
break
}
if (candidateIndex + 1 >= input.length) {
const nextPending = boundedPending(input, candidateIndex)
return {
queries,
state: { pending: nextPending, pendingStartSeq: inputStartSeq + candidateIndex }
}
}
let endIndex = -1
let matches = false
if (input.startsWith(`${ESC}[`, candidateIndex)) {
endIndex = findCsiFinalByteIndex(input, candidateIndex + 2)
if (endIndex !== -1) {
matches = isReplayableCsi(input.slice(candidateIndex, endIndex + 1))
}
} else if (input.startsWith(`${ESC}]`, candidateIndex)) {
const osc = parseTerminalOscColorQuery(input, candidateIndex)
if (osc.kind === 'partial') {
endIndex = -1
} else if (osc.kind === 'match') {
endIndex = osc.endIndex - 1
matches = true
} else {
endIndex = candidateIndex + 1
}
} else if (input.startsWith(`${ESC}P`, candidateIndex)) {
const terminatorIndex = input.indexOf(`${ESC}\\`, candidateIndex + 2)
if (terminatorIndex !== -1) {
endIndex = terminatorIndex + 1
const body = input.slice(candidateIndex + 2, terminatorIndex)
matches = body.startsWith('$q') || body.startsWith('+q')
}
} else {
endIndex = candidateIndex
}
if (endIndex === -1) {
const nextPending = boundedPending(input, candidateIndex)
return {
queries,
state: { pending: nextPending, pendingStartSeq: inputStartSeq + candidateIndex }
}
}
if (matches) {
queries.push({
data: input.slice(candidateIndex, endIndex + 1),
startSeq: inputStartSeq + candidateIndex,
endSeq: inputStartSeq + endIndex + 1
})
}
offset = endIndex + 1
}
return { queries, state: EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE }
}