Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/specs/auto-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/specs/terminal-escapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Unknown non-iTerm2 OSC families pass through to xterm.js unchanged so xterm.js c
| `OSC 2 ; <title> 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) |
Expand All @@ -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.
Expand Down Expand Up @@ -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:

Expand Down
4 changes: 4 additions & 0 deletions docs/specs/vscode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
45 changes: 45 additions & 0 deletions lib/src/lib/css-color.ts
Original file line number Diff line number Diff line change
@@ -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)}`;
}
38 changes: 38 additions & 0 deletions lib/src/lib/platform/vscode-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;

Expand Down Expand Up @@ -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();

Expand Down
20 changes: 20 additions & 0 deletions lib/src/lib/platform/vscode-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
76 changes: 75 additions & 1 deletion lib/src/lib/terminal-protocol.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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[]) {
Expand Down
Loading
Loading