Files
orca/src/shared/terminal-view-attributes.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

204 lines
7.3 KiB
TypeScript

/**
* Phase 5 slice 2 (View-attribute bridge): payload contract for the
* renderer→main `pty:terminalViewAttributes`
* push, plus main/renderer mirrors of xterm's XParseColor color-spec grammar
* so main's responder replies byte-identically to a visible renderer xterm.
*/
import type { TerminalOscColorQueryReplyColors } from './terminal-osc-color-reply'
/** 8-bit-per-channel RGB triple — the same resolution xterm's theme service
* stores internally (`color.toColorRGB`). */
export type TerminalViewRgb = [number, number, number]
export const TERMINAL_VIEW_ANSI_COLOR_COUNT = 256
export type TerminalViewCursorStyle = 'bar' | 'block' | 'underline'
/** One app-global snapshot of the renderer's composed terminal appearance —
* per-pane font zoom never affects these, and terminalColorOverrides /
* cursor settings are global, so one push covers all PTYs. */
export type TerminalViewAttributes = {
foreground: TerminalViewRgb
background: TerminalViewRgb
/** Already blended over the background (xterm ThemeService blends the
* cursor color's alpha at theme-set time, e.g. terminalCursorOpacity). */
cursor: TerminalViewRgb
/** Full 256-entry palette: theme's 16 named colors + extendedAnsi/default
* tail, exactly as the renderer ThemeService resolves them. */
ansi: TerminalViewRgb[]
/** Resolved APP color-scheme mode (the 2031/997 flip source). NOT the DSR
* ?996n answer: that is computed from background/foreground relative
* luminance like a visible xterm (_reportColorScheme), and the two can
* disagree (e.g. dark terminal theme in light app mode). */
colorSchemeMode: 'dark' | 'light'
cursorStyle: TerminalViewCursorStyle
cursorBlink: boolean
}
// Mirror of @xterm XParseColor RGB_REX: r/g/b channels in 1-4 hex digits.
const X_RGB_SPEC_RE =
/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/
const X_HASH_SPEC_RE = /^[\da-f]+$/
/** Mirror of xterm's XParseColor `parseColor` (the grammar the renderer
* accepts for OSC 4/10/11/12 SET payloads): `rgb:h/h/h`..`rgb:hhhh/hhhh/hhhh`
* and `#RGB|#RRGGBB|#RRRGGGBBB|#RRRRGGGGBBBB`. Anything else (named colors,
* rgbi:) is rejected exactly like the renderer rejects it. */
export function parseXColorSpec(spec: string): TerminalViewRgb | null {
if (!spec) {
return null
}
let low = spec.toLowerCase()
if (low.startsWith('rgb:')) {
low = low.slice(4)
const m = X_RGB_SPEC_RE.exec(low)
if (m) {
const base = m[1] ? 15 : m[4] ? 255 : m[7] ? 4095 : 65535
return [
Math.round((Number.parseInt(m[1] || m[4] || m[7] || m[10], 16) / base) * 255),
Math.round((Number.parseInt(m[2] || m[5] || m[8] || m[11], 16) / base) * 255),
Math.round((Number.parseInt(m[3] || m[6] || m[9] || m[12], 16) / base) * 255)
]
}
return null
}
if (low.startsWith('#')) {
low = low.slice(1)
if (X_HASH_SPEC_RE.exec(low) && [3, 6, 9, 12].includes(low.length)) {
const adv = low.length / 3
const result: TerminalViewRgb = [0, 0, 0]
for (let i = 0; i < 3; ++i) {
const c = Number.parseInt(low.slice(adv * i, adv * i + adv), 16)
result[i] = adv === 1 ? c << 4 : adv === 2 ? c : adv === 3 ? c >> 4 : c >> 8
}
return result
}
}
return null
}
function padChannelTo16Bit(value: number): string {
const hex = value.toString(16)
const byte = hex.length < 2 ? `0${hex}` : hex
// Why doubled: xterm reports 16-bit channels by repeating the 8-bit byte
// (XParseColor.toRgbString with bits=16) — pinned reply-format parity.
return byte + byte
}
/** Mirror of xterm's `toRgbString(color, 16)` — the exact channel format a
* visible renderer xterm uses in OSC 4/10/11/12 query replies. */
export function formatXColorRgbSpec(rgb: TerminalViewRgb): string {
return `rgb:${padChannelTo16Bit(rgb[0])}/${padChannelTo16Bit(rgb[1])}/${padChannelTo16Bit(rgb[2])}`
}
export function terminalViewRgbToCssHex(rgb: readonly [number, number, number]): string {
return `#${rgb.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`
}
/** The colours a PTY owner answers OSC 10/11 with for a viewer painting these attributes. */
export function terminalViewColorQueryReplyColors(
attributes: Pick<TerminalViewAttributes, 'foreground' | 'background'>
): Required<TerminalOscColorQueryReplyColors> {
return {
foreground: terminalViewRgbToCssHex(attributes.foreground),
background: terminalViewRgbToCssHex(attributes.background)
}
}
function rgbEqual(a: TerminalViewRgb, b: TerminalViewRgb): boolean {
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]
}
/** Value equality over the whole snapshot. Lets main's store treat a
* re-push of identical attributes (fresh renderer process: second window,
* reload, macOS re-activation) as a no-op instead of a theme apply. */
export function terminalViewAttributesEqual(
a: TerminalViewAttributes,
b: TerminalViewAttributes
): boolean {
if (a === b) {
return true
}
if (
!rgbEqual(a.foreground, b.foreground) ||
!rgbEqual(a.background, b.background) ||
!rgbEqual(a.cursor, b.cursor) ||
a.colorSchemeMode !== b.colorSchemeMode ||
a.cursorStyle !== b.cursorStyle ||
a.cursorBlink !== b.cursorBlink ||
a.ansi.length !== b.ansi.length
) {
return false
}
for (let i = 0; i < a.ansi.length; i++) {
if (!rgbEqual(a.ansi[i], b.ansi[i])) {
return false
}
}
return true
}
function isRgbChannel(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 255
}
function validateRgbTriple(value: unknown): TerminalViewRgb | null {
if (!Array.isArray(value) || value.length !== 3) {
return null
}
const [r, g, b] = value
if (!isRgbChannel(r) || !isRgbChannel(g) || !isRgbChannel(b)) {
return null
}
return [r, g, b]
}
/** IPC-boundary validation for the `pty:terminalViewAttributes` push. Returns
* a normalized copy or null — main must never store a malformed palette (a
* wrong color reply is worse than silence, the OSC-11 lesson). */
export function validateTerminalViewAttributes(payload: unknown): TerminalViewAttributes | null {
if (typeof payload !== 'object' || payload === null) {
return null
}
const candidate = payload as Record<string, unknown>
const foreground = validateRgbTriple(candidate.foreground)
const background = validateRgbTriple(candidate.background)
const cursor = validateRgbTriple(candidate.cursor)
if (!foreground || !background || !cursor) {
return null
}
if (!Array.isArray(candidate.ansi) || candidate.ansi.length !== TERMINAL_VIEW_ANSI_COLOR_COUNT) {
return null
}
const ansi: TerminalViewRgb[] = []
for (const entry of candidate.ansi) {
const triple = validateRgbTriple(entry)
if (!triple) {
return null
}
ansi.push(triple)
}
if (candidate.colorSchemeMode !== 'dark' && candidate.colorSchemeMode !== 'light') {
return null
}
if (
candidate.cursorStyle !== 'bar' &&
candidate.cursorStyle !== 'block' &&
candidate.cursorStyle !== 'underline'
) {
return null
}
if (typeof candidate.cursorBlink !== 'boolean') {
return null
}
return {
foreground,
background,
cursor,
ansi,
colorSchemeMode: candidate.colorSchemeMode,
cursorStyle: candidate.cursorStyle,
cursorBlink: candidate.cursorBlink
}
}