From ee540f32de2d88c8deb4862bf3cac6a7a80a4ca8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:57:14 -0700 Subject: [PATCH] Bridge renderer view attributes to the model responder Co-authored-by: Orca --- src/main/daemon/headless-emulator.ts | 124 +++---- .../daemon/terminal-osc-cwd-title-scanner.ts | 78 ++++ .../terminal-view-attribute-responder.ts | 191 ++++++++++ src/main/ipc/pty.ts | 13 + src/main/runtime/orca-runtime.ts | 20 + .../runtime/terminal-query-responder.test.ts | 349 ++++++++++++++++++ .../runtime/terminal-view-attribute-store.ts | 57 +++ src/preload/api-types.ts | 4 + src/preload/index.ts | 7 + .../terminal-pane/terminal-appearance.test.ts | 66 +++- .../terminal-pane/terminal-appearance.ts | 41 +- ...terminal-view-attributes-publisher.test.ts | 249 +++++++++++++ .../terminal-view-attributes-publisher.ts | 247 +++++++++++++ src/renderer/src/web/web-preload-api.ts | 3 + src/shared/terminal-view-attributes.test.ts | 119 ++++++ src/shared/terminal-view-attributes.ts | 188 ++++++++++ 16 files changed, 1677 insertions(+), 79 deletions(-) create mode 100644 src/main/daemon/terminal-osc-cwd-title-scanner.ts create mode 100644 src/main/daemon/terminal-view-attribute-responder.ts create mode 100644 src/main/runtime/terminal-view-attribute-store.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts create mode 100644 src/shared/terminal-view-attributes.test.ts create mode 100644 src/shared/terminal-view-attributes.ts diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 133e6a700f4..984939cd499 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -1,8 +1,13 @@ import './xterm-env-polyfill' import { Terminal } from '@xterm/headless' import { SerializeAddon } from '@xterm/addon-serialize' -import { extractLastOscTitle } from '../../shared/agent-detection' +import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes' import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror' +import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner' +import { + installTerminalViewAttributeResponder, + type TerminalViewAttributeResponder +} from './terminal-view-attribute-responder' import type { TerminalSnapshot, TerminalModes } from './types' export type HeadlessEmulatorOptions = { @@ -45,46 +50,16 @@ const DEFAULT_SCROLLBACK = 5000 // Keep in sync with the renderer twin in terminal-conpty-device-attributes.ts // (main must not import renderer modules). const CONPTY_DA1_RESPONSE = '\x1b[?61;4c' -const OSC_SCAN_TAIL_LIMIT = 4096 - -function parseFileUriPath(uri: string): string | null { - try { - const url = new URL(uri) - if (url.protocol !== 'file:') { - return null - } - - const decodedPath = decodeURIComponent(url.pathname) - if (process.platform !== 'win32') { - return decodedPath - } - - // Why: Windows OSC-7 cwd updates can describe both drive-letter paths - // (`file:///C:/repo`) and UNC shares (`file://server/share/repo`). Use the - // hostname when present so live cwd tracking, snapshots, and restore all - // round-trip to a native Windows path instead of dropping the server name. - if (url.hostname) { - return `\\\\${url.hostname}${decodedPath.replace(/\//g, '\\')}` - } - if (/^\/[A-Za-z]:/.test(decodedPath)) { - return decodedPath.slice(1) - } - return decodedPath.replace(/\//g, '\\') - } catch { - return null - } -} export class HeadlessEmulator { private terminal: Terminal private serializer: SerializeAddon - private cwd: string | null = null - private lastTitle: string | null = null - private oscScanTail = '' + private oscText = new TerminalOscCwdTitleScanner() private mouseModes = new TerminalMouseModeMirror() private disposed = false private onQueryReply: ((reply: string) => void) | null private conptyDa1OverrideInstalled = false + private viewAttributeResponder: TerminalViewAttributeResponder | null = null // Why: replies must be scoped to the exact write that carried the query. // The window opens around the parse of a forward-flagged chunk and closes // with it, so seeds/snapshots and unsolicited core emissions (e.g. native @@ -149,6 +124,39 @@ export class HeadlessEmulator { }) } + /** Phase-5 slice-2 view-attribute bridge: the headless core has no theme + * service, so OSC 4/10/11/12 queries and DSR ?996n are answered from the + * renderer's pushed attributes via these parser handlers — never from + * emulator defaults. Runtime-only, like onQueryReply: the daemon Session + * must NEVER call this (its emulator stays write-only forever). */ + installViewAttributeResponder(getBaseAttributes: () => TerminalViewAttributes | null): void { + if (this.viewAttributeResponder) { + return + } + this.viewAttributeResponder = installTerminalViewAttributeResponder({ + parser: this.terminal.parser, + getBaseAttributes, + // emitQueryReply keeps replies inside the per-chunk forwarding window, + // so seeded/replayed view-attribute queries answer no one. + emitReply: (reply) => this.emitQueryReply(reply) + }) + } + + /** Applies a renderer view-attribute push: cursor options make xterm core + * answer DECRQSS DECSCUSR / DECRQM 12 renderer-true, and the per-PTY OSC + * color overrides are dropped because a theme apply overwrites mutated + * colors on visible panes too (ThemeService._setTheme parity). Option + * writes happen outside any forwarding window, so any core emission they + * trigger is discarded (main-side replay guard). */ + applyPushedViewAttributes(attributes: TerminalViewAttributes): void { + if (this.disposed) { + return + } + this.terminal.options.cursorStyle = attributes.cursorStyle + this.terminal.options.cursorBlink = attributes.cursorBlink + this.viewAttributeResponder?.clearColorOverrides() + } + private emitQueryReply(reply: string): void { if (this.queryReplyForwardingDepth > 0 && this.onQueryReply) { this.onQueryReply(reply) @@ -167,13 +175,7 @@ export class HeadlessEmulator { return Promise.resolve() } - const oscInput = this.oscScanTail + data - this.oscScanTail = this.extractOscScanTail(oscInput) - this.scanOsc7(oscInput) - const lastTitle = extractLastOscTitle(oscInput) - if (lastTitle !== null) { - this.lastTitle = lastTitle - } + this.oscText.scan(data) const forwardQueryReplies = opts.forwardQueryReplies === true const writeSync = (this.terminal as TerminalWithSynchronousWrite)._core?.writeSync if (typeof writeSync === 'function') { @@ -233,12 +235,12 @@ export class HeadlessEmulator { snapshotAnsi, scrollbackAnsi: '', rehydrateSequences: this.buildRehydrateSequences(modes), - cwd: this.cwd, + cwd: this.oscText.cwd, modes, cols: this.terminal.cols, rows: this.terminal.rows, scrollbackLines: this.terminal.buffer.normal.length - this.terminal.rows, - lastTitle: this.lastTitle ?? undefined + lastTitle: this.oscText.lastTitle ?? undefined } } @@ -256,15 +258,15 @@ export class HeadlessEmulator { } getCwd(): string | null { - return this.cwd + return this.oscText.cwd } setCwd(cwd: string | null): void { - this.cwd = cwd + this.oscText.cwd = cwd } setLastTitle(title: string): void { - this.lastTitle = title + this.oscText.lastTitle = title } clearScrollback(): void { @@ -276,31 +278,6 @@ export class HeadlessEmulator { this.terminal.dispose() } - private scanOsc7(data: string): void { - // OSC-7 format: ESC ] 7 ; BEL or ESC ] 7 ; ST - // BEL = \x07, ST = ESC \ - // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars - const osc7Re = /\x1b\]7;([^\x07\x1b]*?)(?:\x07|\x1b\\)/g - let match: RegExpExecArray | null - while ((match = osc7Re.exec(data)) !== null) { - this.parseOsc7Uri(match[1]) - } - } - - private extractOscScanTail(input: string): string { - const lastOsc = input.lastIndexOf('\x1b]') - const lastEscape = input.endsWith('\x1b') ? input.length - 1 : -1 - const start = Math.max(lastOsc, lastEscape) - if (start === -1) { - return '' - } - const suffix = input.slice(start) - if (suffix.includes('\x07') || suffix.includes('\x1b\\')) { - return '' - } - return suffix.slice(-OSC_SCAN_TAIL_LIMIT) - } - private normalizeSnapshotAnsiForModes(snapshotAnsi: string, modes: TerminalModes): string { if (!modes.alternateScreen) { return snapshotAnsi @@ -316,13 +293,6 @@ export class HeadlessEmulator { return snapshotAnsi.slice(start + alternateScreenMarker.length) } - private parseOsc7Uri(uri: string): void { - const parsed = parseFileUriPath(uri) - if (parsed) { - this.cwd = parsed - } - } - private getModes(): TerminalModes { const buffer = this.terminal.buffer.active const mouseTrackingMode = this.mouseModes.mouseTrackingMode diff --git a/src/main/daemon/terminal-osc-cwd-title-scanner.ts b/src/main/daemon/terminal-osc-cwd-title-scanner.ts new file mode 100644 index 00000000000..fb8800c60cc --- /dev/null +++ b/src/main/daemon/terminal-osc-cwd-title-scanner.ts @@ -0,0 +1,78 @@ +import { extractLastOscTitle } from '../../shared/agent-detection' + +const OSC_SCAN_TAIL_LIMIT = 4096 + +function parseFileUriPath(uri: string): string | null { + try { + const url = new URL(uri) + if (url.protocol !== 'file:') { + return null + } + + const decodedPath = decodeURIComponent(url.pathname) + if (process.platform !== 'win32') { + return decodedPath + } + + // Why: Windows OSC-7 cwd updates can describe both drive-letter paths + // (`file:///C:/repo`) and UNC shares (`file://server/share/repo`). Use the + // hostname when present so live cwd tracking, snapshots, and restore all + // round-trip to a native Windows path instead of dropping the server name. + if (url.hostname) { + return `\\\\${url.hostname}${decodedPath.replace(/\//g, '\\')}` + } + if (/^\/[A-Za-z]:/.test(decodedPath)) { + return decodedPath.slice(1) + } + return decodedPath.replace(/\//g, '\\') + } catch { + return null + } +} + +function extractOscScanTail(input: string): string { + const lastOsc = input.lastIndexOf('\x1b]') + const lastEscape = input.endsWith('\x1b') ? input.length - 1 : -1 + const start = Math.max(lastOsc, lastEscape) + if (start === -1) { + return '' + } + const suffix = input.slice(start) + if (suffix.includes('\x07') || suffix.includes('\x1b\\')) { + return '' + } + return suffix.slice(-OSC_SCAN_TAIL_LIMIT) +} + +/** Regex-side mirror of the OSC sequences the emulator tracks outside xterm: + * OSC 7 cwd updates and OSC 0/2 titles. Keeps an unterminated-sequence tail + * so sequences split across PTY chunks still parse. */ +export class TerminalOscCwdTitleScanner { + private scanTail = '' + cwd: string | null = null + lastTitle: string | null = null + + scan(data: string): void { + const input = this.scanTail + data + this.scanTail = extractOscScanTail(input) + this.scanOsc7(input) + const lastTitle = extractLastOscTitle(input) + if (lastTitle !== null) { + this.lastTitle = lastTitle + } + } + + private scanOsc7(data: string): void { + // OSC-7 format: ESC ] 7 ; BEL or ESC ] 7 ; ST + // BEL = \x07, ST = ESC \ + // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars + const osc7Re = /\x1b\]7;([^\x07\x1b]*?)(?:\x07|\x1b\\)/g + let match: RegExpExecArray | null + while ((match = osc7Re.exec(data)) !== null) { + const parsed = parseFileUriPath(match[1]) + if (parsed) { + this.cwd = parsed + } + } + } +} diff --git a/src/main/daemon/terminal-view-attribute-responder.ts b/src/main/daemon/terminal-view-attribute-responder.ts new file mode 100644 index 00000000000..551ffddbe21 --- /dev/null +++ b/src/main/daemon/terminal-view-attribute-responder.ts @@ -0,0 +1,191 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): OSC 4/10/11/12 and DSR ?996n responder handlers for the runtime + * headless emulator. The headless xterm core has no theme service, so these + * handlers compute replies from the renderer's pushed attribute snapshot, + * with per-PTY OSC SET mutations layered on top — mirroring exactly what the + * renderer's ThemeService reports for a visible pane. Replies route through + * the caller's emit sink, which the slice-1 forwarding window already gates, + * so seeded/replayed bytes and delivered chunks never produce a reply. + */ +import type { Terminal } from '@xterm/headless' +import { + formatXColorRgbSpec, + parseXColorSpec, + TERMINAL_VIEW_ANSI_COLOR_COUNT, + type TerminalViewAttributes, + type TerminalViewRgb +} from '../../shared/terminal-view-attributes' + +type ViewAttributeParser = Pick + +export type TerminalViewAttributeResponderDeps = { + parser: ViewAttributeParser + /** Last renderer push, or null before the first push. Null means SILENCE + * for every view-attribute query — a fabricated default would resurrect + * the default-black OSC-11 bug (design invariant 3). */ + getBaseAttributes: () => TerminalViewAttributes | null + /** Must already be replay/forwarding-window gated by the caller. */ + emitReply: (reply: string) => void +} + +export type TerminalViewAttributeResponder = { + /** A changed renderer attribute push replaces the whole palette, exactly + * like xterm's ThemeService `_setTheme` overwrites OSC-SET-mutated colors + * on a visible pane's theme apply. Identical re-pushes (fresh renderer + * process) are filtered in main's store and never reach this. */ + clearColorOverrides: () => void +} + +type SpecialColorSlot = 'foreground' | 'background' | 'cursor' + +// OSC 10/11/12 stack extra params onto consecutive slots (xterm's +// _setOrReportSpecialColor): `OSC 10;?;?` reports foreground then background. +const SPECIAL_COLOR_SLOTS: SpecialColorSlot[] = ['foreground', 'background', 'cursor'] +const SPECIAL_COLOR_IDENTS: Record = { + foreground: '10', + background: '11', + cursor: '12' +} + +function isValidColorIndex(value: number): boolean { + return value >= 0 && value < TERMINAL_VIEW_ANSI_COLOR_COUNT +} + +// Mirror of xterm's rgb.relativeLuminance2 (common/Color.ts, WCAG formula) — +// the math CoreBrowserTerminal._reportColorScheme answers ?996n with. +function relativeLuminance([r, g, b]: TerminalViewRgb): number { + const linear = (channel: number): number => { + const c = channel / 255 + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) + } + return linear(r) * 0.2126 + linear(g) * 0.7152 + linear(b) * 0.0722 +} + +export function installTerminalViewAttributeResponder( + deps: TerminalViewAttributeResponderDeps +): TerminalViewAttributeResponder { + // Why per-instance maps: SET mutations are per PTY (one emulator per PTY); + // they die with the emulator at teardown, like every other model state. + // They deliberately survive a reveal→re-hide cycle even though the revealed + // xterm restores without palette mutations (SerializeAddon emits no OSC + // color SETs): the TUI never reset its SET, so holding it is + // protocol-correct — the visible-side loss is the pre-existing restore + // limitation, not this model's. + const ansiOverrides = new Map() + const specialOverrides = new Map() + + const reportColor = (ident: string, rgb: TerminalViewRgb): void => { + // Why ST (not BEL) and 16-bit channels: byte-for-byte parity with the + // renderer xterm's reply (CoreBrowserTerminal._handleColorEvent). + deps.emitReply(`\x1b]${ident};${formatXColorRgbSpec(rgb)}\x1b\\`) + } + + const handleSpecialColor = (data: string, offset: number): boolean => { + const slots = data.split(';') + for (let i = 0; i < slots.length; ++i, ++offset) { + if (offset >= SPECIAL_COLOR_SLOTS.length) { + break + } + const slot = SPECIAL_COLOR_SLOTS[offset] + if (slots[i] === '?') { + const base = deps.getBaseAttributes() + if (base) { + reportColor(SPECIAL_COLOR_IDENTS[slot], specialOverrides.get(slot) ?? base[slot]) + } + } else { + const rgb = parseXColorSpec(slots[i]) + if (rgb) { + specialOverrides.set(slot, rgb) + } + } + } + // True consumes the sequence; the headless core's own OSC 10/11/12 + // handler only fires an onColor event nothing consumes. + return true + } + + deps.parser.registerOscHandler(4, (data) => { + const slots = data.split(';') + while (slots.length > 1) { + const idx = slots.shift() as string + const spec = slots.shift() as string + if (!/^\d+$/.exec(idx)) { + continue + } + const index = parseInt(idx, 10) + if (!isValidColorIndex(index)) { + continue + } + if (spec === '?') { + const base = deps.getBaseAttributes() + if (base) { + reportColor(`4;${index}`, ansiOverrides.get(index) ?? base.ansi[index]) + } + } else { + const rgb = parseXColorSpec(spec) + if (rgb) { + ansiOverrides.set(index, rgb) + } + } + } + return true + }) + deps.parser.registerOscHandler(10, (data) => handleSpecialColor(data, 0)) + deps.parser.registerOscHandler(11, (data) => handleSpecialColor(data, 1)) + deps.parser.registerOscHandler(12, (data) => handleSpecialColor(data, 2)) + + // OSC 104/110/111/112 restore the themed color — dropping the override + // falls back to the pushed base, the model twin of ThemeService.restoreColor. + deps.parser.registerOscHandler(104, (data) => { + if (!data) { + ansiOverrides.clear() + return true + } + for (const slot of data.split(';')) { + if (/^\d+$/.exec(slot)) { + ansiOverrides.delete(parseInt(slot, 10)) + } + } + return true + }) + deps.parser.registerOscHandler(110, () => { + specialOverrides.delete('foreground') + return true + }) + deps.parser.registerOscHandler(111, () => { + specialOverrides.delete('background') + return true + }) + deps.parser.registerOscHandler(112, () => { + specialOverrides.delete('cursor') + return true + }) + + deps.parser.registerCsiHandler({ prefix: '?', final: 'n' }, (params) => { + if (params[0] !== 996) { + // Fall through to the core for every other private DSR (?6n CPR etc.). + return false + } + const base = deps.getBaseAttributes() + if (base) { + // Why luminance and not base.colorSchemeMode: a visible xterm answers + // ?996n from the relative luminance of the CURRENT (OSC-SET-mutated) + // background vs foreground (CoreBrowserTerminal._reportColorScheme), + // so a dark terminal theme in a light app mode still answers dark. + // colorSchemeMode is the app mode and feeds the 2031/997 path only. + const background = specialOverrides.get('background') ?? base.background + const foreground = specialOverrides.get('foreground') ?? base.foreground + const dark = relativeLuminance(background) < relativeLuminance(foreground) + deps.emitReply(`\x1b[?997;${dark ? 1 : 2}n`) + } + return true + }) + + return { + clearColorOverrides: () => { + ansiOverrides.clear() + specialOverrides.clear() + } + } +} diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 92bceabc5ab..c146039c5ab 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -78,6 +78,8 @@ import { isNativeWindowsLocalPtySpawn, markNativeWindowsConptyPty } from '../runtime/terminal-model-query-authority' +import { setTerminalViewAttributes } from '../runtime/terminal-view-attribute-store' +import { validateTerminalViewAttributes } from '../../shared/terminal-view-attributes' import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' @@ -2974,6 +2976,17 @@ export function registerPtyHandlers( } }) + ipcMain.removeAllListeners('pty:terminalViewAttributes') + ipcMain.on('pty:terminalViewAttributes', (_event, args: unknown) => { + // Why validate-or-drop: the responder must never store a malformed + // palette — a wrong color reply breaks TUI theme detection worse than + // the documented silent-until-first-push behavior. + const attributes = validateTerminalViewAttributes(args) + if (attributes) { + setTerminalViewAttributes(attributes) + } + }) + ipcMain.removeAllListeners('pty:setPtyDeliveryInterest') ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { if (typeof args.id !== 'string' || !args.id) { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8d0f36bba8d..fbdcd8bb31e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -484,6 +484,10 @@ import { registerConptyDa1OverrideInstaller, shouldModelAnswerHiddenPtyQueries } from './terminal-model-query-authority' +import { + getTerminalViewAttributes, + registerTerminalViewAttributesApplier +} from './terminal-view-attribute-store' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import type { IFilesystemProvider, IPtyProvider } from '../providers/types' @@ -1687,6 +1691,15 @@ export class OrcaRuntimeService { // created this PTY's emulator; the mark retrofits the DA1 override here // (terminal-query-authority.md §ConPTY DA1). registerConptyDa1OverrideInstaller((ptyId) => this.ensureNativeWindowsConptyDa1Override(ptyId)) + // Why: a renderer attribute push must reach already-live emulators too — + // cursor options for DECRQSS/DECRQM parity plus the per-PTY OSC color + // override reset a theme apply implies (terminal-query-authority.md + // §View-attribute bridge). + registerTerminalViewAttributesApplier((attributes) => { + for (const state of this.headlessTerminals.values()) { + state.emulator.applyPushedViewAttributes(attributes) + } + }) } getLocalProvider(): IPtyProvider | null { @@ -4239,6 +4252,13 @@ export class OrcaRuntimeService { if (isNativeWindowsConptyPty(ptyId)) { emulator.installConptyPrimaryDeviceAttributesOverride() } + // Why the lazy getter: replies must use the freshest renderer push at + // parse time, and stay silent (never default) before the first push. + emulator.installViewAttributeResponder(() => getTerminalViewAttributes()) + const viewAttributes = getTerminalViewAttributes() + if (viewAttributes) { + emulator.applyPushedViewAttributes(viewAttributes) + } state = { emulator, outputSequence: 0, writeChain: Promise.resolve() } return state } diff --git a/src/main/runtime/terminal-query-responder.test.ts b/src/main/runtime/terminal-query-responder.test.ts index a2ab1fc96a2..093e1a8efbf 100644 --- a/src/main/runtime/terminal-query-responder.test.ts +++ b/src/main/runtime/terminal-query-responder.test.ts @@ -16,6 +16,11 @@ import { _resetTerminalModelQueryAuthorityForTest, markNativeWindowsConptyPty } from './terminal-model-query-authority' +import { + _resetTerminalViewAttributesForTest, + setTerminalViewAttributes +} from './terminal-view-attribute-store' +import type { TerminalViewAttributes, TerminalViewRgb } from '../../shared/terminal-view-attributes' const settingsState = { terminalMainSideEffectAuthority: true as boolean, @@ -75,9 +80,30 @@ async function settle(runtime: OrcaRuntimeService, ptyId: string): Promise await runtime.serializeMainTerminalBuffer(ptyId) } +/** Renderer-pushed attribute snapshot with distinct, pinned slot values so + * reply fixtures cannot pass by coincidence. */ +function viewAttributes(overrides: Partial = {}): TerminalViewAttributes { + const ansi = Array.from( + { length: 256 }, + (_, i) => [i, (i * 2) % 256, (i * 3) % 256] as TerminalViewRgb + ) + ansi[1] = [0xcc, 0x00, 0x00] + return { + foreground: [0xd0, 0xd0, 0xd0], + background: [0x1e, 0x1e, 0x2e], + cursor: [0xff, 0x99, 0x00], + ansi, + colorSchemeMode: 'dark', + cursorStyle: 'bar', + cursorBlink: true, + ...overrides + } +} + afterEach(() => { _resetHiddenRendererPtyDeliveryGateForTest() _resetTerminalModelQueryAuthorityForTest() + _resetTerminalViewAttributesForTest() settingsState.terminalMainSideEffectAuthority = true settingsState.terminalHiddenDeliveryGate = true settingsState.terminalModelQueryAuthority = true @@ -445,3 +471,326 @@ describe('HeadlessEmulator forwarding window', () => { } }) }) + +describe('view-attribute bridge replies (after renderer push)', () => { + // Reply bytes pinned to the renderer xterm's format: OSC replies use the + // queried ident, 16-bit doubled-byte channels, and ST termination + // (CoreBrowserTerminal._handleColorEvent + toRgbString); ?996n answers with + // the contour 997 report, same bytes as mode2031SequenceFor. + it.each([ + ['OSC 10 foreground', '\x1b]10;?\x07', ['\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\']], + ['OSC 11 background', '\x1b]11;?\x07', ['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']], + ['OSC 12 cursor color', '\x1b]12;?\x1b\\', ['\x1b]12;rgb:ffff/9999/0000\x1b\\']], + ['OSC 4 named palette slot', '\x1b]4;1;?\x07', ['\x1b]4;1;rgb:cccc/0000/0000\x1b\\']], + ['OSC 4 extended palette slot', '\x1b]4;196;?\x07', ['\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\']], + [ + 'OSC 4 multiple slots in one sequence', + '\x1b]4;1;?;196;?\x07', + ['\x1b]4;1;rgb:cccc/0000/0000\x1b\\', '\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\'] + ], + [ + 'OSC 10 stacked params report foreground then background', + '\x1b]10;?;?\x07', + ['\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\', '\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\'] + ], + ['DSR ?996n dark', '\x1b[?996n', ['\x1b[?997;1n']], + ['DECRQSS DECSCUSR from pushed cursor options', '\x1bP$q q\x1b\\', ['\x1bP1$r5 q\x1b\\']], + ['DECRQM ?12 from pushed cursorBlink', '\x1b[?12$p', ['\x1b[?12;1$y']] + ])('%s', async (_label, chunk, expectedReplies) => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-view') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-view', chunk, Date.now()) + await settle(runtime, 'pty-view') + + expect(replies.map((reply) => reply.data)).toEqual(expectedReplies) + }) + + it('answers ?996n from palette luminance, not the pushed app mode (dark palette, light app mode)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-lum-dark') + // Supported divergence: light app mode with terminalUseSeparateLightTheme + // off renders a dark terminal theme. A visible xterm answers ?996n from + // bg/fg relative luminance (CoreBrowserTerminal._reportColorScheme), so + // the hidden reply must say dark here too. + setTerminalViewAttributes(viewAttributes({ colorSchemeMode: 'light' })) + + runtime.onPtyData('pty-lum-dark', '\x1b[?996n', Date.now()) + await settle(runtime, 'pty-lum-dark') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;1n']) + }) + + it('answers ?996n light for a light palette regardless of the pushed app mode', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-lum-light') + setTerminalViewAttributes( + viewAttributes({ + foreground: [0x33, 0x33, 0x33], + background: [0xfa, 0xfa, 0xfa], + colorSchemeMode: 'dark' + }) + ) + + runtime.onPtyData('pty-lum-light', '\x1b[?996n', Date.now()) + await settle(runtime, 'pty-lum-light') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n']) + }) + + it('answers ?996n from OSC-SET-mutated colors like a visible xterm', async () => { + // _reportColorScheme reads the CURRENT theme-service colors, which include + // OSC 10/11 SET mutations — the per-PTY overlays layer the same way. + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-lum-set') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-lum-set', '\x1b]11;#ffffff\x07\x1b]10;#101010\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-lum-set') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n']) + }) + + it('stays silent before the first push, then answers the same query after it', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-first') + + runtime.onPtyData('pty-first', '\x1b]11;?\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-first') + // No fabricated defaults: silence is the documented hidden status quo. + expect(replies).toEqual([]) + + setTerminalViewAttributes(viewAttributes()) + runtime.onPtyData('pty-first', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-first') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']) + }) + + it('retrofits cursor options onto already-live emulators when the push lands late', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-late') + + // Emulator exists before any push: core default DECSCUSR is steady block. + runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now()) + await settle(runtime, 'pty-late') + expect(replies.map((reply) => reply.data)).toEqual(['\x1bP1$r2 q\x1b\\']) + + setTerminalViewAttributes(viewAttributes({ cursorStyle: 'underline', cursorBlink: false })) + runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now()) + await settle(runtime, 'pty-late') + expect(replies.map((reply) => reply.data).at(-1)).toBe('\x1bP1$r4 q\x1b\\') + }) +}) + +describe('per-PTY OSC color SET layering', () => { + it('layers an OSC 4 SET over the pushed base, isolated per PTY', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-a') + markHiddenRendererPty('pty-b') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-a', '\x1b]4;1;rgb:00/ff/00\x07\x1b]4;1;?\x07', Date.now()) + runtime.onPtyData('pty-b', '\x1b]4;1;?\x07', Date.now()) + await settle(runtime, 'pty-a') + await settle(runtime, 'pty-b') + + expect(replies).toEqual([ + { ptyId: 'pty-a', data: '\x1b]4;1;rgb:0000/ffff/0000\x1b\\' }, + { ptyId: 'pty-b', data: '\x1b]4;1;rgb:cccc/0000/0000\x1b\\' } + ]) + }) + + it('restores a single indexed color via OSC 104;', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-104') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-104', '\x1b]4;1;#00ff00\x07\x1b]104;1\x07\x1b]4;1;?\x07', Date.now()) + await settle(runtime, 'pty-104') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]4;1;rgb:cccc/0000/0000\x1b\\']) + }) + + it('restores the whole indexed table via bare OSC 104', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-104all') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData( + 'pty-104all', + '\x1b]4;1;#00ff00;196;#0000ff\x07\x1b]104\x07\x1b]4;1;?;196;?\x07', + Date.now() + ) + await settle(runtime, 'pty-104all') + + expect(replies.map((reply) => reply.data)).toEqual([ + '\x1b]4;1;rgb:cccc/0000/0000\x1b\\', + '\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\' + ]) + }) + + it('layers OSC 10/11/12 SETs and restores them via 110/111/112', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-special') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData( + 'pty-special', + '\x1b]10;#010203\x07\x1b]11;rgb:ff/ff/ff\x07\x1b]12;#0a0b0c\x07' + + '\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07' + + '\x1b]110\x07\x1b]111\x07\x1b]112\x07' + + '\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07', + Date.now() + ) + await settle(runtime, 'pty-special') + + expect(replies.map((reply) => reply.data)).toEqual([ + '\x1b]10;rgb:0101/0202/0303\x1b\\', + '\x1b]11;rgb:ffff/ffff/ffff\x1b\\', + '\x1b]12;rgb:0a0a/0b0b/0c0c\x1b\\', + '\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\', + '\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\', + '\x1b]12;rgb:ffff/9999/0000\x1b\\' + ]) + }) + + it('tracks SET mutations parsed from a seed without replying, like renderer replay', async () => { + // Cold-restore scrollback replayed into a visible renderer xterm re-applies + // OSC SETs to its theme service; the model mirrors that state — but the + // replay guard still keeps the seed from ANSWERING anything. + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-seedset') + setTerminalViewAttributes(viewAttributes()) + + runtime.seedHeadlessTerminal('pty-seedset', 'restored\x1b]4;1;#00ff00\x07\x1b]4;1;?\x07') + await settle(runtime, 'pty-seedset') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-seedset', '\x1b]4;1;?\x07', Date.now()) + await settle(runtime, 'pty-seedset') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]4;1;rgb:0000/ffff/0000\x1b\\']) + }) + + it('preserves per-PTY overrides on an identical re-push (fresh renderer process)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-idem') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-idem', '\x1b]11;#ffffff\x07', Date.now()) + await settle(runtime, 'pty-idem') + + // A second window / renderer reload / macOS re-activation re-pushes + // byte-identical attributes (its publisher dedupe is per-process). That is + // not a theme apply, so the OSC SET overlay must survive. + setTerminalViewAttributes(viewAttributes()) + runtime.onPtyData('pty-idem', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-idem') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:ffff/ffff/ffff\x1b\\']) + }) + + it('clears per-PTY overrides when a new push lands (theme apply parity)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-clear') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-clear', '\x1b]11;#ffffff\x07', Date.now()) + await settle(runtime, 'pty-clear') + + // A theme apply overwrites OSC-SET-mutated colors on visible panes too + // (ThemeService._setTheme), so the model mirrors that on every CHANGED + // push (identical re-pushes are filtered — see the test above). + setTerminalViewAttributes(viewAttributes({ background: [0x10, 0x20, 0x30] })) + runtime.onPtyData('pty-clear', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-clear') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1010/2020/3030\x1b\\']) + }) +}) + +describe('view-attribute replay guard and suppression', () => { + it('never answers view-attribute queries embedded in a seeded snapshot', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vseed') + setTerminalViewAttributes(viewAttributes()) + + runtime.seedHeadlessTerminal('pty-vseed', 'prompt\x1b]11;?\x07\x1b[?996n') + await settle(runtime, 'pty-vseed') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-vseed', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vseed') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']) + }) + + it('never answers view-attribute queries replayed by renderer-buffer hydration', async () => { + const { runtime, replies } = createResponderRuntime({ + rendererBuffer: { data: 'restored\x1b]11;?\x07\x1b[?996n', cols: 80, rows: 24 } + }) + markHiddenRendererPty('pty-vhyd') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vhyd', 'live output', Date.now()) + await settle(runtime, 'pty-vhyd') + + expect(replies).toEqual([]) + }) + + it('never answers a delivered (unmarked) view-attribute query — the visible xterm owns it', async () => { + const { runtime, replies } = createResponderRuntime() + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vvis', '\x1b]11;?\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-vvis') + + expect(replies).toEqual([]) + }) + + it('never answers while renderer delivery interest holds the chunk delivered', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vint') + setRendererPtyDeliveryInterest('pty-vint', true) + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vint', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vint') + + expect(replies).toEqual([]) + }) + + it('yields view-attribute replies while a remote view subscriber is attached', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vrem') + setTerminalViewAttributes(viewAttributes()) + const release = runtime.registerRemoteTerminalViewSubscriber('pty-vrem') + + runtime.onPtyData('pty-vrem', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vrem') + expect(replies).toEqual([]) + + release() + runtime.onPtyData('pty-vrem', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vrem') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']) + }) + + it.each([ + ['terminalModelQueryAuthority', () => (settingsState.terminalModelQueryAuthority = false)], + ['terminalHiddenDeliveryGate', () => (settingsState.terminalHiddenDeliveryGate = false)], + [ + 'terminalMainSideEffectAuthority', + () => (settingsState.terminalMainSideEffectAuthority = false) + ] + ])('never answers view-attribute queries with kill switch %s off', async (_label, flip) => { + flip() + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vkill') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vkill', '\x1b]11;?\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-vkill') + + expect(replies).toEqual([]) + }) +}) diff --git a/src/main/runtime/terminal-view-attribute-store.ts b/src/main/runtime/terminal-view-attribute-store.ts new file mode 100644 index 00000000000..ee55581bfe9 --- /dev/null +++ b/src/main/runtime/terminal-view-attribute-store.ts @@ -0,0 +1,57 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): main-side cache of the renderer's `pty:terminalViewAttributes` + * push. One app-global snapshot, not per-PTY — per-pane font zoom never + * affects these attributes and the color/cursor settings are global. + * + * Null until the first push, and the responder answers NO view-attribute + * query while null (silent-until-first-push): a fabricated default would + * resurrect the default-black OSC-11 bug. Staleness is bounded by one IPC + * hop; subscribed TUIs are corrected by the renderer-owned 2031/997 flip. + */ +import { + terminalViewAttributesEqual, + type TerminalViewAttributes +} from '../../shared/terminal-view-attributes' + +// Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts receives +// the push, the runtime emulators consult it at reply time via the getter. +let currentAttributes: TerminalViewAttributes | null = null + +// Why appliers (pattern of registerConptyDa1OverrideInstaller): each push +// must also reach already-live emulators — cursor options under the replay +// guard, plus the per-PTY override reset a theme apply implies. +type TerminalViewAttributesApplier = (attributes: TerminalViewAttributes) => void +const pushAppliers = new Set() + +export function registerTerminalViewAttributesApplier( + applier: TerminalViewAttributesApplier +): void { + pushAppliers.add(applier) +} + +/** Called from the pty:terminalViewAttributes IPC handler with a validated + * payload. Last push wins (replies always use the freshest snapshot). */ +export function setTerminalViewAttributes(attributes: TerminalViewAttributes): void { + // Why idempotent: the renderer publisher's dedupe is per-process, so a + // fresh renderer (second window, reload, macOS re-activation) re-pushes + // identical attributes. That is not a theme apply — fanning out would wipe + // every PTY's OSC SET overlay while visible panes keep theirs. + if (currentAttributes && terminalViewAttributesEqual(currentAttributes, attributes)) { + return + } + currentAttributes = attributes + for (const applier of pushAppliers) { + applier(attributes) + } +} + +export function getTerminalViewAttributes(): TerminalViewAttributes | null { + return currentAttributes +} + +/** Test seam: reset module state between tests. */ +export function _resetTerminalViewAttributesForTest(): void { + currentAttributes = null + pushAppliers.clear() +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index d1b21503efa..88e1c6382ce 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -146,6 +146,7 @@ import type { WorkspaceSessionState } from '../shared/types' import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' +import type { TerminalViewAttributes } from '../shared/terminal-view-attributes' import type { SetupScriptImportCandidate } from '../shared/setup-script-imports' import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' @@ -943,6 +944,9 @@ export type PreloadApi = { /** Ref-counted-on-the-renderer delivery-interest signal that suppresses * the hidden-delivery gate while any raw-byte consumer is registered. */ setPtyDeliveryInterest: (id: string, interested: boolean) => void + /** View-attribute bridge (Phase 5 slice 2): app-global composed terminal + * appearance push backing main's hidden-PTY OSC/DSR color replies. */ + publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void hasChildProcesses: (id: string) => Promise getForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 79d59435315..2edcbe8acdd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -45,6 +45,7 @@ import type { WorktreeRemoteBranchConflictEvent } from '../shared/types' import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' +import type { TerminalViewAttributes } from '../shared/terminal-view-attributes' import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' @@ -717,6 +718,12 @@ const api = { setPtyDeliveryInterest: (id: string, interested: boolean): void => { ipcRenderer.send('pty:setPtyDeliveryInterest', { id, interested }) }, + /** View-attribute bridge (Phase 5 slice 2): app-global composed terminal + * appearance push that lets main's model responder answer OSC 4/10/11/12 + * and DSR ?996n for hidden-gated PTYs with renderer-true values. */ + publishTerminalViewAttributes: (attributes: TerminalViewAttributes): void => { + ipcRenderer.send('pty:terminalViewAttributes', attributes) + }, kill: (id: string, opts?: { keepHistory?: boolean }): Promise => ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index 863d4cc7725..61b745a606c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { Terminal } from '@xterm/headless' -import type { ManagedPane } from '@/lib/pane-manager/pane-manager' +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import { getDefaultSettings } from '../../../../shared/constants' import { + applyTerminalAppearance, hexToRgba, installMode2031Handlers, maybePushMode2031Flip, @@ -373,6 +375,68 @@ describe('installMode2031Handlers', () => { }) }) +describe('applyTerminalAppearance theme assignment', () => { + // xterm's OptionsService fires the theme change on object IDENTITY, and + // ThemeService._setTheme then rebuilds the palette, discarding OSC + // 4/10/11/12 SET mutations. Attribute-neutral applies (font size, padding, + // zoom) compose a fresh-but-value-identical theme; assigning it anyway + // wipes TUI color mutations on visible panes while the deduped publisher + // keeps hidden overlays — so the assignment must be value-gated. + function makePane(id: number): ManagedPane { + return { id, terminal: { options: {}, cols: 80, rows: 24 } } as unknown as ManagedPane + } + + function makeManager(panes: ManagedPane[]): PaneManager { + return { + getPanes: () => panes, + setPaneLigaturesEnabled: vi.fn(), + setPaneStyleOptions: vi.fn() + } as unknown as PaneManager + } + + function apply(pane: ManagedPane, settings: ReturnType): void { + applyTerminalAppearance( + makeManager([pane]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + } + + it('keeps options.theme identity across attribute-neutral applies (font size tweak)', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, settings) + const firstTheme = pane.terminal.options.theme + expect(firstTheme).toBeDefined() + + apply(pane, { ...settings, terminalFontSize: settings.terminalFontSize + 2 }) + + // Identity-stable theme means xterm never re-runs _setTheme, so a TUI's + // modifyColors mutation survives the font tweak. + expect(pane.terminal.options.theme).toBe(firstTheme) + expect(pane.terminal.options.fontSize).toBe(settings.terminalFontSize + 2) + }) + + it('still assigns a fresh theme when composed values actually change', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, settings) + const firstTheme = pane.terminal.options.theme + + apply(pane, { ...settings, terminalColorOverrides: { background: '#102030' } }) + + expect(pane.terminal.options.theme).not.toBe(firstTheme) + expect(pane.terminal.options.theme?.background).toBe('#102030') + }) +}) + describe('hexToRgba', () => { it('converts 6-char hex to rgba', () => { expect(hexToRgba('#1a1a1a', 0.72)).toBe('rgba(26, 26, 26, 0.72)') diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index 9f7dc5f23d2..f2c4ac1c731 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -16,6 +16,7 @@ import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' import type { PtyTransport } from './pty-transport' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' import { HEX_COLOR_RE } from '../../../../shared/color-validation' +import { publishTerminalViewAttributes } from './terminal-view-attributes-publisher' export { mode2031SequenceFor } @@ -195,6 +196,32 @@ export function composeActiveTerminalTheme( return theme } +// Value equality over composed ITheme objects (flat string slots plus the +// extendedAnsi string array), used to gate the per-pane options.theme write. +function composedTerminalThemesEqual(a: ITheme | undefined, b: ITheme): boolean { + if (!a) { + return false + } + if (a === b) { + return true + } + const keys = new Set([...Object.keys(a), ...Object.keys(b)]) + for (const key of keys) { + if (key === 'extendedAnsi') { + continue + } + if (a[key as keyof ITheme] !== b[key as keyof ITheme]) { + return false + } + } + const extA = a.extendedAnsi + const extB = b.extendedAnsi + if (!extA || !extB) { + return extA === extB + } + return extA.length === extB.length && extA.every((value, i) => value === extB[i]) +} + export function applyTerminalAppearance( manager: PaneManager, settings: GlobalSettings, @@ -209,6 +236,11 @@ export function applyTerminalAppearance( const paneStyles = resolvePaneStyleOptions(settings) const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName) const theme = composeActiveTerminalTheme(baseTheme, settings) + // View-attribute bridge (Phase 5 slice 2): this is the single point where + // the composed app-global terminal appearance exists, so publish it to + // main's hidden-PTY query responder here. Deduped inside the publisher — + // per-pane re-applies and attribute-neutral tweaks do not re-push. + publishTerminalViewAttributes(theme, appearance.mode, settings) const paneBackground = theme?.background ?? '#000000' const terminalFontWeights = resolveTerminalFontWeights(settings.terminalFontWeight) @@ -218,7 +250,14 @@ export function applyTerminalAppearance( ) for (const pane of manager.getPanes()) { - if (theme) { + // Why value-gated: xterm's OptionsService fires on object identity, and + // ThemeService._setTheme rebuilds the palette, discarding TUI OSC + // 4/10/11/12 SET mutations. Attribute-neutral applies (font size/family, + // line height, padding, per-pane zoom) compose a fresh-but-identical + // theme; skipping the write keeps visible-pane mutations alive (a + // pre-existing loss this also fixes) and matches the hidden responder's + // deduped overlay behavior, so hidden and visible no longer drift. + if (theme && !composedTerminalThemesEqual(pane.terminal.options.theme, theme)) { pane.terminal.options.theme = theme } // Why: xterm's allowTransparency has measurable rendering cost, so clear diff --git a/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts new file mode 100644 index 00000000000..ea7613ea354 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts @@ -0,0 +1,249 @@ +/** + * View-attribute bridge publication (terminal-query-authority.md §View- + * attribute bridge): the composed snapshot must mirror xterm ThemeService + * resolution (defaults, cursor blend, 256-entry palette), and pushes must + * happen once per actual change — not per pane, not per font tweak. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' +import { getDefaultSettings } from '../../../../shared/constants' +import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' +import { applyTerminalAppearance } from './terminal-appearance' +import { + _resetTerminalViewAttributesPublisherForTest, + composeTerminalViewAttributes, + publishTerminalViewAttributes +} from './terminal-view-attributes-publisher' + +const cursorSettings = { + terminalCursorStyle: 'block' as const, + terminalCursorBlink: true +} + +beforeEach(() => { + _resetTerminalViewAttributesPublisherForTest() + vi.unstubAllGlobals() +}) + +describe('composeTerminalViewAttributes', () => { + it('resolves a null theme to the xterm ThemeService defaults', () => { + const attrs = composeTerminalViewAttributes(null, 'dark', cursorSettings) + expect(attrs.foreground).toEqual([0xff, 0xff, 0xff]) + expect(attrs.background).toEqual([0x00, 0x00, 0x00]) + expect(attrs.cursor).toEqual([0xff, 0xff, 0xff]) + expect(attrs.ansi).toHaveLength(256) + // DEFAULT_ANSI_COLORS parity: named 16, color cube, greys. + expect(attrs.ansi[0]).toEqual([0x2e, 0x34, 0x36]) + expect(attrs.ansi[1]).toEqual([0xcc, 0x00, 0x00]) + expect(attrs.ansi[15]).toEqual([0xee, 0xee, 0xec]) + expect(attrs.ansi[16]).toEqual([0x00, 0x00, 0x00]) + expect(attrs.ansi[196]).toEqual([0xff, 0x00, 0x00]) + expect(attrs.ansi[232]).toEqual([8, 8, 8]) + expect(attrs.ansi[255]).toEqual([238, 238, 238]) + expect(attrs.colorSchemeMode).toBe('dark') + expect(attrs.cursorStyle).toBe('block') + expect(attrs.cursorBlink).toBe(true) + }) + + it('parses composed theme colors including rgba() opacity forms', () => { + const attrs = composeTerminalViewAttributes( + { + // composeActiveTerminalTheme emits rgba() when terminalBackgroundOpacity + // or terminalCursorOpacity apply; the reply drops alpha like xterm's + // toColorRGB, except the cursor which blends over the background. + background: 'rgba(30, 30, 46, 0.9)', + foreground: '#d0d0d0', + cursor: 'rgba(255, 0, 0, 0.5)', + red: '#ff8800' + }, + 'light', + { terminalCursorStyle: 'underline', terminalCursorBlink: false } + ) + expect(attrs.background).toEqual([30, 30, 46]) + expect(attrs.foreground).toEqual([0xd0, 0xd0, 0xd0]) + // color.blend parity: a = round(0.5*255)/255; ch = bg + round((fg-bg)*a). + expect(attrs.cursor).toEqual([143, 15, 23]) + expect(attrs.ansi[1]).toEqual([0xff, 0x88, 0x00]) + expect(attrs.colorSchemeMode).toBe('light') + expect(attrs.cursorStyle).toBe('underline') + expect(attrs.cursorBlink).toBe(false) + }) + + it('keeps an opaque cursor un-blended and blends short-hex alpha', () => { + const attrs = composeTerminalViewAttributes( + { background: '#000000', cursor: '#ff0000' }, + 'dark', + cursorSettings + ) + expect(attrs.cursor).toEqual([255, 0, 0]) + + const blended = composeTerminalViewAttributes( + { background: '#000000', cursor: '#f00a' }, + 'dark', + cursorSettings + ) + // #f00a → alpha 0xaa: 0 + round(255 * (0xaa/0xff)) = 170. + expect(blended.cursor).toEqual([170, 0, 0]) + }) + + it('overlays extendedAnsi onto the default 256 palette tail', () => { + const attrs = composeTerminalViewAttributes( + { extendedAnsi: ['#102030'] }, + 'dark', + cursorSettings + ) + expect(attrs.ansi[16]).toEqual([0x10, 0x20, 0x30]) + // Untouched tail entries stay on the generated cube. + expect(attrs.ansi[17]).toEqual([0x00, 0x00, 0x5f]) + }) + + it('falls back to slot defaults for named colors (hand-edited settings divergence)', () => { + // A visible pane resolves named CSS via canvas; the composer cannot, so + // hand-edited values fall back — the documented divergence boundary. + const attrs = composeTerminalViewAttributes( + { red: 'darkred', foreground: 'hotpink' }, + 'dark', + cursorSettings + ) + expect(attrs.ansi[1]).toEqual([0xcc, 0x00, 0x00]) + expect(attrs.foreground).toEqual([0xff, 0xff, 0xff]) + }) +}) + +describe('publishTerminalViewAttributes dedupe', () => { + it('publishes once per snapshot change, not per call', () => { + const send = vi.fn(() => true) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(true) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(false) + expect(send).toHaveBeenCalledTimes(1) + + // A real attribute change (theme flip) publishes again. + expect(publishTerminalViewAttributes(null, 'light', cursorSettings, send)).toBe(true) + expect(send).toHaveBeenCalledTimes(2) + }) + + it('does not record a failed send, so the next call retries', () => { + const failingSend = vi.fn(() => false) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, failingSend)).toBe(false) + + const send = vi.fn(() => true) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(true) + }) + + it('skips silently when the preload bridge is unavailable (web client, tests)', () => { + // No window stub: default send must be a safe no-op. + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings)).toBe(false) + }) +}) + +describe('applyTerminalAppearance publication', () => { + function makePane(id: number): ManagedPane { + return { + id, + terminal: { options: {}, cols: 80, rows: 24 } + } as unknown as ManagedPane + } + + function makeManager(panes: ManagedPane[]): PaneManager { + return { + getPanes: () => panes, + setPaneLigaturesEnabled: vi.fn(), + setPaneStyleOptions: vi.fn() + } as unknown as PaneManager + } + + function stubPublishBridge(): ReturnType { + const publish = vi.fn<(attributes: TerminalViewAttributes) => void>() + vi.stubGlobal('window', { api: { pty: { publishTerminalViewAttributes: publish } } }) + return publish + } + + it('pushes the app-global snapshot once per change, not per pane or per manager', () => { + const publish = stubPublishBridge() + const settings = getDefaultSettings('/tmp') + + // Two panes in one manager plus a second manager (another tab): the + // attributes are app-global, so identical applies publish exactly once. + applyTerminalAppearance( + makeManager([makePane(1), makePane(2)]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + applyTerminalAppearance( + makeManager([makePane(3)]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publish).toHaveBeenCalledTimes(1) + const attributes = publish.mock.calls[0][0] as TerminalViewAttributes + expect(attributes.ansi).toHaveLength(256) + expect(attributes.cursorStyle).toBe(settings.terminalCursorStyle) + + // Attribute-neutral tweak (font size) must not re-push… + applyTerminalAppearance( + makeManager([makePane(1)]), + { ...settings, terminalFontSize: settings.terminalFontSize + 2 }, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publish).toHaveBeenCalledTimes(1) + + // …while a cursor-style change is a real attribute change. + applyTerminalAppearance( + makeManager([makePane(1)]), + { ...settings, terminalCursorStyle: 'underline' }, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publish).toHaveBeenCalledTimes(2) + }) + + it('publishes the resolved color-scheme mode flip (system dark toggle)', () => { + const publish = stubPublishBridge() + const settings = { ...getDefaultSettings('/tmp'), theme: 'system' as const } + + applyTerminalAppearance( + makeManager([makePane(1)]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + applyTerminalAppearance( + makeManager([makePane(1)]), + settings, + false, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + + const modes = publish.mock.calls.map( + (call) => (call[0] as TerminalViewAttributes).colorSchemeMode + ) + expect(modes).toEqual(['dark', 'light']) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts new file mode 100644 index 00000000000..05970b77725 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts @@ -0,0 +1,247 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): renderer→main `pty:terminalViewAttributes` publication. Composes + * the reply-relevant slots of the active terminal theme exactly the way + * xterm's browser ThemeService resolves an ITheme (defaults, cursor blend, + * 256-entry palette), so main's hidden-PTY responder replies byte-identically + * to a visible pane's xterm. Deduped module-globally: applyTerminalAppearance + * runs per pane manager and on every font/opacity tweak, but the attributes + * are app-global, so identical snapshots publish once. + */ +import type { ITheme } from '@xterm/xterm' +import type { GlobalSettings } from '../../../../shared/types' +import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' +import type { + TerminalViewAttributes, + TerminalViewRgb +} from '../../../../shared/terminal-view-attributes' + +type ParsedCssColor = { + rgb: TerminalViewRgb + /** 0-255, the precision xterm stores (rgba byte) — blend parity needs it. */ + alpha: number +} + +// ThemeService defaults for the reply-relevant slots (browser/services/ +// ThemeService.ts): fg #ffffff, bg #000000, cursor #ffffff. +const DEFAULT_FOREGROUND: ParsedCssColor = { rgb: [0xff, 0xff, 0xff], alpha: 0xff } +const DEFAULT_BACKGROUND: ParsedCssColor = { rgb: [0x00, 0x00, 0x00], alpha: 0xff } +const DEFAULT_CURSOR: ParsedCssColor = { rgb: [0xff, 0xff, 0xff], alpha: 0xff } + +// xterm's DEFAULT_ANSI_COLORS first 16 entries (browser/Types.ts). +const DEFAULT_ANSI_16: readonly string[] = [ + '#2e3436', + '#cc0000', + '#4e9a06', + '#c4a000', + '#3465a4', + '#75507b', + '#06989a', + '#d3d7cf', + '#555753', + '#ef2929', + '#8ae234', + '#fce94f', + '#729fcf', + '#ad7fa8', + '#34e2e2', + '#eeeeec' +] + +const THEME_ANSI_KEYS: readonly (keyof ITheme)[] = [ + 'black', + 'red', + 'green', + 'yellow', + 'blue', + 'magenta', + 'cyan', + 'white', + 'brightBlack', + 'brightRed', + 'brightGreen', + 'brightYellow', + 'brightBlue', + 'brightMagenta', + 'brightCyan', + 'brightWhite' +] + +function buildDefaultAnsiPalette(): TerminalViewRgb[] { + const palette = DEFAULT_ANSI_16.map((hex) => parseThemeColor(hex, DEFAULT_BACKGROUND).rgb) + // 16-231: the 6x6x6 color cube, 232-255: greys — same generator as xterm's + // DEFAULT_ANSI_COLORS IIFE so untouched extended slots reply identically. + const v = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff] + for (let i = 0; i < 216; i++) { + palette.push([v[((i / 36) % 6) | 0], v[((i / 6) % 6) | 0], v[i % 6]]) + } + for (let i = 0; i < 24; i++) { + const c = 8 + i * 10 + palette.push([c, c, c]) + } + return palette +} + +const DEFAULT_ANSI_PALETTE: readonly TerminalViewRgb[] = buildDefaultAnsiPalette() + +/** Mirror of xterm's css.toColor fast paths (#rgb[a], #rrggbb[aa], rgb(), + * rgba()) — every format first-party inputs produce (builtin themes and the + * ghostty import are hex-validated; composeActiveTerminalTheme only adds the + * rgba() form this regex accepts). Known divergence boundary: the renderer's + * css.toColor also resolves named/modern CSS via a canvas litmus, so a + * hand-edited settings value like `background: 'darkslategray'` renders on + * a visible pane but falls back to the slot default in the hidden reply. */ +export function parseCssColor(css: string): ParsedCssColor | null { + if (/^#[\da-f]{3,8}$/i.test(css)) { + switch (css.length) { + case 4: + return { + rgb: [ + parseInt(css.slice(1, 2).repeat(2), 16), + parseInt(css.slice(2, 3).repeat(2), 16), + parseInt(css.slice(3, 4).repeat(2), 16) + ], + alpha: 0xff + } + case 5: + return { + rgb: [ + parseInt(css.slice(1, 2).repeat(2), 16), + parseInt(css.slice(2, 3).repeat(2), 16), + parseInt(css.slice(3, 4).repeat(2), 16) + ], + alpha: parseInt(css.slice(4, 5).repeat(2), 16) + } + case 7: + return { + rgb: [ + parseInt(css.slice(1, 3), 16), + parseInt(css.slice(3, 5), 16), + parseInt(css.slice(5, 7), 16) + ], + alpha: 0xff + } + case 9: + return { + rgb: [ + parseInt(css.slice(1, 3), 16), + parseInt(css.slice(3, 5), 16), + parseInt(css.slice(5, 7), 16) + ], + alpha: parseInt(css.slice(7, 9), 16) + } + default: + return null + } + } + const rgbaMatch = css.match( + /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/ + ) + if (rgbaMatch) { + return { + rgb: [parseInt(rgbaMatch[1], 10), parseInt(rgbaMatch[2], 10), parseInt(rgbaMatch[3], 10)], + alpha: Math.round((rgbaMatch[5] === undefined ? 1 : parseFloat(rgbaMatch[5])) * 0xff) + } + } + return null +} + +function parseThemeColor(css: string | undefined, fallback: ParsedCssColor): ParsedCssColor { + if (css !== undefined) { + const parsed = parseCssColor(css) + if (parsed) { + return parsed + } + } + return fallback +} + +// Mirror of xterm's color.blend: ThemeService blends the cursor color's +// alpha over the background at theme-set time (terminalCursorOpacity), and +// the OSC 12 reply reports the blended value. +function blendOverBackground(background: TerminalViewRgb, color: ParsedCssColor): TerminalViewRgb { + if (color.alpha === 0xff) { + return color.rgb + } + const a = color.alpha / 0xff + return [ + background[0] + Math.round((color.rgb[0] - background[0]) * a), + background[1] + Math.round((color.rgb[1] - background[1]) * a), + background[2] + Math.round((color.rgb[2] - background[2]) * a) + ] +} + +export function composeTerminalViewAttributes( + theme: ITheme | null, + mode: TerminalColorSchemeMode, + settings: Pick +): TerminalViewAttributes { + const foreground = parseThemeColor(theme?.foreground, DEFAULT_FOREGROUND) + const background = parseThemeColor(theme?.background, DEFAULT_BACKGROUND) + const cursor = parseThemeColor(theme?.cursor, DEFAULT_CURSOR) + const ansi: TerminalViewRgb[] = THEME_ANSI_KEYS.map((key, i) => { + const value = theme?.[key] + return parseThemeColor(typeof value === 'string' ? value : undefined, { + rgb: DEFAULT_ANSI_PALETTE[i], + alpha: 0xff + }).rgb + }) + for (let i = 16; i < DEFAULT_ANSI_PALETTE.length; i++) { + const extended = theme?.extendedAnsi?.[i - 16] + ansi.push( + parseThemeColor(extended, { + rgb: DEFAULT_ANSI_PALETTE[i], + alpha: 0xff + }).rgb + ) + } + return { + foreground: foreground.rgb, + background: background.rgb, + cursor: blendOverBackground(background.rgb, cursor), + ansi, + colorSchemeMode: mode, + // Same resolution as the per-pane option writes in applyTerminalAppearance. + cursorStyle: settings.terminalCursorStyle ?? 'block', + cursorBlink: settings.terminalCursorBlink === true + } +} + +let lastPublishedSnapshot: string | null = null + +function sendViaPreload(attributes: TerminalViewAttributes): boolean { + // Guarded: unit tests and the web client run without the preload bridge + // (remote-runtime PTYs are never hidden-gate markable anyway). + if (typeof window === 'undefined' || !window.api?.pty?.publishTerminalViewAttributes) { + return false + } + window.api.pty.publishTerminalViewAttributes(attributes) + return true +} + +/** Publishes the composed app-global attributes, once per actual change: + * repeat calls from per-pane appearance applies (and attribute-neutral + * tweaks like font size) are deduped against the last published snapshot. */ +export function publishTerminalViewAttributes( + theme: ITheme | null, + mode: TerminalColorSchemeMode, + settings: Pick, + send: (attributes: TerminalViewAttributes) => boolean = sendViaPreload +): boolean { + const attributes = composeTerminalViewAttributes(theme, mode, settings) + const serialized = JSON.stringify(attributes) + if (serialized === lastPublishedSnapshot) { + return false + } + if (!send(attributes)) { + // Not recorded: a later call with a working bridge must still publish. + return false + } + lastPublishedSnapshot = serialized + return true +} + +/** Test seam: reset the dedupe state between tests. */ +export function _resetTerminalViewAttributesPublisherForTest(): void { + lastPublishedSnapshot = null +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 65e0bdf6ee9..38457954ac8 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2258,6 +2258,9 @@ function createPtyApi(): NonNullable['pty']> { setActiveRendererPty: () => {}, setHiddenRendererPty: () => {}, setPtyDeliveryInterest: () => {}, + // Why no-op: remote-runtime PTYs are never hidden-gate markable, so the + // web client has no main-side responder to feed. + publishTerminalViewAttributes: () => {}, hasChildProcesses: () => Promise.resolve(false), getForegroundProcess: () => Promise.resolve(null), getCwd: () => Promise.resolve('~'), diff --git a/src/shared/terminal-view-attributes.test.ts b/src/shared/terminal-view-attributes.test.ts new file mode 100644 index 00000000000..17c77c08d09 --- /dev/null +++ b/src/shared/terminal-view-attributes.test.ts @@ -0,0 +1,119 @@ +/** + * View-attribute bridge (terminal-query-authority.md §View-attribute bridge): + * the XParseColor mirrors must match the bundled xterm grammar exactly — + * main's replies for hidden PTYs must be byte-identical to a visible + * renderer xterm's. + */ +import { describe, expect, it } from 'vitest' +import { + formatXColorRgbSpec, + parseXColorSpec, + terminalViewAttributesEqual, + validateTerminalViewAttributes, + type TerminalViewAttributes, + type TerminalViewRgb +} from './terminal-view-attributes' + +describe('parseXColorSpec', () => { + // Scaling fixtures mirror XParseColor.parseColor: h|hh|hhh|hhhh channels + // scale from their base (15/255/4095/65535) to 8 bit. + it.each([ + ['rgb:f/f/f', [255, 255, 255]], + ['rgb:0/8/f', [0, 136, 255]], + ['rgb:ff/00/80', [255, 0, 128]], + ['rgb:fff/000/888', [255, 0, 136]], + ['rgb:ffff/0000/8888', [255, 0, 136]], + ['RGB:FF/00/80', [255, 0, 128]], + ['#abc', [0xa0, 0xb0, 0xc0]], + ['#aabbcc', [0xaa, 0xbb, 0xcc]], + ['#aaabbbccc', [0xaa, 0xbb, 0xcc]], + ['#aaaabbbbcccc', [0xaa, 0xbb, 0xcc]] + ])('parses %s like xterm', (spec, expected) => { + expect(parseXColorSpec(spec)).toEqual(expected) + }) + + it.each([ + ['', 'empty'], + ['red', 'named colors (xterm rejects them too)'], + ['rgb:ff/ff', 'missing channel'], + ['rgb:ggg/000/000', 'non-hex'], + ['#abcd', 'hash length 4 is not a valid xparsecolor width'], + ['rgbi:1/1/1', 'rgbi is unsupported'] + ])('rejects %s — %s', (spec) => { + expect(parseXColorSpec(spec)).toBeNull() + }) +}) + +describe('formatXColorRgbSpec', () => { + it('reports 16-bit channels by doubling the 8-bit byte (toRgbString parity)', () => { + expect(formatXColorRgbSpec([0x1e, 0x1e, 0x2e])).toBe('rgb:1e1e/1e1e/2e2e') + expect(formatXColorRgbSpec([0, 8, 255])).toBe('rgb:0000/0808/ffff') + }) +}) + +describe('validateTerminalViewAttributes', () => { + const valid = (): TerminalViewAttributes => ({ + foreground: [1, 2, 3], + background: [4, 5, 6], + cursor: [7, 8, 9], + ansi: Array.from({ length: 256 }, (_, i) => [i % 256, 0, 0] as TerminalViewRgb), + colorSchemeMode: 'dark', + cursorStyle: 'block', + cursorBlink: true + }) + + it('accepts and normalizes a well-formed payload', () => { + const attrs = validateTerminalViewAttributes(valid()) + expect(attrs).not.toBeNull() + expect(attrs?.ansi).toHaveLength(256) + expect(attrs?.colorSchemeMode).toBe('dark') + }) + + it.each([ + ['null payload', null], + ['missing foreground', { ...valid(), foreground: undefined }], + ['short triple', { ...valid(), background: [1, 2] }], + ['out-of-range channel', { ...valid(), cursor: [0, 0, 300] }], + ['non-integer channel', { ...valid(), cursor: [0, 0, 1.5] }], + ['short palette', { ...valid(), ansi: valid().ansi.slice(0, 16) }], + ['bad palette entry', { ...valid(), ansi: [...valid().ansi.slice(0, 255), 'red'] }], + ['bad mode', { ...valid(), colorSchemeMode: 'auto' }], + ['bad cursor style', { ...valid(), cursorStyle: 'beam' }], + ['non-boolean blink', { ...valid(), cursorBlink: 1 }] + ])('rejects %s', (_label, payload) => { + expect(validateTerminalViewAttributes(payload)).toBeNull() + }) +}) + +describe('terminalViewAttributesEqual', () => { + // The store's idempotence gate: a deep-equal snapshot from a fresh renderer + // process must compare equal so the re-push never fans out as a theme apply. + const snapshot = (): TerminalViewAttributes => ({ + foreground: [1, 2, 3], + background: [4, 5, 6], + cursor: [7, 8, 9], + ansi: Array.from({ length: 256 }, (_, i) => [i % 256, 0, 0] as TerminalViewRgb), + colorSchemeMode: 'dark', + cursorStyle: 'block', + cursorBlink: true + }) + + it('treats two independently built identical snapshots as equal', () => { + expect(terminalViewAttributesEqual(snapshot(), snapshot())).toBe(true) + }) + + it.each([ + ['foreground', { ...snapshot(), foreground: [1, 2, 4] as TerminalViewRgb }], + ['background', { ...snapshot(), background: [0, 0, 0] as TerminalViewRgb }], + ['cursor', { ...snapshot(), cursor: [7, 8, 10] as TerminalViewRgb }], + [ + 'an ansi entry', + { ...snapshot(), ansi: snapshot().ansi.map((rgb, i) => (i === 200 ? [9, 9, 9] : rgb)) } + ], + ['colorSchemeMode', { ...snapshot(), colorSchemeMode: 'light' as const }], + ['cursorStyle', { ...snapshot(), cursorStyle: 'bar' as const }], + ['cursorBlink', { ...snapshot(), cursorBlink: false }] + ])('detects a change in %s', (_label, changed) => { + expect(terminalViewAttributesEqual(snapshot(), changed as TerminalViewAttributes)).toBe(false) + }) +}) diff --git a/src/shared/terminal-view-attributes.ts b/src/shared/terminal-view-attributes.ts new file mode 100644 index 00000000000..736003845dd --- /dev/null +++ b/src/shared/terminal-view-attributes.ts @@ -0,0 +1,188 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §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. + */ + +/** 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((parseInt(m[1] || m[4] || m[7] || m[10], 16) / base) * 255), + Math.round((parseInt(m[2] || m[5] || m[8] || m[11], 16) / base) * 255), + Math.round((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 = 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])}` +} + +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 + 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 + } +}