diff --git a/.changeset/shiki-syntax-themes.md b/.changeset/shiki-syntax-themes.md
new file mode 100644
index 00000000..1a323577
--- /dev/null
+++ b/.changeset/shiki-syntax-themes.md
@@ -0,0 +1,5 @@
+---
+"hunkdiff": minor
+---
+
+Refresh Hunk's built-in theme system, default to `github-dark-default`, and simplify theme selection around one `theme` setting with `View -> Themes…` / `t` opening the selector. Custom themes can inherit from any built-in theme with `custom_theme.base` while keeping explicit syntax color overrides, and removed theme ids such as `graphite` and `paper` remain accepted as compatibility aliases.
diff --git a/.changeset/theme-contrast-audit.md b/.changeset/theme-contrast-audit.md
new file mode 100644
index 00000000..cdfcf9e4
--- /dev/null
+++ b/.changeset/theme-contrast-audit.md
@@ -0,0 +1,5 @@
+---
+"hunkdiff": patch
+---
+
+Improve generated theme contrast checks for built-in themes, including diff rows, metadata, chrome, and fallback token colors.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index aef95b50..f3f29613 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -72,6 +72,9 @@ jobs:
- name: Typecheck
run: bun run typecheck
+ - name: Theme contrast check
+ run: bun run test:theme-contrast
+
- name: Test suite
run: bun run test
diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml
index e16ce7ea..794e4819 100644
--- a/.github/workflows/pr-ci.yml
+++ b/.github/workflows/pr-ci.yml
@@ -85,6 +85,9 @@ jobs:
- name: Typecheck
run: bun run typecheck
+ - name: Theme contrast check
+ run: bun run test:theme-contrast
+
- name: Test suite
run: bun test ./src ./packages ./scripts ./test/cli ./test/session
@@ -136,6 +139,9 @@ jobs:
- name: Typecheck
run: bun run typecheck
+ - name: Theme contrast check
+ run: bun run test:theme-contrast
+
- name: Test suite
run: bun run test
diff --git a/README.md b/README.md
index 2f769bf1..761947a2 100644
--- a/README.md
+++ b/README.md
@@ -119,7 +119,7 @@ You can persist preferences to a config file:
Example:
```toml
-theme = "graphite" # auto, graphite, midnight, paper, ember, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, zenburn, custom
+theme = "github-dark-default" # any built-in theme id, auto, or custom
mode = "auto" # auto, split, stack
vcs = "git" # git, jj, sl
watch = false
@@ -130,17 +130,18 @@ agent_notes = false
transparent_background = false
```
-`theme = "auto"` and `--theme auto` query the terminal background at startup, choose `paper` for light backgrounds and `graphite` for dark backgrounds, and fall back to `graphite` if the terminal does not answer.
+`theme = "auto"` and `--theme auto` query the terminal background at startup, choose `github-light-default` for light backgrounds and `github-dark-default` for dark backgrounds, and fall back to `github-dark-default` if the terminal does not answer.
+Older theme ids such as `graphite` and `paper` remain accepted as compatibility aliases.
`exclude_untracked` affects Git/Sapling working-tree `hunk diff` sessions only.
`transparent_background` can also be written as `transparentBackground`.
-Custom themes can inherit from any built-in base theme and override only the colors you care about:
+Custom themes can inherit from any built-in theme and override only the colors you care about:
```toml
theme = "custom"
[custom_theme]
-base = "graphite" # graphite, midnight, paper, ember, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, zenburn
+base = "catppuccin-mocha"
label = "My Theme"
accent = "#7fd1ff"
panel = "#10161d"
@@ -150,9 +151,11 @@ noteBorder = "#c49bff"
keyword = "#8ed4ff"
string = "#c7b4ff"
comment = "#6e85a7"
+operator = "#7fd1ff"
+variable = "#eef4ff"
```
-All custom theme colors must use `#rrggbb` hex values.
+All custom theme colors must use `#rrggbb` hex values. Press `t` in the app, or choose `View -> Themes…`, to open the theme selector.
### Git integration
diff --git a/package.json b/package.json
index 6131646b..259b53f6 100644
--- a/package.json
+++ b/package.json
@@ -64,6 +64,7 @@
"release:version": "bunx @changesets/cli@2.31.0 version",
"prepare": "simple-git-hooks",
"test": "\"${npm_execpath:-bun}\" test ./src ./packages ./scripts ./test/cli ./test/session",
+ "test:theme-contrast": "bun test src/ui/themes.test.ts --test-name-pattern contrast",
"test:integration": "\"${npm_execpath:-bun}\" test ./test/pty",
"test:tty-smoke": "HUNK_RUN_TTY_SMOKE=1 \"${npm_execpath:-bun}\" test ./test/smoke",
"check:pack": "bun run ./scripts/check-pack.ts",
diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts
index f0741de1..45bdbb41 100644
--- a/src/core/cli.test.ts
+++ b/src/core/cli.test.ts
@@ -94,7 +94,7 @@ describe("parseCli", () => {
"--mode",
"split",
"--theme",
- "paper",
+ "github-light-default",
"--agent-context",
"notes.json",
"--no-line-numbers",
@@ -111,7 +111,7 @@ describe("parseCli", () => {
staged: false,
options: {
mode: "split",
- theme: "paper",
+ theme: "github-light-default",
agentContext: "notes.json",
watch: true,
lineNumbers: false,
@@ -227,12 +227,12 @@ describe("parseCli", () => {
});
test("parses general pager mode", async () => {
- const parsed = await parseCli(["bun", "hunk", "pager", "--theme", "paper"]);
+ const parsed = await parseCli(["bun", "hunk", "pager", "--theme", "github-light-default"]);
expect(parsed).toMatchObject({
kind: "pager",
options: {
- theme: "paper",
+ theme: "github-light-default",
},
});
});
diff --git a/src/core/config.test.ts b/src/core/config.test.ts
index 576d23c3..7715ea5a 100644
--- a/src/core/config.test.ts
+++ b/src/core/config.test.ts
@@ -56,7 +56,7 @@ describe("config resolution", () => {
writeFileSync(
join(home, ".config", "hunk", "config.toml"),
[
- 'theme = "graphite"',
+ 'theme = "github-dark-default"',
"line_numbers = false",
"transparentBackground = true",
"color_moved = true",
@@ -72,7 +72,13 @@ describe("config resolution", () => {
mkdirSync(join(repo, ".hunk"), { recursive: true });
writeFileSync(
join(repo, ".hunk", "config.toml"),
- ['theme = "paper"', "wrap_lines = true", "", "[pager]", "hunk_headers = false"].join("\n"),
+ [
+ 'theme = "github-light-default"',
+ "wrap_lines = true",
+ "",
+ "[pager]",
+ "hunk_headers = false",
+ ].join("\n"),
);
const resolved = resolveConfiguredCliInput(createPatchPagerInput({ agentNotes: true }), {
@@ -84,7 +90,7 @@ describe("config resolution", () => {
expect(resolved.input.options).toMatchObject({
pager: true,
mode: "stack",
- theme: "paper",
+ theme: "github-light-default",
lineNumbers: false,
wrapLines: true,
hunkHeaders: false,
@@ -106,7 +112,7 @@ describe("config resolution", () => {
'theme = "custom"',
"",
"[custom_theme]",
- 'base = "midnight"',
+ 'base = "github-dark-default"',
'label = "Global Custom"',
'accent = "#123456"',
"",
@@ -137,7 +143,7 @@ describe("config resolution", () => {
expect(resolved.input.options.theme).toBe("custom");
expect(resolved.customTheme).toEqual({
- base: "midnight",
+ base: "github-dark-default",
label: "Repo Custom",
accent: "#123456",
panel: "#654321",
@@ -148,22 +154,31 @@ describe("config resolution", () => {
});
});
- test.each([
- "graphite",
- "midnight",
- "paper",
- "ember",
- "catppuccin-latte",
- "catppuccin-frappe",
- "catppuccin-macchiato",
- "catppuccin-mocha",
- "zenburn",
- ])("accepts custom theme base id: %s", (base) => {
+ test.each(["github-dark-default", "github-light-default", "dracula", "catppuccin-mocha"])(
+ "accepts custom theme base id: %s",
+ (base) => {
+ const home = createTempDir("hunk-config-home-");
+ mkdirSync(join(home, ".config", "hunk"), { recursive: true });
+ writeFileSync(
+ join(home, ".config", "hunk", "config.toml"),
+ ["[custom_theme]", `base = "${base}"`].join("\n"),
+ );
+
+ const resolved = resolveConfiguredCliInput(createPatchPagerInput(), {
+ cwd: createTempDir("hunk-config-cwd-"),
+ env: { HOME: home },
+ });
+
+ expect(resolved.customTheme).toEqual({ base });
+ },
+ );
+
+ test("normalizes legacy custom theme base ids", () => {
const home = createTempDir("hunk-config-home-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(
join(home, ".config", "hunk", "config.toml"),
- ["[custom_theme]", `base = "${base}"`].join("\n"),
+ ["[custom_theme]", 'base = "graphite"'].join("\n"),
);
const resolved = resolveConfiguredCliInput(createPatchPagerInput(), {
@@ -171,7 +186,7 @@ describe("config resolution", () => {
env: { HOME: home },
});
- expect(resolved.customTheme).toEqual({ base });
+ expect(resolved.customTheme).toEqual({ base: "github-dark-default" });
});
test("rejects invalid custom theme base ids", () => {
@@ -187,9 +202,7 @@ describe("config resolution", () => {
cwd: createTempDir("hunk-config-cwd-"),
env: { HOME: home },
}),
- ).toThrow(
- "Expected custom_theme.base to be one of: graphite, midnight, paper, ember, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, zenburn.",
- );
+ ).toThrow("Expected custom_theme.base to be a built-in theme id.");
});
test("rejects invalid custom theme color values", () => {
@@ -248,7 +261,7 @@ describe("config resolution", () => {
expect(overridden.input.options.transparentBackground).toBe(false);
});
- test("defaults unspecified themes to graphite, including piped pager-style patch input", () => {
+ test("defaults unspecified themes to github-dark-default, including piped pager-style patch input", () => {
const home = createTempDir("hunk-config-home-");
const cwd = createTempDir("hunk-config-cwd-");
@@ -258,7 +271,7 @@ describe("config resolution", () => {
});
expect(resolved.repoConfigPath).toBeUndefined();
- expect(resolved.input.options.theme).toBe("graphite");
+ expect(resolved.input.options.theme).toBe("github-dark-default");
});
test("command-specific config sections also apply to show mode", () => {
@@ -469,7 +482,7 @@ describe("config resolution", () => {
writeFileSync(
join(home, ".config", "hunk", "config.toml"),
[
- 'theme = "paper"',
+ 'theme = "github-light-default"',
"line_numbers = false",
"wrap_lines = true",
"hunk_headers = false",
@@ -495,7 +508,7 @@ describe("config resolution", () => {
const bootstrap = await loadAppBootstrap(resolved.input);
expect(bootstrap.initialMode).toBe("auto");
- expect(bootstrap.initialTheme).toBe("paper");
+ expect(bootstrap.initialTheme).toBe("github-light-default");
expect(bootstrap.initialShowLineNumbers).toBe(false);
expect(bootstrap.initialWrapLines).toBe(true);
expect(bootstrap.initialShowHunkHeaders).toBe(false);
@@ -515,7 +528,7 @@ describe("config resolution", () => {
'theme = "custom"',
"",
"[custom_theme]",
- 'base = "paper"',
+ 'base = "catppuccin-mocha"',
'accent = "#7755aa"',
"",
"[custom_theme.syntax]",
@@ -541,7 +554,7 @@ describe("config resolution", () => {
expect(bootstrap.initialTheme).toBe("custom");
expect(bootstrap.customTheme).toEqual({
- base: "paper",
+ base: "catppuccin-mocha",
accent: "#7755aa",
syntax: {
comment: "#998877",
@@ -549,7 +562,7 @@ describe("config resolution", () => {
});
});
- test("loadAppBootstrap exposes graphite when no theme is configured", async () => {
+ test("loadAppBootstrap exposes github-dark-default when no theme is configured", async () => {
const home = createTempDir("hunk-config-home-");
const repo = createTempDir("hunk-config-repo-");
createRepo(repo);
@@ -570,6 +583,6 @@ describe("config resolution", () => {
);
const bootstrap = await loadAppBootstrap(resolved.input);
- expect(bootstrap.initialTheme).toBe("graphite");
+ expect(bootstrap.initialTheme).toBe("github-dark-default");
});
});
diff --git a/src/core/config.ts b/src/core/config.ts
index f0dced64..b3750366 100644
--- a/src/core/config.ts
+++ b/src/core/config.ts
@@ -1,5 +1,7 @@
import fs from "node:fs";
import { join } from "node:path";
+import { BUNDLED_SHIKI_THEME_IDS } from "../ui/lib/shikiThemes";
+import { normalizeBuiltInThemeId } from "../ui/themes";
import { resolveGlobalConfigPath } from "./paths";
import { detectVcs, findVcsRepoRootCandidate, getDefaultVcsAdapter, isVcsId } from "./vcs";
import type {
@@ -12,17 +14,7 @@ import type {
VcsMode,
} from "./types";
-const BUILT_IN_THEME_IDS = [
- "graphite",
- "midnight",
- "paper",
- "ember",
- "catppuccin-latte",
- "catppuccin-frappe",
- "catppuccin-macchiato",
- "catppuccin-mocha",
- "zenburn",
-] as const;
+const BUILT_IN_THEME_IDS = BUNDLED_SHIKI_THEME_IDS;
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
const CUSTOM_THEME_COLOR_KEYS = [
"background",
@@ -68,6 +60,8 @@ const CUSTOM_SYNTAX_COLOR_KEYS = [
"function",
"property",
"type",
+ "variable",
+ "operator",
"punctuation",
] as const;
@@ -129,20 +123,26 @@ function normalizeThemeColor(value: unknown, keyPath: string) {
return value.toLowerCase();
}
-/** Accept only built-in base theme ids for config-defined custom themes. */
+/** Accept only built-in theme ids for config-defined custom themes. */
function normalizeCustomThemeBase(value: unknown) {
if (value === undefined) {
return undefined;
}
- if (
- typeof value !== "string" ||
- !BUILT_IN_THEME_IDS.includes(value as (typeof BUILT_IN_THEME_IDS)[number])
- ) {
- throw new Error(`Expected custom_theme.base to be one of: ${BUILT_IN_THEME_IDS.join(", ")}.`);
+ if (typeof value !== "string") {
+ throw new Error(
+ `Expected custom_theme.base to be a built-in theme id. Known themes: ${BUILT_IN_THEME_IDS.join(", ")}.`,
+ );
+ }
+
+ const resolvedThemeId = normalizeBuiltInThemeId(value);
+ if (!resolvedThemeId) {
+ throw new Error(
+ `Expected custom_theme.base to be a built-in theme id. Known themes: ${BUILT_IN_THEME_IDS.join(", ")}.`,
+ );
}
- return value;
+ return resolvedThemeId;
}
/** Read the nested syntax color overrides from a [custom_theme.syntax] TOML table. */
@@ -213,7 +213,7 @@ function mergeCustomTheme(
return {
...base,
...overrides,
- base: overrides.base ?? base.base ?? "graphite",
+ base: overrides.base ?? base.base ?? "github-dark-default",
label: overrides.label ?? base.label,
syntax:
base.syntax || overrides.syntax
@@ -317,7 +317,7 @@ export function resolveConfiguredCliInput(
vcs: detectRepoVcsMode(cwd),
// Keep the built-in theme default explicit so stdin-backed startup paths do not depend on
// renderer theme-mode detection for their initial palette.
- theme: "graphite",
+ theme: "github-dark-default",
agentContext: input.options.agentContext,
pager: input.options.pager ?? false,
watch: input.options.watch ?? false,
@@ -349,6 +349,7 @@ export function resolveConfiguredCliInput(
pager: input.options.pager ?? false,
watch: input.options.watch ?? resolvedOptions.watch ?? false,
excludeUntracked: resolvedOptions.excludeUntracked ?? false,
+ theme: resolvedOptions.theme,
vcs: resolvedOptions.vcs ?? getDefaultVcsAdapter().id,
mode: resolvedOptions.mode ?? DEFAULT_VIEW_PREFERENCES.mode,
lineNumbers: resolvedOptions.lineNumbers ?? DEFAULT_VIEW_PREFERENCES.showLineNumbers,
diff --git a/src/core/startup.test.ts b/src/core/startup.test.ts
index 49b0c4d4..1ab4d06e 100644
--- a/src/core/startup.test.ts
+++ b/src/core/startup.test.ts
@@ -75,7 +75,7 @@ describe("startup planning", () => {
const plan = await prepareStartupPlan(["bun", "hunk", "pager"], {
parseCliImpl: async () => ({
kind: "pager",
- options: { theme: "paper" },
+ options: { theme: "github-light-default" },
}),
readStdinText: async () => "* main\n feature/demo\n",
looksLikePatchInputImpl: () => false,
@@ -101,7 +101,7 @@ describe("startup planning", () => {
const plan = await prepareStartupPlan(["bun", "hunk", "pager"], {
parseCliImpl: async () => ({
kind: "pager",
- options: { theme: "paper" },
+ options: { theme: "github-light-default" },
}),
readStdinText: async () => text,
looksLikePatchInputImpl: () => false,
@@ -143,7 +143,7 @@ describe("startup planning", () => {
const plan = await prepareStartupPlan(["bun", "hunk", "pager"], {
parseCliImpl: async () => ({
kind: "pager",
- options: { theme: "paper" },
+ options: { theme: "github-light-default" },
}),
readStdinText: async () => "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n",
looksLikePatchInputImpl: () => true,
@@ -178,7 +178,7 @@ describe("startup planning", () => {
file: "-",
text: "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n",
options: {
- theme: "paper",
+ theme: "github-light-default",
pager: true,
},
});
@@ -227,7 +227,7 @@ describe("startup planning", () => {
test("routes diff-like pager stdin to static output when the host advertises a captured pager", async () => {
let loaded = false;
const patchText = "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n";
- const customTheme = { base: "paper", text: "#123456" };
+ const customTheme = { base: "github-light-default", text: "#123456" };
const plan = await prepareStartupPlan(["bun", "hunk", "pager"], {
parseCliImpl: async () => ({
@@ -298,7 +298,7 @@ describe("startup planning", () => {
},
};
const customTheme = {
- base: "midnight",
+ base: "github-dark-default",
accent: "#123456",
};
@@ -341,24 +341,27 @@ describe("startup planning", () => {
kind: "vcs",
staged: false,
options: {
- theme: "graphite",
+ theme: "github-dark-default",
},
};
const controllingTerminal = { stdin: {} as never, close: () => {} };
let opened = 0;
- const plan = await prepareStartupPlan(["bun", "hunk", "diff", "--theme", "graphite"], {
- parseCliImpl: async () => cliInput as ParsedCliInput,
- resolveRuntimeCliInputImpl: (input) => input,
- resolveConfiguredCliInputImpl: (input) => ({ input }) as never,
- loadAppBootstrapImpl: async (input) => createBootstrap(input),
- openControllingTerminalImpl: () => {
- opened += 1;
- return controllingTerminal;
+ const plan = await prepareStartupPlan(
+ ["bun", "hunk", "diff", "--theme", "github-dark-default"],
+ {
+ parseCliImpl: async () => cliInput as ParsedCliInput,
+ resolveRuntimeCliInputImpl: (input) => input,
+ resolveConfiguredCliInputImpl: (input) => ({ input }) as never,
+ loadAppBootstrapImpl: async (input) => createBootstrap(input),
+ openControllingTerminalImpl: () => {
+ opened += 1;
+ return controllingTerminal;
+ },
+ stdinIsTTY: false,
+ stdoutIsTTY: true,
},
- stdinIsTTY: false,
- stdoutIsTTY: true,
- });
+ );
expect(plan).toMatchObject({
kind: "app",
diff --git a/src/core/types.ts b/src/core/types.ts
index 2c2419c9..9da14f08 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -105,6 +105,8 @@ export interface CustomSyntaxColorsConfig {
function?: string;
property?: string;
type?: string;
+ variable?: string;
+ operator?: string;
punctuation?: string;
}
diff --git a/src/opentui/HunkDiffBody.tsx b/src/opentui/HunkDiffBody.tsx
index 6f4dd96e..30a9c35c 100644
--- a/src/opentui/HunkDiffBody.tsx
+++ b/src/opentui/HunkDiffBody.tsx
@@ -12,7 +12,7 @@ export function HunkDiffBody({
file,
layout = "split",
width,
- theme = "graphite",
+ theme = "github-dark-default",
showLineNumbers = true,
showHunkHeaders = true,
wrapLines = false,
@@ -24,7 +24,7 @@ export function HunkDiffBody({
const internalFile = useMemo(() => (file ? toInternalDiffFile(file) : undefined), [file]);
const resolvedHighlighted = useHighlightedDiff({
file: internalFile,
- appearance: resolvedTheme.appearance,
+ theme: resolvedTheme,
shouldLoadHighlight: highlight,
});
const rows = useMemo(
diff --git a/src/opentui/HunkDiffFileHeader.tsx b/src/opentui/HunkDiffFileHeader.tsx
index 8538a22d..4159b49c 100644
--- a/src/opentui/HunkDiffFileHeader.tsx
+++ b/src/opentui/HunkDiffFileHeader.tsx
@@ -8,7 +8,7 @@ import type { HunkDiffFileHeaderProps } from "./types";
export function HunkDiffFileHeader({
file,
width,
- theme = "graphite",
+ theme = "github-dark-default",
onSelect,
}: HunkDiffFileHeaderProps) {
const resolvedTheme = resolveTheme(theme, null);
diff --git a/src/opentui/HunkDiffView.test.tsx b/src/opentui/HunkDiffView.test.tsx
index 1510ac5a..915b7835 100644
--- a/src/opentui/HunkDiffView.test.tsx
+++ b/src/opentui/HunkDiffView.test.tsx
@@ -60,7 +60,7 @@ describe("OpenTUI public components", () => {
,
@@ -79,7 +79,7 @@ describe("OpenTUI public components", () => {
,
@@ -96,8 +96,8 @@ describe("OpenTUI public components", () => {
const diff = createExampleDiff();
const frame = await captureFrame(
-
-
+
+
,
92,
14,
@@ -114,7 +114,7 @@ describe("OpenTUI public components", () => {
files={[createExampleDiff()]}
selectedFileId="example"
width={32}
- theme="midnight"
+ theme="github-dark-default"
/>,
36,
8,
@@ -154,17 +154,10 @@ describe("OpenTUI public components", () => {
expect(files[0]?.patch).toContain("diff --git a/example.ts b/example.ts");
});
- test("exports the documented built-in theme names", () => {
- expect(HUNK_DIFF_THEME_NAMES).toEqual([
- "graphite",
- "midnight",
- "paper",
- "ember",
- "catppuccin-latte",
- "catppuccin-frappe",
- "catppuccin-macchiato",
- "catppuccin-mocha",
- "zenburn",
- ]);
+ test("exports the bundled theme names", () => {
+ expect(HUNK_DIFF_THEME_NAMES).toContain("github-dark-default");
+ expect(HUNK_DIFF_THEME_NAMES).toContain("github-light-default");
+ expect(HUNK_DIFF_THEME_NAMES).toContain("dracula");
+ expect(HUNK_DIFF_THEME_NAMES).toContain("catppuccin-mocha");
});
});
diff --git a/src/opentui/HunkFileNav.tsx b/src/opentui/HunkFileNav.tsx
index da5fbb37..4c20928f 100644
--- a/src/opentui/HunkFileNav.tsx
+++ b/src/opentui/HunkFileNav.tsx
@@ -10,7 +10,7 @@ export function HunkFileNav({
files,
selectedFileId,
width,
- theme = "graphite",
+ theme = "github-dark-default",
onSelectFile = () => {},
}: HunkFileNavProps) {
const resolvedTheme = resolveTheme(theme, null);
diff --git a/src/opentui/HunkReviewStream.tsx b/src/opentui/HunkReviewStream.tsx
index 50be040d..cc7dc5e4 100644
--- a/src/opentui/HunkReviewStream.tsx
+++ b/src/opentui/HunkReviewStream.tsx
@@ -18,7 +18,7 @@ export function HunkReviewStream({
files,
layout = "split",
width,
- theme = "graphite",
+ theme = "github-dark-default",
selection,
showFileHeaders = true,
showFileSeparators = true,
diff --git a/src/opentui/themes.ts b/src/opentui/themes.ts
index 94dcfffc..460742fb 100644
--- a/src/opentui/themes.ts
+++ b/src/opentui/themes.ts
@@ -1,13 +1,5 @@
-export const HUNK_DIFF_THEME_NAMES = [
- "graphite",
- "midnight",
- "paper",
- "ember",
- "catppuccin-latte",
- "catppuccin-frappe",
- "catppuccin-macchiato",
- "catppuccin-mocha",
- "zenburn",
-] as const;
+import { BUNDLED_SHIKI_THEME_IDS } from "../ui/lib/shikiThemes";
+
+export const HUNK_DIFF_THEME_NAMES = BUNDLED_SHIKI_THEME_IDS;
export type HunkDiffThemeName = (typeof HUNK_DIFF_THEME_NAMES)[number];
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 71c95db5..99fcf198 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -41,6 +41,9 @@ const LazyHelpDialog = lazy(async () => ({
const LazyMenuDropdown = lazy(async () => ({
default: (await import("./components/chrome/MenuDropdown")).MenuDropdown,
}));
+const LazyThemeSelectorDialog = lazy(async () => ({
+ default: (await import("./components/chrome/ThemeSelectorDialog")).ThemeSelectorDialog,
+}));
/** Clamp a value into an inclusive range. */
function clamp(value: number, min: number, max: number) {
@@ -123,6 +126,8 @@ export function App({
const [copyDecorations, setCopyDecorations] = useState(bootstrap.initialCopyDecorations ?? false);
const [codeHorizontalOffset, setCodeHorizontalOffset] = useState(0);
const [showHunkHeaders, setShowHunkHeaders] = useState(bootstrap.initialShowHunkHeaders ?? true);
+ const [themeSelectorOpen, setThemeSelectorOpen] = useState(false);
+ const [themeSelectorIndex, setThemeSelectorIndex] = useState(0);
const [sidebarVisible, setSidebarVisible] = useState(() => !pagerMode);
const [forceSidebarOpen, setForceSidebarOpen] = useState(false);
const [showHelp, setShowHelp] = useState(false);
@@ -133,6 +138,7 @@ export function App({
const [resizeStartWidth, setResizeStartWidth] = useState(null);
const [sessionNoticeText, setSessionNoticeText] = useState(null);
const sessionNoticeTimeoutRef = useRef | null>(null);
+ const themeSelectorOriginRef = useRef<{ themeId: string } | null>(null);
const themeOptions = useMemo(
() => availableThemes(bootstrap.customTheme),
@@ -149,6 +155,17 @@ export function App({
: baseTheme,
[baseTheme, bootstrap.input.options.transparentBackground],
);
+
+ const themeSelectorItems = useMemo(
+ () =>
+ themeOptions.map((theme) => ({
+ id: theme.id,
+ label: theme.label,
+ description: theme.id === activeTheme.id ? "active" : "",
+ active: theme.id === activeTheme.id,
+ })),
+ [activeTheme.id, themeOptions],
+ );
const review = useReviewController({ files: bootstrap.changeset.files });
const filteredFiles = review.visibleFiles;
const selectedFile = review.selectedFile;
@@ -383,7 +400,7 @@ export function App({
setWrapLines((current) => !current);
};
- /** Switch the active theme and surface the result in the shared footer notice area. */
+ /** Switch the active theme. */
const selectTheme = useCallback(
(nextThemeId: string) => {
const nextTheme = themeOptions.find((theme) => theme.id === nextThemeId);
@@ -393,6 +410,54 @@ export function App({
[showTransientNotice, themeOptions],
);
+ /** Preview one theme selector row without committing the choice yet. */
+ const previewThemeSelectorItem = useCallback((item: (typeof themeSelectorItems)[number]) => {
+ setThemeId(item.id);
+ }, []);
+
+ /** Open the keyboard-driven theme selector with the current theme highlighted. */
+ const openThemeSelector = useCallback(() => {
+ const currentIndex = themeSelectorItems.findIndex((item) => item.id === activeTheme.id);
+ themeSelectorOriginRef.current = { themeId };
+ setThemeSelectorIndex(Math.max(0, currentIndex));
+ setThemeSelectorOpen(true);
+ }, [activeTheme.id, themeId, themeSelectorItems]);
+
+ const closeThemeSelector = useCallback(() => {
+ const origin = themeSelectorOriginRef.current;
+ if (origin) {
+ setThemeId(origin.themeId);
+ themeSelectorOriginRef.current = null;
+ }
+ setThemeSelectorOpen(false);
+ }, []);
+
+ const moveThemeSelector = useCallback(
+ (delta: number) => {
+ setThemeSelectorIndex((current) => {
+ if (themeSelectorItems.length === 0) {
+ return 0;
+ }
+
+ const nextIndex = (current + delta + themeSelectorItems.length) % themeSelectorItems.length;
+ previewThemeSelectorItem(themeSelectorItems[nextIndex]!);
+ return nextIndex;
+ });
+ },
+ [previewThemeSelectorItem, themeSelectorItems],
+ );
+
+ const acceptThemeSelector = useCallback(() => {
+ const item = themeSelectorItems[themeSelectorIndex];
+ if (!item) {
+ return;
+ }
+
+ themeSelectorOriginRef.current = null;
+ selectTheme(item.id);
+ setThemeSelectorOpen(false);
+ }, [selectTheme, themeSelectorIndex, themeSelectorItems]);
+
/** Toggle the sidebar, forcing it open on narrower layouts when the app can still fit both panes. */
const toggleSidebar = () => {
if (sidebarVisible && (responsiveLayout.showSidebar || forceSidebarOpen)) {
@@ -616,18 +681,9 @@ export function App({
setFocusArea("files");
}, [review.cancelDraftNote]);
- /** Cycle through the themes exposed by the current app configuration. */
- const cycleTheme = useCallback(() => {
- const currentIndex = themeOptions.findIndex((theme) => theme.id === activeTheme.id);
- const nextIndex = (currentIndex + 1) % themeOptions.length;
- selectTheme(themeOptions[nextIndex]!.id);
- }, [activeTheme.id, selectTheme, themeOptions]);
-
const menus = useMemo(
() =>
buildAppMenus({
- activeThemeId: activeTheme.id,
- availableThemes: themeOptions,
canRefreshCurrentInput,
focusFilter,
layoutMode,
@@ -637,7 +693,7 @@ export function App({
refreshCurrentInput: triggerRefreshCurrentInput,
requestQuit,
selectLayoutMode,
- selectThemeId: selectTheme,
+ openThemeSelector,
copyDecorations,
showAgentNotes,
showHelp,
@@ -656,8 +712,6 @@ export function App({
wrapLines,
}),
[
- activeTheme.id,
- themeOptions,
canRefreshCurrentInput,
copyDecorations,
focusFilter,
@@ -667,7 +721,7 @@ export function App({
requestQuit,
review.moveToHunk,
selectLayoutMode,
- selectTheme,
+ openThemeSelector,
triggerRefreshCurrentInput,
toggleCopyDecorations,
showAgentNotes,
@@ -709,15 +763,18 @@ export function App({
canRefreshCurrentInput,
closeHelp,
closeMenu,
- cycleTheme,
+ acceptThemeSelector,
cancelDraftNote,
+ closeThemeSelector,
focusArea,
focusFilter,
moveToAnnotatedHunk,
moveToFile,
moveToHunk: review.moveToHunk,
moveMenuItem,
+ moveThemeSelector,
openMenu,
+ openThemeSelector,
pagerMode,
requestQuit,
scrollCodeHorizontally,
@@ -727,6 +784,7 @@ export function App({
showHelp,
startUserNote: () => startUserNote(),
switchMenu,
+ themeSelectorOpen,
toggleAgentNotes,
toggleFocusArea,
toggleGapForSelectedHunk: review.toggleSelectedHunkGap,
@@ -976,6 +1034,31 @@ export function App({
/>
) : null}
+
+ {!pagerMode && themeSelectorOpen ? (
+
+ {
+ setThemeSelectorIndex(index);
+ const item = themeSelectorItems[index];
+ if (item) {
+ previewThemeSelectorItem(item);
+ }
+ }}
+ onSelectItem={(item) => {
+ themeSelectorOriginRef.current = null;
+ selectTheme(item.id);
+ setThemeSelectorOpen(false);
+ }}
+ />
+
+ ) : null}
);
}
diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx
index 0b106b9b..0a400d0d 100644
--- a/src/ui/AppHost.interactions.test.tsx
+++ b/src/ui/AppHost.interactions.test.tsx
@@ -485,8 +485,8 @@ function hasLineWithBackground(
});
}
-/** Open the top-level Theme menu and wait for the expected active light theme marker. */
-async function openThemeMenu(setup: Awaited>) {
+/** Open the theme selector modal through the View menu. */
+async function openThemesModalFromViewMenu(setup: Awaited>) {
await act(async () => {
await setup.mockInput.pressKey("F10");
});
@@ -498,14 +498,16 @@ async function openThemeMenu(setup: Awaited>) {
);
expect(openedFrame).toContain("Toggle files/filter focus");
- for (let index = 0; index < 3; index += 1) {
- await act(async () => {
- await setup.mockInput.pressArrow("right");
- });
- await flush(setup);
- }
+ await act(async () => {
+ await setup.mockInput.pressArrow("right");
+ });
+ await flush(setup);
+
+ await act(async () => {
+ await setup.mockInput.typeText("t");
+ });
- return waitForFrame(setup, (frame) => frame.includes("[x] Paper"), 12);
+ return waitForFrame(setup, (frame) => frame.includes("Theme selector"), 12);
}
async function pressHunkNavigationKey(
@@ -719,7 +721,7 @@ describe("App interactions", () => {
}
});
- test("theme shortcut shows the selected theme in the status bar", async () => {
+ test("theme shortcut opens a selector and Enter applies the highlighted theme", async () => {
const setup = await testRender(, {
width: 240,
height: 24,
@@ -731,14 +733,127 @@ describe("App interactions", () => {
await act(async () => {
await setup.mockInput.typeText("t");
});
- let frame = await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme: Paper"));
- expect(frame).toContain("Theme: Paper");
+ let frame = await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme selector"));
+ expect(frame).toContain("↑/↓/Tab preview Enter select Esc cancel");
+ expect(frame).toContain("› github-dark-default");
+ expect(frame).toContain("active");
+
+ await act(async () => {
+ await setup.mockInput.pressArrow("down");
+ });
+ frame = await waitForFrame(setup, (nextFrame) => nextFrame.includes("› github-dark-dimmed"));
+ expect(frame).not.toContain("UI");
+ expect(frame).not.toContain("Syntax");
+
+ await act(async () => {
+ await setup.mockInput.pressEnter();
+ });
+ frame = await waitForFrame(setup, (nextFrame) =>
+ nextFrame.includes("Theme: github-dark-dimmed"),
+ );
+ expect(frame).toContain("Theme: github-dark-dimmed");
+ expect(frame).not.toContain("Theme selector");
+ } finally {
+ await act(async () => {
+ setup.renderer.destroy();
+ });
+ }
+ });
+
+ test("theme selector reopens on the active theme", async () => {
+ const bootstrap = createTestVcsAppBootstrap({
+ files: [
+ createTestDiffFile(
+ "alpha",
+ "alpha.ts",
+ "export const alpha = 1;\n",
+ "export const alpha = 2;\n",
+ ),
+ ],
+ initialTheme: "dracula",
+ });
+ const setup = await testRender(, {
+ width: 240,
+ height: 24,
+ });
+
+ try {
+ await flush(setup);
+
+ await act(async () => {
+ await setup.mockInput.typeText("t");
+ });
+ let frame = await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme selector"));
+ expect(frame).toContain("› dracula");
+ expect(frame).toContain("active");
+
+ await act(async () => {
+ await setup.mockInput.pressTab();
+ });
+ frame = await waitForFrame(setup, (nextFrame) => nextFrame.includes("› dracula-soft"));
+ expect(frame).toContain("› dracula-soft");
+
+ await act(async () => {
+ await setup.mockInput.pressEnter();
+ });
+ frame = await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme: dracula-soft"));
+ expect(frame).not.toContain("Theme selector");
+
+ await act(async () => {
+ await setup.mockInput.typeText("t");
+ });
+ frame = await waitForFrame(setup, (nextFrame) => nextFrame.includes("› dracula-soft"));
+ expect(frame).toContain("active");
+ } finally {
+ await act(async () => {
+ setup.renderer.destroy();
+ });
+ }
+ });
+
+ test("theme selector Escape reverts previews", async () => {
+ const bootstrap = createTestVcsAppBootstrap({
+ files: [
+ createTestDiffFile(
+ "alpha",
+ "alpha.ts",
+ "export const alpha = 1;\n",
+ "export const alpha = 2;\n",
+ ),
+ ],
+ initialTheme: "github-dark-default",
+ });
+ const setup = await testRender(, {
+ width: 240,
+ height: 24,
+ });
+
+ try {
+ await flush(setup);
+
+ await act(async () => {
+ await setup.mockInput.typeText("t");
+ });
+ await waitForFrame(setup, (nextFrame) => nextFrame.includes("› github-dark-default"));
+
+ await act(async () => {
+ await setup.mockInput.pressArrow("down");
+ await setup.mockInput.pressArrow("down");
+ });
+ await waitForFrame(setup, (nextFrame) => nextFrame.includes("› github-dark-high-contrast"));
+
+ await act(async () => {
+ await setup.mockInput.pressEscape();
+ });
+ await waitForFrame(setup, (nextFrame) => !nextFrame.includes("Theme selector"));
await act(async () => {
await setup.mockInput.typeText("t");
});
- frame = await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme: Ember"));
- expect(frame).toContain("Theme: Ember");
+ const frame = await waitForFrame(setup, (nextFrame) =>
+ nextFrame.includes("› github-dark-default"),
+ );
+ expect(frame).toContain("active");
} finally {
await act(async () => {
setup.renderer.destroy();
@@ -1137,7 +1252,7 @@ describe("App interactions", () => {
],
},
initialMode: "split",
- initialTheme: "paper",
+ initialTheme: "github-light-default",
initialShowLineNumbers: false,
initialWrapLines: true,
initialShowHunkHeaders: false,
@@ -1498,9 +1613,9 @@ describe("App interactions", () => {
);
expect(refreshedFrame).toContain("export const added = true;");
- const menuFrame = await openThemeMenu(setup);
- expect(menuFrame).toContain("[x] Paper");
- expect(menuFrame).toContain("[ ] Graphite");
+ const modalFrame = await openThemesModalFromViewMenu(setup);
+ expect(modalFrame).toContain("› github-light-default");
+ expect(modalFrame).toContain("active");
} finally {
await act(async () => {
setup.renderer.destroy();
@@ -1509,11 +1624,11 @@ describe("App interactions", () => {
}
});
- test("custom theme stays active in the Theme menu when bootstrap provides a custom palette", async () => {
+ test("custom theme stays active in the theme selector when bootstrap provides a custom palette", async () => {
const bootstrap = createBootstrap();
bootstrap.initialTheme = "custom";
bootstrap.customTheme = {
- base: "paper",
+ base: "github-light-default",
label: "My Theme",
accent: "#7755aa",
};
@@ -1526,22 +1641,9 @@ describe("App interactions", () => {
try {
await flush(setup);
- await act(async () => {
- await setup.mockInput.pressKey("F10");
- });
-
- await waitForFrame(setup, (frame) => frame.includes("Toggle files/filter focus"), 12);
-
- for (let index = 0; index < 3; index += 1) {
- await act(async () => {
- await setup.mockInput.pressArrow("right");
- });
- await flush(setup);
- }
-
- const menuFrame = await waitForFrame(setup, (frame) => frame.includes("My Theme"), 12);
- expect(menuFrame).toContain("[x] My Theme");
- expect(menuFrame).toContain("[ ] Graphite");
+ const menuFrame = await openThemesModalFromViewMenu(setup);
+ expect(menuFrame).toContain("› My Theme");
+ expect(menuFrame).toContain("active");
} finally {
await act(async () => {
setup.renderer.destroy();
@@ -1921,7 +2023,7 @@ describe("App interactions", () => {
files: [createTestDiffFile("space", "space.ts", before, after)],
},
initialMode: "split",
- initialTheme: "midnight",
+ initialTheme: "github-dark-default",
};
const setup = await testRender(, {
@@ -1974,7 +2076,7 @@ describe("App interactions", () => {
files: [createTestDiffFile("pageup", "pageup.ts", before, after)],
},
initialMode: "split",
- initialTheme: "midnight",
+ initialTheme: "github-dark-default",
};
const setup = await testRender(, {
@@ -2032,7 +2134,7 @@ describe("App interactions", () => {
files: [createTestDiffFile("half", "half.ts", before, after)],
},
initialMode: "split",
- initialTheme: "midnight",
+ initialTheme: "github-dark-default",
};
const setup = await testRender(, {
@@ -2105,7 +2207,7 @@ describe("App interactions", () => {
files: [createTestDiffFile("g", "g.ts", before, after)],
},
initialMode: "split",
- initialTheme: "midnight",
+ initialTheme: "github-dark-default",
};
const setup = await testRender(, {
@@ -2167,7 +2269,7 @@ describe("App interactions", () => {
files: [createTestDiffFile("pager-g", "pager-g.ts", before, after)],
},
initialMode: "split",
- initialTheme: "midnight",
+ initialTheme: "github-dark-default",
};
const setup = await testRender(, {
@@ -2599,7 +2701,7 @@ describe("App interactions", () => {
await flush(setup);
let frame = setup.captureCharFrame();
- expect(frame).not.toContain("File View Navigate Theme Agent Help");
+ expect(frame).not.toContain("File View Navigate Agent Help");
expect((frame.match(/alpha\.ts/g) ?? []).length).toBe(1);
await act(async () => {
@@ -2608,7 +2710,7 @@ describe("App interactions", () => {
await flush(setup);
frame = setup.captureCharFrame();
- expect(frame).not.toContain("File View Navigate Theme Agent Help");
+ expect(frame).not.toContain("File View Navigate Agent Help");
expect((frame.match(/alpha\.ts/g) ?? []).length).toBe(2);
await act(async () => {
diff --git a/src/ui/AppHost.responsive.test.tsx b/src/ui/AppHost.responsive.test.tsx
index b26f10bf..adf0dfdd 100644
--- a/src/ui/AppHost.responsive.test.tsx
+++ b/src/ui/AppHost.responsive.test.tsx
@@ -192,12 +192,12 @@ describe("responsive app", () => {
const wide = await captureFrameForBootstrap(createBootstrap("auto", true), 220);
const narrow = await captureFrameForBootstrap(createBootstrap("auto", true), 150);
- expect(wide).not.toContain("File View Navigate Theme Agent Help");
+ expect(wide).not.toContain("File View Navigate Agent Help");
expect(wide).not.toContain("F10 menu");
expect((wide.match(/alpha\.ts/g) ?? []).length).toBe(1);
expect(wide).toMatch(/▌.*▌/);
- expect(narrow).not.toContain("File View Navigate Theme Agent Help");
+ expect(narrow).not.toContain("File View Navigate Agent Help");
expect(narrow).not.toContain("F10 menu");
expect((narrow.match(/alpha\.ts/g) ?? []).length).toBe(1);
expect(narrow).not.toMatch(/▌.*▌/);
diff --git a/src/ui/components/chrome/HelpDialog.tsx b/src/ui/components/chrome/HelpDialog.tsx
index 1c222574..629f8de4 100644
--- a/src/ui/components/chrome/HelpDialog.tsx
+++ b/src/ui/components/chrome/HelpDialog.tsx
@@ -44,7 +44,7 @@ export function HelpDialog({
title: "View",
items: [
["1 / 2 / 0", "split / stack / auto"],
- ["s / t", "sidebar / theme"],
+ ["s / t", "sidebar / theme selector"],
["a", "toggle AI notes"],
["z", "toggle unchanged context"],
["l / w / m", "lines / wrap / metadata"],
diff --git a/src/ui/components/chrome/ThemeSelectorDialog.tsx b/src/ui/components/chrome/ThemeSelectorDialog.tsx
new file mode 100644
index 00000000..2d4d369d
--- /dev/null
+++ b/src/ui/components/chrome/ThemeSelectorDialog.tsx
@@ -0,0 +1,104 @@
+import type { MouseEvent as TuiMouseEvent } from "@opentui/core";
+import { fitText, padText } from "../../lib/text";
+import type { AppTheme } from "../../themes";
+import { ModalFrame } from "./ModalFrame";
+
+export interface ThemeSelectorItem {
+ id: string;
+ label: string;
+ description: string;
+ active: boolean;
+}
+
+/** Keep the selected row visible in the fixed-height theme selector list. */
+function visibleWindowStart(selectedIndex: number, rowCount: number, visibleRows: number) {
+ if (rowCount <= visibleRows) {
+ return 0;
+ }
+
+ const centered = selectedIndex - Math.floor(visibleRows / 2);
+ return Math.min(Math.max(centered, 0), rowCount - visibleRows);
+}
+
+/** Render an opencode-style selector for Hunk themes. */
+export function ThemeSelectorDialog({
+ items,
+ selectedIndex,
+ terminalHeight,
+ terminalWidth,
+ theme,
+ onClose,
+ onHoverItem,
+ onSelectItem,
+}: {
+ items: ThemeSelectorItem[];
+ selectedIndex: number;
+ terminalHeight: number;
+ terminalWidth: number;
+ theme: AppTheme;
+ onClose: () => void;
+ onHoverItem: (index: number) => void;
+ onSelectItem: (item: ThemeSelectorItem) => void;
+}) {
+ const width = Math.min(82, Math.max(56, terminalWidth - 8));
+ const modalHeight = Math.min(Math.max(12, terminalHeight - 4), 28);
+ const bodyWidth = Math.max(1, width - 4);
+ // ModalFrame contributes border/title/padding; reserve help/footer rows inside the body.
+ const visibleRows = Math.max(4, modalHeight - 7);
+ const start = visibleWindowStart(selectedIndex, items.length, visibleRows);
+ const visibleItems = items.slice(start, start + visibleRows);
+ const markerWidth = 3;
+ const descriptionWidth = 12;
+ const labelWidth = Math.max(8, bodyWidth - markerWidth - descriptionWidth - 2);
+
+ return (
+
+
+
+ {fitText("↑/↓/Tab preview Enter select Esc cancel", bodyWidth)}
+
+
+
+ {visibleItems.map((item, offset) => {
+ const index = start + offset;
+ const selected = index === selectedIndex;
+ const marker = selected ? "›" : item.active ? "✓" : " ";
+ const bg = selected ? theme.accentMuted : theme.panel;
+ const fg = selected ? theme.text : item.active ? theme.badgeNeutral : theme.muted;
+
+ return (
+ onHoverItem(index)}
+ onMouseUp={(event: TuiMouseEvent) => {
+ event.stopPropagation();
+ onSelectItem(item);
+ }}
+ >
+ {padText(marker, markerWidth)}
+ {padText(fitText(item.label, labelWidth), labelWidth)}
+ {fitText(item.description, descriptionWidth)}
+
+ );
+ })}
+ {start + visibleRows < items.length ? (
+
+
+ {fitText(`… ${items.length - start - visibleRows} more`, bodyWidth)}
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/ui/components/chrome/menu.ts b/src/ui/components/chrome/menu.ts
index 9d5ca9b4..aa13adf5 100644
--- a/src/ui/components/chrome/menu.ts
+++ b/src/ui/components/chrome/menu.ts
@@ -1,4 +1,4 @@
-export type MenuId = "file" | "view" | "navigate" | "theme" | "agent" | "help";
+export type MenuId = "file" | "view" | "navigate" | "agent" | "help";
export type MenuEntry =
| {
@@ -23,7 +23,6 @@ const MENU_LABELS: Record = {
file: "File",
view: "View",
navigate: "Navigate",
- theme: "Theme",
agent: "Agent",
help: "Help",
};
diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx
index 59c1a255..e26ed999 100644
--- a/src/ui/components/panes/DiffPane.tsx
+++ b/src/ui/components/panes/DiffPane.tsx
@@ -1114,10 +1114,10 @@ export function DiffPane({
void prefetchHighlightedDiff({
file,
- appearance: theme.appearance,
+ theme,
});
}
- }, [files, highlightPrefetchFileIds, theme.appearance]);
+ }, [files, highlightPrefetchFileIds, theme]);
// Keep the selected file/hunk derived from the visible viewport for actual scroll-driven
// movement, while leaving the initial mount and non-scroll relayouts alone.
diff --git a/src/ui/components/panes/copySelection.test.ts b/src/ui/components/panes/copySelection.test.ts
index 97be2e82..86223442 100644
--- a/src/ui/components/panes/copySelection.test.ts
+++ b/src/ui/components/panes/copySelection.test.ts
@@ -109,7 +109,7 @@ function buildContext(
fileSectionLayouts: ReturnType;
sectionGeometry: ReturnType[];
} {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const geometry = measureDiffSectionGeometry(file, layout, true, theme, [], width, true, false);
const sectionGeometry = [geometry];
const fileSectionLayouts = buildFileSectionLayouts([file], [geometry.bodyHeight]);
diff --git a/src/ui/components/scrollbar/VerticalScrollbar.test.tsx b/src/ui/components/scrollbar/VerticalScrollbar.test.tsx
index 82c7cad6..4c026c1c 100644
--- a/src/ui/components/scrollbar/VerticalScrollbar.test.tsx
+++ b/src/ui/components/scrollbar/VerticalScrollbar.test.tsx
@@ -70,7 +70,7 @@ function createScrollBootstrapWithManyFiles(fileCount: number): AppBootstrap {
files,
},
initialMode: "split",
- initialTheme: "midnight",
+ initialTheme: "github-dark-default",
};
}
@@ -209,7 +209,7 @@ describe("Vertical scrollbar", () => {
files: [createDiffFile("scroll", "src/scroll.ts", before, after)],
},
initialMode: "split",
- initialTheme: "midnight",
+ initialTheme: "github-dark-default",
};
const setup = await testRender(, {
@@ -275,7 +275,7 @@ describe("Vertical scrollbar", () => {
files: [createDiffFile("small", "src/small.ts", before, after)],
},
initialMode: "split",
- initialTheme: "midnight",
+ initialTheme: "github-dark-default",
};
const setup = await testRender(, {
@@ -318,7 +318,7 @@ describe("Vertical scrollbar", () => {
files: [createDiffFile("drag", "src/drag.ts", before, after)],
},
initialMode: "split",
- initialTheme: "midnight",
+ initialTheme: "github-dark-default",
};
const setup = await testRender(, {
@@ -384,7 +384,7 @@ describe("Vertical scrollbar", () => {
files: [createDiffFile("track", "src/track.ts", before, after)],
},
initialMode: "split",
- initialTheme: "midnight",
+ initialTheme: "github-dark-default",
};
const setup = await testRender(, {
@@ -463,7 +463,7 @@ describe("Vertical scrollbar", () => {
files: [createDiffFile("edge", "src/edge.ts", before, after)],
},
initialMode: "split",
- initialTheme: "midnight",
+ initialTheme: "github-dark-default",
};
const setup = await testRender(, {
diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx
index 7dd94b1a..5add44d7 100644
--- a/src/ui/components/ui-components.test.tsx
+++ b/src/ui/components/ui-components.test.tsx
@@ -227,7 +227,7 @@ function createExpandableContextDiffFile(
function createDiffPaneProps(
files: DiffFile[],
- theme = resolveTheme("midnight", null),
+ theme = resolveTheme("github-dark-default", null),
overrides: Partial[0]> = {},
): Parameters[0] {
return {
@@ -397,7 +397,7 @@ function renderedWordDiffBackgroundDistance(
describe("UI components", () => {
test("SidebarPane renders grouped file rows with indented filenames and right-aligned stats", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const files = [
createTestDiffFile(
"app",
@@ -470,7 +470,7 @@ describe("UI components", () => {
test("DiffPane renders all diff sections in file order", async () => {
const bootstrap = createBootstrap();
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
});
test("DiffFileHeaderRow leaves one column after line counts", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
});
test("DiffRowView renders a clickable add-note affordance for a hovered diff row", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const startUserNote = mock(() => undefined);
const setup = await testRender(
{
});
test("DiffRowView keeps wrapped text stable when showing the add-note affordance", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const row = {
type: "stack-line" as const,
key: "alpha:line:hover-wrap",
@@ -632,7 +632,7 @@ describe("UI components", () => {
});
test("DiffRowView fills the reserved wrapped add-note column with row background", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const setup = await testRender(
{
});
test("DiffRowView keeps metadata row background within the measured row width", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const setup = await testRender(
{
});
test("DiffRowView preserves zero-width combining spans in nowrap and wrapped rows", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const row = {
type: "stack-line" as const,
key: "alpha:line:combining",
@@ -785,7 +785,7 @@ describe("UI components", () => {
test("DiffPane only shows the add-note affordance after pointer movement", async () => {
const files = createWindowingFiles(6);
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const scrollRef = createRef();
const props = createDiffPaneProps(files, theme, {
diffContentWidth: 88,
@@ -848,7 +848,7 @@ describe("UI components", () => {
test("DiffPane add-note clicks keep targeting the current hunk after navigation", async () => {
const file = createWideTwoHunkDiffFile("target", "target.ts");
const files = [file];
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const scrollRef = createRef();
const calls: Array<{
fileId: string;
@@ -927,7 +927,7 @@ describe("UI components", () => {
test("DiffPane scrolls a later selected file into view in the windowed path", async () => {
const files = createWindowingFiles(6);
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const props = createDiffPaneProps(files, theme, {
diffContentWidth: 88,
selectedFileId: files[5]?.id,
@@ -958,7 +958,7 @@ describe("UI components", () => {
});
test("DiffPane scrolls to the selected later hunk when hunk headers are hidden", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const files = [
createTestDiffFile(
"intro",
@@ -1000,7 +1000,7 @@ describe("UI components", () => {
});
test("DiffPane viewport-follow selection does not move the scroll position", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const files = [
createTestDiffFile(
"first",
@@ -1075,7 +1075,7 @@ describe("UI components", () => {
});
test("DiffPane keeps the sticky-header lane stable through the divider and next-header handoff", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const firstFile = createTallDiffFile("first", "first.ts", 18);
const secondFile = createTallDiffFile("second", "second.ts", 18);
const scrollRef = createRef();
@@ -1189,7 +1189,7 @@ describe("UI components", () => {
});
test("DiffPane positions later files after expanded context rows", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const beforeLines = Array.from({ length: 30 }, (_, index) => `first line ${index + 1}`);
const afterLines = [...beforeLines];
afterLines[4] = "first line 5 changed";
@@ -1269,7 +1269,7 @@ describe("UI components", () => {
});
test("DiffPane advances the review stream under the always-pinned file header above a collapsed gap", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const firstFile = createCollapsedTopDiffFile("late", "late.ts", 400, 366);
const secondFile = createTallDiffFile("second", "second.ts", 4);
const scrollRef = createRef();
@@ -1311,7 +1311,7 @@ describe("UI components", () => {
});
test("DiffPane returns cleanly to the collapsed-gap view after scrolling back up under the pinned file header", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const firstFile = createCollapsedTopDiffFile("late", "late.ts", 400, 366);
const secondFile = createTallDiffFile("second", "second.ts", 4);
const scrollRef = createRef();
@@ -1363,7 +1363,7 @@ describe("UI components", () => {
});
test("DiffPane keeps bottom scroll stable when offscreen agent notes are windowed out", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const firstFile = createTallDiffFile("first", "first.ts", 18);
firstFile.agent = {
path: firstFile.path,
@@ -1421,7 +1421,7 @@ describe("UI components", () => {
});
test("DiffPane lets manual scrolling move away from a bottom-clamped file-top alignment", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const files = [
createTallDiffFile("first", "first.ts", 30),
createTestDiffFile(
@@ -1490,7 +1490,7 @@ describe("UI components", () => {
});
test("DiffPane keeps a viewport-sized selected hunk fully visible when it fits", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const props = createDiffPaneProps(
[createViewportSizedBottomHunkDiffFile("target", "target.ts")],
theme,
@@ -1528,7 +1528,7 @@ describe("UI components", () => {
});
test("DiffPane keeps a selected wrapped hunk fully visible when it fits", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const props = createDiffPaneProps(
[createWrappedViewportSizedBottomHunkDiffFile("target", "target.ts")],
theme,
@@ -1566,7 +1566,7 @@ describe("UI components", () => {
});
test("DiffPane keeps a distant selected hunk visible when row windowing narrows one file body", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const props = createDiffPaneProps([createWideTwoHunkDiffFile("target", "target.ts")], theme, {
diffContentWidth: 96,
headerLabelWidth: 48,
@@ -1594,7 +1594,7 @@ describe("UI components", () => {
});
test("DiffPane keeps a selected hunk with inline notes fully visible when it fits", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const file = createViewportSizedBottomHunkDiffFile("target", "target.ts");
file.agent = {
path: file.path,
@@ -1640,7 +1640,7 @@ describe("UI components", () => {
});
test("DiffPane scrollToNote positions the inline note near the viewport top instead of the hunk top", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
// Build a file with two distant hunks so the second hunk is far below the first when scrolled
// to the hunk top. The annotation anchors on the second hunk.
@@ -1738,7 +1738,7 @@ describe("UI components", () => {
});
test("AgentCard removes top and bottom padding while keeping the footer inside the frame", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
});
test("AgentInlineNote renders a connected bordered panel without a blank connector row", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
});
test("AgentInlineNote renders draft notes as an editable composer", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const file = createTestDiffFile(
"draft",
"src/core/cli.ts",
@@ -1840,7 +1840,7 @@ describe("UI components", () => {
});
test("AgentInlineNote grows draft composer for soft-wrapped text", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const file = createTestDiffFile(
"draft-wrap",
"src/core/cli.ts",
@@ -1879,7 +1879,7 @@ describe("UI components", () => {
});
test("AgentInlineNote shows author name in title when author is set", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
});
test("AgentInlineNote falls back to 'Agent note' when author is absent", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
});
test("AgentInlineNote includes index when multiple notes share a hunk", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
});
test("AgentInlineNote preserves special characters in author", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
});
test("AgentCard shows author in title when set", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
});
test("AgentCard falls back to 'AI note' when author absent", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
],
};
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
test("DiffPane split inline notes hand off directly to the anchored row without shifting it", async () => {
const bootstrap = createBootstrap();
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
test("DiffPane shows all inline notes when a hunk has multiple notes", async () => {
const bootstrap = createBootstrap();
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const file = bootstrap.changeset.files[0]!;
file.agent = {
...file.agent!,
@@ -2167,7 +2167,7 @@ describe("UI components", () => {
});
test("MenuDropdown renders checked items and key hints", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
});
test("MenuDropdown repositions wide menus to stay inside the terminal", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
});
test("StatusBar renders filter mode affordance", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
});
test("StatusBar renders a notice when no filter is active", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
});
test("StatusBar keeps filter input precedence over a notice", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
});
test("StatusBar keeps filter summary precedence over a notice", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
});
test("HelpDialog renders every documented control row without overlap", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
});
test("DiffPane renders an empty-state message when no files are visible", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
test("DiffPane can hide line numbers while keeping diff signs visible", async () => {
const bootstrap = createBootstrap();
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
test("DiffPane can wrap long diff lines onto continuation rows", async () => {
const bootstrap = createWrapBootstrap();
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
test("DiffPane can hide hunk metadata rows without hiding code lines", async () => {
const bootstrap = createBootstrap();
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
test("PierreDiffView renders stack-mode wrapped continuation rows", async () => {
const file = createWrapBootstrap().changeset.files[0]!;
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
test("PierreDiffView can reveal offscreen code columns in nowrap mode", async () => {
const file = createWrapBootstrap().changeset.files[0]!;
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const baseFrame = await captureFrame(
{
test("split view wraps the same long diff line across more rows than stack view at the same width", async () => {
const file = createWrapBootstrap().changeset.files[0]!;
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const width = 64;
const splitFrame = await captureFrame(
@@ -2620,7 +2620,7 @@ describe("UI components", () => {
"export const value = 1;\n",
"export const value = 2;\nexport const added = true;\n",
);
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const frame = await captureFrame(
{
});
test("PierreDiffView shows contextual messages when there is no selected file or no textual hunks", async () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const noFileFrame = await captureFrame(
{
test("PierreDiffView shows the expand chevron only when a source fetcher is attached", async () => {
const { file: baseFile } = createExpandableContextDiffFile("expand-affordance", "expand.ts");
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const noFetcherFrame = await captureFrame(
{
...expandable.file,
sourceFetcher: createTestSourceFetcher(() => expandable.after),
};
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const setup = await testRender(
{
sourceFetcher: createTestSourceFetcher(() => expandable.after),
};
const toggledGaps: string[] = [];
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const setup = await testRender(
{
id: "expanded-highlight",
path: "expanded-highlight.ts",
});
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const setup = await testRender(
{
"export const answer = 41;\nexport const stable = true;\n",
"export const answer = 42;\nexport const stable = true;\n",
);
- const theme = resolveTheme("graphite", null);
+ const theme = resolveTheme("github-dark-default", null);
const setup = await testRender(
{
"export const cacheMarker = 1;\nexport function cacheKeep(value: number) { return value + 1; }\n",
"export const cacheMarker = 2;\nexport function cacheKeep(value: number) { return value * 2; }\n",
);
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const firstSetup = await testRender(
{
test("DiffPane prefetches highlight data for files approaching the viewport before they mount", async () => {
const files = createHighlightPrefetchWindowFiles();
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const setup = await testRender(
{
const bootstrap = createBootstrap();
const frame = await captureFrame(, 280, 24);
- expect(frame).toContain("File View Navigate Theme Agent Help");
+ expect(frame).toContain("File View Navigate Agent Help");
expect(frame).toContain("alpha.ts");
expect(frame).toContain("beta.ts");
expect(frame).toContain("@@ -1,1 +1,2 @@");
diff --git a/src/ui/diff/PierreDiffView.tsx b/src/ui/diff/PierreDiffView.tsx
index 8efc3ce4..5903f917 100644
--- a/src/ui/diff/PierreDiffView.tsx
+++ b/src/ui/diff/PierreDiffView.tsx
@@ -180,7 +180,7 @@ export function PierreDiffView({
const resolvedHighlighted = useHighlightedDiff({
file,
- appearance: theme.appearance,
+ theme,
shouldLoadHighlight,
});
const sourceTextForHighlight =
@@ -188,7 +188,7 @@ export function PierreDiffView({
const resolvedHighlightedSource = useHighlightedSource({
file,
text: sourceTextForHighlight,
- appearance: theme.appearance,
+ theme,
shouldLoadHighlight: shouldLoadHighlight && expandedGapKeys.size > 0,
});
const sourceLineSpans = useCallback(
diff --git a/src/ui/diff/diffSectionGeometry.test.ts b/src/ui/diff/diffSectionGeometry.test.ts
index 0c2fcc4b..19d5e0d2 100644
--- a/src/ui/diff/diffSectionGeometry.test.ts
+++ b/src/ui/diff/diffSectionGeometry.test.ts
@@ -9,7 +9,7 @@ import {
} from "../../../test/helpers/diff-helpers";
describe("measureDiffSectionGeometry", () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
test("measures split and stack layouts from the render plan", () => {
const file = createTestDiffFile();
diff --git a/src/ui/diff/diffSectionGeometry.ts b/src/ui/diff/diffSectionGeometry.ts
index f53ebf65..573ac280 100644
--- a/src/ui/diff/diffSectionGeometry.ts
+++ b/src/ui/diff/diffSectionGeometry.ts
@@ -236,7 +236,18 @@ export function measureDiffSectionGeometry(
// Width, wrapping, and line-number visibility all affect rendered row heights, so they must
// participate in the cache key alongside the structural file/layout inputs. Expansion state
// changes the row stream, so it has to participate too.
- const cacheKey = `${file.id}:${layout}:${showHunkHeaders ? 1 : 0}:${theme.id}:${width}:${showLineNumbers ? 1 : 0}:${wrapLines ? 1 : 0}:${reserveAddNoteColumn ? 1 : 0}${expansionCacheKey(expandedKeys, sourceStatus)}${notesCacheKey(visibleAgentNotes)}`;
+ const themeCacheKey = [
+ theme.id,
+ theme.syntaxTheme ?? "",
+ theme.background,
+ theme.panelAlt,
+ theme.contextBg,
+ theme.addedBg,
+ theme.removedBg,
+ theme.lineNumberBg,
+ theme.lineNumberFg,
+ ].join(":");
+ const cacheKey = `${file.id}:${layout}:${showHunkHeaders ? 1 : 0}:${themeCacheKey}:${width}:${showLineNumbers ? 1 : 0}:${wrapLines ? 1 : 0}:${reserveAddNoteColumn ? 1 : 0}${expansionCacheKey(expandedKeys, sourceStatus)}${notesCacheKey(visibleAgentNotes)}`;
const cacheSlot = sectionGeometryCacheSlot(visibleAgentNotes);
const cached = getCachedSectionGeometry(file, cacheSlot, cacheKey);
if (cached) {
diff --git a/src/ui/diff/pierre.test.ts b/src/ui/diff/pierre.test.ts
index a8fb3a88..a315e823 100644
--- a/src/ui/diff/pierre.test.ts
+++ b/src/ui/diff/pierre.test.ts
@@ -110,7 +110,7 @@ function createMarkdownDiffFile(): DiffFile {
describe("Pierre diff rows", () => {
test("builds split rows with Pierre-highlighted emphasis spans", async () => {
const file = createDiffFile();
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const highlighted = await loadHighlightedDiff(file);
const rows = buildSplitRows(file, highlighted, theme);
@@ -149,7 +149,7 @@ describe("Pierre diff rows", () => {
test("keeps word-diff highlight backgrounds transparent in transparent mode", async () => {
const file = createDiffFile();
- const theme = withTransparentBackground(resolveTheme("midnight", null));
+ const theme = withTransparentBackground(resolveTheme("github-dark-default", null));
const highlighted = await loadHighlightedDiff(file);
const rows = buildSplitRows(file, highlighted, theme);
const changedRow = rows.find(
@@ -171,7 +171,7 @@ describe("Pierre diff rows", () => {
test("builds stacked rows with separate deletion and addition lines", () => {
const file = createDiffFile();
- const theme = resolveTheme("paper", null);
+ const theme = resolveTheme("github-light-default", null);
const rows = buildStackRows(file, null, theme);
const deletionRow = rows.find(
@@ -204,7 +204,7 @@ describe("Pierre diff rows", () => {
deletionLines: ["moved"],
additionLines: ["moved"],
};
- const theme = resolveTheme("graphite", null);
+ const theme = resolveTheme("github-dark-default", null);
const rows = buildStackRows(file, null, theme);
const movedDeletion = rows.find(
(row) => row.type === "stack-line" && row.cell.kind === "deletion",
@@ -236,7 +236,7 @@ describe("Pierre diff rows", () => {
test("renders planned split rows to copyable visible text", () => {
const file = createDiffFile();
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const rows = buildSplitRows(file, null, theme);
const plannedRows = buildReviewRenderPlan({
fileId: file.id,
@@ -290,7 +290,7 @@ describe("Pierre diff rows", () => {
metadata,
agent: null,
};
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const rows = buildSplitRows(file, null, theme);
const plannedRows = buildReviewRenderPlan({ fileId: file.id, rows, showHunkHeaders: true });
const changedRow = plannedRows.find(
@@ -328,7 +328,7 @@ describe("Pierre diff rows", () => {
test("renders planned stack rows with horizontal copy offset", () => {
const file = createDiffFile();
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const rows = buildStackRows(file, null, theme);
const plannedRows = buildReviewRenderPlan({
fileId: file.id,
@@ -363,7 +363,7 @@ describe("Pierre diff rows", () => {
test("renders planned rows as code-only copy text when decorations are disabled", () => {
const file = createDiffFile();
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const rows = buildSplitRows(file, null, theme);
const plannedRows = buildReviewRenderPlan({
fileId: file.id,
@@ -409,7 +409,7 @@ describe("Pierre diff rows", () => {
test("does not produce newline characters in spans for highlighted empty lines", async () => {
const file = createEmptyLineDiffFile();
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const highlighted = await loadHighlightedDiff(file);
for (const buildRows of [buildSplitRows, buildStackRows]) {
@@ -426,12 +426,12 @@ describe("Pierre diff rows", () => {
test("builds syntax spans for highlighted full-source lines", async () => {
const file = createDiffFile();
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const text = "export const hiddenMarker = true;\n";
const highlighted = await loadHighlightedSourceLines({
file,
text,
- appearance: theme.appearance,
+ theme,
});
const spans = spansForHighlightedSourceLine(
"export const hiddenMarker = true;",
@@ -449,8 +449,8 @@ describe("Pierre diff rows", () => {
const file = createMarkdownDiffFile();
for (const themeId of [
- "midnight",
- "paper",
+ "github-dark-default",
+ "github-light-default",
"catppuccin-latte",
"catppuccin-frappe",
"catppuccin-macchiato",
@@ -520,7 +520,7 @@ describe("Pierre diff rows", () => {
agent: null,
};
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
for (const buildRows of [buildSplitRows, buildStackRows]) {
const rows = buildRows(file, null, theme);
@@ -565,7 +565,7 @@ describe("Pierre diff rows", () => {
agent: null,
};
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const rows = buildSplitRows(file, null, theme);
const between = rows.find(
(row): row is Extract =>
@@ -582,9 +582,9 @@ describe("Pierre diff rows", () => {
const highlighted = await loadHighlightedDiff(file, "dark");
for (const themeId of [
- "graphite",
- "midnight",
- "ember",
+ "github-dark-default",
+ "github-dark-default",
+ "dracula",
"catppuccin-frappe",
"catppuccin-macchiato",
"catppuccin-mocha",
@@ -644,8 +644,15 @@ describe("Pierre diff rows", () => {
agent: null,
};
- for (const themeId of ["graphite", "paper"] as const) {
- const theme = resolveTheme(themeId, null);
+ for (const themeId of ["github-dark-default", "github-light-default"] as const) {
+ const theme = resolveTheme("custom", null, {
+ base: themeId,
+ syntax: {
+ keyword: "#112233",
+ function: "#223344",
+ string: "#334455",
+ },
+ });
const highlighted = await loadHighlightedDiff(file, theme.appearance);
const spans = buildStackRows(file, highlighted, theme)
.filter(
@@ -654,19 +661,50 @@ describe("Pierre diff rows", () => {
)
.flatMap((row) => row.cell.spans);
- expect(spans.find((span) => span.text.includes("function"))?.fg).toBe(
- theme.syntaxColors.keyword,
- );
- expect(spans.find((span) => span.text.includes("compute"))?.fg).toBe(
- theme.syntaxColors.function,
- );
- expect(spans.find((span) => span.text.trim() === "42")?.fg).toBe(theme.syntaxColors.number);
- expect(spans.find((span) => span.text.includes("greeting"))?.fg).toBe(
- theme.syntaxColors.default,
- );
- expect(spans.find((span) => span.text.includes('"hello"'))?.fg).toBe(
- theme.syntaxColors.string,
- );
+ expect(spans.find((span) => span.text.includes("function"))?.fg).toBe("#112233");
+ expect(spans.find((span) => span.text.includes("compute"))?.fg).toBe("#223344");
+ expect(spans.find((span) => span.text.includes('"hello"'))?.fg).toBe("#334455");
}
});
+
+ test("uses Shiki's bundled Catppuccin theme for Catppuccin syntax", async () => {
+ const metadata = parseDiffFromFile(
+ { name: "syntax.ts", contents: "const a = 1;\n", cacheKey: "catppuccin-before" },
+ {
+ name: "syntax.ts",
+ contents:
+ 'const a = 1;\nexport class Greeter {\n count = 42;\n greet(user: User) {\n return "hello" + user.name;\n }\n}\n',
+ cacheKey: "catppuccin-after",
+ },
+ { context: 3 },
+ true,
+ );
+ const file: DiffFile = {
+ id: "catppuccin-syntax",
+ path: "syntax.ts",
+ patch: "",
+ language: "typescript",
+ stats: { additions: 6, deletions: 0 },
+ metadata,
+ agent: null,
+ };
+ const theme = resolveTheme("catppuccin-mocha", null);
+ const highlighted = await loadHighlightedDiff(file, theme);
+ const spans = buildStackRows(file, highlighted, theme)
+ .filter(
+ (row): row is Extract =>
+ row.type === "stack-line" && row.cell.kind === "addition",
+ )
+ .flatMap((row) => row.cell.spans);
+
+ expect(theme.syntaxTheme).toBe("catppuccin-mocha");
+ expect(spans.find((span) => span.text.includes("class"))?.fg?.toLowerCase()).toBe("#cba6f7");
+ expect(spans.find((span) => span.text.includes("Greeter"))?.fg?.toLowerCase()).toBe("#f9e2af");
+ expect(spans.find((span) => span.text.includes("=") && span.fg)?.fg?.toLowerCase()).toBe(
+ "#94e2d5",
+ );
+ expect(spans.find((span) => span.text.includes("user") && span.fg)?.fg?.toLowerCase()).toBe(
+ "#eba0ac",
+ );
+ });
});
diff --git a/src/ui/diff/pierre.ts b/src/ui/diff/pierre.ts
index ea75ac86..41af3497 100644
--- a/src/ui/diff/pierre.ts
+++ b/src/ui/diff/pierre.ts
@@ -19,31 +19,34 @@ const PIERRE_THEME = {
dark: "pierre-dark",
} as const;
-/** Resolve the single Pierre theme name needed for the current appearance. */
+type HighlightThemeInput = AppTheme | AppTheme["appearance"];
+
+/** Resolve the default Pierre theme name needed for one light/dark appearance. */
function pierreThemeName(appearance: AppTheme["appearance"]) {
return PIERRE_THEME[appearance];
}
-const PIERRE_RENDER_OPTIONS_BY_APPEARANCE = {
- light: {
- theme: pierreThemeName("light"),
- useTokenTransformer: false,
- tokenizeMaxLineLength: 1_000,
- lineDiffType: "word-alt" as const,
- maxLineDiffLength: 10_000,
- },
- dark: {
- theme: pierreThemeName("dark"),
+/** Return the light/dark mode for a theme object or legacy appearance argument. */
+function highlightThemeAppearance(theme: HighlightThemeInput) {
+ return typeof theme === "string" ? theme : theme.appearance;
+}
+
+/** Resolve the Shiki/Pierre syntax theme that should color highlighted code. */
+function highlighterThemeName(theme: HighlightThemeInput) {
+ return typeof theme === "string"
+ ? pierreThemeName(theme)
+ : (theme.syntaxTheme ?? pierreThemeName(theme.appearance));
+}
+
+/** Build render options for the active syntax theme. */
+function pierreRenderOptions(theme: HighlightThemeInput) {
+ return {
+ theme: highlighterThemeName(theme),
useTokenTransformer: false,
tokenizeMaxLineLength: 1_000,
lineDiffType: "word-alt" as const,
maxLineDiffLength: 10_000,
- },
-} as const;
-
-/** Reuse the render options for one appearance so startup work avoids extra object churn. */
-function pierreRenderOptions(appearance: AppTheme["appearance"]) {
- return PIERRE_RENDER_OPTIONS_BY_APPEARANCE[appearance];
+ };
}
type HighlightOptions = ReturnType;
@@ -51,6 +54,35 @@ type HighlightOptions = ReturnType;
const highlighterOptionsByKey = new Map();
let queuedHighlightWork = Promise.resolve();
+/** Build a cache key for theme-dependent terminal colors, not just the stable UI theme id. */
+function themeRenderCacheKey(theme: AppTheme) {
+ return [
+ theme.id,
+ theme.syntaxTheme ?? "",
+ theme.appearance,
+ theme.background,
+ theme.panelAlt,
+ theme.contextBg,
+ theme.addedBg,
+ theme.removedBg,
+ theme.addedContentBg,
+ theme.removedContentBg,
+ theme.addedSignColor,
+ theme.removedSignColor,
+ theme.syntaxColors.default,
+ theme.syntaxColors.keyword,
+ theme.syntaxColors.string,
+ theme.syntaxColors.comment,
+ theme.syntaxColors.number,
+ theme.syntaxColors.function,
+ theme.syntaxColors.property,
+ theme.syntaxColors.type,
+ theme.syntaxColors.variable ?? "",
+ theme.syntaxColors.operator ?? "",
+ theme.syntaxColors.punctuation,
+ ].join(":");
+}
+
type HastNode = HastTextNode | HastElementNode;
interface HastTextNode {
@@ -193,6 +225,10 @@ const RESERVED_PIERRE_TOKEN_COLORS = {
"#ffca00": "default",
"#68cdf2": "number",
"#5ecc71": "string",
+ "#ffa359": "property",
+ "#a3a3a3": "variable",
+ "#08c0ef": "operator",
+ "#636363": "punctuation",
},
light: {
"#d52c36": "keyword",
@@ -207,6 +243,10 @@ const RESERVED_PIERRE_TOKEN_COLORS = {
"#d5a910": "default",
"#1ca1c7": "number",
"#199f43": "string",
+ "#d47628": "property",
+ "#a3a3a3": "variable",
+ "#08c0ef": "operator",
+ "#636363": "punctuation",
},
} as const;
// After style parsing, token colors still need one normalization step so syntax hues never
@@ -262,15 +302,7 @@ function resolveWordDiffHighlightBg(contentBg: string, lineBg: string, signColor
/** Resolve the inline word-diff background, strengthening theme colors that are too subtle to see. */
function wordDiffHighlightBg(kind: SplitLineCell["kind"], theme: AppTheme) {
- const cacheKey = [
- theme.id,
- theme.addedBg,
- theme.addedContentBg,
- theme.removedBg,
- theme.removedContentBg,
- theme.contextContentBg,
- theme.panelAlt,
- ].join(":");
+ const cacheKey = [themeRenderCacheKey(theme), theme.contextContentBg, theme.panelAlt].join(":");
let cached = wordDiffBackgroundCache.get(cacheKey);
if (!cached) {
const addition = resolveWordDiffHighlightBg(
@@ -302,10 +334,11 @@ function normalizeHighlightedColor(color: string | undefined, theme: AppTheme) {
return color;
}
- let cacheForTheme = normalizedColorCache.get(theme.id);
+ const themeKey = themeRenderCacheKey(theme);
+ let cacheForTheme = normalizedColorCache.get(themeKey);
if (!cacheForTheme) {
cacheForTheme = new Map();
- normalizedColorCache.set(theme.id, cacheForTheme);
+ normalizedColorCache.set(themeKey, cacheForTheme);
}
const cached = cacheForTheme.get(color);
@@ -318,7 +351,10 @@ function normalizeHighlightedColor(color: string | undefined, theme: AppTheme) {
RESERVED_PIERRE_TOKEN_COLORS[theme.appearance][
normalized as keyof (typeof RESERVED_PIERRE_TOKEN_COLORS)[typeof theme.appearance]
];
- const resolvedColor = reserved ? theme.syntaxColors[reserved] : color;
+ const resolvedColor = reserved
+ ? (theme.syntaxColors[reserved] ??
+ (reserved === "operator" ? theme.syntaxColors.punctuation : theme.syntaxColors.default))
+ : color;
cacheForTheme.set(color, resolvedColor);
return resolvedColor;
}
@@ -344,7 +380,7 @@ function flattenHighlightedLine(node: HastNode | undefined, theme: AppTheme, emp
return [];
}
- const cacheKey = `${theme.id}:${emphasisBg}`;
+ const cacheKey = `${themeRenderCacheKey(theme)}:${emphasisBg}`;
const cachedByTheme = flattenedHighlightedLineCache.get(node);
const cached = cachedByTheme?.get(cacheKey);
if (cached) {
@@ -535,17 +571,15 @@ export function trailingCollapsedLines(metadata: FileDiffMetadata) {
return Math.max(additionRemaining, 0);
}
-/** Prepare syntax highlighting for one language/appearance pair using Pierre's shared highlighter. */
-async function prepareHighlighter(
- language: string | undefined,
- appearance: AppTheme["appearance"],
-) {
+/** Prepare syntax highlighting for one language/theme pair using Pierre's shared highlighter. */
+async function prepareHighlighter(language: string | undefined, theme: HighlightThemeInput) {
const resolvedLanguage = language ?? "text";
- const cacheKey = `${appearance}:${resolvedLanguage}`;
+ const syntaxTheme = highlighterThemeName(theme);
+ const cacheKey = `${syntaxTheme}:${resolvedLanguage}`;
const options =
highlighterOptionsByKey.get(cacheKey) ??
getHighlighterOptions(resolvedLanguage, {
- theme: pierreThemeName(appearance),
+ theme: syntaxTheme,
});
if (!highlighterOptionsByKey.has(cacheKey)) {
@@ -644,15 +678,15 @@ function aliasHighlightedContextLines(file: DiffFile, highlighted: HighlightedDi
/** Highlight a diff file and return just the rendered line trees the UI needs. */
export async function loadHighlightedDiff(
file: DiffFile,
- appearance: AppTheme["appearance"] = "dark",
+ theme: HighlightThemeInput = "dark",
): Promise {
try {
- const highlighter = await prepareHighlighter(file.language, appearance);
+ const highlighter = await prepareHighlighter(file.language, theme);
return queueHighlightedWork(() => {
const highlighted = renderDiffWithHighlighter(
file.metadata,
highlighter,
- pierreRenderOptions(appearance),
+ pierreRenderOptions(theme),
);
return aliasHighlightedContextLines(file, {
deletionLines: highlighted.code.deletionLines as Array,
@@ -660,12 +694,13 @@ export async function loadHighlightedDiff(
});
});
} catch {
- const highlighter = await prepareHighlighter("text", appearance);
+ const fallbackTheme = highlightThemeAppearance(theme);
+ const highlighter = await prepareHighlighter("text", fallbackTheme);
return queueHighlightedWork(() => {
const highlighted = renderDiffWithHighlighter(
{ ...file.metadata, lang: "text" },
highlighter,
- pierreRenderOptions(appearance),
+ pierreRenderOptions(fallbackTheme),
);
return aliasHighlightedContextLines(file, {
deletionLines: highlighted.code.deletionLines as Array,
@@ -679,31 +714,32 @@ export async function loadHighlightedDiff(
export async function loadHighlightedSourceLines({
file,
text,
- appearance = "dark",
+ theme = "dark",
}: {
file: DiffFile;
text: string;
- appearance?: AppTheme["appearance"];
+ theme?: HighlightThemeInput;
}): Promise {
try {
- const highlighter = await prepareHighlighter(file.language, appearance);
+ const highlighter = await prepareHighlighter(file.language, theme);
return queueHighlightedWork(() => {
const highlighted = renderFileWithHighlighter(
sourceFileContents(file, text, file.language),
highlighter,
- pierreRenderOptions(appearance),
+ pierreRenderOptions(theme),
);
return {
lines: highlighted.code as Array,
};
});
} catch {
- const highlighter = await prepareHighlighter("text", appearance);
+ const fallbackTheme = highlightThemeAppearance(theme);
+ const highlighter = await prepareHighlighter("text", fallbackTheme);
return queueHighlightedWork(() => {
const highlighted = renderFileWithHighlighter(
sourceFileContents(file, text, "text"),
highlighter,
- pierreRenderOptions(appearance),
+ pierreRenderOptions(fallbackTheme),
);
return {
lines: highlighted.code as Array,
diff --git a/src/ui/diff/renderRows.tsx b/src/ui/diff/renderRows.tsx
index 2b59af89..60bba1cf 100644
--- a/src/ui/diff/renderRows.tsx
+++ b/src/ui/diff/renderRows.tsx
@@ -905,7 +905,7 @@ function renderSplitCell(
{renderInlineSpans(
cell.spans,
contentWidth,
- theme.text,
+ theme.syntaxColors.default,
palette.contentBg,
`${keyPrefix}:content`,
contentOffset,
@@ -970,7 +970,7 @@ function renderStackCell(
{renderInlineSpans(
cell.spans,
contentWidth,
- theme.text,
+ theme.syntaxColors.default,
palette.contentBg,
`${keyPrefix}:content`,
contentOffset,
@@ -1029,7 +1029,7 @@ function renderWrappedSplitCellLine(
{renderInlineSpans(
line.spans,
contentWidth,
- theme.text,
+ theme.syntaxColors.default,
resolvedPalette.contentBg,
`${keyPrefix}:content`,
0,
@@ -1087,7 +1087,7 @@ function renderWrappedStackCellLine(
{renderInlineSpans(
line.spans,
contentWidth,
- theme.text,
+ theme.syntaxColors.default,
resolvedPalette.contentBg,
`${keyPrefix}:content`,
0,
diff --git a/src/ui/diff/reviewRenderPlan.test.ts b/src/ui/diff/reviewRenderPlan.test.ts
index 625c3d90..04dbac02 100644
--- a/src/ui/diff/reviewRenderPlan.test.ts
+++ b/src/ui/diff/reviewRenderPlan.test.ts
@@ -70,7 +70,7 @@ function guidedSplitLineNumbers(plannedRows: PlannedReviewRow[], side: "old" | "
describe("review render plan", () => {
test("inserts an inline note before the anchor row and starts the guide after the anchor", () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const file = createDiffFile(
"alpha",
"alpha.ts",
@@ -116,7 +116,7 @@ describe("review render plan", () => {
});
test("anchors deletion-only notes to old-side rows without a dangling guide below the note", () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const file = createDiffFile(
"deleted",
"deleted.ts",
@@ -161,7 +161,7 @@ describe("review render plan", () => {
});
test("assigns hunk anchor ids from the first visible row for every hunk when hunk headers are hidden", () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const file = createDiffFile(
"beta",
"beta.ts",
@@ -217,7 +217,7 @@ describe("review render plan", () => {
});
test("anchors range-less notes to the first visible line row without guide rows", () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const file = createDiffFile(
"stack",
"stack.ts",
@@ -259,7 +259,7 @@ describe("review render plan", () => {
});
test("anchors notes on the matching hunk in multi-hunk diffs", () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const file = createDiffFile(
"multi",
"multi.ts",
@@ -322,7 +322,7 @@ describe("review render plan", () => {
});
test("renders every visible note at its own anchor row", () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const file = createDiffFile(
"counted",
"counted.ts",
diff --git a/src/ui/diff/useHighlightedDiff.ts b/src/ui/diff/useHighlightedDiff.ts
index d705b814..74ab5774 100644
--- a/src/ui/diff/useHighlightedDiff.ts
+++ b/src/ui/diff/useHighlightedDiff.ts
@@ -1,5 +1,6 @@
import { useLayoutEffect, useState } from "react";
import type { DiffFile } from "../../core/types";
+import type { AppTheme } from "../themes";
import { loadHighlightedDiff, type HighlightedDiffCode } from "./pierre";
/**
@@ -78,8 +79,8 @@ function patchFingerprint(file: DiffFile) {
/** Cache key that includes a content fingerprint so stale entries are never served
* after reload. Unchanged files keep their cache hit across reloads. */
-function buildCacheKey(appearance: string, file: DiffFile) {
- return `${appearance}:${file.id}:${patchFingerprint(file)}`;
+function buildCacheKey(theme: AppTheme, file: DiffFile) {
+ return `${theme.id}:${theme.syntaxTheme ?? theme.appearance}:${file.id}:${patchFingerprint(file)}`;
}
/** Only commit a highlight result if the promise is still the active one for that key.
@@ -102,8 +103,8 @@ function commitHighlightResult(
/** Start one shared highlight request unless the cache or an in-flight promise already has it. */
function ensureHighlightedDiffLoaded(
file: DiffFile,
- appearance: "light" | "dark",
- cacheKey = buildCacheKey(appearance, file),
+ theme: AppTheme,
+ cacheKey = buildCacheKey(theme, file),
) {
const cached = SHARED_HIGHLIGHTED_DIFF_CACHE.get(cacheKey);
if (cached) {
@@ -116,7 +117,7 @@ function ensureHighlightedDiffLoaded(
}
let pending: Promise;
- pending = loadHighlightedDiff(file, appearance)
+ pending = loadHighlightedDiff(file, theme)
.then((nextHighlighted) => {
commitHighlightResult(cacheKey, pending, nextHighlighted);
return nextHighlighted;
@@ -135,14 +136,8 @@ function ensureHighlightedDiffLoaded(
}
/** Queue syntax highlighting for one file without mounting its diff rows first. */
-export function prefetchHighlightedDiff({
- file,
- appearance,
-}: {
- file: DiffFile;
- appearance: "light" | "dark";
-}) {
- return ensureHighlightedDiffLoaded(file, appearance);
+export function prefetchHighlightedDiff({ file, theme }: { file: DiffFile; theme: AppTheme }) {
+ return ensureHighlightedDiffLoaded(file, theme);
}
/** Read the best already-available highlight result without starting async work during render. */
@@ -169,16 +164,16 @@ function resolveHighlightedSnapshot({
/** Resolve highlighted diff content with shared caching and background prefetch support. */
export function useHighlightedDiff({
file,
- appearance,
+ theme,
shouldLoadHighlight,
}: {
file: DiffFile | undefined;
- appearance: "light" | "dark";
+ theme: AppTheme;
shouldLoadHighlight?: boolean;
}) {
const [highlighted, setHighlighted] = useState(null);
const [highlightedCacheKey, setHighlightedCacheKey] = useState(null);
- const appearanceCacheKey = file ? buildCacheKey(appearance, file) : null;
+ const appearanceCacheKey = file ? buildCacheKey(theme, file) : null;
// Use a layout effect so a newly available cached result can replace the plain-text fallback
// before the next diff paint whenever possible. That reduces flash/stutter as files enter view.
@@ -207,7 +202,7 @@ export function useHighlightedDiff({
let cancelled = false;
setHighlighted(null);
- ensureHighlightedDiffLoaded(file, appearance, appearanceCacheKey).then((nextHighlighted) => {
+ ensureHighlightedDiffLoaded(file, theme, appearanceCacheKey).then((nextHighlighted) => {
if (cancelled) {
return;
}
@@ -219,7 +214,7 @@ export function useHighlightedDiff({
return () => {
cancelled = true;
};
- }, [appearance, appearanceCacheKey, file, highlightedCacheKey, shouldLoadHighlight]);
+ }, [appearanceCacheKey, file, highlightedCacheKey, shouldLoadHighlight]);
// Prefer cached highlights during render so revisiting a file can paint immediately.
return resolveHighlightedSnapshot({
diff --git a/src/ui/diff/useHighlightedSource.ts b/src/ui/diff/useHighlightedSource.ts
index 5cbd3325..94897449 100644
--- a/src/ui/diff/useHighlightedSource.ts
+++ b/src/ui/diff/useHighlightedSource.ts
@@ -1,5 +1,6 @@
import { useLayoutEffect, useMemo, useState } from "react";
import type { DiffFile } from "../../core/types";
+import type { AppTheme } from "../themes";
import { loadHighlightedSourceLines, type HighlightedSourceCode } from "./pierre";
interface HighlightedSourceState {
@@ -20,26 +21,26 @@ function sourceTextFingerprint(text: string) {
}
/** Cache key for full-source highlights used by expanded unchanged rows. */
-function buildSourceCacheKey(appearance: string, file: DiffFile, text: string) {
- return `${appearance}:${file.id}:${file.path}:${file.language ?? ""}:${sourceTextFingerprint(text)}`;
+function buildSourceCacheKey(theme: AppTheme, file: DiffFile, text: string) {
+ return `${theme.id}:${theme.syntaxTheme ?? theme.appearance}:${file.id}:${file.path}:${file.language ?? ""}:${sourceTextFingerprint(text)}`;
}
/** Resolve highlighted full-source content for expanded unchanged rows. */
export function useHighlightedSource({
file,
text,
- appearance,
+ theme,
shouldLoadHighlight,
}: {
file: DiffFile | undefined;
text: string | undefined;
- appearance: "light" | "dark";
+ theme: AppTheme;
shouldLoadHighlight?: boolean;
}) {
const [state, setState] = useState(null);
const cacheKey = useMemo(
- () => (file && text !== undefined ? buildSourceCacheKey(appearance, file, text) : null),
- [appearance, file, text],
+ () => (file && text !== undefined ? buildSourceCacheKey(theme, file, text) : null),
+ [file, text, theme],
);
useLayoutEffect(() => {
@@ -55,7 +56,7 @@ export function useHighlightedSource({
let cancelled = false;
setState(null);
- loadHighlightedSourceLines({ file, text, appearance })
+ loadHighlightedSourceLines({ file, text, theme })
.then((highlighted) => {
if (!cancelled) {
setState({ cacheKey, highlighted });
@@ -70,7 +71,7 @@ export function useHighlightedSource({
return () => {
cancelled = true;
};
- }, [appearance, cacheKey, file, shouldLoadHighlight, state?.cacheKey, text]);
+ }, [cacheKey, file, shouldLoadHighlight, state?.cacheKey, text]);
return state?.cacheKey === cacheKey ? state.highlighted : null;
}
diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts
index 9154b130..6c6465d5 100644
--- a/src/ui/hooks/useAppKeyboardShortcuts.ts
+++ b/src/ui/hooks/useAppKeyboardShortcuts.ts
@@ -48,15 +48,18 @@ export interface UseAppKeyboardShortcutsOptions {
canRefreshCurrentInput: boolean;
closeHelp: () => void;
closeMenu: () => void;
- cycleTheme: () => void;
+ acceptThemeSelector: () => void;
cancelDraftNote: () => void;
+ closeThemeSelector: () => void;
focusArea: FocusArea;
focusFilter: () => void;
moveToAnnotatedHunk: (delta: number) => void;
moveToFile: (delta: number) => void;
moveToHunk: (delta: number) => void;
moveMenuItem: (delta: number) => void;
+ moveThemeSelector: (delta: number) => void;
openMenu: (menuId: MenuId) => void;
+ openThemeSelector: () => void;
pagerMode: boolean;
requestQuit: () => void;
scrollCodeHorizontally: (delta: number) => void;
@@ -73,6 +76,7 @@ export interface UseAppKeyboardShortcutsOptions {
toggleHunkHeaders: () => void;
toggleLineNumbers: () => void;
toggleLineWrap: () => void;
+ themeSelectorOpen: boolean;
toggleSidebar: () => void;
triggerEditSelectedFile: () => void;
triggerRefreshCurrentInput: () => void;
@@ -85,15 +89,18 @@ export function useAppKeyboardShortcuts({
canRefreshCurrentInput,
closeHelp,
closeMenu,
- cycleTheme,
+ acceptThemeSelector,
cancelDraftNote,
+ closeThemeSelector,
focusArea,
focusFilter,
moveToAnnotatedHunk,
moveToFile,
moveToHunk,
moveMenuItem,
+ moveThemeSelector,
openMenu,
+ openThemeSelector,
pagerMode,
requestQuit,
scrollCodeHorizontally,
@@ -107,6 +114,7 @@ export function useAppKeyboardShortcuts({
toggleFocusArea,
toggleGapForSelectedHunk,
toggleHelp,
+ themeSelectorOpen,
toggleHunkHeaders,
triggerEditSelectedFile,
toggleLineNumbers,
@@ -118,11 +126,13 @@ export function useAppKeyboardShortcuts({
const focusAreaRef = useRef(focusArea);
const pagerModeRef = useRef(pagerMode);
const showHelpRef = useRef(showHelp);
+ const themeSelectorOpenRef = useRef(themeSelectorOpen);
activeMenuIdRef.current = activeMenuId;
focusAreaRef.current = focusArea;
pagerModeRef.current = pagerMode;
showHelpRef.current = showHelp;
+ themeSelectorOpenRef.current = themeSelectorOpen;
const resolveJumpShortcut = (key: KeyEvent): JumpShortcut | null => {
if (isUppercaseGKey(key)) {
@@ -250,6 +260,44 @@ export function useAppKeyboardShortcuts({
return true;
};
+ const handleThemeSelectorShortcut = (key: KeyEvent) => {
+ if (!themeSelectorOpenRef.current) {
+ return false;
+ }
+
+ if (isEscapeKey(key)) {
+ consumeKey(key);
+ closeThemeSelector();
+ return true;
+ }
+
+ if (key.name === "up") {
+ consumeKey(key);
+ moveThemeSelector(-1);
+ return true;
+ }
+
+ if (key.name === "down") {
+ consumeKey(key);
+ moveThemeSelector(1);
+ return true;
+ }
+
+ if (key.name === "tab") {
+ consumeKey(key);
+ moveThemeSelector(key.shift ? -1 : 1);
+ return true;
+ }
+
+ if (key.name === "return" || key.name === "enter") {
+ consumeKey(key);
+ acceptThemeSelector();
+ return true;
+ }
+
+ return true;
+ };
+
const handleMenuShortcut = (key: KeyEvent) => {
if (!activeMenuIdRef.current) {
return false;
@@ -438,7 +486,7 @@ export function useAppKeyboardShortcuts({
}
if (key.name === "t") {
- runAndCloseMenu(cycleTheme);
+ runAndCloseMenu(openThemeSelector);
return;
}
@@ -516,6 +564,10 @@ export function useAppKeyboardShortcuts({
return;
}
+ if (handleThemeSelectorShortcut(key)) {
+ return;
+ }
+
if (handleMenuShortcut(key)) {
return;
}
diff --git a/src/ui/lib/appMenus.ts b/src/ui/lib/appMenus.ts
index cf44d1d1..00cc1f28 100644
--- a/src/ui/lib/appMenus.ts
+++ b/src/ui/lib/appMenus.ts
@@ -1,10 +1,7 @@
import type { LayoutMode } from "../../core/types";
import type { MenuEntry, MenuId } from "../components/chrome/menu";
-import type { AppTheme } from "../themes";
export interface BuildAppMenusOptions {
- activeThemeId: string;
- availableThemes: AppTheme[];
canRefreshCurrentInput: boolean;
focusFilter: () => void;
layoutMode: LayoutMode;
@@ -14,7 +11,7 @@ export interface BuildAppMenusOptions {
refreshCurrentInput: () => void;
requestQuit: () => void;
selectLayoutMode: (mode: LayoutMode) => void;
- selectThemeId: (themeId: string) => void;
+ openThemeSelector: () => void;
copyDecorations: boolean;
showAgentNotes: boolean;
showHelp: boolean;
@@ -35,8 +32,6 @@ export interface BuildAppMenusOptions {
/** Build the top-level app menus from the current app state and actions. */
export function buildAppMenus({
- activeThemeId,
- availableThemes,
canRefreshCurrentInput,
focusFilter,
layoutMode,
@@ -46,7 +41,7 @@ export function buildAppMenus({
refreshCurrentInput,
requestQuit,
selectLayoutMode,
- selectThemeId,
+ openThemeSelector,
copyDecorations,
showAgentNotes,
showHelp,
@@ -64,13 +59,6 @@ export function buildAppMenus({
triggerEditSelectedFile,
wrapLines,
}: BuildAppMenusOptions): Record {
- const themeMenuEntries: MenuEntry[] = availableThemes.map((theme) => ({
- kind: "item",
- label: theme.label,
- checked: theme.id === activeThemeId,
- action: () => selectThemeId(theme.id),
- }));
-
const fileMenuEntries: MenuEntry[] = [
{
kind: "item",
@@ -144,6 +132,13 @@ export function buildAppMenus({
action: toggleSidebar,
},
{ kind: "separator" },
+ {
+ kind: "item",
+ label: "Themes…",
+ hint: "t",
+ action: openThemeSelector,
+ },
+ { kind: "separator" },
{
kind: "item",
label: "Agent notes",
@@ -213,7 +208,6 @@ export function buildAppMenus({
action: focusFilter,
},
],
- theme: themeMenuEntries,
agent: [
{
kind: "item",
diff --git a/src/ui/lib/color.ts b/src/ui/lib/color.ts
index d6683224..18915796 100644
--- a/src/ui/lib/color.ts
+++ b/src/ui/lib/color.ts
@@ -32,6 +32,31 @@ export function blendHex(fg: string, bg: string, ratio: number) {
.padStart(6, "0")}`;
}
+/** Convert one sRGB channel into linear-light space for WCAG contrast math. */
+function linearizedChannel(channel: number) {
+ const value = channel / 255;
+ return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
+}
+
+/** Return the WCAG relative luminance for a #rrggbb color. */
+export function relativeLuminance(hex: string) {
+ const color = hexToRgb(hex);
+ return (
+ 0.2126 * linearizedChannel(color.r) +
+ 0.7152 * linearizedChannel(color.g) +
+ 0.0722 * linearizedChannel(color.b)
+ );
+}
+
+/** Return the WCAG contrast ratio between two #rrggbb colors. */
+export function contrastRatio(foreground: string, background: string) {
+ const foregroundLuminance = relativeLuminance(foreground);
+ const backgroundLuminance = relativeLuminance(background);
+ const lighter = Math.max(foregroundLuminance, backgroundLuminance);
+ const darker = Math.min(foregroundLuminance, backgroundLuminance);
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
/** Measure how visually separated two #rrggbb colors are using channel deltas. */
export function hexColorDistance(left: string, right: string) {
const a = hexToRgb(left);
diff --git a/src/ui/lib/keyboard.ts b/src/ui/lib/keyboard.ts
index 0a60d124..d16c396b 100644
--- a/src/ui/lib/keyboard.ts
+++ b/src/ui/lib/keyboard.ts
@@ -9,7 +9,13 @@ function isSpaceKey(key: KeyEvent) {
/** Normalize the escape key aliases emitted by different terminal input paths. */
export function isEscapeKey(key: KeyEvent) {
- return key.name === "escape" || key.name === "esc";
+ return (
+ key.name === "escape" ||
+ key.name === "esc" ||
+ key.name === "Escape" ||
+ key.sequence === "\u001b" ||
+ key.raw === "\u001b"
+ );
}
/** Match Ctrl-S across raw, Kitty/CSI-u, and tmux control-mode encodings. */
diff --git a/src/ui/lib/shikiThemes.ts b/src/ui/lib/shikiThemes.ts
new file mode 100644
index 00000000..99aa4490
--- /dev/null
+++ b/src/ui/lib/shikiThemes.ts
@@ -0,0 +1,306 @@
+export const BUNDLED_SHIKI_THEME_IDS = [
+ "andromeeda",
+ "aurora-x",
+ "ayu-dark",
+ "ayu-light",
+ "ayu-mirage",
+ "catppuccin-frappe",
+ "catppuccin-latte",
+ "catppuccin-macchiato",
+ "catppuccin-mocha",
+ "dark-plus",
+ "dracula",
+ "dracula-soft",
+ "everforest-dark",
+ "everforest-light",
+ "github-dark",
+ "github-dark-default",
+ "github-dark-dimmed",
+ "github-dark-high-contrast",
+ "github-light",
+ "github-light-default",
+ "github-light-high-contrast",
+ "gruvbox-dark-hard",
+ "gruvbox-dark-medium",
+ "gruvbox-dark-soft",
+ "gruvbox-light-hard",
+ "gruvbox-light-medium",
+ "gruvbox-light-soft",
+ "horizon",
+ "horizon-bright",
+ "houston",
+ "kanagawa-dragon",
+ "kanagawa-lotus",
+ "kanagawa-wave",
+ "laserwave",
+ "light-plus",
+ "material-theme",
+ "material-theme-darker",
+ "material-theme-lighter",
+ "material-theme-ocean",
+ "material-theme-palenight",
+ "min-dark",
+ "min-light",
+ "monokai",
+ "night-owl",
+ "night-owl-light",
+ "nord",
+ "one-dark-pro",
+ "one-light",
+ "plastic",
+ "poimandres",
+ "red",
+ "rose-pine",
+ "rose-pine-dawn",
+ "rose-pine-moon",
+ "slack-dark",
+ "slack-ochin",
+ "snazzy-light",
+ "solarized-dark",
+ "solarized-light",
+ "synthwave-84",
+ "tokyo-night",
+ "vesper",
+ "vitesse-black",
+ "vitesse-dark",
+ "vitesse-light",
+] as const;
+
+export type BundledShikiThemeId = (typeof BUNDLED_SHIKI_THEME_IDS)[number];
+
+export const LEGACY_THEME_ID_ALIASES = {
+ graphite: "github-dark-default",
+ midnight: "github-dark-dimmed",
+ paper: "github-light-default",
+ ember: "dark-plus",
+ zenburn: "everforest-dark",
+} as const satisfies Record;
+
+/** Map removed pre-refactor theme ids to their closest built-in replacements. */
+export function resolveLegacyThemeId(themeId: string | undefined) {
+ return themeId
+ ? (LEGACY_THEME_ID_ALIASES[themeId as keyof typeof LEGACY_THEME_ID_ALIASES] ?? themeId)
+ : undefined;
+}
+
+export interface BundledShikiThemeDiffColors {
+ added?: string;
+ removed?: string;
+ modified?: string;
+}
+
+export const BUNDLED_SHIKI_THEME_BACKGROUNDS: Record = {
+ andromeeda: "#23262e",
+ "aurora-x": "#07090f",
+ "ayu-dark": "#10141c",
+ "ayu-light": "#fcfcfc",
+ "ayu-mirage": "#242936",
+ "catppuccin-frappe": "#303446",
+ "catppuccin-latte": "#eff1f5",
+ "catppuccin-macchiato": "#24273a",
+ "catppuccin-mocha": "#1e1e2e",
+ "dark-plus": "#1e1e1e",
+ dracula: "#282a36",
+ "dracula-soft": "#282a36",
+ "everforest-dark": "#2d353b",
+ "everforest-light": "#fdf6e3",
+ "github-dark": "#24292e",
+ "github-dark-default": "#0d1117",
+ "github-dark-dimmed": "#22272e",
+ "github-dark-high-contrast": "#0a0c10",
+ "github-light": "#ffffff",
+ "github-light-default": "#ffffff",
+ "github-light-high-contrast": "#ffffff",
+ "gruvbox-dark-hard": "#1d2021",
+ "gruvbox-dark-medium": "#282828",
+ "gruvbox-dark-soft": "#32302f",
+ "gruvbox-light-hard": "#f9f5d7",
+ "gruvbox-light-medium": "#fbf1c7",
+ "gruvbox-light-soft": "#f2e5bc",
+ horizon: "#1c1e26",
+ "horizon-bright": "#fdf0ed",
+ houston: "#17191e",
+ "kanagawa-dragon": "#181616",
+ "kanagawa-lotus": "#f2ecbc",
+ "kanagawa-wave": "#1f1f28",
+ laserwave: "#27212e",
+ "light-plus": "#ffffff",
+ "material-theme": "#263238",
+ "material-theme-darker": "#212121",
+ "material-theme-lighter": "#fafafa",
+ "material-theme-ocean": "#0f111a",
+ "material-theme-palenight": "#292d3e",
+ "min-dark": "#1f1f1f",
+ "min-light": "#ffffff",
+ monokai: "#272822",
+ "night-owl": "#011627",
+ "night-owl-light": "#fbfbfb",
+ nord: "#2e3440",
+ "one-dark-pro": "#282c34",
+ "one-light": "#fafafa",
+ plastic: "#21252b",
+ poimandres: "#1b1e28",
+ red: "#390000",
+ "rose-pine": "#191724",
+ "rose-pine-dawn": "#faf4ed",
+ "rose-pine-moon": "#232136",
+ "slack-dark": "#222222",
+ "slack-ochin": "#ffffff",
+ "snazzy-light": "#fafbfc",
+ "solarized-dark": "#002b36",
+ "solarized-light": "#fdf6e3",
+ "synthwave-84": "#262335",
+ "tokyo-night": "#1a1b26",
+ vesper: "#101010",
+ "vitesse-black": "#000000",
+ "vitesse-dark": "#121212",
+ "vitesse-light": "#ffffff",
+};
+
+export const BUNDLED_SHIKI_THEME_FOREGROUNDS: Partial> = {
+ andromeeda: "#d5ced9",
+ "ayu-dark": "#bfbdb6",
+ "ayu-light": "#5c6166",
+ "ayu-mirage": "#cccac2",
+ "catppuccin-frappe": "#c6d0f5",
+ "catppuccin-latte": "#4c4f69",
+ "catppuccin-macchiato": "#cad3f5",
+ "catppuccin-mocha": "#cdd6f4",
+ "dark-plus": "#d4d4d4",
+ dracula: "#f8f8f2",
+ "dracula-soft": "#f6f6f4",
+ "everforest-dark": "#d3c6aa",
+ "everforest-light": "#5c6a72",
+ "github-dark": "#e1e4e8",
+ "github-dark-default": "#e6edf3",
+ "github-dark-dimmed": "#adbac7",
+ "github-dark-high-contrast": "#f0f3f6",
+ "github-light": "#24292e",
+ "github-light-default": "#1f2328",
+ "github-light-high-contrast": "#0e1116",
+ "gruvbox-dark-hard": "#ebdbb2",
+ "gruvbox-dark-medium": "#ebdbb2",
+ "gruvbox-dark-soft": "#ebdbb2",
+ "gruvbox-light-hard": "#3c3836",
+ "gruvbox-light-medium": "#3c3836",
+ "gruvbox-light-soft": "#3c3836",
+ houston: "#eef0f9",
+ "kanagawa-dragon": "#c5c9c5",
+ "kanagawa-lotus": "#545464",
+ "kanagawa-wave": "#dcd7ba",
+ laserwave: "#ffffff",
+ "light-plus": "#000000",
+ "material-theme": "#eeffff",
+ "material-theme-darker": "#eeffff",
+ "material-theme-lighter": "#90a4ae",
+ "material-theme-ocean": "#babed8",
+ "material-theme-palenight": "#babed8",
+ "min-light": "#212121",
+ monokai: "#f8f8f2",
+ "night-owl": "#d6deeb",
+ "night-owl-light": "#403f53",
+ nord: "#d8dee9",
+ "one-dark-pro": "#abb2bf",
+ "one-light": "#383a42",
+ plastic: "#a9b2c3",
+ poimandres: "#a6accd",
+ red: "#f8f8f8",
+ "rose-pine": "#e0def4",
+ "rose-pine-dawn": "#575279",
+ "rose-pine-moon": "#e0def4",
+ "slack-dark": "#e6e6e6",
+ "slack-ochin": "#000000",
+ "snazzy-light": "#565869",
+ "solarized-dark": "#839496",
+ "solarized-light": "#657b83",
+ "tokyo-night": "#a9b1d6",
+ vesper: "#ffffff",
+ "vitesse-black": "#dbd7ca",
+ "vitesse-dark": "#dbd7ca",
+ "vitesse-light": "#393a34",
+};
+
+export const BUNDLED_SHIKI_THEME_DIFF_COLORS: Partial<
+ Record
+> = {
+ andromeeda: { added: "#96e072", removed: "#ee5d43", modified: "#7cb7ff" },
+ "aurora-x": { added: "#63d188", removed: "#dd5074", modified: "#c778db" },
+ "ayu-dark": { added: "#70bf56", removed: "#f26d78", modified: "#73b8ff" },
+ "ayu-light": { added: "#6cbf43", removed: "#ff7383", modified: "#478acc" },
+ "ayu-mirage": { added: "#87d96c", removed: "#f27983", modified: "#80bfff" },
+ "catppuccin-frappe": { added: "#a6d189", removed: "#e78284", modified: "#e5c890" },
+ "catppuccin-latte": { added: "#40a02b", removed: "#d20f39", modified: "#df8e1d" },
+ "catppuccin-macchiato": { added: "#a6da95", removed: "#ed8796", modified: "#eed49f" },
+ "catppuccin-mocha": { added: "#a6e3a1", removed: "#f38ba8", modified: "#f9e2af" },
+ dracula: { added: "#50fa7b", removed: "#ff5555", modified: "#8be9fd" },
+ "dracula-soft": { added: "#62e884", removed: "#ee6666", modified: "#97e1f1" },
+ "everforest-dark": { added: "#7a8c66", removed: "#a16366", modified: "#608986" },
+ "everforest-light": { added: "#b7c155", removed: "#fa9188", modified: "#83b9d0" },
+ "github-dark": { added: "#34d058", removed: "#ea4a5a", modified: "#79b8ff" },
+ "github-dark-default": { added: "#3fb950", removed: "#f85149", modified: "#d29922" },
+ "github-dark-dimmed": { added: "#57ab5a", removed: "#e5534b", modified: "#c69026" },
+ "github-dark-high-contrast": { added: "#26cd4d", removed: "#ff6a69", modified: "#f0b72f" },
+ "github-light": { added: "#28a745", removed: "#d73a49", modified: "#005cc5" },
+ "github-light-default": { added: "#1a7f37", removed: "#cf222e", modified: "#9a6700" },
+ "github-light-high-contrast": { added: "#055d20", removed: "#a0111f", modified: "#744500" },
+ "gruvbox-dark-hard": { added: "#ebdbb2", removed: "#cc241d", modified: "#d79921" },
+ "gruvbox-dark-medium": { added: "#ebdbb2", removed: "#cc241d", modified: "#d79921" },
+ "gruvbox-dark-soft": { added: "#ebdbb2", removed: "#cc241d", modified: "#d79921" },
+ "gruvbox-light-hard": { added: "#3c3836", removed: "#cc241d", modified: "#d79921" },
+ "gruvbox-light-medium": { added: "#3c3836", removed: "#cc241d", modified: "#d79921" },
+ "gruvbox-light-soft": { added: "#3c3836", removed: "#cc241d", modified: "#d79921" },
+ horizon: { added: "#24a075", removed: "#f43e5c", modified: "#fab38e" },
+ "horizon-bright": { added: "#60c9a0", removed: "#f43e5c", modified: "#af5427" },
+ houston: { added: "#4bf3c8", removed: "#f4587e", modified: "#ffd493" },
+ "kanagawa-dragon": { added: "#8a9a7b", removed: "#c4746e", modified: "#8ba4b0" },
+ "kanagawa-lotus": { added: "#6f894e", removed: "#c84053", modified: "#4d699b" },
+ "kanagawa-wave": { added: "#76946a", removed: "#c34043", modified: "#7e9cd8" },
+ laserwave: { added: "#74dfc4", removed: "#b381c5", modified: "#74dfc4" },
+ "material-theme": { added: "#c3e88d", removed: "#98565c", modified: "#5a76a8" },
+ "material-theme-darker": { added: "#c3e88d", removed: "#964e52", modified: "#586e9e" },
+ "material-theme-lighter": { added: "#91b859", removed: "#ee8d8b", modified: "#a4b6d5" },
+ "material-theme-ocean": { added: "#c3e88d", removed: "#8e474f", modified: "#50679b" },
+ "material-theme-palenight": { added: "#c3e88d", removed: "#99535f", modified: "#5b74ab" },
+ "min-light": { added: "#77cc00", removed: "#d32f2f", modified: "#e0e0e0" },
+ monokai: { added: "#86b42b", removed: "#c4265e", modified: "#6a7ec8" },
+ "night-owl": { added: "#22da6e", removed: "#87383e", modified: "#a2bffc" },
+ "night-owl-light": { added: "#08916a", removed: "#de3d3b", modified: "#288ed7" },
+ nord: { added: "#a3be8c", removed: "#bf616a", modified: "#ebcb8b" },
+ "one-dark-pro": { added: "#8cc265", removed: "#e05561", modified: "#4aa5f0" },
+ plastic: { added: "#98c379", removed: "#e06c75", modified: "#d19a66" },
+ poimandres: { added: "#5fb3a1", removed: "#d0679d", modified: "#add7ff" },
+ "rose-pine": { added: "#9ccfd8", removed: "#908caa", modified: "#ebbcba" },
+ "rose-pine-dawn": { added: "#56949f", removed: "#797593", modified: "#d7827e" },
+ "rose-pine-moon": { added: "#9ccfd8", removed: "#908caa", modified: "#ea9a97" },
+ "slack-dark": { added: "#ecb22e", removed: "#ffffff", modified: "#ecb22e" },
+ "slack-ochin": { added: "#ecb22e", removed: "#ffffff", modified: "#ecb22e" },
+ "snazzy-light": { added: "#2dae58", removed: "#ff5c57", modified: "#00a39f" },
+ "solarized-dark": { added: "#859900", removed: "#dc322f", modified: "#268bd2" },
+ "solarized-light": { added: "#859900", removed: "#dc322f", modified: "#268bd2" },
+ "synthwave-84": { added: "#63c89e", removed: "#fe4450", modified: "#ae8cc4" },
+ "tokyo-night": { added: "#449dab", removed: "#914c54", modified: "#6183bb" },
+ "vitesse-black": { added: "#4d9375", removed: "#cb7676", modified: "#6394bf" },
+ "vitesse-dark": { added: "#4d9375", removed: "#cb7676", modified: "#6394bf" },
+ "vitesse-light": { added: "#1e754f", removed: "#ab5959", modified: "#296aa3" },
+};
+
+/** Return the editor surface declared by a bundled Shiki theme, when Hunk knows it. */
+export function getBundledShikiThemeBackground(themeId: string | undefined) {
+ return themeId && themeId in BUNDLED_SHIKI_THEME_BACKGROUNDS
+ ? BUNDLED_SHIKI_THEME_BACKGROUNDS[themeId as BundledShikiThemeId]
+ : undefined;
+}
+
+/** Return the editor foreground declared by a bundled Shiki theme, when Hunk knows it. */
+export function getBundledShikiThemeForeground(themeId: string | undefined) {
+ return themeId && themeId in BUNDLED_SHIKI_THEME_FOREGROUNDS
+ ? BUNDLED_SHIKI_THEME_FOREGROUNDS[themeId as BundledShikiThemeId]
+ : undefined;
+}
+
+/** Return semantic diff colors declared by a bundled Shiki theme, when Hunk knows them. */
+export function getBundledShikiThemeDiffColors(themeId: string | undefined) {
+ return themeId && themeId in BUNDLED_SHIKI_THEME_DIFF_COLORS
+ ? BUNDLED_SHIKI_THEME_DIFF_COLORS[themeId as BundledShikiThemeId]
+ : undefined;
+}
diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts
index e2a6a202..bce1ee76 100644
--- a/src/ui/lib/ui-lib.test.ts
+++ b/src/ui/lib/ui-lib.test.ts
@@ -30,7 +30,7 @@ import {
measureDiffSectionGeometry,
} from "../diff/diffSectionGeometry";
import { resizeSidebarWidth } from "./sidebar";
-import { availableThemes, resolveTheme } from "../themes";
+import { resolveTheme } from "../themes";
function lines(...values: string[]) {
return `${values.join("\n")}\n`;
@@ -81,21 +81,13 @@ describe("ui helpers", () => {
test("buildMenuSpecs lays out the fixed top-level order", () => {
const specs = buildMenuSpecs();
- expect(specs.map((spec) => spec.id)).toEqual([
- "file",
- "view",
- "navigate",
- "theme",
- "agent",
- "help",
- ]);
+ expect(specs.map((spec) => spec.id)).toEqual(["file", "view", "navigate", "agent", "help"]);
expect(specs).toMatchObject([
{ id: "file", left: 1, width: 6, label: "File" },
{ id: "view", left: 7, width: 6, label: "View" },
{ id: "navigate", left: 13, width: 10, label: "Navigate" },
- { id: "theme", left: 23, width: 7, label: "Theme" },
- { id: "agent", left: 30, width: 7, label: "Agent" },
- { id: "help", left: 37, width: 6, label: "Help" },
+ { id: "agent", left: 23, width: 7, label: "Agent" },
+ { id: "help", left: 30, width: 6, label: "Help" },
]);
});
@@ -138,8 +130,6 @@ describe("ui helpers", () => {
test("buildAppMenus creates checked entries from the current app state", () => {
const menus = buildAppMenus({
- activeThemeId: "graphite",
- availableThemes: availableThemes(),
canRefreshCurrentInput: true,
focusFilter: () => {},
layoutMode: "stack",
@@ -149,7 +139,7 @@ describe("ui helpers", () => {
refreshCurrentInput: () => {},
requestQuit: () => {},
selectLayoutMode: () => {},
- selectThemeId: () => {},
+ openThemeSelector: () => {},
copyDecorations: true,
showAgentNotes: true,
showHelp: false,
@@ -193,83 +183,10 @@ describe("ui helpers", () => {
.map((entry) => entry.label),
).toEqual(["Stacked view", "Agent notes", "Line numbers", "Line wrapping", "Copy decorations"]);
expect(
- menus.theme
- .filter((entry): entry is Extract => entry.kind === "item")
- .map((entry) => entry.label),
- ).toEqual([
- "Graphite",
- "Midnight",
- "Paper",
- "Ember",
- "Catppuccin Latte",
- "Catppuccin Frappé",
- "Catppuccin Macchiato",
- "Catppuccin Mocha",
- "Zenburn",
- ]);
- expect(
- menus.theme.some(
- (entry) => entry.kind === "item" && entry.label === "Graphite" && entry.checked,
- ),
- ).toBe(true);
- });
-
- test("buildAppMenus includes a config-defined custom theme when available", () => {
- const menus = buildAppMenus({
- activeThemeId: "custom",
- availableThemes: availableThemes({
- base: "midnight",
- label: "My Theme",
- }),
- canRefreshCurrentInput: false,
- focusFilter: () => {},
- layoutMode: "split",
- moveToAnnotatedFile: () => {},
- moveToAnnotatedHunk: () => {},
- moveToHunk: () => {},
- refreshCurrentInput: () => {},
- requestQuit: () => {},
- selectLayoutMode: () => {},
- selectThemeId: () => {},
- copyDecorations: false,
- showAgentNotes: false,
- showHelp: false,
- showHunkHeaders: true,
- showLineNumbers: true,
- renderSidebar: true,
- toggleCopyDecorations: () => {},
- toggleAgentNotes: () => {},
- toggleFocusArea: () => {},
- toggleHelp: () => {},
- toggleHunkHeaders: () => {},
- toggleLineNumbers: () => {},
- toggleLineWrap: () => {},
- toggleSidebar: () => {},
- triggerEditSelectedFile: () => {},
- wrapLines: false,
- });
-
- expect(
- menus.theme
+ menus.view
.filter((entry): entry is Extract => entry.kind === "item")
.map((entry) => entry.label),
- ).toEqual([
- "Graphite",
- "Midnight",
- "Paper",
- "Ember",
- "Catppuccin Latte",
- "Catppuccin Frappé",
- "Catppuccin Macchiato",
- "Catppuccin Mocha",
- "Zenburn",
- "My Theme",
- ]);
- expect(
- menus.theme.some(
- (entry) => entry.kind === "item" && entry.label === "My Theme" && entry.checked,
- ),
- ).toBe(true);
+ ).toContain("Themes…");
});
test("keyboard alias helpers normalize the shared scroll shortcut keys", () => {
@@ -409,7 +326,7 @@ describe("ui helpers", () => {
test("estimateDiffSectionBodyRows matches split and stack row counts from the render plan", async () => {
const file = createDiffFile();
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
expect(estimateDiffSectionBodyRows(file, "split", true, theme)).toBeGreaterThan(0);
expect(estimateDiffSectionBodyRows(file, "stack", true, theme)).toBeGreaterThan(
@@ -451,7 +368,7 @@ describe("ui helpers", () => {
"const line12 = 12;",
),
);
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const metrics = measureDiffSectionGeometry(file, "split", false, theme);
expect(metrics.bodyHeight).toBeGreaterThan(0);
@@ -466,7 +383,7 @@ describe("ui helpers", () => {
test("measureDiffSectionGeometry includes visible inline note rows in split mode", () => {
const file = createDiffFile();
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
const baseGeometry = measureDiffSectionGeometry(file, "split", true, theme);
const noteGeometry = measureDiffSectionGeometry(
file,
@@ -509,14 +426,14 @@ describe("ui helpers", () => {
).toBe(16);
});
- test("resolveTheme falls back by requested id to graphite while lazily exposing syntax styles", () => {
- const midnight = resolveTheme("midnight", null);
+ test("resolveTheme falls back to GitHub defaults while lazily exposing syntax styles", () => {
+ const dracula = resolveTheme("dracula", null);
const missingLight = resolveTheme("missing", "light");
const missingDark = resolveTheme("missing", "dark");
const autoLight = resolveTheme("auto", "light");
const autoDark = resolveTheme("auto", "dark");
const custom = resolveTheme("custom", null, {
- base: "paper",
+ base: "github-light-default",
label: "My Theme",
accent: "#7755aa",
syntax: {
@@ -525,18 +442,18 @@ describe("ui helpers", () => {
});
const missingCustom = resolveTheme("custom", null);
- expect(midnight.id).toBe("midnight");
- expect(missingLight.id).toBe("graphite");
- expect(missingDark.id).toBe("graphite");
- expect(autoLight.id).toBe("paper");
- expect(autoDark.id).toBe("graphite");
+ expect(dracula.id).toBe("dracula");
+ expect(missingLight.id).toBe("github-light-default");
+ expect(missingDark.id).toBe("github-dark-default");
+ expect(autoLight.id).toBe("github-light-default");
+ expect(autoDark.id).toBe("github-dark-default");
expect(custom.id).toBe("custom");
expect(custom.label).toBe("My Theme");
expect(custom.appearance).toBe("light");
expect(custom.accent).toBe("#7755aa");
expect(custom.syntaxColors.keyword).toBe("#123456");
- expect(missingCustom.id).toBe("graphite");
- expect(resolveTheme("ember", null).syntaxStyle).toBeDefined();
+ expect(missingCustom.id).toBe("github-dark-default");
+ expect(resolveTheme("github-dark-default", null).syntaxStyle).toBeDefined();
expect(custom.syntaxStyle).toBeDefined();
expect(resolveTheme("catppuccin-latte", null).syntaxStyle).toBeDefined();
expect(resolveTheme("catppuccin-frappe", null).syntaxStyle).toBeDefined();
diff --git a/src/ui/lib/viewportAnchor.test.ts b/src/ui/lib/viewportAnchor.test.ts
index f2c0ad22..0bd6ef4f 100644
--- a/src/ui/lib/viewportAnchor.test.ts
+++ b/src/ui/lib/viewportAnchor.test.ts
@@ -6,7 +6,7 @@ import { findViewportRowAnchor, resolveViewportRowAnchorTop } from "./viewportAn
import { createTestDiffFile, lines } from "../../../test/helpers/diff-helpers";
describe("viewport row anchors", () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
function createChangedFile() {
return createTestDiffFile({
diff --git a/src/ui/lib/viewportSelection.test.ts b/src/ui/lib/viewportSelection.test.ts
index 90d3bce7..c5456fff 100644
--- a/src/ui/lib/viewportSelection.test.ts
+++ b/src/ui/lib/viewportSelection.test.ts
@@ -31,7 +31,7 @@ function scrollTopForCenter(centerOffset: number, viewportHeight: number) {
}
describe("findViewportCenteredHunkTarget", () => {
- const theme = resolveTheme("midnight", null);
+ const theme = resolveTheme("github-dark-default", null);
test("switches the active file when the viewport center enters a later file", () => {
const firstFile = createTestDiffFile({
diff --git a/src/ui/staticDiffPager.test.ts b/src/ui/staticDiffPager.test.ts
index bce4fc03..99a69b0e 100644
--- a/src/ui/staticDiffPager.test.ts
+++ b/src/ui/staticDiffPager.test.ts
@@ -102,7 +102,7 @@ describe("static diff pager", () => {
const output = await renderStaticDiffPager(
patchText,
{ theme: "custom" },
- { customTheme: { base: "graphite", text: "#123456" } },
+ { customTheme: { base: "github-dark-default", text: "#123456" } },
);
expect(stripAnsi(output)).toContain("a.ts modified +1 -1");
@@ -122,8 +122,8 @@ describe("static diff pager", () => {
expect(lineWith("@@ -1,3 +1,3 @@")).not.toContain("\x1b[48;2;");
expect(lineWith("const a = 1;")).not.toContain("\x1b[48;2;");
expect(lineWith("const z = 3;")).not.toContain("\x1b[48;2;");
- expect(lineWith("const value = 1;")).toContain("\x1b[48;2;55;37;38m");
- expect(lineWith("const value = 2;")).toContain("\x1b[48;2;31;48;37m");
+ expect(lineWith("const value = 1;")).toContain("\x1b[48;2;");
+ expect(lineWith("const value = 2;")).toContain("\x1b[48;2;");
});
test("shows semantic file metadata without raw patch headers", async () => {
diff --git a/src/ui/staticDiffPager.ts b/src/ui/staticDiffPager.ts
index 5242c1bc..5aece264 100644
--- a/src/ui/staticDiffPager.ts
+++ b/src/ui/staticDiffPager.ts
@@ -313,7 +313,7 @@ async function renderStaticFile(
width: number,
) {
const highlighted =
- file.isBinary || file.isTooLarge ? null : await loadHighlightedDiff(file, theme.appearance);
+ file.isBinary || file.isTooLarge ? null : await loadHighlightedDiff(file, theme);
const layout = resolveStaticLayout(options);
const rows =
layout === "split"
diff --git a/src/ui/themes.test.ts b/src/ui/themes.test.ts
index 0472b89f..2d72a32d 100644
--- a/src/ui/themes.test.ts
+++ b/src/ui/themes.test.ts
@@ -1,73 +1,215 @@
import { describe, expect, test } from "bun:test";
-import { blendHex, hexColorDistance } from "./lib/color";
+import { blendHex, contrastRatio, hexColorDistance } from "./lib/color";
+import { BUNDLED_SHIKI_THEME_IDS } from "./lib/shikiThemes";
import {
- CATPPUCCIN_PALETTES,
+ availableThemeIds,
+ availableThemes,
+ DEFAULT_DARK_THEME_ID,
+ DEFAULT_LIGHT_THEME_ID,
resolveTheme,
TRANSPARENT_BACKGROUND,
withTransparentBackground,
withTransparentSurfaces,
} from "./themes";
+const MIN_READABLE_TEXT_CONTRAST = 4.5;
+const SYNTAX_ROLES = [
+ "default",
+ "keyword",
+ "string",
+ "comment",
+ "number",
+ "function",
+ "property",
+ "type",
+ "variable",
+ "operator",
+ "punctuation",
+] as const;
+
+/** Return a compact failure list for semantic theme foreground/background pairs. */
+function themeContrastFailures(
+ pairs: Array<{ label: string; foreground: string; background: string; minimum?: number }>,
+) {
+ return pairs.flatMap(
+ ({ label, foreground, background, minimum = MIN_READABLE_TEXT_CONTRAST }) => {
+ const ratio = contrastRatio(foreground, background);
+ return ratio + 0.005 < minimum
+ ? [`${label}: ${ratio.toFixed(2)} (${foreground} on ${background})`]
+ : [];
+ },
+ );
+}
+
describe("themes", () => {
- test("resolves all Catppuccin flavors by theme id", () => {
- const latte = resolveTheme("catppuccin-latte", null);
- const frappe = resolveTheme("catppuccin-frappe", null);
- const macchiato = resolveTheme("catppuccin-macchiato", null);
- const mocha = resolveTheme("catppuccin-mocha", null);
-
- expect(latte.id).toBe("catppuccin-latte");
- expect(latte.label).toBe("Catppuccin Latte");
- expect(latte.appearance).toBe("light");
- expect(frappe.id).toBe("catppuccin-frappe");
- expect(frappe.label).toBe("Catppuccin Frappé");
- expect(frappe.appearance).toBe("dark");
- expect(macchiato.id).toBe("catppuccin-macchiato");
- expect(macchiato.label).toBe("Catppuccin Macchiato");
- expect(macchiato.appearance).toBe("dark");
- expect(mocha.id).toBe("catppuccin-mocha");
- expect(mocha.label).toBe("Catppuccin Mocha");
- expect(mocha.appearance).toBe("dark");
+ test("defaults to GitHub's dark theme and auto chooses GitHub light/dark", () => {
+ expect(resolveTheme(undefined, null).id).toBe(DEFAULT_DARK_THEME_ID);
+ expect(resolveTheme("missing", null).id).toBe(DEFAULT_DARK_THEME_ID);
+ expect(resolveTheme("auto", "dark").id).toBe(DEFAULT_DARK_THEME_ID);
+ expect(resolveTheme("auto", "light").id).toBe(DEFAULT_LIGHT_THEME_ID);
});
- test("keeps official Catppuccin sentinel colors in source", () => {
- expect(CATPPUCCIN_PALETTES.latte.base).toBe("#eff1f5");
- expect(CATPPUCCIN_PALETTES.latte.mauve).toBe("#8839ef");
- expect(CATPPUCCIN_PALETTES.latte.green).toBe("#40a02b");
- expect(CATPPUCCIN_PALETTES.latte.red).toBe("#d20f39");
- expect(CATPPUCCIN_PALETTES.frappe.base).toBe("#303446");
- expect(CATPPUCCIN_PALETTES.frappe.mauve).toBe("#ca9ee6");
- expect(CATPPUCCIN_PALETTES.frappe.green).toBe("#a6d189");
- expect(CATPPUCCIN_PALETTES.frappe.red).toBe("#e78284");
- expect(CATPPUCCIN_PALETTES.macchiato.base).toBe("#24273a");
- expect(CATPPUCCIN_PALETTES.macchiato.mauve).toBe("#c6a0f6");
- expect(CATPPUCCIN_PALETTES.macchiato.green).toBe("#a6da95");
- expect(CATPPUCCIN_PALETTES.macchiato.red).toBe("#ed8796");
- expect(CATPPUCCIN_PALETTES.mocha.base).toBe("#1e1e2e");
- expect(CATPPUCCIN_PALETTES.mocha.mauve).toBe("#cba6f7");
- expect(CATPPUCCIN_PALETTES.mocha.green).toBe("#a6e3a1");
- expect(CATPPUCCIN_PALETTES.mocha.red).toBe("#f38ba8");
+ test("maps removed theme ids to compatible built-in themes", () => {
+ expect(resolveTheme("graphite", null).id).toBe("github-dark-default");
+ expect(resolveTheme("paper", null).id).toBe("github-light-default");
+ expect(resolveTheme("midnight", null).id).toBe("github-dark-dimmed");
+ expect(resolveTheme("ember", null).id).toBe("dark-plus");
+ expect(resolveTheme("zenburn", null).id).toBe("everforest-dark");
});
- test("derives Catppuccin diff backgrounds from official semantic tokens", () => {
- const latte = resolveTheme("catppuccin-latte", null);
- const mocha = resolveTheme("catppuccin-mocha", null);
-
- expect(latte.addedBg).toBe(blendHex(CATPPUCCIN_PALETTES.latte.green, latte.contextBg, 0.15));
- expect(latte.removedBg).toBe(blendHex(CATPPUCCIN_PALETTES.latte.red, latte.contextBg, 0.15));
- expect(latte.addedContentBg).toBe(
- blendHex(CATPPUCCIN_PALETTES.latte.green, latte.contextBg, 0.25),
- );
- expect(latte.removedContentBg).toBe(
- blendHex(CATPPUCCIN_PALETTES.latte.red, latte.contextBg, 0.25),
- );
- expect(mocha.addedBg).toBe(blendHex(CATPPUCCIN_PALETTES.mocha.green, mocha.contextBg, 0.15));
- expect(mocha.removedBg).toBe(blendHex(CATPPUCCIN_PALETTES.mocha.red, mocha.contextBg, 0.15));
- expect(mocha.addedContentBg).toBe(
- blendHex(CATPPUCCIN_PALETTES.mocha.green, mocha.contextBg, 0.25),
- );
- expect(mocha.removedContentBg).toBe(
- blendHex(CATPPUCCIN_PALETTES.mocha.red, mocha.contextBg, 0.25),
- );
+ test("exposes every bundled theme as a selectable theme", () => {
+ expect(availableThemeIds()).toEqual([...BUNDLED_SHIKI_THEME_IDS]);
+ expect(availableThemes().map((theme) => theme.id)).toEqual([...BUNDLED_SHIKI_THEME_IDS]);
+
+ for (const themeId of BUNDLED_SHIKI_THEME_IDS) {
+ const theme = resolveTheme(themeId, null);
+ expect(theme.id).toBe(themeId);
+ expect(theme.label).toBe(themeId);
+ expect(theme.syntaxTheme).toBe(themeId);
+ expect(theme.syntaxStyle).toBeDefined();
+ }
+ });
+
+ test("derives GitHub default surfaces from bundled theme metadata", () => {
+ const dark = resolveTheme("github-dark-default", null);
+ const light = resolveTheme("github-light-default", null);
+
+ expect(dark.background).toBe("#0d1117");
+ expect(dark.syntaxColors.default).toBe("#e6edf3");
+ expect(dark.addedSignColor).toBe("#3fb950");
+ expect(dark.removedSignColor).toBe("#f85149");
+ expect(dark.addedBg).toBe(blendHex("#3fb950", "#0d1117", 0.2));
+ expect(dark.removedBg).toBe(blendHex("#f85149", "#0d1117", 0.2));
+
+ expect(light.background).toBe("#ffffff");
+ expect(light.syntaxColors.default).toBe("#1f2328");
+ expect(light.addedSignColor).toBe("#1a7f37");
+ expect(light.removedSignColor).toBe("#cf222e");
+ expect(light.addedBg).toBe(blendHex("#1a7f37", "#ffffff", 0.12));
+ expect(light.removedBg).toBe(blendHex("#cf222e", "#ffffff", 0.12));
+ });
+
+ test("contrast keeps every bundled theme diff row text and gutters readable", () => {
+ const failures = BUNDLED_SHIKI_THEME_IDS.flatMap((themeId) => {
+ const theme = resolveTheme(themeId, null);
+ return [
+ ...themeContrastFailures([
+ {
+ label: `${theme.id} text/contextBg`,
+ foreground: theme.text,
+ background: theme.contextBg,
+ },
+ { label: `${theme.id} text/addedBg`, foreground: theme.text, background: theme.addedBg },
+ {
+ label: `${theme.id} text/removedBg`,
+ foreground: theme.text,
+ background: theme.removedBg,
+ },
+ {
+ label: `${theme.id} text/contextContentBg`,
+ foreground: theme.text,
+ background: theme.contextContentBg,
+ },
+ {
+ label: `${theme.id} text/addedContentBg`,
+ foreground: theme.text,
+ background: theme.addedContentBg,
+ },
+ {
+ label: `${theme.id} text/removedContentBg`,
+ foreground: theme.text,
+ background: theme.removedContentBg,
+ },
+ {
+ label: `${theme.id} addedSignColor/addedBg`,
+ foreground: theme.addedSignColor,
+ background: theme.addedBg,
+ minimum: 2.4,
+ },
+ {
+ label: `${theme.id} removedSignColor/removedBg`,
+ foreground: theme.removedSignColor,
+ background: theme.removedBg,
+ minimum: 2.4,
+ },
+ {
+ label: `${theme.id} lineNumberFg/lineNumberBg`,
+ foreground: theme.lineNumberFg,
+ background: theme.lineNumberBg,
+ },
+ ]),
+ ...(theme.addedBg === theme.contextBg ? [`${theme.id} added bg matches context`] : []),
+ ...(theme.removedBg === theme.contextBg ? [`${theme.id} removed bg matches context`] : []),
+ ];
+ });
+
+ expect(failures).toEqual([]);
+ });
+
+ test("contrast keeps fallback syntax colors readable on every bundled theme", () => {
+ const failures = BUNDLED_SHIKI_THEME_IDS.flatMap((themeId) => {
+ const theme = resolveTheme(themeId, null);
+ return themeContrastFailures(
+ SYNTAX_ROLES.flatMap((role) => [
+ {
+ label: `${theme.id} syntax.${role}/contextBg`,
+ foreground: theme.syntaxColors[role] ?? theme.syntaxColors.default,
+ background: theme.contextBg,
+ },
+ {
+ label: `${theme.id} syntax.${role}/addedBg`,
+ foreground: theme.syntaxColors[role] ?? theme.syntaxColors.default,
+ background: theme.addedBg,
+ },
+ {
+ label: `${theme.id} syntax.${role}/removedBg`,
+ foreground: theme.syntaxColors[role] ?? theme.syntaxColors.default,
+ background: theme.removedBg,
+ },
+ ]),
+ );
+ });
+
+ expect(failures).toEqual([]);
+ });
+
+ test("contrast keeps every bundled theme chrome colors readable", () => {
+ const failures = BUNDLED_SHIKI_THEME_IDS.flatMap((themeId) => {
+ const theme = resolveTheme(themeId, null);
+ const sidebarForegrounds = [
+ ["badgeAdded", theme.badgeAdded],
+ ["badgeRemoved", theme.badgeRemoved],
+ ["badgeNeutral", theme.badgeNeutral],
+ ["fileNew", theme.fileNew],
+ ["fileDeleted", theme.fileDeleted],
+ ["fileRenamed", theme.fileRenamed],
+ ["fileModified", theme.fileModified],
+ ["fileUntracked", theme.fileUntracked],
+ ] as const;
+ const sidebarPairs = sidebarForegrounds.flatMap(([field, foreground]) => [
+ { label: `${theme.id} ${field}/panel`, foreground, background: theme.panel },
+ { label: `${theme.id} ${field}/panelAlt`, foreground, background: theme.panelAlt },
+ ]);
+
+ return themeContrastFailures([
+ { label: `${theme.id} text/panel`, foreground: theme.text, background: theme.panel },
+ { label: `${theme.id} text/panelAlt`, foreground: theme.text, background: theme.panelAlt },
+ { label: `${theme.id} muted/panel`, foreground: theme.muted, background: theme.panel },
+ {
+ label: `${theme.id} muted/panelAlt`,
+ foreground: theme.muted,
+ background: theme.panelAlt,
+ },
+ {
+ label: `${theme.id} active menu text/accentMuted`,
+ foreground: theme.text,
+ background: theme.accentMuted,
+ },
+ ...sidebarPairs,
+ ]);
+ });
+
+ expect(failures).toEqual([]);
});
test("keeps Catppuccin add and remove rows semantically distinct", () => {
@@ -89,51 +231,24 @@ describe("themes", () => {
}
});
- test("maps Catppuccin syntax roles to documented editor tokens", () => {
- const latte = resolveTheme("catppuccin-latte", null);
- const mocha = resolveTheme("catppuccin-mocha", null);
-
- expect(latte.syntaxColors).toMatchObject({
- keyword: CATPPUCCIN_PALETTES.latte.mauve,
- string: CATPPUCCIN_PALETTES.latte.green,
- comment: CATPPUCCIN_PALETTES.latte.overlay2,
- number: CATPPUCCIN_PALETTES.latte.peach,
- function: CATPPUCCIN_PALETTES.latte.blue,
- property: CATPPUCCIN_PALETTES.latte.blue,
- type: CATPPUCCIN_PALETTES.latte.yellow,
- punctuation: CATPPUCCIN_PALETTES.latte.overlay2,
+ test("layers custom theme overrides on a bundled base", () => {
+ const custom = resolveTheme("custom", null, {
+ base: "catppuccin-mocha",
+ label: "My Theme",
+ text: "#ffffff",
+ syntax: { keyword: "#ff00ff" },
});
- expect(mocha.syntaxColors).toMatchObject({
- keyword: CATPPUCCIN_PALETTES.mocha.mauve,
- string: CATPPUCCIN_PALETTES.mocha.green,
- comment: CATPPUCCIN_PALETTES.mocha.overlay2,
- number: CATPPUCCIN_PALETTES.mocha.peach,
- function: CATPPUCCIN_PALETTES.mocha.blue,
- property: CATPPUCCIN_PALETTES.mocha.blue,
- type: CATPPUCCIN_PALETTES.mocha.yellow,
- punctuation: CATPPUCCIN_PALETTES.mocha.overlay2,
- });
- });
- test("resolves Zenburn by theme id with its tuned dark palette", () => {
- const zenburn = resolveTheme("zenburn", null);
-
- expect(zenburn.id).toBe("zenburn");
- expect(zenburn.label).toBe("Zenburn");
- expect(zenburn.appearance).toBe("dark");
- expect(zenburn.background).toBe("#3f3f3f");
- expect(zenburn.text).toBe("#dcdccc");
- expect(zenburn.syntaxColors).toMatchObject({
- keyword: "#f0dfaf",
- string: "#dca3a3",
- comment: "#60b48a",
- function: "#94bff3",
- type: "#94bff3",
- });
+ expect(custom.id).toBe("custom");
+ expect(custom.label).toBe("My Theme");
+ expect(custom.background).toBe(resolveTheme("catppuccin-mocha", null).background);
+ expect(custom.text).toBe("#ffffff");
+ expect(custom.syntaxTheme).toBeUndefined();
+ expect(custom.syntaxColors.keyword).toBe("#ff00ff");
});
test("withTransparentBackground only swaps painted background fields", () => {
- const theme = resolveTheme("graphite", null);
+ const theme = resolveTheme("github-dark-default", null);
const transparent = withTransparentBackground(theme);
expect(transparent).toMatchObject({
@@ -162,7 +277,7 @@ describe("themes", () => {
});
test("withTransparentSurfaces keeps added/removed row tints", () => {
- const theme = resolveTheme("graphite", null);
+ const theme = resolveTheme("github-dark-default", null);
const transparent = withTransparentSurfaces(theme);
expect(transparent).toMatchObject({
diff --git a/src/ui/themes.ts b/src/ui/themes.ts
index 975b7b6b..78a91864 100644
--- a/src/ui/themes.ts
+++ b/src/ui/themes.ts
@@ -1,47 +1,256 @@
import type { ThemeMode } from "@opentui/core";
import type { CustomThemeConfig } from "../core/types";
+import { blendHex, contrastRatio, relativeLuminance } from "./lib/color";
import {
- CATPPUCCIN_FRAPPE_THEME,
- CATPPUCCIN_LATTE_THEME,
- CATPPUCCIN_MACCHIATO_THEME,
- CATPPUCCIN_MOCHA_THEME,
-} from "./themes/catppuccin";
-import { EMBER_THEME } from "./themes/ember";
-import { GRAPHITE_THEME } from "./themes/graphite";
-import { MIDNIGHT_THEME } from "./themes/midnight";
-import { PAPER_THEME } from "./themes/paper";
+ BUNDLED_SHIKI_THEME_IDS,
+ resolveLegacyThemeId,
+ getBundledShikiThemeBackground,
+ getBundledShikiThemeDiffColors,
+ getBundledShikiThemeForeground,
+ type BundledShikiThemeDiffColors,
+ type BundledShikiThemeId,
+} from "./lib/shikiThemes";
import { withLazySyntaxStyle } from "./themes/syntax";
-import type { AppTheme, ThemeBase } from "./themes/types";
-import { ZENBURN_THEME } from "./themes/zenburn";
+import type { AppTheme, SyntaxColors, ThemeBase } from "./themes/types";
-export { CATPPUCCIN_PALETTES } from "./themes/catppuccin";
export type { AppTheme, SyntaxColors, ThemeBase } from "./themes/types";
export const TRANSPARENT_BACKGROUND = "transparent";
+export const DEFAULT_DARK_THEME_ID = "github-dark-default";
+export const DEFAULT_LIGHT_THEME_ID = "github-light-default";
-export const THEMES: AppTheme[] = [
- GRAPHITE_THEME,
- MIDNIGHT_THEME,
- PAPER_THEME,
- EMBER_THEME,
- CATPPUCCIN_LATTE_THEME,
- CATPPUCCIN_FRAPPE_THEME,
- CATPPUCCIN_MACCHIATO_THEME,
- CATPPUCCIN_MOCHA_THEME,
- ZENBURN_THEME,
-];
+const MIN_GUTTER_CONTRAST = 4.5;
+const MIN_DIFF_SIGN_CONTRAST = 3;
+
+const FALLBACK_DIFF_COLORS = {
+ dark: { added: "#5ecc71", removed: "#ff6762", modified: "#69b1ff" },
+ light: { added: "#0dbe4e", removed: "#ff2e3f", modified: "#009fff" },
+} as const;
+
+/** Return a high-contrast foreground layered over an arbitrary editor surface. */
+function readableForeground(preferred: string | undefined, background: string) {
+ if (preferred && contrastRatio(preferred, background) >= MIN_GUTTER_CONTRAST) {
+ return preferred;
+ }
+
+ return relativeLuminance(background) > 0.45 ? "#000000" : "#ffffff";
+}
+
+/** Return a readable dim foreground for gutters layered over an arbitrary editor surface. */
+function readableDimForeground(preferred: string, background: string) {
+ if (contrastRatio(preferred, background) >= MIN_GUTTER_CONTRAST) {
+ return preferred;
+ }
+
+ return relativeLuminance(background) > 0.45
+ ? blendHex("#000000", background, 0.62)
+ : blendHex("#ffffff", background, 0.62);
+}
+
+/** Return a semantic diff marker color that remains legible on a theme editor surface. */
+function readableDiffSign(preferred: string, background: string) {
+ if (contrastRatio(preferred, background) >= MIN_DIFF_SIGN_CONTRAST) {
+ return preferred;
+ }
+
+ return relativeLuminance(background) > 0.45
+ ? blendHex("#000000", preferred, 0.45)
+ : blendHex("#ffffff", preferred, 0.45);
+}
+
+/** Build Hunk's fallback semantic syntax palette for non-Shiki custom highlighting. */
+function buildSyntaxColors(codeForeground: string): SyntaxColors {
+ return {
+ default: codeForeground,
+ keyword: codeForeground,
+ string: codeForeground,
+ comment: codeForeground,
+ number: codeForeground,
+ function: codeForeground,
+ property: codeForeground,
+ type: codeForeground,
+ variable: codeForeground,
+ operator: codeForeground,
+ punctuation: codeForeground,
+ };
+}
+
+/** Return the strongest tinted background that keeps foreground text readable. */
+function readableTintedBackground(
+ tintColor: string,
+ background: string,
+ foreground: string,
+ preferredAmount: number,
+) {
+ for (let amount = preferredAmount; amount >= 0.02; amount -= 0.02) {
+ const candidate = blendHex(tintColor, background, amount);
+ if (contrastRatio(foreground, candidate) >= MIN_GUTTER_CONTRAST) {
+ return candidate;
+ }
+ }
+
+ return background;
+}
+
+/** Keep semantic status colors readable on sidebar and menu surfaces. */
+function readableChromeColor(preferred: string, panel: string, panelAlt: string) {
+ if (
+ contrastRatio(preferred, panel) >= MIN_GUTTER_CONTRAST &&
+ contrastRatio(preferred, panelAlt) >= MIN_GUTTER_CONTRAST
+ ) {
+ return preferred;
+ }
+
+ const lightPanel = relativeLuminance(panelAlt) > 0.45;
+ const anchor = lightPanel ? "#000000" : "#ffffff";
+ for (const amount of [0.35, 0.5, 0.65, 0.8, 1]) {
+ const candidate = blendHex(anchor, preferred, amount);
+ if (
+ contrastRatio(candidate, panel) >= MIN_GUTTER_CONTRAST &&
+ contrastRatio(candidate, panelAlt) >= MIN_GUTTER_CONTRAST
+ ) {
+ return candidate;
+ }
+ }
+
+ return anchor;
+}
+
+/** Derive one complete Hunk theme from one bundled Shiki editor theme. */
+function buildShikiTheme(themeId: BundledShikiThemeId): AppTheme {
+ const editorBackground = getBundledShikiThemeBackground(themeId) ?? "#0d1117";
+ const editorForeground = getBundledShikiThemeForeground(themeId);
+ const diffColors = getBundledShikiThemeDiffColors(themeId);
+ const isLightSurface = relativeLuminance(editorBackground) > 0.45;
+ const fallbackDiffColors = FALLBACK_DIFF_COLORS[isLightSurface ? "light" : "dark"];
+ const rowTint = isLightSurface ? 0.12 : 0.2;
+ const contentTint = isLightSurface ? 0.18 : 0.28;
+ const selectedTint = isLightSurface ? 0.18 : 0.25;
+ const codeForeground = readableForeground(editorForeground, editorBackground);
+ const neutralPanel = blendHex(codeForeground, editorBackground, isLightSurface ? 0.04 : 0.08);
+ const neutralPanelAlt = blendHex(codeForeground, editorBackground, isLightSurface ? 0.08 : 0.12);
+ const neutralBorder = blendHex(codeForeground, editorBackground, isLightSurface ? 0.15 : 0.18);
+ const textForeground = readableForeground(editorForeground ?? codeForeground, neutralPanelAlt);
+ const lineNumberForeground = readableDimForeground(
+ blendHex(textForeground, editorBackground, 0.56),
+ editorBackground,
+ );
+ const mutedForeground = readableDimForeground(
+ blendHex(textForeground, editorBackground, 0.56),
+ neutralPanelAlt,
+ );
+ const addedSignColor = readableDiffSign(
+ diffColors?.added ?? fallbackDiffColors.added,
+ editorBackground,
+ );
+ const removedSignColor = readableDiffSign(
+ diffColors?.removed ?? fallbackDiffColors.removed,
+ editorBackground,
+ );
+ const modifiedColor = readableDiffSign(
+ diffColors?.modified ?? fallbackDiffColors.modified,
+ editorBackground,
+ );
+ const addedBg = readableTintedBackground(
+ addedSignColor,
+ editorBackground,
+ textForeground,
+ rowTint,
+ );
+ const removedBg = readableTintedBackground(
+ removedSignColor,
+ editorBackground,
+ textForeground,
+ rowTint,
+ );
+ const movedBg = readableTintedBackground(
+ modifiedColor,
+ editorBackground,
+ textForeground,
+ rowTint,
+ );
+ const addedContentBg = readableTintedBackground(
+ addedSignColor,
+ editorBackground,
+ textForeground,
+ contentTint,
+ );
+ const removedContentBg = readableTintedBackground(
+ removedSignColor,
+ editorBackground,
+ textForeground,
+ contentTint,
+ );
+ const accentMuted = readableTintedBackground(
+ modifiedColor,
+ editorBackground,
+ textForeground,
+ selectedTint,
+ );
+ const syntaxColors = buildSyntaxColors(textForeground);
+ const badgeAdded = readableChromeColor(addedSignColor, neutralPanel, neutralPanelAlt);
+ const badgeRemoved = readableChromeColor(removedSignColor, neutralPanel, neutralPanelAlt);
+ const badgeModified = readableChromeColor(modifiedColor, neutralPanel, neutralPanelAlt);
+ const themeBase: ThemeBase = {
+ id: themeId,
+ label: themeId,
+ appearance: isLightSurface ? "light" : "dark",
+ background: editorBackground,
+ panel: neutralPanel,
+ panelAlt: neutralPanelAlt,
+ border: neutralBorder,
+ accent: modifiedColor,
+ accentMuted,
+ text: textForeground,
+ muted: mutedForeground,
+ contextBg: editorBackground,
+ contextContentBg: editorBackground,
+ addedBg,
+ removedBg,
+ movedAddedBg: movedBg,
+ movedRemovedBg: movedBg,
+ addedContentBg,
+ removedContentBg,
+ addedSignColor,
+ removedSignColor,
+ lineNumberBg: editorBackground,
+ lineNumberFg: lineNumberForeground,
+ selectedHunk: blendHex(modifiedColor, editorBackground, selectedTint),
+ noteBackground: neutralPanel,
+ noteBorder: modifiedColor,
+ noteTitleBackground: neutralPanel,
+ noteTitleText: textForeground,
+ badgeAdded,
+ badgeRemoved,
+ badgeNeutral: mutedForeground,
+ fileNew: badgeAdded,
+ fileDeleted: badgeRemoved,
+ fileRenamed: badgeModified,
+ fileModified: badgeModified,
+ fileUntracked: badgeAdded,
+ syntaxTheme: themeId,
+ };
+
+ return withLazySyntaxStyle(themeBase, syntaxColors);
+}
+
+export const THEMES: AppTheme[] = BUNDLED_SHIKI_THEME_IDS.map((themeId) =>
+ buildShikiTheme(themeId),
+);
/** Return the built-in theme by id so config-defined themes can inherit from it. */
function builtInThemeById(themeId: string | undefined) {
- return THEMES.find((theme) => theme.id === themeId);
+ const resolvedThemeId = resolveLegacyThemeId(themeId);
+ return THEMES.find((theme) => theme.id === resolvedThemeId);
}
/** Return the explicit built-in fallback theme used across startup and missing ids. */
-function fallbackTheme() {
- return builtInThemeById("graphite") ?? THEMES[0]!;
+function fallbackTheme(themeMode?: ThemeMode | null) {
+ const fallbackId = themeMode === "light" ? DEFAULT_LIGHT_THEME_ID : DEFAULT_DARK_THEME_ID;
+ return builtInThemeById(fallbackId) ?? THEMES[0]!;
}
-/** Build one config-defined custom theme by inheriting from a built-in base palette. */
+/** Build one config-defined custom theme by inheriting from a Shiki-backed base palette. */
function buildCustomTheme(customTheme: CustomThemeConfig) {
const baseTheme = builtInThemeById(customTheme.base) ?? fallbackTheme();
const themeBase: ThemeBase = {
@@ -81,6 +290,9 @@ function buildCustomTheme(customTheme: CustomThemeConfig) {
noteBackground: customTheme.noteBackground ?? baseTheme.noteBackground,
noteTitleBackground: customTheme.noteTitleBackground ?? baseTheme.noteTitleBackground,
noteTitleText: customTheme.noteTitleText ?? baseTheme.noteTitleText,
+ // Explicit syntax color overrides should use Hunk's semantic remap path rather than the
+ // inherited Shiki theme, otherwise the overrides would never affect highlighted code.
+ syntaxTheme: customTheme.syntax ? undefined : baseTheme.syntaxTheme,
};
return withLazySyntaxStyle(themeBase, {
@@ -98,30 +310,46 @@ export function availableThemeIds(customTheme?: CustomThemeConfig): string[] {
return themeIds;
}
-/** Return the menu/cycle themes, adding the config-defined custom theme only when available. */
+/** Return selectable themes, adding the config-defined custom theme only when available. */
export function availableThemes(customTheme?: CustomThemeConfig): AppTheme[] {
return customTheme ? [...THEMES, buildCustomTheme(customTheme)] : THEMES;
}
-/** Resolve a named theme, including explicit terminal-background auto mode and custom themes, or fall back to Hunk's explicit built-in default. */
+/** Resolve a named theme, including terminal-background auto mode and custom themes. */
export function resolveTheme(
requested: string | undefined,
themeMode: ThemeMode | null,
customTheme?: CustomThemeConfig,
) {
if (requested === "auto") {
- const preferred = themeMode === "light" ? "paper" : "graphite";
- return THEMES.find((theme) => theme.id === preferred) ?? THEMES[0]!;
- } else if (requested === "custom" && customTheme) {
+ return fallbackTheme(themeMode);
+ }
+
+ if (requested === "custom" && customTheme) {
return buildCustomTheme(customTheme);
}
- const exact = THEMES.find((theme) => theme.id === requested);
+ const exact = builtInThemeById(requested);
if (exact) {
return exact;
}
- return fallbackTheme();
+ return fallbackTheme(themeMode);
+}
+
+/** Return whether a custom theme base id can inherit from a built-in theme. */
+export function isBuiltInThemeId(themeId: string) {
+ return builtInThemeById(themeId) !== undefined;
+}
+
+/** Return the canonical built-in theme id, preserving legacy config compatibility. */
+export function normalizeBuiltInThemeId(themeId: string) {
+ return isBuiltInThemeId(themeId) ? resolveLegacyThemeId(themeId) : undefined;
+}
+
+/** Return known semantic diff colors for a bundled Shiki-backed theme. */
+export function bundledThemeDiffColors(themeId: string): BundledShikiThemeDiffColors | undefined {
+ return getBundledShikiThemeDiffColors(themeId);
}
/** Return a copy of a theme whose painted surfaces allow the terminal background through. */
diff --git a/src/ui/themes/catppuccin.ts b/src/ui/themes/catppuccin.ts
deleted file mode 100644
index d04e1e61..00000000
--- a/src/ui/themes/catppuccin.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-import { blendHex } from "../lib/color";
-import { withLazySyntaxStyle } from "./syntax";
-import type { AppTheme } from "./types";
-
-type CatppuccinPalette = {
- rosewater: string;
- flamingo: string;
- pink: string;
- mauve: string;
- red: string;
- maroon: string;
- peach: string;
- yellow: string;
- green: string;
- teal: string;
- sky: string;
- sapphire: string;
- blue: string;
- lavender: string;
- text: string;
- subtext1: string;
- subtext0: string;
- overlay2: string;
- overlay1: string;
- overlay0: string;
- surface2: string;
- surface1: string;
- surface0: string;
- base: string;
- mantle: string;
- crust: string;
-};
-
-// Source: https://github.com/catppuccin/palette/blob/main/palette.json
-// Cross-check reference: https://catppuccin.com/palette/
-// Semantic guidance: https://github.com/catppuccin/catppuccin/blob/main/docs/style-guide.md
-export const CATPPUCCIN_PALETTES = {
- latte: {
- rosewater: "#dc8a78",
- flamingo: "#dd7878",
- pink: "#ea76cb",
- mauve: "#8839ef",
- red: "#d20f39",
- maroon: "#e64553",
- peach: "#fe640b",
- yellow: "#df8e1d",
- green: "#40a02b",
- teal: "#179299",
- sky: "#04a5e5",
- sapphire: "#209fb5",
- blue: "#1e66f5",
- lavender: "#7287fd",
- text: "#4c4f69",
- subtext1: "#5c5f77",
- subtext0: "#6c6f85",
- overlay2: "#7c7f93",
- overlay1: "#8c8fa1",
- overlay0: "#9ca0b0",
- surface2: "#acb0be",
- surface1: "#bcc0cc",
- surface0: "#ccd0da",
- base: "#eff1f5",
- mantle: "#e6e9ef",
- crust: "#dce0e8",
- },
- frappe: {
- rosewater: "#f2d5cf",
- flamingo: "#eebebe",
- pink: "#f4b8e4",
- mauve: "#ca9ee6",
- red: "#e78284",
- maroon: "#ea999c",
- peach: "#ef9f76",
- yellow: "#e5c890",
- green: "#a6d189",
- teal: "#81c8be",
- sky: "#99d1db",
- sapphire: "#85c1dc",
- blue: "#8caaee",
- lavender: "#babbf1",
- text: "#c6d0f5",
- subtext1: "#b5bfe2",
- subtext0: "#a5adce",
- overlay2: "#949cbb",
- overlay1: "#838ba7",
- overlay0: "#737994",
- surface2: "#626880",
- surface1: "#51576d",
- surface0: "#414559",
- base: "#303446",
- mantle: "#292c3c",
- crust: "#232634",
- },
- macchiato: {
- rosewater: "#f4dbd6",
- flamingo: "#f0c6c6",
- pink: "#f5bde6",
- mauve: "#c6a0f6",
- red: "#ed8796",
- maroon: "#ee99a0",
- peach: "#f5a97f",
- yellow: "#eed49f",
- green: "#a6da95",
- teal: "#8bd5ca",
- sky: "#91d7e3",
- sapphire: "#7dc4e4",
- blue: "#8aadf4",
- lavender: "#b7bdf8",
- text: "#cad3f5",
- subtext1: "#b8c0e0",
- subtext0: "#a5adcb",
- overlay2: "#939ab7",
- overlay1: "#8087a2",
- overlay0: "#6e738d",
- surface2: "#5b6078",
- surface1: "#494d64",
- surface0: "#363a4f",
- base: "#24273a",
- mantle: "#1e2030",
- crust: "#181926",
- },
- mocha: {
- rosewater: "#f5e0dc",
- flamingo: "#f2cdcd",
- pink: "#f5c2e7",
- mauve: "#cba6f7",
- red: "#f38ba8",
- maroon: "#eba0ac",
- peach: "#fab387",
- yellow: "#f9e2af",
- green: "#a6e3a1",
- teal: "#94e2d5",
- sky: "#89dceb",
- sapphire: "#74c7ec",
- blue: "#89b4fa",
- lavender: "#b4befe",
- text: "#cdd6f4",
- subtext1: "#bac2de",
- subtext0: "#a6adc8",
- overlay2: "#9399b2",
- overlay1: "#7f849c",
- overlay0: "#6c7086",
- surface2: "#585b70",
- surface1: "#45475a",
- surface0: "#313244",
- base: "#1e1e2e",
- mantle: "#181825",
- crust: "#11111b",
- },
-} as const satisfies Record<"latte" | "frappe" | "macchiato" | "mocha", CatppuccinPalette>;
-
-type CatppuccinFlavor = keyof typeof CATPPUCCIN_PALETTES;
-
-const CATPPUCCIN_LABELS: Record = {
- latte: "Catppuccin Latte",
- frappe: "Catppuccin Frappé",
- macchiato: "Catppuccin Macchiato",
- mocha: "Catppuccin Mocha",
-};
-
-/** Map official Catppuccin palette tokens into Hunk's semantic theme slots. */
-export function createCatppuccinTheme(flavor: CatppuccinFlavor) {
- const palette = CATPPUCCIN_PALETTES[flavor];
- const label = CATPPUCCIN_LABELS[flavor];
- const appearance: AppTheme["appearance"] = flavor === "latte" ? "light" : "dark";
- const panel = flavor === "latte" ? palette.base : palette.mantle;
- const panelAlt = flavor === "latte" ? palette.mantle : palette.base;
- const contextBg = palette.base;
-
- return withLazySyntaxStyle(
- {
- id: `catppuccin-${flavor}`,
- label,
- appearance,
- background: palette.crust,
- panel,
- panelAlt,
- border: palette.surface1,
- accent: palette.mauve,
- accentMuted: blendHex(palette.mauve, panel, 0.2),
- text: palette.text,
- muted: palette.subtext0,
- addedBg: blendHex(palette.green, contextBg, 0.15),
- removedBg: blendHex(palette.red, contextBg, 0.15),
- movedAddedBg: blendHex(palette.sky, contextBg, 0.18),
- movedRemovedBg: blendHex(palette.mauve, contextBg, 0.18),
- contextBg,
- addedContentBg: blendHex(palette.green, contextBg, 0.25),
- removedContentBg: blendHex(palette.red, contextBg, 0.25),
- contextContentBg: contextBg,
- addedSignColor: palette.green,
- removedSignColor: palette.red,
- lineNumberBg: palette.mantle,
- lineNumberFg: palette.overlay1,
- selectedHunk: blendHex(palette.overlay2, contextBg, 0.25),
- badgeAdded: palette.green,
- badgeRemoved: palette.red,
- badgeNeutral: palette.overlay2,
- fileNew: palette.green,
- fileDeleted: palette.red,
- fileRenamed: palette.yellow,
- fileModified: palette.mauve,
- fileUntracked: palette.sky,
- noteBorder: palette.mauve,
- noteBackground: blendHex(palette.mauve, panel, 0.12),
- noteTitleBackground: blendHex(palette.mauve, panel, 0.22),
- noteTitleText: palette.text,
- },
- {
- default: palette.text,
- keyword: palette.mauve,
- string: palette.green,
- comment: palette.overlay2,
- number: palette.peach,
- function: palette.blue,
- property: palette.blue,
- type: palette.yellow,
- punctuation: palette.overlay2,
- },
- );
-}
-
-/** Built-in Catppuccin Latte theme. */
-export const CATPPUCCIN_LATTE_THEME = createCatppuccinTheme("latte");
-
-/** Built-in Catppuccin Frappé theme. */
-export const CATPPUCCIN_FRAPPE_THEME = createCatppuccinTheme("frappe");
-
-/** Built-in Catppuccin Macchiato theme. */
-export const CATPPUCCIN_MACCHIATO_THEME = createCatppuccinTheme("macchiato");
-
-/** Built-in Catppuccin Mocha theme. */
-export const CATPPUCCIN_MOCHA_THEME = createCatppuccinTheme("mocha");
diff --git a/src/ui/themes/ember.ts b/src/ui/themes/ember.ts
deleted file mode 100644
index adf185f5..00000000
--- a/src/ui/themes/ember.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { withLazySyntaxStyle } from "./syntax";
-import type { AppTheme } from "./types";
-
-/** Warm dark theme with ember-like reds and oranges. */
-export const EMBER_THEME: AppTheme = withLazySyntaxStyle(
- {
- id: "ember",
- label: "Ember",
- appearance: "dark",
- background: "#140b08",
- panel: "#22120d",
- panelAlt: "#2c1710",
- border: "#643627",
- accent: "#ffb07a",
- accentMuted: "#5d3428",
- text: "#fff0e6",
- muted: "#c7a18d",
- addedBg: "#183424",
- removedBg: "#4a1f1f",
- movedAddedBg: "#17303a",
- movedRemovedBg: "#3c273b",
- contextBg: "#24140e",
- addedContentBg: "#21432c",
- removedContentBg: "#5a2727",
- contextContentBg: "#2b1711",
- addedSignColor: "#83d99d",
- removedSignColor: "#ff9d8f",
- lineNumberBg: "#1c100c",
- lineNumberFg: "#9a735f",
- selectedHunk: "#8a4d3a",
- badgeAdded: "#83d99d",
- badgeRemoved: "#ff9d8f",
- badgeNeutral: "#f1be9d",
- fileNew: "#83d99d",
- fileDeleted: "#ff9d8f",
- fileRenamed: "#ffd08f",
- fileModified: "#d8b4fe",
- fileUntracked: "#ffb07a",
- noteBorder: "#e1a3ff",
- noteBackground: "#311d36",
- noteTitleBackground: "#452650",
- noteTitleText: "#fff0ff",
- },
- {
- default: "#fff0e6",
- keyword: "#ffb47f",
- string: "#ffd3a8",
- comment: "#a17d69",
- number: "#ffd08f",
- function: "#ffd9b3",
- property: "#ffc89f",
- type: "#f7c5b0",
- punctuation: "#a17d69",
- },
-);
diff --git a/src/ui/themes/graphite.ts b/src/ui/themes/graphite.ts
deleted file mode 100644
index f20a177c..00000000
--- a/src/ui/themes/graphite.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { withLazySyntaxStyle } from "./syntax";
-import type { AppTheme } from "./types";
-
-/** Default dark theme with a neutral graphite palette. */
-export const GRAPHITE_THEME: AppTheme = withLazySyntaxStyle(
- {
- id: "graphite",
- label: "Graphite",
- appearance: "dark",
- background: "#111315",
- panel: "#171a1d",
- panelAlt: "#1d2126",
- border: "#343c45",
- accent: "#d5e0ea",
- accentMuted: "#414a54",
- text: "#f2f4f6",
- muted: "#9aa4af",
- addedBg: "#1f3025",
- removedBg: "#372526",
- movedAddedBg: "#1d3140",
- movedRemovedBg: "#34283d",
- contextBg: "#181c20",
- addedContentBg: "#24362a",
- removedContentBg: "#432b2d",
- contextContentBg: "#1e2328",
- addedSignColor: "#88d39b",
- removedSignColor: "#f0a0a0",
- lineNumberBg: "#14181b",
- lineNumberFg: "#798592",
- selectedHunk: "#4f5d6b",
- badgeAdded: "#88d39b",
- badgeRemoved: "#f0a0a0",
- badgeNeutral: "#a9b4bf",
- fileNew: "#88d39b",
- fileDeleted: "#f0a0a0",
- fileRenamed: "#e6cf98",
- fileModified: "#c49bff",
- fileUntracked: "#7fd1ff",
- noteBorder: "#c6a0ff",
- noteBackground: "#241c31",
- noteTitleBackground: "#322446",
- noteTitleText: "#f5edff",
- },
- {
- default: "#f2f4f6",
- keyword: "#c4d0da",
- string: "#d8c6ef",
- comment: "#7f8b97",
- number: "#e6cf98",
- function: "#dfe6ed",
- property: "#bac8d4",
- type: "#d3d9e2",
- punctuation: "#7f8b97",
- },
-);
diff --git a/src/ui/themes/midnight.ts b/src/ui/themes/midnight.ts
deleted file mode 100644
index d933ae14..00000000
--- a/src/ui/themes/midnight.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { withLazySyntaxStyle } from "./syntax";
-import type { AppTheme } from "./types";
-
-/** Cool dark theme optimized for blue-toned terminals. */
-export const MIDNIGHT_THEME: AppTheme = withLazySyntaxStyle(
- {
- id: "midnight",
- label: "Midnight",
- appearance: "dark",
- background: "#08111f",
- panel: "#0e1b2e",
- panelAlt: "#13243a",
- border: "#284264",
- accent: "#7fd1ff",
- accentMuted: "#355578",
- text: "#eef4ff",
- muted: "#8da5c7",
- addedBg: "#153526",
- removedBg: "#47262a",
- movedAddedBg: "#123247",
- movedRemovedBg: "#3a2748",
- contextBg: "#0f1b2d",
- addedContentBg: "#102a1f",
- removedContentBg: "#371b1e",
- contextContentBg: "#132238",
- addedSignColor: "#69d69a",
- removedSignColor: "#ff8e8e",
- lineNumberBg: "#0b1627",
- lineNumberFg: "#56739a",
- selectedHunk: "#2a6a8a",
- badgeAdded: "#5ad188",
- badgeRemoved: "#ff8b8b",
- badgeNeutral: "#89a5d3",
- fileNew: "#5ad188",
- fileDeleted: "#ff8b8b",
- fileRenamed: "#ffd883",
- fileModified: "#b794f6",
- fileUntracked: "#7fd1ff",
- noteBorder: "#c49bff",
- noteBackground: "#211a36",
- noteTitleBackground: "#30234f",
- noteTitleText: "#f5eeff",
- },
- {
- default: "#e8f1ff",
- keyword: "#8ed4ff",
- string: "#c7b4ff",
- comment: "#6e85a7",
- number: "#ffd883",
- function: "#b6c9ff",
- property: "#a8d6ff",
- type: "#a4b7ff",
- punctuation: "#6e85a7",
- },
-);
diff --git a/src/ui/themes/paper.ts b/src/ui/themes/paper.ts
deleted file mode 100644
index 56c8cf80..00000000
--- a/src/ui/themes/paper.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { withLazySyntaxStyle } from "./syntax";
-import type { AppTheme } from "./types";
-
-/** Warm light theme with paper-inspired neutrals. */
-export const PAPER_THEME: AppTheme = withLazySyntaxStyle(
- {
- id: "paper",
- label: "Paper",
- appearance: "light",
- background: "#f4efe6",
- panel: "#fffaf3",
- panelAlt: "#f8f1e7",
- border: "#d8c8b3",
- accent: "#77593a",
- accentMuted: "#d7ccbe",
- text: "#2f2417",
- muted: "#786753",
- addedBg: "#dff0e1",
- removedBg: "#f6ddde",
- movedAddedBg: "#dcebf4",
- movedRemovedBg: "#eadff1",
- contextBg: "#faf6ee",
- addedContentBg: "#eaf8ec",
- removedContentBg: "#fbebeb",
- contextContentBg: "#fffaf3",
- addedSignColor: "#3f8d58",
- removedSignColor: "#b4545b",
- lineNumberBg: "#f2e9dc",
- lineNumberFg: "#9b8367",
- selectedHunk: "#d2c0a5",
- badgeAdded: "#3f8d58",
- badgeRemoved: "#b4545b",
- badgeNeutral: "#8e7355",
- fileNew: "#3f8d58",
- fileDeleted: "#b4545b",
- fileRenamed: "#9f6c1f",
- fileModified: "#7d5bc4",
- fileUntracked: "#4a6890",
- noteBorder: "#7d5bc4",
- noteBackground: "#efe6ff",
- noteTitleBackground: "#e3d7ff",
- noteTitleText: "#462b74",
- },
- {
- default: "#2f2417",
- keyword: "#7b5a35",
- string: "#4a6890",
- comment: "#8f7a65",
- number: "#9f6c1f",
- function: "#5a4a8e",
- property: "#356b7f",
- type: "#5f5f9a",
- punctuation: "#8f7a65",
- },
-);
diff --git a/src/ui/themes/syntax.ts b/src/ui/themes/syntax.ts
index a3841ba4..8a9d0699 100644
--- a/src/ui/themes/syntax.ts
+++ b/src/ui/themes/syntax.ts
@@ -12,10 +12,11 @@ export function createSyntaxStyle(colors: SyntaxColors) {
function: { fg: RGBA.fromHex(colors.function) },
method: { fg: RGBA.fromHex(colors.function) },
property: { fg: RGBA.fromHex(colors.property) },
- variable: { fg: RGBA.fromHex(colors.default) },
+ variable: { fg: RGBA.fromHex(colors.variable ?? colors.default) },
constant: { fg: RGBA.fromHex(colors.number), bold: true },
type: { fg: RGBA.fromHex(colors.type) },
class: { fg: RGBA.fromHex(colors.type) },
+ operator: { fg: RGBA.fromHex(colors.operator ?? colors.punctuation) },
punctuation: { fg: RGBA.fromHex(colors.punctuation) },
});
}
diff --git a/src/ui/themes/types.ts b/src/ui/themes/types.ts
index 9b677233..b884af7a 100644
--- a/src/ui/themes/types.ts
+++ b/src/ui/themes/types.ts
@@ -37,6 +37,8 @@ export interface AppTheme {
noteBackground: string;
noteTitleBackground: string;
noteTitleText: string;
+ /** Optional Shiki/Pierre theme name for source-accurate code highlighting. */
+ syntaxTheme?: string;
syntaxColors: SyntaxColors;
syntaxStyle: SyntaxStyle;
}
@@ -50,6 +52,8 @@ export type SyntaxColors = {
function: string;
property: string;
type: string;
+ variable?: string;
+ operator?: string;
punctuation: string;
};
diff --git a/src/ui/themes/zenburn.ts b/src/ui/themes/zenburn.ts
deleted file mode 100644
index 8e0b769b..00000000
--- a/src/ui/themes/zenburn.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { withLazySyntaxStyle } from "./syntax";
-import type { AppTheme } from "./types";
-
-/**
- * Zenburn — a warm, low-contrast dark theme by @ramin, inspired by and slightly
- * modified from the original Zenburn by Jani Nurminen. Warm off-white text,
- * cyan-blue functions and types, sage comments, and dusty-red strings.
- */
-export const ZENBURN_THEME: AppTheme = withLazySyntaxStyle(
- {
- id: "zenburn",
- label: "Zenburn",
- appearance: "dark",
- background: "#3f3f3f",
- panel: "#3a3a3a",
- panelAlt: "#313633",
- border: "#4d4d4d",
- accent: "#93e0e3",
- accentMuted: "#709080",
- text: "#dcdccc",
- muted: "#709080",
- addedBg: "#2e3d30",
- removedBg: "#43302f",
- movedAddedBg: "#2f4548",
- movedRemovedBg: "#46364b",
- contextBg: "#393939",
- addedContentBg: "#3a4d3c",
- removedContentBg: "#553a39",
- contextContentBg: "#3f3f3f",
- addedSignColor: "#60b48a",
- removedSignColor: "#dca3a3",
- lineNumberBg: "#3a3a3a",
- lineNumberFg: "#709080",
- selectedHunk: "#4a554d",
- badgeAdded: "#60b48a",
- badgeRemoved: "#dca3a3",
- badgeNeutral: "#c3bf9f",
- fileNew: "#60b48a",
- fileDeleted: "#dca3a3",
- fileRenamed: "#e0cf9f",
- fileModified: "#e0cf9f",
- fileUntracked: "#8cd0d3",
- noteBorder: "#dc8cc3",
- noteBackground: "#3a3340",
- noteTitleBackground: "#46394e",
- noteTitleText: "#f0e6f5",
- },
- {
- default: "#dcdccc",
- keyword: "#f0dfaf",
- string: "#dca3a3",
- comment: "#60b48a",
- number: "#8cd0d3",
- function: "#94bff3",
- property: "#c3bf9f",
- type: "#94bff3",
- punctuation: "#dcdccc",
- },
-);
diff --git a/test/cli/entrypoint.test.ts b/test/cli/entrypoint.test.ts
index 923521c6..d72e0c29 100644
--- a/test/cli/entrypoint.test.ts
+++ b/test/cli/entrypoint.test.ts
@@ -220,7 +220,7 @@ describe("CLI entrypoint contracts", () => {
expect(stderr).toBe("");
expect(stdout).toContain("* main");
expect(stdout).toContain("feature/demo");
- expect(stdout).not.toContain("View Navigate Theme Agent Help");
+ expect(stdout).not.toContain("View Navigate Agent Help");
expect(stdout).not.toContain("\u001b[?1049h");
});
diff --git a/test/helpers/app-bootstrap.ts b/test/helpers/app-bootstrap.ts
index 6b8b664e..44ccfec0 100644
--- a/test/helpers/app-bootstrap.ts
+++ b/test/helpers/app-bootstrap.ts
@@ -10,7 +10,7 @@ export function createTestVcsAppBootstrap({
initialShowAgentNotes,
initialShowHunkHeaders,
initialShowLineNumbers,
- initialTheme = "midnight",
+ initialTheme = "github-dark-default",
initialWrapLines,
inputMode = initialMode,
pager = false,
diff --git a/test/pty/chrome.test.ts b/test/pty/chrome.test.ts
index 3fded7d6..fb407946 100644
--- a/test/pty/chrome.test.ts
+++ b/test/pty/chrome.test.ts
@@ -11,7 +11,7 @@ afterEach(() => {
});
describe("PTY chrome", () => {
- test("top menu mouse navigation can select themes, toggle agent notes, and open help", async () => {
+ test("top menu mouse navigation can open themes, toggle agent notes, and open help", async () => {
const fixture = harness.createAgentFilePair();
const session = await harness.launchHunk({
args: [
@@ -32,14 +32,18 @@ describe("PTY chrome", () => {
const initial = await session.waitForText(/Adds bonus export\./, { timeout: 15_000 });
expect(initial).toContain("Highlights the follow-up addition for review.");
- await session.click(/Theme/);
- const themeMenu = await session.waitForText(/Midnight/, { timeout: 5_000 });
- expect(themeMenu).toContain("Paper");
+ await session.click(/View/);
+ const viewMenu = await session.waitForText(/Themes…/, { timeout: 5_000 });
+ expect(viewMenu).toContain("Themes…");
+
+ await session.click(/Themes…/);
+ const themeSelector = await session.waitForText(/github-light-default/, { timeout: 5_000 });
+ expect(themeSelector).toContain("Theme selector");
- await session.click(/Paper/);
+ await session.click(/github-light-default/);
const themeSelected = await harness.waitForSnapshot(
session,
- (text) => text.includes("Adds bonus export.") && !text.includes("Midnight"),
+ (text) => text.includes("Adds bonus export.") && !text.includes("Theme selector"),
5_000,
);
expect(themeSelected).toContain("Adds bonus export.");
@@ -81,7 +85,7 @@ describe("PTY chrome", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -116,7 +120,7 @@ describe("PTY chrome", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -159,7 +163,7 @@ describe("PTY chrome", () => {
});
try {
- await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -189,7 +193,7 @@ describe("PTY chrome", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -230,7 +234,7 @@ describe("PTY chrome", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
diff --git a/test/pty/filter-escape.test.ts b/test/pty/filter-escape.test.ts
index 6174dd25..b386d05a 100644
--- a/test/pty/filter-escape.test.ts
+++ b/test/pty/filter-escape.test.ts
@@ -21,7 +21,7 @@ describe("filter escape clearing (PTY)", () => {
});
try {
- await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, { timeout: 15_000 });
+ await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 });
// Open filter, type a no-match query.
await session.type("/");
diff --git a/test/pty/layout.test.ts b/test/pty/layout.test.ts
index 10ed5374..17b691a1 100644
--- a/test/pty/layout.test.ts
+++ b/test/pty/layout.test.ts
@@ -25,7 +25,7 @@ describe("PTY layout", () => {
});
try {
- await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -52,7 +52,7 @@ describe("PTY layout", () => {
});
try {
- await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
const snapshot = await harness.waitForSnapshot(
@@ -92,7 +92,7 @@ describe("PTY layout", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -132,7 +132,7 @@ describe("PTY layout", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -171,7 +171,7 @@ describe("PTY layout", () => {
});
try {
- const wide = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const wide = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -200,7 +200,7 @@ describe("PTY layout", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
const initialMainColumn = rightmostColumnOf(initial, "alpha.ts");
@@ -231,7 +231,7 @@ describe("PTY layout", () => {
});
try {
- const wide = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const wide = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -261,7 +261,7 @@ describe("PTY layout", () => {
});
try {
- const narrow = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const narrow = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -291,7 +291,7 @@ describe("PTY layout", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -339,7 +339,7 @@ describe("PTY layout", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -393,7 +393,7 @@ describe("PTY layout", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -437,7 +437,7 @@ describe("PTY layout", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
diff --git a/test/pty/nav.test.ts b/test/pty/nav.test.ts
index 3cf8e531..1cdabae7 100644
--- a/test/pty/nav.test.ts
+++ b/test/pty/nav.test.ts
@@ -21,7 +21,7 @@ describe("PTY navigation", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
expect(initial).not.toContain("Maximum update depth exceeded");
@@ -62,7 +62,7 @@ describe("PTY navigation", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -93,7 +93,7 @@ describe("PTY navigation", () => {
});
try {
- await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -140,7 +140,7 @@ describe("PTY navigation", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -181,7 +181,7 @@ describe("PTY navigation", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -215,7 +215,7 @@ describe("PTY navigation", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
diff --git a/test/pty/notes.test.ts b/test/pty/notes.test.ts
index ae3b35c2..35b5a7fa 100644
--- a/test/pty/notes.test.ts
+++ b/test/pty/notes.test.ts
@@ -36,7 +36,7 @@ describe("PTY notes", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -69,7 +69,7 @@ describe("PTY notes", () => {
});
try {
- await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -125,7 +125,7 @@ describe("PTY notes", () => {
});
try {
- await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -172,7 +172,7 @@ describe("PTY notes", () => {
});
try {
- await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -204,7 +204,7 @@ describe("PTY notes", () => {
});
try {
- await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -410,7 +410,7 @@ describe("PTY notes", () => {
});
try {
- await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
diff --git a/test/pty/pager.test.ts b/test/pty/pager.test.ts
index d8de4e8e..df2056d7 100644
--- a/test/pty/pager.test.ts
+++ b/test/pty/pager.test.ts
@@ -48,7 +48,7 @@ describe("PTY pager", () => {
try {
const initial = await session.waitForText(/scroll\.ts/, { timeout: 15_000 });
- expect(initial).not.toContain("View Navigate Theme Agent Help");
+ expect(initial).not.toContain("View Navigate Agent Help");
expect(initial).toContain("before_01");
expect(initial).not.toContain("before_23");
@@ -61,7 +61,7 @@ describe("PTY pager", () => {
5_000,
);
- expect(paged).not.toContain("View Navigate Theme Agent Help");
+ expect(paged).not.toContain("View Navigate Agent Help");
expect(paged).toContain("before_23");
} finally {
session.close();
@@ -145,14 +145,14 @@ describe("PTY pager", () => {
test("piped stdin still allows concrete-theme app startup to read terminal input", async () => {
const fixture = harness.createTwoFileRepoFixture();
const session = await harness.launchShellCommand({
- command: `printf ignored | ${harness.buildHunkCommand(["diff", "--theme", "graphite"])}`,
+ command: `printf ignored | ${harness.buildHunkCommand(["diff", "--theme", "github-dark-default"])}`,
cwd: fixture.dir,
cols: 120,
rows: 14,
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
expect(initial).toContain("alpha.ts");
@@ -176,7 +176,7 @@ describe("PTY pager", () => {
try {
const initial = await session.waitForText(/scroll\.ts/, { timeout: 15_000 });
- expect(initial).not.toContain("View Navigate Theme Agent Help");
+ expect(initial).not.toContain("View Navigate Agent Help");
expect(initial).toContain("before_01");
expect(initial).not.toContain("before_12");
@@ -187,7 +187,7 @@ describe("PTY pager", () => {
(text) => !text.includes("before_01") && text.includes("before_12"),
);
- expect(scrolled).not.toContain("View Navigate Theme Agent Help");
+ expect(scrolled).not.toContain("View Navigate Agent Help");
expect(scrolled).not.toContain("before_01");
expect(scrolled).toContain("before_12");
@@ -244,7 +244,7 @@ describe("PTY pager", () => {
try {
const initial = await session.waitForText(/scroll\.ts/, { timeout: 15_000 });
- expect(initial).not.toContain("View Navigate Theme Agent Help");
+ expect(initial).not.toContain("View Navigate Agent Help");
expect(initial).toContain("before_01");
expect(initial).not.toContain("before_12");
@@ -255,7 +255,7 @@ describe("PTY pager", () => {
(text) => !text.includes("before_01") && text.includes("before_12"),
);
- expect(scrolled).not.toContain("View Navigate Theme Agent Help");
+ expect(scrolled).not.toContain("View Navigate Agent Help");
expect(scrolled).not.toContain("before_01");
expect(scrolled).toContain("before_12");
@@ -284,7 +284,7 @@ describe("PTY pager", () => {
try {
const initial = await session.waitForText(/scroll\.ts/, { timeout: 15_000 });
- expect(initial).not.toContain("View Navigate Theme Agent Help");
+ expect(initial).not.toContain("View Navigate Agent Help");
expect(harness.countMatches(initial, /scroll\.ts/g)).toBe(1);
await session.press("s");
@@ -295,7 +295,7 @@ describe("PTY pager", () => {
5_000,
);
- expect(withSidebar).not.toContain("View Navigate Theme Agent Help");
+ expect(withSidebar).not.toContain("View Navigate Agent Help");
expect(withSidebar).toMatch(sidebarRow);
} finally {
session.close();
@@ -313,7 +313,7 @@ describe("PTY pager", () => {
try {
const initial = await session.waitForText(/scroll\.ts/, { timeout: 15_000 });
- expect(initial).not.toContain("View Navigate Theme Agent Help");
+ expect(initial).not.toContain("View Navigate Agent Help");
expect(initial).toContain("before_01");
expect(initial).not.toContain("before_12");
@@ -324,7 +324,7 @@ describe("PTY pager", () => {
(text) => !text.includes("before_01") && text.includes("before_12"),
);
- expect(scrolled).not.toContain("View Navigate Theme Agent Help");
+ expect(scrolled).not.toContain("View Navigate Agent Help");
expect(scrolled).not.toContain("before_01");
expect(scrolled).toContain("before_12");
diff --git a/test/pty/scroll.test.ts b/test/pty/scroll.test.ts
index 3451e13c..b3b9307e 100644
--- a/test/pty/scroll.test.ts
+++ b/test/pty/scroll.test.ts
@@ -21,7 +21,7 @@ describe("PTY scrolling", () => {
});
try {
- await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -60,7 +60,7 @@ describe("PTY scrolling", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -119,7 +119,7 @@ describe("PTY scrolling", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -194,7 +194,7 @@ describe("PTY scrolling", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -238,7 +238,7 @@ describe("PTY scrolling", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
@@ -269,7 +269,7 @@ describe("PTY scrolling", () => {
});
try {
- const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
timeout: 15_000,
});
const initialHeaderCount = harness.countMatches(initial, /aaa-collapsed\.ts/g);
diff --git a/test/session/broker-e2e.test.ts b/test/session/broker-e2e.test.ts
index 83da9fb0..eca47d63 100644
--- a/test/session/broker-e2e.test.ts
+++ b/test/session/broker-e2e.test.ts
@@ -539,7 +539,7 @@ describe("session broker end-to-end", () => {
expect([0, 124]).toContain(exitCode);
const transcript = stripTerminalControl(await Bun.file(fixture.transcript).text());
- expect(transcript).toContain("View Navigate Theme Agent Help");
+ expect(transcript).toContain("View Navigate Agent Help");
expect(transcript).toContain(`${fixture.afterName}`);
expect(transcript).toContain("export const gamma = true;");
} finally {
diff --git a/test/smoke/tty.test.ts b/test/smoke/tty.test.ts
index 9b3108b8..8e3c89aa 100644
--- a/test/smoke/tty.test.ts
+++ b/test/smoke/tty.test.ts
@@ -231,7 +231,7 @@ describe("TTY render smoke", () => {
const output = await runTtySmoke({ mode: "split", agentContext: true });
- expect(output).toContain("View Navigate Theme Agent Help");
+ expect(output).toContain("View Navigate Agent Help");
expect(output).toContain("before.ts ↔ after.ts");
expect(output).not.toContain("[AI]");
expect(output).toContain("▌@@ -1,1 +1,2 @@");
@@ -278,7 +278,7 @@ describe("TTY render smoke", () => {
const output = await runTtySmoke({ mode: "stack" });
- expect(output).toContain("View Navigate Theme Agent Help");
+ expect(output).toContain("View Navigate Agent Help");
expect(output).toContain("▌1 - export const answer = 41;");
expect(output).toContain("▌ 1 + export const answer = 42;");
expect(output).not.toContain("│1 + export const answer = 42;");
@@ -292,7 +292,7 @@ describe("TTY render smoke", () => {
const output = await runTtySmoke({ pager: true });
- expect(output).not.toContain("View Navigate Theme Agent Help");
+ expect(output).not.toContain("View Navigate Agent Help");
expect(output).not.toContain("F10 menu");
expect(output).toContain("before.ts -> after.ts");
expect(output).toContain("export const answer = 42;");
@@ -321,7 +321,7 @@ describe("TTY render smoke", () => {
const output = await runStdinPagerSmoke();
- expect(output).not.toContain("View Navigate Theme Agent Help");
+ expect(output).not.toContain("View Navigate Agent Help");
expect(output).not.toContain("F10 menu");
expect(output).toContain("after.ts");
expect(output).toContain("@@ -1 +1,2 @@");
@@ -349,7 +349,7 @@ describe("TTY render smoke", () => {
const output = await runStdinPagerSmoke({ command: "pager" });
- expect(output).not.toContain("View Navigate Theme Agent Help");
+ expect(output).not.toContain("View Navigate Agent Help");
expect(output).toContain("after.ts");
expect(output).toContain("@@ -1 +1,2 @@");
expect(output).toContain("export const answer = 42;");