Bridge renderer view attributes to the model responder

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-06-10 22:57:14 -07:00
co-authored by Orca
parent c864530ca7
commit ee540f32de
16 changed files with 1677 additions and 79 deletions
+47 -77
View File
@@ -1,8 +1,13 @@
import './xterm-env-polyfill' import './xterm-env-polyfill'
import { Terminal } from '@xterm/headless' import { Terminal } from '@xterm/headless'
import { SerializeAddon } from '@xterm/addon-serialize' 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 { 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' import type { TerminalSnapshot, TerminalModes } from './types'
export type HeadlessEmulatorOptions = { export type HeadlessEmulatorOptions = {
@@ -45,46 +50,16 @@ const DEFAULT_SCROLLBACK = 5000
// Keep in sync with the renderer twin in terminal-conpty-device-attributes.ts // Keep in sync with the renderer twin in terminal-conpty-device-attributes.ts
// (main must not import renderer modules). // (main must not import renderer modules).
const CONPTY_DA1_RESPONSE = '\x1b[?61;4c' 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 { export class HeadlessEmulator {
private terminal: Terminal private terminal: Terminal
private serializer: SerializeAddon private serializer: SerializeAddon
private cwd: string | null = null private oscText = new TerminalOscCwdTitleScanner()
private lastTitle: string | null = null
private oscScanTail = ''
private mouseModes = new TerminalMouseModeMirror() private mouseModes = new TerminalMouseModeMirror()
private disposed = false private disposed = false
private onQueryReply: ((reply: string) => void) | null private onQueryReply: ((reply: string) => void) | null
private conptyDa1OverrideInstalled = false private conptyDa1OverrideInstalled = false
private viewAttributeResponder: TerminalViewAttributeResponder | null = null
// Why: replies must be scoped to the exact write that carried the query. // 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 // 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 // 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 { private emitQueryReply(reply: string): void {
if (this.queryReplyForwardingDepth > 0 && this.onQueryReply) { if (this.queryReplyForwardingDepth > 0 && this.onQueryReply) {
this.onQueryReply(reply) this.onQueryReply(reply)
@@ -167,13 +175,7 @@ export class HeadlessEmulator {
return Promise.resolve() return Promise.resolve()
} }
const oscInput = this.oscScanTail + data this.oscText.scan(data)
this.oscScanTail = this.extractOscScanTail(oscInput)
this.scanOsc7(oscInput)
const lastTitle = extractLastOscTitle(oscInput)
if (lastTitle !== null) {
this.lastTitle = lastTitle
}
const forwardQueryReplies = opts.forwardQueryReplies === true const forwardQueryReplies = opts.forwardQueryReplies === true
const writeSync = (this.terminal as TerminalWithSynchronousWrite)._core?.writeSync const writeSync = (this.terminal as TerminalWithSynchronousWrite)._core?.writeSync
if (typeof writeSync === 'function') { if (typeof writeSync === 'function') {
@@ -233,12 +235,12 @@ export class HeadlessEmulator {
snapshotAnsi, snapshotAnsi,
scrollbackAnsi: '', scrollbackAnsi: '',
rehydrateSequences: this.buildRehydrateSequences(modes), rehydrateSequences: this.buildRehydrateSequences(modes),
cwd: this.cwd, cwd: this.oscText.cwd,
modes, modes,
cols: this.terminal.cols, cols: this.terminal.cols,
rows: this.terminal.rows, rows: this.terminal.rows,
scrollbackLines: this.terminal.buffer.normal.length - 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 { getCwd(): string | null {
return this.cwd return this.oscText.cwd
} }
setCwd(cwd: string | null): void { setCwd(cwd: string | null): void {
this.cwd = cwd this.oscText.cwd = cwd
} }
setLastTitle(title: string): void { setLastTitle(title: string): void {
this.lastTitle = title this.oscText.lastTitle = title
} }
clearScrollback(): void { clearScrollback(): void {
@@ -276,31 +278,6 @@ export class HeadlessEmulator {
this.terminal.dispose() this.terminal.dispose()
} }
private scanOsc7(data: string): void {
// OSC-7 format: ESC ] 7 ; <uri> BEL or ESC ] 7 ; <uri> 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 { private normalizeSnapshotAnsiForModes(snapshotAnsi: string, modes: TerminalModes): string {
if (!modes.alternateScreen) { if (!modes.alternateScreen) {
return snapshotAnsi return snapshotAnsi
@@ -316,13 +293,6 @@ export class HeadlessEmulator {
return snapshotAnsi.slice(start + alternateScreenMarker.length) return snapshotAnsi.slice(start + alternateScreenMarker.length)
} }
private parseOsc7Uri(uri: string): void {
const parsed = parseFileUriPath(uri)
if (parsed) {
this.cwd = parsed
}
}
private getModes(): TerminalModes { private getModes(): TerminalModes {
const buffer = this.terminal.buffer.active const buffer = this.terminal.buffer.active
const mouseTrackingMode = this.mouseModes.mouseTrackingMode const mouseTrackingMode = this.mouseModes.mouseTrackingMode
@@ -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 ; <uri> BEL or ESC ] 7 ; <uri> 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
}
}
}
}
@@ -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<Terminal['parser'], 'registerOscHandler' | 'registerCsiHandler'>
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<SpecialColorSlot, string> = {
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<number, TerminalViewRgb>()
const specialOverrides = new Map<SpecialColorSlot, TerminalViewRgb>()
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()
}
}
}
+13
View File
@@ -78,6 +78,8 @@ import {
isNativeWindowsLocalPtySpawn, isNativeWindowsLocalPtySpawn,
markNativeWindowsConptyPty markNativeWindowsConptyPty
} from '../runtime/terminal-model-query-authority' } 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 { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' 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.removeAllListeners('pty:setPtyDeliveryInterest')
ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => {
if (typeof args.id !== 'string' || !args.id) { if (typeof args.id !== 'string' || !args.id) {
+20
View File
@@ -484,6 +484,10 @@ import {
registerConptyDa1OverrideInstaller, registerConptyDa1OverrideInstaller,
shouldModelAnswerHiddenPtyQueries shouldModelAnswerHiddenPtyQueries
} from './terminal-model-query-authority' } from './terminal-model-query-authority'
import {
getTerminalViewAttributes,
registerTerminalViewAttributesApplier
} from './terminal-view-attribute-store'
import { killAllProcessesForWorktree } from './worktree-teardown' import { killAllProcessesForWorktree } from './worktree-teardown'
import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits'
import type { IFilesystemProvider, IPtyProvider } from '../providers/types' 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 // created this PTY's emulator; the mark retrofits the DA1 override here
// (terminal-query-authority.md §ConPTY DA1). // (terminal-query-authority.md §ConPTY DA1).
registerConptyDa1OverrideInstaller((ptyId) => this.ensureNativeWindowsConptyDa1Override(ptyId)) 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 { getLocalProvider(): IPtyProvider | null {
@@ -4239,6 +4252,13 @@ export class OrcaRuntimeService {
if (isNativeWindowsConptyPty(ptyId)) { if (isNativeWindowsConptyPty(ptyId)) {
emulator.installConptyPrimaryDeviceAttributesOverride() 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() } state = { emulator, outputSequence: 0, writeChain: Promise.resolve() }
return state return state
} }
@@ -16,6 +16,11 @@ import {
_resetTerminalModelQueryAuthorityForTest, _resetTerminalModelQueryAuthorityForTest,
markNativeWindowsConptyPty markNativeWindowsConptyPty
} from './terminal-model-query-authority' } from './terminal-model-query-authority'
import {
_resetTerminalViewAttributesForTest,
setTerminalViewAttributes
} from './terminal-view-attribute-store'
import type { TerminalViewAttributes, TerminalViewRgb } from '../../shared/terminal-view-attributes'
const settingsState = { const settingsState = {
terminalMainSideEffectAuthority: true as boolean, terminalMainSideEffectAuthority: true as boolean,
@@ -75,9 +80,30 @@ async function settle(runtime: OrcaRuntimeService, ptyId: string): Promise<void>
await runtime.serializeMainTerminalBuffer(ptyId) 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> = {}): 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(() => { afterEach(() => {
_resetHiddenRendererPtyDeliveryGateForTest() _resetHiddenRendererPtyDeliveryGateForTest()
_resetTerminalModelQueryAuthorityForTest() _resetTerminalModelQueryAuthorityForTest()
_resetTerminalViewAttributesForTest()
settingsState.terminalMainSideEffectAuthority = true settingsState.terminalMainSideEffectAuthority = true
settingsState.terminalHiddenDeliveryGate = true settingsState.terminalHiddenDeliveryGate = true
settingsState.terminalModelQueryAuthority = 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;<idx>', 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([])
})
})
@@ -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<TerminalViewAttributesApplier>()
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()
}
+4
View File
@@ -146,6 +146,7 @@ import type {
WorkspaceSessionState WorkspaceSessionState
} from '../shared/types' } from '../shared/types'
import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' 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 { SetupScriptImportCandidate } from '../shared/setup-script-imports'
import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history'
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
@@ -943,6 +944,9 @@ export type PreloadApi = {
/** Ref-counted-on-the-renderer delivery-interest signal that suppresses /** Ref-counted-on-the-renderer delivery-interest signal that suppresses
* the hidden-delivery gate while any raw-byte consumer is registered. */ * the hidden-delivery gate while any raw-byte consumer is registered. */
setPtyDeliveryInterest: (id: string, interested: boolean) => void 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<boolean> hasChildProcesses: (id: string) => Promise<boolean>
getForegroundProcess: (id: string) => Promise<string | null> getForegroundProcess: (id: string) => Promise<string | null>
getCwd: (id: string) => Promise<string> getCwd: (id: string) => Promise<string>
+7
View File
@@ -45,6 +45,7 @@ import type {
WorktreeRemoteBranchConflictEvent WorktreeRemoteBranchConflictEvent
} from '../shared/types' } from '../shared/types'
import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' 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 { GitHistoryOptions, GitHistoryResult } from '../shared/git-history'
import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types'
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills'
@@ -717,6 +718,12 @@ const api = {
setPtyDeliveryInterest: (id: string, interested: boolean): void => { setPtyDeliveryInterest: (id: string, interested: boolean): void => {
ipcRenderer.send('pty:setPtyDeliveryInterest', { id, interested }) 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<void> => kill: (id: string, opts?: { keepHistory?: boolean }): Promise<void> =>
ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }),
@@ -1,7 +1,9 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { Terminal } from '@xterm/headless' 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 { import {
applyTerminalAppearance,
hexToRgba, hexToRgba,
installMode2031Handlers, installMode2031Handlers,
maybePushMode2031Flip, 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<typeof getDefaultSettings>): 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', () => { describe('hexToRgba', () => {
it('converts 6-char hex to rgba', () => { it('converts 6-char hex to rgba', () => {
expect(hexToRgba('#1a1a1a', 0.72)).toBe('rgba(26, 26, 26, 0.72)') expect(hexToRgba('#1a1a1a', 0.72)).toBe('rgba(26, 26, 26, 0.72)')
@@ -16,6 +16,7 @@ import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides'
import type { PtyTransport } from './pty-transport' import type { PtyTransport } from './pty-transport'
import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt'
import { HEX_COLOR_RE } from '../../../../shared/color-validation' import { HEX_COLOR_RE } from '../../../../shared/color-validation'
import { publishTerminalViewAttributes } from './terminal-view-attributes-publisher'
export { mode2031SequenceFor } export { mode2031SequenceFor }
@@ -195,6 +196,32 @@ export function composeActiveTerminalTheme(
return theme 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( export function applyTerminalAppearance(
manager: PaneManager, manager: PaneManager,
settings: GlobalSettings, settings: GlobalSettings,
@@ -209,6 +236,11 @@ export function applyTerminalAppearance(
const paneStyles = resolvePaneStyleOptions(settings) const paneStyles = resolvePaneStyleOptions(settings)
const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName) const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName)
const theme = composeActiveTerminalTheme(baseTheme, settings) 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 paneBackground = theme?.background ?? '#000000'
const terminalFontWeights = resolveTerminalFontWeights(settings.terminalFontWeight) const terminalFontWeights = resolveTerminalFontWeights(settings.terminalFontWeight)
@@ -218,7 +250,14 @@ export function applyTerminalAppearance(
) )
for (const pane of manager.getPanes()) { 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 pane.terminal.options.theme = theme
} }
// Why: xterm's allowTransparency has measurable rendering cost, so clear // Why: xterm's allowTransparency has measurable rendering cost, so clear
@@ -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<typeof vi.fn> {
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'])
})
})
@@ -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<GlobalSettings, 'terminalCursorStyle' | 'terminalCursorBlink'>
): 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<GlobalSettings, 'terminalCursorStyle' | 'terminalCursorBlink'>,
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
}
+3
View File
@@ -2258,6 +2258,9 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
setActiveRendererPty: () => {}, setActiveRendererPty: () => {},
setHiddenRendererPty: () => {}, setHiddenRendererPty: () => {},
setPtyDeliveryInterest: () => {}, 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), hasChildProcesses: () => Promise.resolve(false),
getForegroundProcess: () => Promise.resolve(null), getForegroundProcess: () => Promise.resolve(null),
getCwd: () => Promise.resolve('~'), getCwd: () => Promise.resolve('~'),
+119
View File
@@ -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)
})
})
+188
View File
@@ -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<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
}
}