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 './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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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('~'),
|
||||||
|
|||||||
@@ -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