Files
orca/src/shared/pty-startup-reply-delivery.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

168 lines
6.7 KiB
TypeScript

import type { PtyOwnerBackend } from './pty-owner-backend'
import {
isBetterEchoMatch,
locateEcho,
replyEchoProjections,
type EchoProjection,
type PtyStartupReplyEchoMatch
} from './pty-startup-reply-echo-shapes'
export type { PtyStartupReplyEchoMatch } from './pty-startup-reply-echo-shapes'
// Why this module exists: a reply is written to the PTY master, so whatever line
// discipline sits between Orca and the querying program can echo it straight back out as
// ordinary output (#12112). ConPTY echoes it with the ESC bytes stripped; a POSIX tty
// echoes it while the querying program is still cooked.
//
// The reply is written IMMEDIATELY, in the caller's turn. Orca used to withhold it until
// an ECHO probe proved the line discipline was quiet, and that withholding was the
// mistake: it made the write asynchronous, so a reply written later could overtake a held
// one and land in the next program's stdin (#15559). Delay never removed the echo either
// — the wait was bounded and always ended in a write — so the output-side projections
// below were always the load-bearing half. Now they are the only half, which also means
// replies leave in call order by construction, with no queue to reorder them.
//
// There are TWO echo sources and they are independent:
//
// 1. The kernel line discipline, when ECHO is set. Its ECHOCTL caret form is the POSIX
// default; a `stty -echoctl` tty instead echoes the reply verbatim.
// 2. The foreground line editor, in software. readline echoes a master write as if it
// were typed *while the tty is raw with ECHO off*, so no reading of the kernel's
// ECHO bit can predict it. Verified on a live pty: at a bash prompt the kernel
// reports quiet and readline still emits `BEL 10;rgb:2e2e/3434/3434`.
//
// Both are projected on every write, which is why (2) is covered at all.
/** `owner`: a reply this PTY's owner produced itself; `relayed`: one a viewer sent in. */
export type PtyReplySource = 'owner' | 'relayed'
type ExpectedEcho = {
projections: readonly EchoProjection[]
remainingBytes: number
source: PtyReplySource
}
// Why bytes and not reads: the echo is a fixed ~30 bytes, but nothing bounds how the tty
// chunks them — an SSH relay or a slow drain delivers a few bytes at a time, and a
// per-read budget is then spent inside the echo itself. This is a backstop against a
// pathological stream, set well above any splash an echo could arrive behind.
const ECHO_SEARCH_BUDGET_BYTES = 256 * 1024
// Why far tighter for the owner's own late replies: it answers in the query's turn, so past
// the launch splash nothing large sits between its reply and the echo. A relayed reply keeps
// the long watch: it left while the app kept printing, so its echo can trail that output.
const ECHO_POST_STARTUP_BUDGET_BYTES = 512
// Live replies make the queue session-lived, so cap it under query floods.
const MAX_TRACKED_ECHOES = 64
/** Owns how a reply's own echo is recognized in the output that follows it. */
export class PtyStartupReplyDelivery {
private readonly expectedEchoes: ExpectedEcho[] = []
private closed = false
private startupWindowOpen = true
/** Why a clock and not a timer: a timer per PTY for the whole session would outlive its use. */
constructor(
private readonly ownerBackend: PtyOwnerBackend,
private readonly writeProvider: (data: string) => void,
private readonly startupWindowEndsAt: number
) {}
get hasExpectedEcho(): boolean {
return this.expectedEchoes.length > 0
}
/**
* Writes the reply now and arms its echo shapes. False means the write threw and
* nothing was sent — the return value is the truth, because the write already happened
* by the time it is returned.
*/
answer(reply: string, source: PtyReplySource = 'relayed'): boolean {
if (this.closed) {
return false
}
this.endStartupWindowIfDue()
const projections = replyEchoProjections(reply, this.ownerBackend)
const remainingBytes =
source === 'owner' && !this.startupWindowOpen
? ECHO_POST_STARTUP_BUDGET_BYTES
: ECHO_SEARCH_BUDGET_BYTES
// Why register before the write: node-pty can synchronously re-enter onData, so the
// echo can arrive inside `writeProvider` itself.
const expected: ExpectedEcho | null =
projections.length > 0 ? { projections, remainingBytes, source } : null
if (expected) {
this.expectedEchoes.push(expected)
}
try {
this.writeProvider(reply)
if (this.expectedEchoes.length > MAX_TRACKED_ECHOES) {
this.expectedEchoes.shift()
}
return true
} catch {
// Why splice by identity, not pop: the write above can re-enter onData and retire a
// different projection, so the last slot is not necessarily ours.
const index = expected ? this.expectedEchoes.indexOf(expected) : -1
if (index !== -1) {
this.expectedEchoes.splice(index, 1)
}
return false
}
}
/** Recognizes any written reply's echo anywhere in the span, earliest match first. */
matchEcho(data: string): PtyStartupReplyEchoMatch {
let best: PtyStartupReplyEchoMatch = { kind: 'none' }
let bestIndex = -1
for (const [index, expected] of this.expectedEchoes.entries()) {
const match = locateEcho(expected.projections, data)
if (isBetterEchoMatch(match, best)) {
best = match
bestIndex = index
}
}
if (best.kind === 'complete') {
this.expectedEchoes.splice(bestIndex, 1)
return best
}
return best
}
/**
* Bytes that went by without completing an echo. Charged by the caller once per PTY
* read rather than per `matchEcho` call, which runs several times over one span.
*/
chargeEchoSearch(byteCount: number): void {
this.endStartupWindowIfDue()
for (let index = this.expectedEchoes.length - 1; index >= 0; index -= 1) {
const expected = this.expectedEchoes[index]
if (!expected) {
continue
}
expected.remainingBytes -= byteCount
if (expected.remainingBytes <= 0) {
this.expectedEchoes.splice(index, 1)
}
}
}
/** Startup window closed: the owner's own replies are watched for a short span only. */
private endStartupWindowIfDue(): void {
if (!this.startupWindowOpen || Date.now() < this.startupWindowEndsAt) {
return
}
this.startupWindowOpen = false
for (const expected of this.expectedEchoes) {
if (expected.source === 'owner') {
expected.remainingBytes = Math.min(expected.remainingBytes, ECHO_POST_STARTUP_BUDGET_BYTES)
}
}
}
/** Teardown: nothing is in flight, because a reply is written as it is accepted. */
close(): void {
this.closed = true
this.expectedEchoes.length = 0
}
}