mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Bridge renderer view attributes to the model responder
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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 ; <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 {
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<void>
|
||||
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(() => {
|
||||
_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;<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()
|
||||
}
|
||||
@@ -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<boolean>
|
||||
getForegroundProcess: (id: string) => Promise<string | null>
|
||||
getCwd: (id: string) => Promise<string>
|
||||
|
||||
@@ -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<void> =>
|
||||
ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }),
|
||||
|
||||
@@ -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<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', () => {
|
||||
it('converts 6-char hex to rgba', () => {
|
||||
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 { 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2258,6 +2258,9 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['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('~'),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user