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;");