Files
orca/src/shared/fish-query-reply-child-stdin.node-pty.test.ts
T
Brennan Benson 59ef74876f fix(terminal): the terminal's owner answers colour queries for the terminal's whole life (#23925)
* fix(terminal): the PTY owner answers OSC 10/11 for the terminal's whole life

Codex and Claude's `theme: auto` ask the terminal for its foreground and
background colours (OSC 10/11) and pick their colours from the reply. Orca
answered in the process that owns the PTY only for agent launches and only
for 5 s; after that the query was handed to whichever viewer was attached.
On Windows ConPTY the owner kept swallowing the query but stopped answering
it, so a Codex started from an older shell tab lost its message shading
(#22332). On a headless `orca serve` host no viewer existed yet, so a Codex
started before anyone attached got no reply at all (#22500).

The owner (in-process provider, terminal daemon, SSH relay) now answers
every OSC 10/11 query for the PTY's whole life and strips it, so no
downstream view ever sees one to answer twice. It answers from, in order:
the host-wide viewer theme pushed to that process, the creating viewer's
colours sent at spawn (now for every PTY, not only agents), and Orca's
default dark theme. The desktop pushes its renderer theme to every owner
on change and on (re)connect: a daemon request gated on protocol v38, and
an SSH relay notification that older relays ignore. The answer-once rule,
the 5 s colour window and the colour-authority handoff are removed; Kitty
keyboard queries keep their startup window.

Viewer-side answerers (renderer xterm, main's hidden-pane model responder,
the mobile webview) stay as the fallback for older owners, which still hand
queries off; they are never reached for a new owner.

* fix(terminal): answer OSC 10/11 with the colours the pane is really painted with

Review follow-ups to the lifetime PTY-owner colour answerer.

- The theme catalog moves to src/shared so the renderer and the PTY owners
  read one source; the owner's last-resort default is derived from it
  rather than copied.
- Main seeds every owner from the host's saved theme settings (light or
  dark, custom themes, colour overrides) at startup, so a headless host
  and a desktop pane that queries before the renderer's first push are
  not told dark to a light-theme user. The renderer's push replaces it.
- Colours an app sets with OSC 10/11, and clears with OSC 110/111, are
  tracked per terminal and reported back, as a viewer paints them; a theme
  change drops them, as a viewer's theme apply does.
- A terminal a paired client created with its own colours answers with
  those, not the host's theme (`colorSource: 'remote-viewer'` on the
  spawn intent), so a light client on a dark host is told light.
- After the 5 s startup window a reply's echo is watched for 512 bytes
  instead of 256 KB, and a torn query candidate is released after 500 ms
  rather than held indefinitely.

* fix(terminal): keep the long echo watch for relayed replies; one theme lookup

The 512-byte post-startup echo watch now applies only to replies the PTY
owner produced itself. A viewer's reply relayed through
answerLiveQueryReply keeps the 256 KB watch, because a cooked-mode app can
keep printing after it queries and the echo then trails that output.

The renderer's getTerminalTheme now calls the shared lookupTerminalTheme,
so the custom-vs-built-in theme lookup exists once.

* perf(terminal): scan colour overrides in one pass over each PTY chunk

Two indexOf searches per OSC went quadratic on long runs of ST-terminated
hyperlinks, and the tracker now sees every chunk of every terminal.

* fix(terminal): one host viewer colour value, set by whichever viewer acted last

A paired client's colours reached the host only as frozen spawn colours on
terminal.create, tagged remote-viewer. UI-started agent sessions on a headless
host answered OSC 10/11 with the host's saved theme, and a client's theme flip
never reached panes it had created.

The host now holds one viewer colour value that every PTY owner answers with.
The desktop renderer's push, a window focus on the host, the new
terminal.setViewerColors RPC, and terminal.create colours from older clients
all set it; equal values do not re-notify daemons or relays. The remote-viewer
tag (colorSource / terminalColorQuerySource / spawnFromRemoteViewer) is gone;
it never shipped in a release.

* fix(terminal): paired clients push their terminal colours on connect, change and focus

The renderer publisher now hands each published fg/bg to subscribers. A new
remote-runtime-terminal-color-push module calls terminal.setViewerColors on
every host this client is connected to when it connects (or the host restarts),
when the colours change, and when the window gains focus. A host that answers
method_not_found or forbidden is not asked again until it reconnects.

The app shell also republishes terminal view attributes on settings and system
theme changes, so a theme change reaches main and paired hosts with no
terminal pane open.

* fix(terminal): a host with its own window answers OSC 10/11 with its own theme

Round 1 kept one host-wide viewer colour value set by whichever viewer acted
last, so a paired client's push (reconnect after sleep, a dusk theme flip)
took over the host desktop's own panes until its window regained focus.

The value is now derived: this host's renderer colours when a local window
has pushed, otherwise the last paired client's push (terminal.setViewerColors
or terminal.create colours), otherwise the saved theme. Only a headless host
takes a client's theme. The window-focus reassert and the identical-re-push
takeover are gone; owners are notified only when the derived value changes.

