diff --git a/docs/specs/auto-update.md b/docs/specs/auto-update.md index 0c8e01ec..e5f18e18 100644 --- a/docs/specs/auto-update.md +++ b/docs/specs/auto-update.md @@ -40,6 +40,8 @@ The `Update` object returned by `check()` is held in memory as an available upda The NSIS installer overwrites files inside the bundled sidecar — including node-pty's native `conpty.node`. Windows refuses to overwrite a native module that a live process still has loaded, so if the Node sidecar is running when NSIS reaches `node_modules`, the install fails with *"Error opening file for writing: …\_up_\sidecar\node_modules\node-pty\prebuilds\win32-x64\conpty.node"*. The Rust `RunEvent::Exit` kill is too late and asynchronous — NSIS starts copying files immediately after `install()` force-kills the app, racing the sidecar's shutdown. +Because `pty-core` spawns with `useConptyDll: true` on Windows (see [terminal-escapes.md](terminal-escapes.md#osc-color-queries-on-windows-require-the-bundled-conpty)), the same hazard now covers two more bundled files: the sidecar additionally `LoadLibrary`s node-pty's `conpty/conpty.dll`, and each pseudoconsole runs an `OpenConsole.exe` child process. `conpty.dll` is released when the sidecar exits (same as `conpty.node`); the `OpenConsole.exe` children run inside the sidecar's job object (`process_wrap`'s `JobObject`), so terminating the sidecar tears them down too. + So on Windows the close handler `invoke`s `kill_sidecar_now` and awaits it before `install()`. That command is synchronous on the Rust side: it sends the kill, then polls `try_wait` (capped at ~5s) until the process has actually exited and released its file handles. `try_wait` is used instead of the job-object `wait()` because `wait()` consumes a completion-port message the reaper thread relies on and could block forever if the sidecar had already exited. macOS and Linux can replace open files in place, so they skip this and rely on the existing `RunEvent::Exit` cleanup. ## Update notice in the Baseboard diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index 3fa2f21b..846dc678 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -49,6 +49,7 @@ Unknown non-iTerm2 OSC families pass through to xterm.js unchanged so xterm.js c | `OSC 2 ; ST` | Window title | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 7 ; file://host/path ST` | CWD (xterm-style URI) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 8 ; <params> ; <URI> ST ... OSC 8 ; ; ST` | Explicit hyperlink region; passed through to xterm.js for rendering, then opened only after Dormouse shows the real target in a confirmation dialog. | This spec | +| `OSC 10 ; ? ST` / `OSC 11 ; ? ST` / `OSC 12 ; ? ST` | Foreground / background / cursor color **query**. Dormouse answers from the active terminal theme with `OSC <code> ; rgb:RRRR/GGGG/BBBB ST` (16-bit channels) and consumes the query, so background-detecting TUIs (e.g. Codex's adaptive composer "pill") see the real colors instead of assuming dark. The parser needs the theme colors: the **standalone** frontend adapter reads them directly (`getTerminalTheme()`); the **VS Code** extension-host parser has no DOM, so the webview pushes them up via `dormouse:themeColors` (see [vscode.md](vscode.md#osc-color-query-answering)). Only the `?` (report) form is intercepted; color *set* requests pass through to xterm.js. Until the theme is known (before the first push, or if unparseable) the query falls through to xterm.js. | This spec | | `OSC 9 ; <message> ST` | iTerm2 legacy notification | [alert.md](alert.md#osc-9) | | `OSC 9 ; 4 ; <state> [; <progress>] ST` | iTerm2 progress | [alert.md](alert.md#osc-94-progress) | | `OSC 9 ; 9 ; <cwd> ST` | CWD (Windows Terminal / ConEmu) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | @@ -62,6 +63,10 @@ Unknown non-iTerm2 OSC families pass through to xterm.js unchanged so xterm.js c Some sequences are dual-purpose. The notification rows for `OSC 9 ; <message> ST`, `OSC 99` (`p=title`/`p=body`), and `OSC 777 ; notify` also feed the title-candidate channel in `terminal-state.md` — see its [Title candidate diagnostics](terminal-state.md#supported-osc-inputs) table. Only the OSC 9 *message* form can become a header/door label; OSC 99 and OSC 777 candidates are stored for the diagnostic popup only. The OSC 9 *progress* form (`OSC 9 ; 4`) carries no text and never contributes a title candidate. +#### OSC color queries on Windows require the bundled ConPTY + +OSC 10/11/12 answering only works if the program's query actually reaches the consumer. On Windows that depends on the ConPTY backend node-pty uses: the **in-box `CreatePseudoConsole`** silently swallows color queries (they never reach the consumer, so nothing can answer and TUIs fall back to a dark background), while node-pty's **bundled OpenConsole** (`conpty.dll`, currently 1.25.x) forwards them to the consumer — the same passthrough Windows Terminal relies on. So `pty-core.js` spawns with `useConptyDll: true` on Windows; the parser then replies from the active theme (standalone reads it directly, VS Code from the webview-pushed colors — see the OSC 10/11/12 row above). That requires `node-pty/prebuilds/<arch>/conpty.node` plus its sibling `conpty/{conpty.dll,OpenConsole.exe}` to ship: standalone bundles them via the Tauri `resources: ["../sidecar/**/*"]` glob; the VS Code extension via `cp -RL node_modules/node-pty dist/node-pty`. macOS/Linux PTYs forward queries natively, so the flag is Windows-only. `useConptyDll: true` also has an installer consequence on Windows — see [auto-update.md](auto-update.md#sidecar-teardown-on-windows). + ### OSC 8 hyperlinks `OSC 8 ; <params> ; <URI> ST` starts a hyperlink region and `OSC 8 ; ; ST` closes it. `params` may be empty or include `id=<group-id>` for multi-line/shared link regions. Dormouse does not parse the `params` or URI at the PTY boundary; it passes the sequence through to xterm.js. @@ -117,6 +122,7 @@ Environment for spawned PTYs: | `TERM_PROGRAM_VERSION` | Dormouse's chosen iTerm2 compatibility version, not the package version | | `LC_TERMINAL` | `iTerm2` only if needed by real-world shell integrations | | `LC_TERMINAL_VERSION` | same compatibility version as `TERM_PROGRAM_VERSION` | +| `COLORTERM` | `truecolor` — advertise 24-bit color, which xterm.js renders. The PTY is spawned as `xterm-256color` with no other depth hint, so env-sniffing tools (e.g. `supports-color`) would otherwise assume 256/ANSI-16 and quantize RGB output to the nearest palette entry. Truecolor-aware TUIs (Codex's composer pill, syntax highlighters) then render smooth RGB. Windows Terminal is recognized as truecolor via `WT_SESSION`; Dormouse isn't, so it advertises `COLORTERM` explicitly. This is a color-*depth* signal, **independent** of the light/dark *background* detection (OSC color queries above) that drives those TUIs' theme choice. Not iTerm2-identity-specific. | Device/version query: diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 37241d8d..5357d1de 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -168,6 +168,10 @@ debugger traces VSCode-exposed `--vscode-*` variables and Dormouse materialized fallbacks, but it does not attempt to read raw built-in VSCode theme files. +### OSC color query answering + +TUIs query the terminal's foreground/background/cursor colors with `OSC 10/11/12 ; ?` to adapt their UI (see [terminal-escapes.md](terminal-escapes.md#supported-oscs)). Dormouse answers these from the active theme, but PTY parsing happens in the **extension host**, which has no DOM to read the theme from. So the webview pushes its resolved colors up: `VSCodeAdapter.pushThemeColors()` reads `getTerminalTheme()` and posts `dormouse:themeColors { foreground, background, cursor }` on `requestInit` and again whenever `onTerminalThemeChange` fires (the shared `terminal-theme.ts` observer). `message-router.ts` caches the latest colors and feeds them to every PTY's parser via a `TerminalColorProvider`, so the parser replies and consumes the query exactly like the standalone frontend adapter. Before the first push (or if a color is unparseable) the provider returns `null` and the query falls through to xterm.js. On Windows this also depends on `useConptyDll: true` so the query reaches the extension host at all — see [terminal-escapes.md](terminal-escapes.md#osc-color-queries-on-windows-require-the-bundled-conpty). + ### CSP policy Source of truth: `vscode-ext/src/webview-html.ts` assembles the CSP directives (`getNonce()` + the directive list). diff --git a/lib/src/lib/css-color.ts b/lib/src/lib/css-color.ts new file mode 100644 index 00000000..0b5f7296 --- /dev/null +++ b/lib/src/lib/css-color.ts @@ -0,0 +1,45 @@ +/* Pure CSS color-string parsing — no DOM, safe in the browser and the Node + * extension host. Shared by theme alpha-flattening (`themes/flatten-alpha.ts`) + * and OSC 10/11/12 color-query replies (`terminal-protocol.ts`). */ + +export interface Rgba { r: number; g: number; b: number; a: number } + +const HEX_SHORT = /^#([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])?$/i; +const HEX_LONG = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?$/i; +const RGB_FN = /^rgba?\(\s*([0-9.]+)\s*[, ]\s*([0-9.]+)\s*[, ]\s*([0-9.]+)(?:\s*[,/]\s*([0-9.]+%?))?\s*\)$/i; + +/** Parse a `#rgb` / `#rgba` / `#rrggbb` / `#rrggbbaa` / `rgb()` / `rgba()` + * color string to 0–255 channels + 0–1 alpha. Returns null if unparseable. */ +export function parseColor(value: string): Rgba | null { + const v = value.trim(); + + let m = HEX_SHORT.exec(v); + if (m) { + const dup = (h: string) => parseInt(h + h, 16); + return { r: dup(m[1]), g: dup(m[2]), b: dup(m[3]), a: m[4] ? dup(m[4]) / 255 : 1 }; + } + + m = HEX_LONG.exec(v); + if (m) { + return { + r: parseInt(m[1], 16), + g: parseInt(m[2], 16), + b: parseInt(m[3], 16), + a: m[4] ? parseInt(m[4], 16) / 255 : 1, + }; + } + + m = RGB_FN.exec(v); + if (m) { + const a = m[4] ? (m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4])) : 1; + return { r: parseFloat(m[1]), g: parseFloat(m[2]), b: parseFloat(m[3]), a }; + } + + return null; +} + +/** Format the RGB channels of an `Rgba` as an opaque `#rrggbb` string (alpha dropped). */ +export function toHex({ r, g, b }: Rgba): string { + const h = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'); + return `#${h(r)}${h(g)}${h(b)}`; +} diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 5a6db36c..7c3a6b4b 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -10,6 +10,19 @@ vi.mock('../terminal-state-store', () => ({ removeTerminalPaneState: terminalStateStoreMocks.removeTerminalPaneState, })); +const terminalThemeMocks = vi.hoisted(() => ({ + getTerminalTheme: vi.fn(() => ({ foreground: '#eeeeee', background: '#111111', cursor: '#abcabc' })), + listeners: new Set<() => void>(), +})); + +vi.mock('../terminal-theme', () => ({ + getTerminalTheme: terminalThemeMocks.getTerminalTheme, + onTerminalThemeChange: (cb: () => void) => { + terminalThemeMocks.listeners.add(cb); + return () => terminalThemeMocks.listeners.delete(cb); + }, +})); + import { collectTerminalSemanticEvents, TerminalProtocolParser, @@ -23,6 +36,8 @@ describe('VSCodeAdapter PTY exit handling', () => { beforeEach(() => { windowTarget = new EventTarget(); postMessage = vi.fn(); + terminalThemeMocks.listeners.clear(); + terminalThemeMocks.getTerminalTheme.mockReturnValue({ foreground: '#eeeeee', background: '#111111', cursor: '#abcabc' }); class TestCustomEvent<T = unknown> extends Event { readonly detail: T; @@ -69,6 +84,29 @@ describe('VSCodeAdapter PTY exit handling', () => { expect(postMessage).toHaveBeenCalledWith({ type: 'pty:kill', id: 'pane-1' }); }); + it('pushes resolved theme colors to the extension host on init and on theme change', () => { + const adapter = new VSCodeAdapter(); + + adapter.requestInit(); + expect(postMessage).toHaveBeenCalledWith({ + type: 'dormouse:themeColors', + foreground: '#eeeeee', + background: '#111111', + cursor: '#abcabc', + }); + + // A VS Code theme switch fires the observer, which re-pushes current colors. + postMessage.mockClear(); + terminalThemeMocks.getTerminalTheme.mockReturnValue({ foreground: '#000000', background: '#ffffff', cursor: '#ff0000' }); + for (const listener of terminalThemeMocks.listeners) listener(); + expect(postMessage).toHaveBeenCalledWith({ + type: 'dormouse:themeColors', + foreground: '#000000', + background: '#ffffff', + cursor: '#ff0000', + }); + }); + it('posts external hyperlink open requests to the extension host', () => { const adapter = new VSCodeAdapter(); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 117a0c76..f59991cc 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -8,6 +8,7 @@ import { import { applyTerminalSemanticEventsByPtyId, } from '../terminal-state-store'; +import { getTerminalTheme, onTerminalThemeChange } from '../terminal-theme'; import type { DorControlResult } from 'dor/protocol'; import type { VSCodeWorkbenchCommand } from '../vscode-keybindings'; @@ -48,6 +49,12 @@ export class VSCodeAdapter implements PlatformAdapter { setDefaultShellOpts({ shell: injectedShell.shell, args: injectedShell.args }); } + // The extension-host parser has no DOM, so it can't read the theme to answer + // OSC 10/11/12 color queries. Push the resolved colors up whenever the theme + // changes (initial push happens in requestInit) so it can — matching the + // standalone frontend adapter. See docs/specs/terminal-escapes.md. + onTerminalThemeChange(() => this.pushThemeColors()); + window.addEventListener('message', (event: MessageEvent) => { const msg = event.data; if (!msg || !msg.type) return; @@ -334,6 +341,19 @@ export class VSCodeAdapter implements PlatformAdapter { requestInit(): void { this.vscode.postMessage({ type: 'dormouse:init' }); + this.pushThemeColors(); + } + + /** Send the resolved terminal theme colors to the extension host so its + * parser can answer OSC 10/11/12 color queries (it has no DOM of its own). */ + private pushThemeColors(): void { + const theme = getTerminalTheme(); + this.vscode.postMessage({ + type: 'dormouse:themeColors', + foreground: theme.foreground, + background: theme.background, + cursor: theme.cursor, + }); } onPtyList(handler: (detail: { ptys: PtyInfo[] }) => void): void { diff --git a/lib/src/lib/terminal-protocol.test.ts b/lib/src/lib/terminal-protocol.test.ts index 11c2acdd..8bd94d8d 100644 --- a/lib/src/lib/terminal-protocol.test.ts +++ b/lib/src/lib/terminal-protocol.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { collectTerminalSemanticEvents, ITERM2_DEVICE_ATTRIBUTES_RESPONSE, TerminalProtocolParser } from './terminal-protocol'; +import { collectTerminalSemanticEvents, formatOscColorResponse, ITERM2_DEVICE_ATTRIBUTES_RESPONSE, TerminalProtocolParser } from './terminal-protocol'; import { createTerminalPaneState, deriveHeader, reduceTerminalState, type TerminalSemanticEvent } from './terminal-state'; describe('TerminalProtocolParser', () => { @@ -352,6 +352,80 @@ describe('TerminalProtocolParser', () => { expect(parser.process('\x1b[')).toEqual({ visibleData: '', events: [] }); expect(parser.process('31mred')).toEqual({ visibleData: '\x1b[31mred', events: [] }); }); + + it('answers an OSC 11 background color query from the theme and consumes it', () => { + const parser = new TerminalProtocolParser((target) => (target === 'background' ? '#272822' : null)); + const result = parser.process('before\x1b]11;?\x1b\\after'); + + // Query is consumed (not forwarded to xterm), and we reply with rgb: bytes. + expect(result.visibleData).toBe('beforeafter'); + expect(result.events).toEqual([ + { kind: 'response', data: '\x1b]11;rgb:2727/2828/2222\x1b\\' }, + ]); + }); + + it('answers OSC 10 foreground and OSC 12 cursor queries', () => { + const provider = (target: 'foreground' | 'background' | 'cursor') => + ({ foreground: '#ccc', background: '#000', cursor: '#aeafad' })[target]; + const fg = new TerminalProtocolParser(provider).process('\x1b]10;?\x07'); + const cursor = new TerminalProtocolParser(provider).process('\x1b]12;?\x07'); + + expect(fg.events).toEqual([{ kind: 'response', data: '\x1b]10;rgb:cccc/cccc/cccc\x1b\\' }]); + expect(cursor.events).toEqual([{ kind: 'response', data: '\x1b]12;rgb:aeae/afaf/adad\x1b\\' }]); + }); + + it('buffers a split OSC 11 background query and still answers it', () => { + const parser = new TerminalProtocolParser(() => '#1e1e1e'); + + expect(parser.process('\x1b]11;')).toEqual({ visibleData: '', events: [] }); + const result = parser.process('?\x1b\\done'); + + expect(result.visibleData).toBe('done'); + expect(result.events).toEqual([ + { kind: 'response', data: '\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\' }, + ]); + }); + + it('forwards OSC 11 color queries to xterm when no theme provider is supplied', () => { + const parser = new TerminalProtocolParser(); + const result = parser.process('\x1b]11;?\x1b\\'); + + // No provider (e.g. VS Code host parser): leave the query for xterm.js. + expect(result.visibleData).toBe('\x1b]11;?\x1b\\'); + expect(result.events).toEqual([]); + }); + + it('forwards OSC 11 color *set* requests rather than answering them', () => { + const parser = new TerminalProtocolParser(() => '#272822'); + const result = parser.process('\x1b]11;rgb:00/00/00\x1b\\'); + + expect(result.visibleData).toBe('\x1b]11;rgb:00/00/00\x1b\\'); + expect(result.events).toEqual([]); + }); + + it('forwards the query unchanged when the theme color is unparseable', () => { + const parser = new TerminalProtocolParser(() => 'transparent'); + const result = parser.process('\x1b]11;?\x07'); + + expect(result.visibleData).toBe('\x1b]11;?\x07'); + expect(result.events).toEqual([]); + }); +}); + +describe('formatOscColorResponse', () => { + it('expands 8-bit channels to the 16-bit rgb: reply shape', () => { + expect(formatOscColorResponse('11', '#0c0c0c')).toBe('\x1b]11;rgb:0c0c/0c0c/0c0c\x1b\\'); + expect(formatOscColorResponse('11', '#abc')).toBe('\x1b]11;rgb:aaaa/bbbb/cccc\x1b\\'); + expect(formatOscColorResponse('11', '#272822ff')).toBe('\x1b]11;rgb:2727/2828/2222\x1b\\'); + // Theme colors can be rgb()/rgba() too (parseColor handles them). + expect(formatOscColorResponse('10', 'rgb(255, 0, 12)')).toBe('\x1b]10;rgb:ffff/0000/0c0c\x1b\\'); + }); + + it('returns null for missing or unparseable colors', () => { + expect(formatOscColorResponse('11', null)).toBeNull(); + expect(formatOscColorResponse('11', 'transparent')).toBeNull(); + expect(formatOscColorResponse('11', '#12')).toBeNull(); + }); }); function reduceSemanticEvents(events: TerminalSemanticEvent[]) { diff --git a/lib/src/lib/terminal-protocol.ts b/lib/src/lib/terminal-protocol.ts index a620a865..801c443e 100644 --- a/lib/src/lib/terminal-protocol.ts +++ b/lib/src/lib/terminal-protocol.ts @@ -1,4 +1,5 @@ import type { ActivityNotification, ProtocolProgressUpdate } from './alert-manager'; +import { parseColor } from './css-color'; import { cwdFromOsc1337, cwdFromOsc633, @@ -16,6 +17,19 @@ export type TerminalProtocolEvent = | { kind: 'response'; data: string } | { kind: 'semantic'; event: TerminalSemanticEvent }; +/** The terminal colors an OSC 10/11/12 query can ask about. */ +export type TerminalColorTarget = 'foreground' | 'background' | 'cursor'; + +/** A resolved value for each queryable terminal color, as CSS color strings. */ +export type TerminalColors = Record<TerminalColorTarget, string>; + +/** + * Resolves the active terminal theme color for an OSC 10/11/12 query, returned + * as a CSS hex string (e.g. `#1e1e1e` / `#1e1e1eff` / `#abc`). Return `null` to + * decline (the query is then forwarded to xterm.js unchanged). + */ +export type TerminalColorProvider = (target: TerminalColorTarget) => string | null; + export interface TerminalProtocolAlertSink { notifyFromProtocol(id: string, notification: ActivityNotification): void; updateProtocolProgress(id: string, progress: ProtocolProgressUpdate): void; @@ -52,6 +66,15 @@ export class TerminalProtocolParser { private pending = ''; private osc99Pending = new Map<string, Osc99PendingNotification>(); + /** + * @param colorProvider Resolves the active terminal theme color for OSC + * 10/11/12 background/foreground/cursor *queries*. Frontend adapters pass a + * provider backed by the live xterm theme, so TUIs (e.g. Codex) can detect + * the real terminal background. Host-side parsers (VS Code extension host) + * omit it, leaving such queries to xterm.js as before. + */ + constructor(private readonly colorProvider?: TerminalColorProvider) {} + process(data: string): TerminalProtocolParseResult { if (this.pending === '' && !NEEDS_PARSE_RE.test(data)) { return { visibleData: data, events: NO_EVENTS as TerminalProtocolEvent[] }; @@ -108,10 +131,29 @@ export class TerminalProtocolParser { if (content === '2' || content.startsWith('2;')) return parseOscTitle(content, 'osc2'); if (content === '99' || content.startsWith('99;')) return this.parseOsc99(content); if (content === '777' || content.startsWith('777;')) return this.parseOsc777(content); + const colorResponse = this.parseColorQuery(content); + if (colorResponse) return colorResponse; if (isKnownUnsupportedIterm2Osc(content)) return []; return null; } + private parseColorQuery(content: string): TerminalProtocolEvent[] | null { + // OSC 10/11/12 ; ? — foreground / background / cursor color *query*. Codex + // (and other TUIs) query the terminal background to blend adaptive UI — its + // composer "pill" — against it; with no answer it assumes dark and paints a + // near-black fill that is unreadable on a light theme. xterm.js does not + // reliably answer these in our pipeline (especially on Windows ConPTY), so + // we answer from the active theme ourselves, like the iTerm2 CSI > q reply. + // Only the report ('?') form is intercepted; color *set* requests and the + // no-provider/unparseable cases fall through (null) to xterm.js unchanged. + const match = /^(10|11|12);\?$/.exec(content); + if (!match || !this.colorProvider) return null; + const code = match[1]; + const target: TerminalColorTarget = code === '10' ? 'foreground' : code === '11' ? 'background' : 'cursor'; + const data = formatOscColorResponse(code, this.colorProvider(target)); + return data ? [{ kind: 'response', data }] : null; + } + private parseOsc9(content: string): TerminalProtocolEvent[] { if (!content.startsWith('9;')) return []; @@ -420,6 +462,19 @@ function isKnownUnsupportedIterm2Osc(content: string): boolean { ); } +/** + * Build the reply to an OSC 10/11/12 color query: `ESC ] <code> ; rgb:RRRR/GGGG/BBBB ST`, + * matching the 16-bit-per-channel shape xterm/Windows Terminal emit (each 8-bit + * channel is doubled, e.g. `0c` → `0c0c`). Returns null if `color` is missing or + * not a parseable CSS color (see `parseColor`). + */ +export function formatOscColorResponse(code: string, color: string | null): string | null { + const rgb = color ? parseColor(color) : null; + if (!rgb) return null; + const channel = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0').repeat(2); + return `\x1b]${code};rgb:${channel(rgb.r)}/${channel(rgb.g)}/${channel(rgb.b)}\x1b\\`; +} + function commandStartEvent(source: CommandRunSource): TerminalProtocolEvent { return { kind: 'semantic', event: { type: 'commandStart', source } }; } diff --git a/lib/src/lib/terminal-theme.ts b/lib/src/lib/terminal-theme.ts index 8e1b2681..4460bb96 100644 --- a/lib/src/lib/terminal-theme.ts +++ b/lib/src/lib/terminal-theme.ts @@ -1,5 +1,6 @@ import { Terminal } from '@xterm/xterm'; import { registry } from './terminal-store'; +import type { TerminalColorProvider } from './terminal-protocol'; export function getTerminalTheme(): Record<string, string> { const style = getComputedStyle(document.body); @@ -28,6 +29,13 @@ export function getTerminalTheme(): Record<string, string> { }; } +/** + * Answers OSC 10/11/12 foreground/background/cursor color queries from the live + * xterm theme. Read lazily per query so it tracks theme changes. Frontend + * platform adapters pass this to their `TerminalProtocolParser`s. + */ +export const themeColorProvider: TerminalColorProvider = (target) => getTerminalTheme()[target] ?? null; + const XTERM_HOST_SELECTOR = '.xterm-screen, .xterm-scrollable-element, .xterm-viewport'; let xtermSelectorWarned = false; @@ -55,6 +63,18 @@ export function paintTerminalHost(element: HTMLDivElement, terminal: Terminal, b let themeObserverStarted = false; let lastAppliedThemeKey: string | null = null; +const themeChangeListeners = new Set<() => void>(); + +/** + * Subscribe to terminal theme changes, fired by the shared theme observer when + * the resolved theme actually changes. The VS Code adapter uses this to push + * current colors to the extension host (which has no DOM) so its parser can + * answer OSC color queries. Returns an unsubscribe function. + */ +export function onTerminalThemeChange(listener: () => void): () => void { + themeChangeListeners.add(listener); + return () => { themeChangeListeners.delete(listener); }; +} export function startThemeObserver(): void { if (themeObserverStarted) return; @@ -69,6 +89,7 @@ export function startThemeObserver(): void { entry.terminal.options.theme = theme; paintTerminalHost(entry.element, entry.terminal, theme.background); } + for (const listener of themeChangeListeners) listener(); }); observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'style'] }); diff --git a/lib/src/lib/themes/flatten-alpha.ts b/lib/src/lib/themes/flatten-alpha.ts index 1d9d4779..7a72cce6 100644 --- a/lib/src/lib/themes/flatten-alpha.ts +++ b/lib/src/lib/themes/flatten-alpha.ts @@ -5,44 +5,7 @@ * fully opaque color, but VSCode authors selection tints with alpha because * VSCode itself renders them as overlays on the sidebar background. */ -interface Rgba { r: number; g: number; b: number; a: number } - -const HEX_SHORT = /^#([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])?$/i; -const HEX_LONG = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?$/i; -const RGB_FN = /^rgba?\(\s*([0-9.]+)\s*[, ]\s*([0-9.]+)\s*[, ]\s*([0-9.]+)(?:\s*[,/]\s*([0-9.]+%?))?\s*\)$/i; - -function parseColor(value: string): Rgba | null { - const v = value.trim(); - - let m = HEX_SHORT.exec(v); - if (m) { - const dup = (h: string) => parseInt(h + h, 16); - return { r: dup(m[1]), g: dup(m[2]), b: dup(m[3]), a: m[4] ? dup(m[4]) / 255 : 1 }; - } - - m = HEX_LONG.exec(v); - if (m) { - return { - r: parseInt(m[1], 16), - g: parseInt(m[2], 16), - b: parseInt(m[3], 16), - a: m[4] ? parseInt(m[4], 16) / 255 : 1, - }; - } - - m = RGB_FN.exec(v); - if (m) { - const a = m[4] ? (m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4])) : 1; - return { r: parseFloat(m[1]), g: parseFloat(m[2]), b: parseFloat(m[3]), a }; - } - - return null; -} - -function toHex({ r, g, b }: Rgba): string { - const h = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'); - return `#${h(r)}${h(g)}${h(b)}`; -} +import { parseColor, toHex } from '../css-color'; /** Composite `value` over `base`. If `value` has no alpha, it's returned * unchanged. If either color can't be parsed, returns `value` as-is. */ diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index 0bda0bed..51d61b67 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -280,6 +280,14 @@ function resolveSpawnConfig(options, runtime = {}) { TERM_PROGRAM_VERSION: ITERM2_COMPAT_VERSION, LC_TERMINAL: 'iTerm2', LC_TERMINAL_VERSION: ITERM2_COMPAT_VERSION, + // Advertise 24-bit color. xterm.js renders full truecolor, but the PTY is + // spawned as `xterm-256color` with no other depth signal, so tools that gate + // truecolor on env (e.g. supports-color) otherwise assume 256/ANSI-16 and + // quantize RGB output. Windows Terminal is recognized as truecolor via + // WT_SESSION; we aren't, so we advertise it explicitly. This is a color + // *depth* signal only — light/dark *background* detection is separate (see + // the OSC 10/11/12 color-query handling in terminal-protocol.ts). + COLORTERM: 'truecolor', DORMOUSE_SURFACE_ID: surfaceId || options?.id || '', }; const integrated = applyShellIntegration(shell, childEnv, shellArgs, integrationDir, runtime); @@ -1039,6 +1047,14 @@ module.exports.create = function create(send, ptyModule) { rows: config.rows, cwd: config.cwd, env: config.env, + // Use node-pty's bundled OpenConsole (conpty.dll) on Windows instead of + // the in-box CreatePseudoConsole. The in-box conhost *swallows* programs' + // OSC 10/11/12 color queries (they never reach us, so we can't answer and + // TUIs assume a dark background); the bundled OpenConsole forwards them to + // the consumer, letting our protocol parser reply from the active theme — + // the same passthrough Windows Terminal relies on. Verified end-to-end on + // Windows. Ignored by node-pty on non-Windows platforms. + useConptyDll: process.platform === 'win32', }); } catch (err) { console.error(`[pty-core] spawn failed for ${id}:`, err.message); diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 44df738b..e4f0d9b6 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -43,6 +43,7 @@ test('resolveSpawnConfig uses POSIX shell and home defaults', () => { assert.equal(config.env.TERM_PROGRAM_VERSION, '3.5.0'); assert.equal(config.env.LC_TERMINAL, 'iTerm2'); assert.equal(config.env.LC_TERMINAL_VERSION, '3.5.0'); + assert.equal(config.env.COLORTERM, 'truecolor'); assert.deepEqual(config.shellArgs, ['-l']); }); @@ -129,6 +130,7 @@ test('resolveSpawnConfig uses Windows shell and profile defaults', () => { assert.equal(config.env.TERM_PROGRAM_VERSION, '3.5.0'); assert.equal(config.env.LC_TERMINAL, 'iTerm2'); assert.equal(config.env.LC_TERMINAL_VERSION, '3.5.0'); + assert.equal(config.env.COLORTERM, 'truecolor'); assert.deepEqual(config.shellArgs, []); }); diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index 6760275a..ca9a37f4 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -21,6 +21,7 @@ import { collectTerminalProtocolResponses, TerminalProtocolParser, } from "dormouse-lib/lib/terminal-protocol"; +import { themeColorProvider } from "dormouse-lib/lib/terminal-theme"; import { applyTerminalSemanticEventsByPtyId } from "dormouse-lib/lib/terminal-state-store"; import type { DorControlRequestPayload, DorControlResult } from "dor/protocol"; import { BrowserSidecarHost } from "./browser-sidecar-host"; @@ -88,7 +89,7 @@ export class BrowserSidecarAdapter implements PlatformAdapter { } spawnPty(id: string, options?: { cols?: number; rows?: number; cwd?: string; shell?: string; args?: string[] }): void { - this.protocolParsers.set(id, new TerminalProtocolParser()); + this.protocolParsers.set(id, new TerminalProtocolParser(themeColorProvider)); this.host.send("pty_spawn", { id, options }); } @@ -279,7 +280,7 @@ export class BrowserSidecarAdapter implements PlatformAdapter { private getProtocolParser(id: string): TerminalProtocolParser { let parser = this.protocolParsers.get(id); if (!parser) { - parser = new TerminalProtocolParser(); + parser = new TerminalProtocolParser(themeColorProvider); this.protocolParsers.set(id, parser); } return parser; diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 4c6e8c53..217b6f68 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -24,6 +24,7 @@ import { collectTerminalProtocolResponses, TerminalProtocolParser, } from "dormouse-lib/lib/terminal-protocol"; +import { themeColorProvider } from "dormouse-lib/lib/terminal-theme"; import { applyTerminalSemanticEventsByPtyId, } from "dormouse-lib/lib/terminal-state-store"; @@ -178,7 +179,7 @@ export class TauriAdapter implements PlatformAdapter { } spawnPty(id: string, options?: { cols?: number; rows?: number; cwd?: string; shell?: string; args?: string[] }): void { - this.protocolParsers.set(id, new TerminalProtocolParser()); + this.protocolParsers.set(id, new TerminalProtocolParser(themeColorProvider)); invoke("pty_spawn", { id, options }); } @@ -451,7 +452,7 @@ export class TauriAdapter implements PlatformAdapter { private getProtocolParser(id: string): TerminalProtocolParser { let parser = this.protocolParsers.get(id); if (!parser) { - parser = new TerminalProtocolParser(); + parser = new TerminalProtocolParser(themeColorProvider); this.protocolParsers.set(id, parser); } return parser; diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 1f14ff80..a91e5e88 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -6,6 +6,8 @@ import { collectTerminalSemanticEvents, collectTerminalProtocolResponses, TerminalProtocolParser, + type TerminalColorProvider, + type TerminalColors, } from '../../lib/src/lib/terminal-protocol'; import { normalizeExternalUri } from '../../lib/src/lib/external-links'; import { VSCODE_WORKBENCH_COMMANDS } from '../../lib/src/lib/vscode-keybindings'; @@ -40,6 +42,13 @@ const ALLOWED_WORKBENCH_COMMANDS = new Set<string>(VSCODE_WORKBENCH_COMMANDS); const alertManager = new AlertManager(); const alertProtocolParsers = new Map<string, TerminalProtocolParser>(); +// The extension-host parser has no DOM, so webviews push their resolved terminal +// theme colors (see VSCodeAdapter.pushThemeColors). Cached here and read lazily +// per query so the parser can answer OSC 10/11/12 like the standalone adapter; +// null until the first push, in which case queries fall through to xterm.js. +let latestThemeColors: TerminalColors | null = null; +const themeColorProvider: TerminalColorProvider = (target) => latestThemeColors?.[target] ?? null; + // Subscribers that want each PTY chunk *after* OSC sequences have been parsed // out (display path). Decoupled from ptyManager.addCallbacks so we only run // the protocol parser once per chunk regardless of webview count. @@ -117,7 +126,7 @@ ptyManager.onDorControlRequest((request) => { function getAlertProtocolParser(id: string): TerminalProtocolParser { let parser = alertProtocolParsers.get(id); if (!parser) { - parser = new TerminalProtocolParser(); + parser = new TerminalProtocolParser(themeColorProvider); alertProtocolParsers.set(id, parser); } return parser; @@ -274,7 +283,7 @@ export function attachRouter( switch (msg.type) { case 'pty:spawn': { claim(msg.id); - alertProtocolParsers.set(msg.id, new TerminalProtocolParser()); + alertProtocolParsers.set(msg.id, new TerminalProtocolParser(themeColorProvider)); const spawnOptions = { ...msg.options }; if (!spawnOptions.cwd) { spawnOptions.cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; @@ -449,6 +458,10 @@ export function attachRouter( } satisfies ExtensionMessage), ); break; + case 'dormouse:themeColors': + // Webview reports its resolved terminal theme; cache for OSC color replies. + latestThemeColors = { foreground: msg.foreground, background: msg.background, cursor: msg.cursor }; + break; case 'dormouse:init': { // Webview has (re-)initialized — subscribe to live events. // Tear down previous subscriptions first (webview was destroyed and recreated). diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 5cdd70f3..c71c9ce8 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -1,5 +1,6 @@ import type { ActivityNotification, SessionStatus, TodoState } from '../../lib/src/lib/alert-manager'; import type { TerminalSemanticEvent } from '../../lib/src/lib/terminal-state'; +import type { TerminalColors } from '../../lib/src/lib/terminal-protocol'; import type { DorControlRequestPayload, DorControlResponsePayload } from '../../dor/src/protocol'; import type { AgentBrowserStreamStatusResult, IframeProxyResult, OpenPort } from '../../lib/src/lib/platform/types'; import type { VSCodeWorkbenchCommand } from '../../lib/src/lib/vscode-keybindings'; @@ -28,6 +29,7 @@ export type WebviewMessage = | { type: 'agentBrowser:popIn'; session: string; url?: string; binaryPath?: string; requestId: string } | { type: 'iframe:createProxyUrl'; url: string; requestId: string } | { type: 'dormouse:init' } + | ({ type: 'dormouse:themeColors' } & TerminalColors) | { type: 'dormouse:saveState'; state: unknown } | { type: 'dormouse:flushSessionSaveDone'; requestId: string } | ({ type: 'dor:controlResponse' } & DorControlResponsePayload)