From b715de140b6ee3daa44a6cb36eec35aba51ffe78 Mon Sep 17 00:00:00 2001 From: PullApprove Agent Date: Fri, 7 Aug 2026 03:19:32 +0000 Subject: [PATCH] Keep a VS Code theme's own terminal palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A theme resolved from VS Code JSON dropped its `terminal.ansi*` colors on the floor: `COLOR_MAP` never read them, so `ansiPaletteFor` found no entry for the `vscode-*` id and substituted the generic Tomorrow Night / GitHub Light ramp. Turning on "match VS Code theme" with Catppuccin therefore gave Catppuccin chrome around another theme's terminal output — the two-themes- in-one-window failure `terminal-palettes.ts` exists to prevent for the bundled themes. `UiTheme` now has an optional `ansi` ramp; `resolveVscodeTheme` fills it from the sixteen keys VS Code themes publish, all-or-nothing so a partial ramp can't pair one theme's normals with another's brights. The generic ramp stays as the fallback it was meant to be. Getting the ramp to the terminal needs the theme object, not just its id: a VS Code-derived theme is built on the fly and is not in `getAllUiThemes()`. `applyUiTheme` now records the applied theme in `lib/active-theme.ts` and `buildXtermTheme` reads it there, which also retires the `data-ui-theme` round-trip that existed only to rejoin a palette to its theme. --- .../components/Terminal/terminal-palettes.ts | 35 +++----- .../components/Terminal/xterm-theme.test.ts | 84 +++++++++++++++++-- desktop/ui/components/Terminal/xterm-theme.ts | 3 +- desktop/ui/lib/active-theme.ts | 25 ++++++ desktop/ui/lib/ui-themes.ts | 40 ++++++++- desktop/ui/lib/vscode-theme-resolver.test.ts | 82 ++++++++++++++++++ desktop/ui/lib/vscode-theme-resolver.ts | 51 ++++++++++- 7 files changed, 285 insertions(+), 35 deletions(-) create mode 100644 desktop/ui/lib/active-theme.ts create mode 100644 desktop/ui/lib/vscode-theme-resolver.test.ts diff --git a/desktop/ui/components/Terminal/terminal-palettes.ts b/desktop/ui/components/Terminal/terminal-palettes.ts index 4bebea1d..bc15a959 100644 --- a/desktop/ui/components/Terminal/terminal-palettes.ts +++ b/desktop/ui/components/Terminal/terminal-palettes.ts @@ -14,28 +14,14 @@ * * Provenance is recorded per palette. Where Ghostty's vendored copy disagreed * with the theme's own upstream, the note says which one won and why. + * + * These are the bundled themes' ramps. A theme resolved from VS Code publishes + * its own sixteen colors and carries them on `UiTheme.ansi`, which wins over + * anything here — see `lib/vscode-theme-resolver.ts`. */ import { contrast, mixColors } from "../../lib/color"; - -export interface AnsiPalette { - black: string; - red: string; - green: string; - yellow: string; - blue: string; - magenta: string; - cyan: string; - white: string; - brightBlack: string; - brightRed: string; - brightGreen: string; - brightYellow: string; - brightBlue: string; - brightMagenta: string; - brightCyan: string; - brightWhite: string; -} +import type { AnsiPalette, UiTheme } from "../../lib/ui-themes"; /** Tomorrow Night — Ghostty's own compiled-in default (`terminal/color.zig`). */ const TOMORROW_NIGHT: AnsiPalette = { @@ -254,8 +240,10 @@ function nudgeUntilVisible( } /** - * The palette for a theme, or a shared ramp when the theme has no published - * one (Review's own light theme, and anything derived from a VS Code theme). + * The palette for a theme: the ramp the theme carries itself (VS Code themes + * publish their sixteen `terminal.ansi*` colors), else the one transcribed + * above for it, else a shared ramp for themes with no published palette at all + * (Review's own light theme, and VS Code themes that set no terminal colors). * * Both schemes get one correction, for the same defect at opposite ends. * @@ -271,12 +259,13 @@ function nudgeUntilVisible( * fine is passed through untouched. */ export function ansiPaletteFor( - themeId: string, + theme: UiTheme | null, colorScheme: "light" | "dark", background: string, ): AnsiPalette { const base = - PALETTES[themeId] ?? + theme?.ansi ?? + (theme ? PALETTES[theme.id] : undefined) ?? (colorScheme === "light" ? GITHUB_LIGHT : TOMORROW_NIGHT); // 1.6:1 is well below a text-legibility threshold — the goal is only to stop // a color being *identical* to the background, not to restyle the theme. diff --git a/desktop/ui/components/Terminal/xterm-theme.test.ts b/desktop/ui/components/Terminal/xterm-theme.test.ts index 1cc71a6d..35bcd238 100644 --- a/desktop/ui/components/Terminal/xterm-theme.test.ts +++ b/desktop/ui/components/Terminal/xterm-theme.test.ts @@ -1,21 +1,67 @@ import { describe, it, expect, afterEach } from "vitest"; import { buildXtermTheme } from "./xterm-theme"; import { ansiPaletteFor } from "./terminal-palettes"; +import { setActiveUiTheme } from "../../lib/active-theme"; +import type { AnsiPalette, UiTheme, UiThemeTokens } from "../../lib/ui-themes"; + +/** A theme's own sixteen colors, the way a VS Code theme publishes them. */ +const PUBLISHED_RAMP: AnsiPalette = { + black: "#45475a", + red: "#f38ba8", + green: "#a6e3a1", + yellow: "#f9e2af", + blue: "#89b4fa", + magenta: "#f5c2e7", + cyan: "#94e2d5", + white: "#bac2de", + brightBlack: "#585b70", + brightRed: "#f37799", + brightGreen: "#89d88b", + brightYellow: "#ebd391", + brightBlue: "#74a8fc", + brightMagenta: "#f2aede", + brightCyan: "#6bd7ca", + brightWhite: "#a6adc8", +}; /** Set a CSS custom property on , mirroring applyUiTheme's inline-style approach. */ function setVar(name: string, value: string): void { document.documentElement.style.setProperty(name, value); } +/** + * A stand-in for an applied theme. The palette lookup reads only `id`, + * `colorScheme` and `ansi`; the tokens reach the terminal as CSS variables, + * which these tests set directly. + */ +function uiTheme( + id: string, + colorScheme: "light" | "dark", + ansi?: AnsiPalette, +): UiTheme { + return { + id, + label: id, + colorScheme, + preview: ["#000000", "#ffffff", "#ffffff"], + codeTheme: "github-dark", + tokens: {} as UiThemeTokens, + ansi, + }; +} + /** Select a theme the way applyUiTheme does. */ -function setTheme(id: string, scheme: "light" | "dark"): void { - document.documentElement.dataset.uiTheme = id; +function setTheme( + id: string, + scheme: "light" | "dark", + ansi?: AnsiPalette, +): void { + setActiveUiTheme(uiTheme(id, scheme, ansi)); document.documentElement.style.setProperty("color-scheme", scheme); } afterEach(() => { document.documentElement.removeAttribute("style"); - delete document.documentElement.dataset.uiTheme; }); describe("buildXtermTheme", () => { @@ -74,6 +120,20 @@ describe("buildXtermTheme", () => { } }); + /** + * A theme resolved from VS Code carries its own ramp. Substituting the + * shared one there puts two themes in one window — the editor's chrome + * around another theme's terminal output. + */ + it("prefers a ramp the active theme carries over the shared fallback", () => { + setTheme("vscode-something-custom", "dark", PUBLISHED_RAMP); + + const theme = buildXtermTheme(); + + expect(theme.red).toBe("#f38ba8"); + expect(theme.brightRed).toBe("#f37799"); + }); + it("falls back to a shared ramp for a theme with no published palette", () => { setTheme("vscode-something-custom", "dark"); const theme = buildXtermTheme(); @@ -102,16 +162,28 @@ describe("ansiPaletteFor", () => { * invisible text. Only the colliding slots are corrected. */ it("rescues light-theme whites that match the background", () => { - const solarized = ansiPaletteFor("solarized-light", "light", "#eee8d5"); + const solarized = ansiPaletteFor( + uiTheme("solarized-light", "light"), + "light", + "#eee8d5", + ); expect(solarized.white).not.toBe("#eee8d5"); - const flexoki = ansiPaletteFor("flexoki-light", "light", "#F2F0E5"); + const flexoki = ansiPaletteFor( + uiTheme("flexoki-light", "light"), + "light", + "#F2F0E5", + ); expect(flexoki.brightWhite).not.toBe("#F2F0E5"); }); it("leaves a palette alone when nothing collides", () => { - const dark = ansiPaletteFor("dracula", "dark", "#21222c"); + const dark = ansiPaletteFor(uiTheme("dracula", "dark"), "dark", "#21222c"); expect(dark.white).toBe("#f8f8f2"); expect(dark.brightWhite).toBe("#ffffff"); }); + + it("uses the shared ramp when no theme has been applied yet", () => { + expect(ansiPaletteFor(null, "dark", "#1c1917").red).toBe("#CC6666"); + }); }); diff --git a/desktop/ui/components/Terminal/xterm-theme.ts b/desktop/ui/components/Terminal/xterm-theme.ts index 6543b01a..1df37741 100644 --- a/desktop/ui/components/Terminal/xterm-theme.ts +++ b/desktop/ui/components/Terminal/xterm-theme.ts @@ -1,4 +1,5 @@ import type { ITheme } from "@xterm/xterm"; +import { getActiveUiTheme } from "../../lib/active-theme"; import { ansiPaletteFor } from "./terminal-palettes"; /** @@ -26,7 +27,7 @@ export function buildXtermTheme(): ITheme { const scheme = cs.getPropertyValue("color-scheme").trim() === "light" ? "light" : "dark"; - const ansi = ansiPaletteFor(el.dataset.uiTheme ?? "", scheme, background); + const ansi = ansiPaletteFor(getActiveUiTheme(), scheme, background); return { background, diff --git a/desktop/ui/lib/active-theme.ts b/desktop/ui/lib/active-theme.ts new file mode 100644 index 00000000..456b2597 --- /dev/null +++ b/desktop/ui/lib/active-theme.ts @@ -0,0 +1,25 @@ +/** + * The UI theme currently applied to ``. + * + * `applyUiTheme` writes the theme's colors out as CSS variables, which is all + * the UI needs. The terminal needs the theme *object*: its 16-color ANSI ramp + * is per-theme published data that no semantic token carries. A theme resolved + * from VS Code is built on the fly from the editor's JSON and never enters + * `getAllUiThemes()`, so an id alone is not enough to find it again. + * + * It lives in its own module so the terminal can read the active theme without + * pulling in the whole theme catalog (and Shiki behind it). + */ + +import type { UiTheme } from "./ui-themes"; + +let activeUiTheme: UiTheme | null = null; + +export function setActiveUiTheme(theme: UiTheme): void { + activeUiTheme = theme; +} + +/** The applied theme, or null before the first `applyUiTheme` call. */ +export function getActiveUiTheme(): UiTheme | null { + return activeUiTheme; +} diff --git a/desktop/ui/lib/ui-themes.ts b/desktop/ui/lib/ui-themes.ts index 561196e3..73641a37 100644 --- a/desktop/ui/lib/ui-themes.ts +++ b/desktop/ui/lib/ui-themes.ts @@ -10,6 +10,7 @@ */ import { registerCustomTheme } from "@pierre/diffs"; +import { setActiveUiTheme } from "./active-theme"; export interface UiTheme { id: string; @@ -20,6 +21,37 @@ export interface UiTheme { /** Recommended code (Shiki) theme to pair with this UI theme */ codeTheme: string; tokens: UiThemeTokens; + /** + * The theme's own 16-color ANSI ramp, when it carries one. Themes resolved + * from VS Code JSON fill this from their `terminal.ansi*` colors; the + * bundled themes' ramps are transcribed in `terminal-palettes.ts`, which is + * also where the shared fallback for themes that publish nothing lives. + */ + ansi?: AnsiPalette; +} + +/** + * The 16 ANSI colors a terminal addresses by index. Declared here because it + * is part of a theme's shape; the values and their provenance live in + * `components/Terminal/terminal-palettes.ts`. + */ +export interface AnsiPalette { + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + brightBlue: string; + brightMagenta: string; + brightCyan: string; + brightWhite: string; } export interface UiThemeTokens { @@ -987,10 +1019,10 @@ const TOKEN_TO_CSS_VAR: Record = { export function applyUiTheme(theme: UiTheme): void { const el = document.documentElement; el.style.setProperty("color-scheme", theme.colorScheme); - // The terminal needs to know *which* theme is active, not just its colors: - // its 16-color ANSI ramp is per-theme published data that cannot be derived - // from the handful of semantic tokens below. - el.dataset.uiTheme = theme.id; + // The terminal needs the theme itself, not just its colors: its 16-color + // ANSI ramp is per-theme published data that cannot be derived from the + // handful of semantic tokens below. + setActiveUiTheme(theme); for (const [token, value] of Object.entries(theme.tokens)) { const cssVar = TOKEN_TO_CSS_VAR[token as keyof UiThemeTokens]; diff --git a/desktop/ui/lib/vscode-theme-resolver.test.ts b/desktop/ui/lib/vscode-theme-resolver.test.ts new file mode 100644 index 00000000..8ff11448 --- /dev/null +++ b/desktop/ui/lib/vscode-theme-resolver.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { + resolveVscodeTheme, + type VscodeThemeDetection, +} from "./vscode-theme-resolver"; + +/** The sixteen keys VS Code themes use for the terminal palette. */ +const ANSI_COLORS: Record = { + "terminal.ansiBlack": "#45475a", + "terminal.ansiRed": "#f38ba8", + "terminal.ansiGreen": "#a6e3a1", + "terminal.ansiYellow": "#f9e2af", + "terminal.ansiBlue": "#89b4fa", + "terminal.ansiMagenta": "#f5c2e7", + "terminal.ansiCyan": "#94e2d5", + "terminal.ansiWhite": "#bac2de", + "terminal.ansiBrightBlack": "#585b70", + "terminal.ansiBrightRed": "#f37799", + "terminal.ansiBrightGreen": "#89d88b", + "terminal.ansiBrightYellow": "#ebd391", + "terminal.ansiBrightBlue": "#74a8fc", + "terminal.ansiBrightMagenta": "#f2aede", + "terminal.ansiBrightCyan": "#6bd7ca", + "terminal.ansiBrightWhite": "#a6adc8", +}; + +function detection(colors: Record): VscodeThemeDetection { + return { + name: "Something Custom", + themeType: "dark", + colors: { "editor.background": "#1e1e2e", ...colors }, + // Empty so no Shiki theme is registered — this file is about the colors. + tokenColors: [], + }; +} + +describe("resolveVscodeTheme", () => { + /** + * Without this the terminal falls back to a generic ramp, so a Catppuccin + * user gets Catppuccin chrome around Tomorrow Night output. + */ + it("carries the theme's own terminal palette", () => { + const theme = resolveVscodeTheme(detection(ANSI_COLORS)); + + expect(theme.ansi).toEqual({ + black: "#45475a", + red: "#f38ba8", + green: "#a6e3a1", + yellow: "#f9e2af", + blue: "#89b4fa", + magenta: "#f5c2e7", + cyan: "#94e2d5", + white: "#bac2de", + brightBlack: "#585b70", + brightRed: "#f37799", + brightGreen: "#89d88b", + brightYellow: "#ebd391", + brightBlue: "#74a8fc", + brightMagenta: "#f2aede", + brightCyan: "#6bd7ca", + brightWhite: "#a6adc8", + }); + }); + + it("leaves the palette unset for a theme that publishes no terminal colors", () => { + expect(resolveVscodeTheme(detection({})).ansi).toBeUndefined(); + }); + + /** + * A partial ramp topped up from the shared fallback would pair one theme's + * normals with another's brights — the mismatch this avoids, in miniature. + */ + it("leaves the palette unset when the ramp is incomplete", () => { + const partial = Object.fromEntries( + Object.entries(ANSI_COLORS).filter( + ([key]) => key !== "terminal.ansiBrightCyan", + ), + ); + + expect(resolveVscodeTheme(detection(partial)).ansi).toBeUndefined(); + }); +}); diff --git a/desktop/ui/lib/vscode-theme-resolver.ts b/desktop/ui/lib/vscode-theme-resolver.ts index 4f871052..55a22e3a 100644 --- a/desktop/ui/lib/vscode-theme-resolver.ts +++ b/desktop/ui/lib/vscode-theme-resolver.ts @@ -7,7 +7,7 @@ */ import { registerCustomTheme } from "@pierre/diffs"; -import type { UiTheme, UiThemeTokens } from "./ui-themes"; +import type { AnsiPalette, UiTheme, UiThemeTokens } from "./ui-themes"; import { UI_THEMES } from "./ui-themes"; import { mixColors } from "./color"; @@ -201,6 +201,54 @@ const COLOR_MAP: [keyof UiThemeTokens, string[]][] = [ ["status-info", ["editorInfo.foreground"]], ]; +// --------------------------------------------------------------------------- +// Terminal ANSI ramp +// --------------------------------------------------------------------------- + +/** + * VS Code publishes the 16 terminal colors under fixed keys, so a theme that + * sets them is handing us exactly the ramp our terminal needs. + */ +const ANSI_KEYS: [keyof AnsiPalette, string][] = [ + ["black", "terminal.ansiBlack"], + ["red", "terminal.ansiRed"], + ["green", "terminal.ansiGreen"], + ["yellow", "terminal.ansiYellow"], + ["blue", "terminal.ansiBlue"], + ["magenta", "terminal.ansiMagenta"], + ["cyan", "terminal.ansiCyan"], + ["white", "terminal.ansiWhite"], + ["brightBlack", "terminal.ansiBrightBlack"], + ["brightRed", "terminal.ansiBrightRed"], + ["brightGreen", "terminal.ansiBrightGreen"], + ["brightYellow", "terminal.ansiBrightYellow"], + ["brightBlue", "terminal.ansiBrightBlue"], + ["brightMagenta", "terminal.ansiBrightMagenta"], + ["brightCyan", "terminal.ansiBrightCyan"], + ["brightWhite", "terminal.ansiBrightWhite"], +]; + +/** + * Pull the theme's own terminal ramp out of its colors. + * + * All sixteen or nothing: a half-published ramp topped up from the shared + * fallback would pair one theme's normals with another's brights, which is the + * mismatch this is meant to avoid rather than a smaller version of it. + */ +function resolveAnsiPalette( + colors: Record, +): AnsiPalette | undefined { + const palette = {} as AnsiPalette; + + for (const [slot, key] of ANSI_KEYS) { + const value = colors[key]; + if (!value) return undefined; + palette[slot] = value; + } + + return palette; +} + // --------------------------------------------------------------------------- // Shiki theme resolution // --------------------------------------------------------------------------- @@ -445,5 +493,6 @@ export function resolveVscodeTheme(detection: VscodeThemeDetection): UiTheme { preview: [tokens.surface, tokens["fg-secondary"], tokens["focus-ring"]], codeTheme, tokens, + ansi: resolveAnsiPalette(detection.colors), }; }