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), }; }