* perf(terminal): scan only OSC starts for colour queries once the Kitty window closes

The PTY owner answers OSC 10/11 for the terminal's whole life, and it tried
every ESC as a query start: a 240 KB SGR-heavy read cost about 2 ms and 256 KB
of bare ESC about 15 ms, long after startup.

Once the Kitty query window closes only an OSC colour query can match, so the
scan jumps between ESC ] starts, plus a trailing lone ESC so a query torn right
after its ESC still resolves on the next read. Output and replies are
unchanged.
2026-09-29 19:06:57 -07:00

228 lines
8.2 KiB
TypeScript
Raw 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.
/**
* Real-fish regression for #13892: a terminal query reply Orca held back is overtaken
* by the DA1 answer written later in the same turn, so fish's read sentinel hands the
* tty to the child while the OSC 11 reply is still queued — and the CHILD READS IT.
*
* What is real here: node-pty running fish, `PtyStartupIngress`, `PtyStartupReplyDelivery`
* and both echo probes, plus the host's own write gate
* (`answerLiveQueryReply` — copied from local-pty-provider.write, which
* is what decides whether a reply is deferred at all). The ingress answers OSC 10/11
* itself; only the renderer is modelled for the rest, answering strictly in stream order,
* so any inversion the child sees was produced by the delivery split and nothing else.
*
* The assertion is about a CHILD PROCESS'S STDIN, not the screen: a rendered-output check
* passes while the bytes are still being eaten by the next `npx` / `brew` confirm prompt.
* The child reads a full LINE because a leaked reply carries no newline, so a
* once('data') child would report it alone whenever it happened to land in its own read.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { fishRequirementViolation, resolveFishBinary } from './fish-binary-requirement'
import { PtyStartupIngress } from './pty-startup-ingress'
// Why fish 4: the DA1-sentinel handoff this measures lives in the 4.0 Rust tty_handoff.
const FISH = resolveFishBinary(4)
const itWithFish = FISH.available ? it : it.skip
const PROMPT_MARK = 'ORCA13892> '
/* oxlint-disable no-control-regex -- terminal query grammars are control sequences */
/** Anchored at an ESC, first match wins; reply values match xterm.js's. */
const QUERY_GRAMMARS = [
{
re: /^\x1b\]1[012];\?(\x07|\x1b\\)/,
reply: (m: RegExpExecArray) => `\x1b]11;rgb:1e1e/1e1e/1e1e${m[1]}`
},
{ re: /^\x1b\[\?6n/, reply: () => '\x1b[?1;1;1R' },
{ re: /^\x1b\[6n/, reply: () => '\x1b[1;1R' },
{ re: /^\x1b\[\?996n/, reply: () => '\x1b[?997;1n' },
{ re: /^\x1b\[>0?c/, reply: () => '\x1b[>0;276;0c' },
{ re: /^\x1b\[0?c/, reply: () => '\x1b[?1;2c' },
{ re: /^\x1b\[>0?q/, reply: () => '\x1bP>|Orca\x1b\\' },
{ re: /^\x1b\[\?u/, reply: () => '\x1b[?0u' }
] as const
/** Still accumulating: no CSI final byte and no OSC/DCS terminator yet. */
const PARTIAL_QUERY_RE =
/^(?:\x1b|\x1b\[[?>=]?[0-9;]*|\x1b\][0-9]*(?:;[^\x07\x1b]*)?\x1b?|\x1bP[^\x1b]*\x1b?)$/
/* oxlint-enable no-control-regex */
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (predicate()) {
return true
}
await sleep(10)
}
return false
}
describe('a held query reply never reaches the next child process (#13892)', () => {
let configHome: string | null = null
// Always runs, so the CI lane cannot report green with the regression below skipped.
it('has the fish this suite needs when CI requires one', () => {
expect(fishRequirementViolation(FISH)).toBeNull()
})
// Same contract for the other half of the setup: a prebuilt node-pty has no echoState,
// so the fix under test would be off and the regression below would run vacuously.
afterEach(() => {
if (configHome) {
rmSync(configHome, { recursive: true, force: true })
configHome = null
}
})
itWithFish(
'answers OSC 11 in the query turn so the reply cannot land in the child’s stdin',
async () => {
configHome = mkdtempSync(path.join(tmpdir(), 'orca-fish-13892-'))
mkdirSync(path.join(configHome, 'fish'), { recursive: true })
writeFileSync(
path.join(configHome, 'fish/config.fish'),
[
'set -g fish_greeting ""',
`function fish_prompt; printf '${PROMPT_MARK}'; end`,
'function fish_right_prompt; end',
''
].join('\n')
)
const childScript = path.join(configHome, 'read-stdin.mjs')
writeFileSync(
childScript,
"let buffered = ''\n" +
"process.stdin.on('data', (d) => {\n" +
" buffered += d.toString('utf8')\n" +
" if (!buffered.includes('\\n')) return\n" +
" process.stdout.write('CHILD-READ:' + JSON.stringify(buffered) + '\\n')\n" +
' process.exit(0)\n' +
'})\n'
)
const nodePty = await import('node-pty')
const term = nodePty.spawn(FISH.path as string, ['-l', '-i'], {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: configHome,
env: {
PATH: process.env.PATH ?? '/usr/bin:/bin',
HOME: configHome,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
LANG: 'en_US.UTF-8',
XDG_CONFIG_HOME: configHome,
XDG_DATA_HOME: path.join(configHome, 'data'),
ORCA_NODE_BIN: process.execPath,
ORCA_CHILD_SCRIPT: childScript
}
})
let rendered = ''
let oscQueryCount = 0
const ingress = new PtyStartupIngress({
ownerBackend: 'posix-pty',
write: (data) => {
if (data.startsWith('\x1b]1')) {
oscQueryCount += 1
}
term.write(data)
},
onEmission: (emission) => {
rendered += emission.data
answerQueriesInOrder(emission.data)
}
})
// The host gate: cooked-echo-risk replies are written by the ingress with their
// echo shapes armed; DA1/CPR stay on the host's own path, in call order.
const hostWrite = (data: string): void => {
if (ingress.answerLiveQueryReply(data)) {
return
}
term.write(data)
}
let tail = ''
function answerQueriesInOrder(chunk: string): void {
let buffer = tail + chunk
tail = ''
let index = 0
while (index < buffer.length) {
const at = buffer.indexOf('\x1b', index)
if (at === -1) {
return
}
const rest = buffer.slice(at)
const grammar = QUERY_GRAMMARS.map((candidate) => ({
candidate,
match: candidate.re.exec(rest)
})).find((entry) => entry.match)
if (grammar?.match) {
hostWrite(grammar.candidate.reply(grammar.match))
index = at + grammar.match[0].length
continue
}
if (PARTIAL_QUERY_RE.test(rest)) {
tail = rest
return
}
index = at + 1
}
}
let exited = false
term.onExit(() => {
exited = true
})
term.onData((data) => ingress.accept(data))
try {
expect(await waitUntil(() => rendered.includes(PROMPT_MARK), 15_000)).toBe(true)
await sleep(500)
const oscQueriesBeforeHandoff = oscQueryCount
// Type-ahead is the deterministic shape: queue the child's command while an
// external command still owns the tty, so fish repaints its prompt (re-querying
// OSC 11) and hands the tty over in the same breath.
term.write('sleep 0.4\r')
await sleep(150)
term.write('"$ORCA_NODE_BIN" "$ORCA_CHILD_SCRIPT"\r')
await sleep(1_500)
expect(oscQueryCount).toBeGreaterThan(oscQueriesBeforeHandoff)
const renderedBeforeChildInput = rendered.length
term.write('hello\r')
expect(
await waitUntil(
() => rendered.slice(renderedBeforeChildInput).includes('CHILD-READ:'),
10_000
)
).toBe(true)
const childRead =
rendered.slice(renderedBeforeChildInput).match(/CHILD-READ:[^\r\n]*/)?.[0] ?? ''
// The merge-blocking assertion: the child's first LINE is what the user typed,
// with no escape byte in front of it. Pre-fix this reads
// `\u001b]11;rgb:1e1e/1e1e/1e1e\u001b\\hello\n`.
expect(childRead).toBe('CHILD-READ:"hello\\n"')
} finally {
term.write('exit\r')
await waitUntil(() => exited, 3_000)
try {
term.kill()
} catch {
// already gone
}
}
},
45_000
)
})