mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 16:03:45 +00:00
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>
140 lines
4.8 KiB
TypeScript
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 }
|
|
}
|