diff --git a/.changeset/tidy-hounds-repeat.md b/.changeset/tidy-hounds-repeat.md new file mode 100644 index 000000000..c2972fa29 --- /dev/null +++ b/.changeset/tidy-hounds-repeat.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Accept a `[theme]` table that names one theme per terminal background, so `dark` and `light` terminals each get a theme you chose instead of only Hunk's GitHub defaults. `fallback` covers terminals that never report a background. diff --git a/docs/themes.md b/docs/themes.md index e953491ac..4f8ca53ee 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -18,6 +18,25 @@ Hunk chooses `github-light-default` for light backgrounds and `github-dark-default` for dark backgrounds, falling back to `github-dark-default` when the terminal does not answer. +To pick the two themes yourself, write `theme` as a table instead of an id: + +```toml +[theme] +dark = "catppuccin-mocha" # required +light = "catppuccin-latte" # required +fallback = "github-dark-default" # optional +``` + +Hunk queries the terminal background the same way `auto` does, then draws +`dark` or `light`. `fallback` covers sessions where Hunk never gets an answer: +terminals that ignore the query, and captured pager hosts such as LazyGit, +where Hunk never asks. Without it those sessions use `dark`. Both sides accept +any built-in id, a custom theme id, and the compatibility aliases. + +A `--theme ` flag overrides the table for that run, and picking a theme in +the app (`t`, or `View -> Themes…`) replaces the pair with the single id you +chose — the save-on-quit prompt shows that before writing anything. + Older theme ids such as `graphite` and `paper` remain accepted as compatibility aliases. diff --git a/src/app/startup.test.ts b/src/app/startup.test.ts index 2a346bcb5..d5bf9647b 100644 --- a/src/app/startup.test.ts +++ b/src/app/startup.test.ts @@ -641,6 +641,63 @@ describe("startup planning", () => { expect(opened).toBe(1); }); + test("detects the terminal background for an adaptive theme pair", async () => { + const cliInput: CliInput = { + kind: "patch", + file: "-", + options: { + theme: { dark: "vitesse-dark", light: "one-light" }, + pager: true, + }, + }; + const controllingTerminal = { stdin: {} as never, close: () => {} }; + let probes = 0; + + const plan = await prepareStartupPlan(["bun", "hunk", "patch", "-"], { + parseCliImpl: async () => cliInput as ParsedCliInput, + resolveRuntimeCliInputImpl: (input) => input, + resolveConfiguredCliInputImpl: (input) => createTestConfigResolution(input), + loadAppBootstrapImpl: async (input) => createBootstrap(input), + openControllingTerminalImpl: () => controllingTerminal, + detectTerminalThemeModeFromBackgroundImpl: async () => { + probes += 1; + return "light"; + }, + stdinIsTTY: false, + stdoutIsTTY: true, + stdout: { write: () => true } as never, + }); + + expect(plan).toMatchObject({ kind: "app", bootstrap: { initialThemeMode: "light" } }); + expect(probes).toBe(1); + }); + + test("skips the background probe when one theme covers every terminal", async () => { + const cliInput: CliInput = { + kind: "patch", + file: "-", + options: { theme: "dracula", pager: true }, + }; + let probes = 0; + + await prepareStartupPlan(["bun", "hunk", "patch", "-", "--theme", "dracula"], { + parseCliImpl: async () => cliInput as ParsedCliInput, + resolveRuntimeCliInputImpl: (input) => input, + resolveConfiguredCliInputImpl: (input) => createTestConfigResolution(input), + loadAppBootstrapImpl: async (input) => createBootstrap(input), + openControllingTerminalImpl: () => ({ stdin: {} as never, close: () => {} }), + detectTerminalThemeModeFromBackgroundImpl: async () => { + probes += 1; + return "dark"; + }, + stdinIsTTY: false, + stdoutIsTTY: true, + stdout: { write: () => true } as never, + }); + + expect(probes).toBe(0); + }); + test("opens the controlling terminal for piped patch startup", async () => { const cliInput: CliInput = { kind: "patch", diff --git a/src/app/startup.ts b/src/app/startup.ts index 79c05c50e..96dd19850 100644 --- a/src/app/startup.ts +++ b/src/app/startup.ts @@ -7,6 +7,7 @@ import type { loadAppBootstrap } from "../core/changeset/loaders"; import { looksLikePatchInput } from "../core/process/pager"; import { sanitizeTerminalText } from "../lib/terminalText"; import { detectTerminalThemeModeFromBackground } from "../core/theme/detection"; +import { themeSelectionNeedsTerminalMode } from "../core/theme/selection"; import { openControllingTerminal, resolveRuntimeCliInput, @@ -485,7 +486,7 @@ export async function prepareStartupPlan( } let initialThemeMode: AppBootstrap["initialThemeMode"]; - if (cliInput.options.theme === "auto" && stdoutIsTTY) { + if (themeSelectionNeedsTerminalMode(cliInput.options.theme) && stdoutIsTTY) { const themeInput = controllingTerminal?.stdin ?? (stdinIsTTY ? process.stdin : null); if (themeInput) { initialThemeMode = diff --git a/src/core/bootstrap.ts b/src/core/bootstrap.ts index 76e47f73e..bd443072a 100644 --- a/src/core/bootstrap.ts +++ b/src/core/bootstrap.ts @@ -17,6 +17,7 @@ import type { CliInput, CursorLine, LayoutMode, SidebarVisibility } from "./run/ import type { UserKeyBinding } from "./run/config"; import type { StartupNotice } from "./process/startupNotice"; import type { TerminalThemeMode } from "./theme/detection"; +import type { ThemeSelection } from "./theme/selection"; import type { VcsCatalog } from "./vcs/types"; /** Where a review was loaded from, retained so the session can reload and watch it. */ @@ -39,7 +40,7 @@ export interface AppBootstrap { reloadContext: ReloadContext; changeset: Changeset; initialMode: LayoutMode; - initialTheme?: string; + initialTheme?: ThemeSelection; initialThemeMode?: TerminalThemeMode; /** Selectable custom themes for this session, in menu order. */ customThemes?: readonly NamedCustomThemeConfig[]; diff --git a/src/core/run/commandInputs.ts b/src/core/run/commandInputs.ts index 8dca48432..0e0f8443a 100644 --- a/src/core/run/commandInputs.ts +++ b/src/core/run/commandInputs.ts @@ -14,6 +14,7 @@ import type { ExtensionVcsStashShowInput, } from "../../extension-api/types"; import type { InstallSource } from "../install/installSource"; +import type { ThemeSelection } from "../theme/selection"; export type LayoutMode = "auto" | "split" | "stack"; export type CursorLine = "row" | "number" | "off"; @@ -24,7 +25,7 @@ export interface CommonOptions { mode?: LayoutMode; cursorLine?: CursorLine; vcs?: VcsMode; - theme?: string; + theme?: ThemeSelection; agentContext?: string; pager?: boolean; watch?: boolean; diff --git a/src/core/run/config.test.ts b/src/core/run/config.test.ts index 9b5f312e7..b05c8baf7 100644 --- a/src/core/run/config.test.ts +++ b/src/core/run/config.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getBundledVcsCatalog } from "../../app/vcsCatalog"; import type { CliInput } from "./commandInputs"; +import type { PersistedViewPreferences } from "./config"; import { diffPersistedViewPreferences, resolveConfiguredCliInput, @@ -130,6 +131,45 @@ describe("config persistence", () => { ); }); + test("appends after a trailing comment when the file has no table to document", () => { + const home = createTempDir("hunk-save-config-trailing-comment-home-"); + const configPath = join(home, ".config", "hunk", "config.toml"); + mkdirSync(join(home, ".config", "hunk"), { recursive: true }); + // No trailing newline, so the comment is the last line the writer sees. + writeFileSync(configPath, "# personal defaults"); + + saveGlobalViewPreferences( + { + mode: "split", + theme: "dracula", + showLineNumbers: false, + wrapLines: true, + showHunkHeaders: false, + showMenuBar: false, + showAgentNotes: true, + copyDecorations: true, + cursorLine: "row", + }, + { env: { HOME: home } }, + ); + + expect(readFileSync(configPath, "utf8")).toBe( + [ + "# personal defaults", + 'theme = "dracula"', + 'mode = "split"', + "line_numbers = false", + "wrap_lines = true", + "hunk_headers = false", + "menu_bar = false", + "agent_notes = true", + "copy_decorations = true", + 'cursor_line = "row"', + "", + ].join("\n"), + ); + }); + test("diffs view preference snapshots as the TOML assignments a save would rewrite", () => { const initial = { mode: "auto", @@ -163,6 +203,380 @@ describe("config persistence", () => { }); }); +describe("adaptive theme config", () => { + function writeUserConfig(home: string, lines: readonly string[]) { + const configPath = join(home, ".config", "hunk", "config.toml"); + mkdirSync(join(home, ".config", "hunk"), { recursive: true }); + writeFileSync(configPath, lines.join("\n")); + return configPath; + } + + test("reads a [theme] table into an adaptive selection", () => { + const home = createTempDir("hunk-adaptive-theme-home-"); + const repo = createTempDir("hunk-adaptive-theme-repo-"); + createRepo(repo); + writeUserConfig(home, [ + "[theme]", + 'dark = "catppuccin-mocha"', + 'light = "catppuccin-latte"', + 'fallback = "nord"', + ]); + + const resolved = resolveConfiguredCliInput(createPatchPagerInput(), { + cwd: repo, + env: { HOME: home }, + }); + + expect(resolved.input.options.theme).toEqual({ + dark: "catppuccin-mocha", + light: "catppuccin-latte", + fallback: "nord", + }); + }); + + test("lets an explicit --theme id outrank a configured pair", () => { + const home = createTempDir("hunk-adaptive-theme-cli-home-"); + const repo = createTempDir("hunk-adaptive-theme-cli-repo-"); + createRepo(repo); + writeUserConfig(home, ["[theme]", 'dark = "vitesse-dark"', 'light = "vitesse-light"']); + + const resolved = resolveConfiguredCliInput(createPatchPagerInput({ theme: "dracula" }), { + cwd: repo, + env: { HOME: home }, + }); + + expect(resolved.input.options.theme).toBe("dracula"); + }); + + test("lets a repo layer replace a user pair with one id", () => { + const home = createTempDir("hunk-adaptive-theme-layer-home-"); + const repo = createTempDir("hunk-adaptive-theme-layer-repo-"); + createRepo(repo); + writeUserConfig(home, ["[theme]", 'dark = "vitesse-dark"', 'light = "vitesse-light"']); + mkdirSync(join(repo, ".hunk"), { recursive: true }); + writeFileSync(join(repo, ".hunk", "config.toml"), 'theme = "nord"\n'); + + const resolved = resolveConfiguredCliInput(createPatchPagerInput(), { + cwd: repo, + env: { HOME: home }, + }); + + expect(resolved.input.options.theme).toBe("nord"); + }); + + test("rejects a [theme] table that leaves one background unanswered", () => { + const home = createTempDir("hunk-adaptive-theme-invalid-home-"); + const repo = createTempDir("hunk-adaptive-theme-invalid-repo-"); + createRepo(repo); + writeUserConfig(home, ["[theme]", 'dark = "vitesse-dark"']); + + expect(() => + resolveConfiguredCliInput(createPatchPagerInput(), { cwd: repo, env: { HOME: home } }), + ).toThrow("Expected [theme] to set both `dark` and `light` to theme ids."); + }); + + test("requires a [custom_theme] table when a pair names the custom theme", () => { + const home = createTempDir("hunk-adaptive-theme-custom-home-"); + const repo = createTempDir("hunk-adaptive-theme-custom-repo-"); + createRepo(repo); + writeUserConfig(home, ["[theme]", 'dark = "custom"', 'light = "github-light-default"']); + + expect(() => + resolveConfiguredCliInput(createPatchPagerInput(), { cwd: repo, env: { HOME: home } }), + ).toThrow('Expected a [custom_theme] table when config selects theme = "custom".'); + }); + + test("treats an unchanged pair as clean and shows the collapse when one theme is picked", () => { + const base = { + mode: "auto", + theme: { dark: "vitesse-dark", light: "vitesse-light" }, + showLineNumbers: true, + wrapLines: false, + showHunkHeaders: true, + showMenuBar: true, + showAgentNotes: false, + copyDecorations: false, + cursorLine: "row", + } as const; + + expect( + diffPersistedViewPreferences(base, { + ...base, + theme: { dark: "vitesse-dark", light: "vitesse-light" }, + }), + ).toEqual([]); + expect(diffPersistedViewPreferences(base, { ...base, theme: "dracula" })).toEqual([ + { + configKey: "theme", + previousValue: '{ dark = "vitesse-dark", light = "vitesse-light" }', + nextValue: '"dracula"', + }, + ]); + }); + + test("rewrites a [theme] table in place instead of duplicating the key", () => { + const home = createTempDir("hunk-adaptive-theme-save-home-"); + const configPath = writeUserConfig(home, [ + "wrap_lines = false", + "", + "[theme]", + "# follow the terminal", + 'dark = "vitesse-dark"', + 'light = "vitesse-light"', + "", + "[custom_theme]", + 'label = "Keep me"', + ]); + + saveGlobalViewPreferences( + { + mode: "auto", + theme: { dark: "nord", light: "one-light", fallback: "nord" }, + showLineNumbers: true, + wrapLines: true, + showHunkHeaders: true, + showMenuBar: true, + showAgentNotes: false, + copyDecorations: false, + cursorLine: "row", + }, + { configPath }, + ); + + const saved = readFileSync(configPath, "utf8"); + expect(saved).toContain( + [ + "[theme]", + "# follow the terminal", + 'dark = "nord"', + 'light = "one-light"', + 'fallback = "nord"', + "", + "[custom_theme]", + 'label = "Keep me"', + ].join("\n"), + ); + expect(saved).not.toContain("theme = "); + expect(saved).toContain("wrap_lines = true"); + }); + + test("removes the [theme] table when a single theme replaces the pair", () => { + const home = createTempDir("hunk-adaptive-theme-collapse-home-"); + const configPath = writeUserConfig(home, [ + "[theme]", + 'dark = "vitesse-dark"', + 'light = "vitesse-light"', + "", + "[custom_theme]", + 'label = "Keep me"', + ]); + + saveGlobalViewPreferences( + { + mode: "auto", + theme: "dracula", + showLineNumbers: true, + wrapLines: false, + showHunkHeaders: true, + showMenuBar: true, + showAgentNotes: false, + copyDecorations: false, + cursorLine: "row", + }, + { configPath }, + ); + + const saved = readFileSync(configPath, "utf8"); + expect(saved).toContain('theme = "dracula"'); + expect(saved).not.toContain("[theme]"); + expect(saved).toContain("[custom_theme]"); + }); + + function themePreferences(theme: PersistedViewPreferences["theme"]): PersistedViewPreferences { + return { + mode: "auto", + theme, + showLineNumbers: true, + wrapLines: false, + showHunkHeaders: true, + showMenuBar: true, + showAgentNotes: false, + copyDecorations: false, + cursorLine: "row", + }; + } + + test("replaces dotted theme keys instead of appending a second definition", () => { + const home = createTempDir("hunk-adaptive-theme-dotted-home-"); + const configPath = writeUserConfig(home, [ + "wrap_lines = false", + 'theme.dark = "vitesse-dark"', + 'theme.light = "vitesse-light"', + ]); + + saveGlobalViewPreferences(themePreferences("dracula"), { configPath }); + + const saved = readFileSync(configPath, "utf8"); + expect(() => Bun.TOML.parse(saved)).not.toThrow(); + expect(Bun.TOML.parse(saved)).toMatchObject({ theme: "dracula" }); + expect(saved).not.toContain("theme.light"); + }); + + test("replaces a quoted key inside the [theme] table rather than duplicating it", () => { + const home = createTempDir("hunk-adaptive-theme-quoted-home-"); + const configPath = writeUserConfig(home, [ + "[theme]", + '"dark" = "vitesse-dark"', + 'light = "vitesse-light"', + ]); + + saveGlobalViewPreferences(themePreferences({ dark: "nord", light: "one-light" }), { + configPath, + }); + + const saved = readFileSync(configPath, "utf8"); + expect(() => Bun.TOML.parse(saved)).not.toThrow(); + expect(Bun.TOML.parse(saved)).toMatchObject({ + theme: { dark: "nord", light: "one-light" }, + }); + }); + + test("keeps the next section's comments when the [theme] table collapses", () => { + const home = createTempDir("hunk-adaptive-theme-comments-home-"); + const configPath = writeUserConfig(home, [ + 'mode = "split"', + "", + "# theme picked per background", + "[theme]", + 'dark = "vitesse-dark" # night', + 'light = "vitesse-light"', + "", + "# my custom colors", + "[custom_theme]", + 'label = "Keep me"', + ]); + + saveGlobalViewPreferences(themePreferences({ dark: "nord", light: "one-light" }), { + configPath, + }); + + let saved = readFileSync(configPath, "utf8"); + expect(saved).toContain('dark = "nord" # night'); + expect(saved).toContain("# my custom colors"); + + saveGlobalViewPreferences(themePreferences("dracula"), { configPath }); + + saved = readFileSync(configPath, "utf8"); + expect(() => Bun.TOML.parse(saved)).not.toThrow(); + expect(saved).not.toContain("[theme]"); + // The collapsed key takes the table's place, so each comment still introduces what follows it. + expect(saved).toContain(["# theme picked per background", 'theme = "dracula"'].join("\n")); + expect(saved).toContain(["# my custom colors", "[custom_theme]"].join("\n")); + }); + + test("indents a key added to an indented [theme] table like its siblings", () => { + const home = createTempDir("hunk-adaptive-theme-indent-home-"); + const configPath = writeUserConfig(home, [ + "[theme]", + ' dark = "vitesse-dark"', + ' light = "vitesse-light"', + ]); + + saveGlobalViewPreferences( + themePreferences({ dark: "nord", light: "one-light", fallback: "dracula" }), + { configPath }, + ); + + const saved = readFileSync(configPath, "utf8"); + expect(() => Bun.TOML.parse(saved)).not.toThrow(); + expect(saved).toContain(' fallback = "dracula"'); + }); + + test("keeps a collapsed theme out of a table that precedes it", () => { + const home = createTempDir("hunk-adaptive-theme-late-table-home-"); + const configPath = writeUserConfig(home, [ + "[custom_theme]", + 'label = "Keep me"', + "", + "[theme]", + 'dark = "vitesse-dark"', + 'light = "vitesse-light"', + ]); + + saveGlobalViewPreferences(themePreferences("dracula"), { configPath }); + + const saved = readFileSync(configPath, "utf8"); + expect(Bun.TOML.parse(saved)).toMatchObject({ + theme: "dracula", + custom_theme: { label: "Keep me" }, + }); + }); + + test("leaves one blank line where a collapsed [theme] table used to separate its neighbours", () => { + const home = createTempDir("hunk-adaptive-theme-seam-home-"); + const configPath = writeUserConfig(home, [ + "[custom_theme]", + 'label = "Keep me"', + "", + "[theme]", + 'dark = "vitesse-dark"', + 'light = "vitesse-light"', + "", + "[extensions]", + "enabled = true", + "", + ]); + + saveGlobalViewPreferences(themePreferences("dracula"), { configPath }); + + const saved = readFileSync(configPath, "utf8"); + expect(saved).toContain('label = "Keep me"\n\n[extensions]'); + expect(saved).not.toMatch(/\n\n\n/); + expect(Bun.TOML.parse(saved)).toMatchObject({ + theme: "dracula", + custom_theme: { label: "Keep me" }, + extensions: { enabled: true }, + }); + }); + + test("collapsing a theme-only config leaves no leading blank line", () => { + const home = createTempDir("hunk-adaptive-theme-only-home-"); + const configPath = writeUserConfig(home, [ + "[theme]", + 'dark = "vitesse-dark"', + 'light = "vitesse-light"', + ]); + + saveGlobalViewPreferences(themePreferences("dracula"), { configPath }); + + expect(readFileSync(configPath, "utf8").startsWith('theme = "dracula"\n')).toBe(true); + }); + + test("writes a pair as an inline table when the file has no [theme] section", () => { + const home = createTempDir("hunk-adaptive-theme-inline-home-"); + const configPath = writeUserConfig(home, ['theme = "dracula"', "wrap_lines = false"]); + + saveGlobalViewPreferences( + { + mode: "auto", + theme: { dark: "nord", light: "one-light" }, + showLineNumbers: true, + wrapLines: false, + showHunkHeaders: true, + showMenuBar: true, + showAgentNotes: false, + copyDecorations: false, + cursorLine: "row", + }, + { configPath }, + ); + + expect(readFileSync(configPath, "utf8")).toContain( + 'theme = { dark = "nord", light = "one-light" }', + ); + }); +}); + describe("config resolution", () => { test("merges global, repo, pager, command, and CLI overrides in the right order", () => { const home = createTempDir("hunk-config-home-"); diff --git a/src/core/run/config.ts b/src/core/run/config.ts index 68ebc02b2..3fb233a15 100644 --- a/src/core/run/config.ts +++ b/src/core/run/config.ts @@ -16,6 +16,13 @@ import { LEGACY_CUSTOM_SYNTAX_COLOR_KEYS, resolveSyntaxScopeOverrides, } from "../theme/legacySyntaxScopes"; +import { + ADAPTIVE_THEME_SELECTION_KEYS, + isAdaptiveThemeSelection, + readThemeSelection, + themeSelectionsEqual, + type ThemeSelection, +} from "../theme/selection"; import { resolveGlobalConfigPath } from "./paths"; import { LEGACY_CUSTOM_SYNTAX_NOTICES, type StartupNotice } from "../process/startupNotice"; import { @@ -74,7 +81,7 @@ export type UserKeyBinding = string | readonly string[] | false; /** The view options a session persists back to config when the reader saves them. */ export interface PersistedViewPreferences { mode: LayoutMode; - theme?: string; + theme?: ThemeSelection; showLineNumbers: boolean; wrapLines: boolean; showHunkHeaders: boolean; @@ -100,11 +107,25 @@ const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = { }; const VIEW_PREFERENCES_PROMPT_CONFIG_KEY = "prompt_save_view_preferences"; + +type PersistedPreferenceValue = string | boolean | ThemeSelection | undefined; + const PERSISTED_VIEW_PREFERENCE_KEYS: Array<{ configKey: string; - value: (preferences: PersistedViewPreferences) => string | boolean | undefined; + value: (preferences: PersistedViewPreferences) => PersistedPreferenceValue; + equals?: (previous: PersistedPreferenceValue, next: PersistedPreferenceValue) => boolean; + upsert?: (source: string, value: PersistedPreferenceValue) => string; }> = [ - { configKey: "theme", value: (preferences) => preferences.theme }, + { + configKey: "theme", + value: (preferences) => preferences.theme, + equals: (previous, next) => + themeSelectionsEqual( + previous as ThemeSelection | undefined, + next as ThemeSelection | undefined, + ), + upsert: (source, value) => upsertThemeTomlValue(source, value as ThemeSelection), + }, { configKey: "mode", value: (preferences) => preferences.mode }, { configKey: "line_numbers", value: (preferences) => preferences.showLineNumbers }, { configKey: "wrap_lines", value: (preferences) => preferences.wrapLines }, @@ -172,34 +193,93 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** Serialize one primitive TOML preference value. */ -function serializeTomlPreferenceValue(value: string | boolean) { +/** Serialize one primitive or inline-table TOML preference value. */ +function serializeTomlPreferenceValue(value: string | boolean | ThemeSelection) { if (typeof value === "boolean") { return value ? "true" : "false"; } + if (isAdaptiveThemeSelection(value)) { + const entries = ADAPTIVE_THEME_SELECTION_KEYS.filter((key) => value[key] !== undefined).map( + (key) => `${key} = ${JSON.stringify(value[key])}`, + ); + return `{ ${entries.join(", ")} }`; + } + return JSON.stringify(value); } +function tomlKeyPattern(key: string) { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`^\\s*(?:${escaped}|"${escaped}"|'${escaped}')\\s*(?:\\.|=)`); +} + +function findTrailingTomlComment(line: string) { + let inSingle = false; + let inDouble = false; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + if (char === "\\" && inDouble) { + index += 1; + continue; + } + if (char === '"' && !inSingle) { + inDouble = !inDouble; + continue; + } + if (char === "'" && !inDouble) { + inSingle = !inSingle; + continue; + } + if (char === "#" && !inSingle && !inDouble) { + return line.slice(index).trimEnd(); + } + } + + return ""; +} + +function rewriteAssignmentLine(existing: string, assignment: string) { + const indent = existing.match(/^\s*/)?.[0] ?? ""; + const comment = findTrailingTomlComment(existing); + return `${indent}${assignment}${comment ? ` ${comment}` : ""}`; +} + /** Update one top-level TOML key while preserving sections and unrelated comments. */ -function upsertTopLevelTomlValue(source: string, key: string, value: string | boolean) { +function upsertTopLevelTomlValue( + source: string, + key: string, + value: string | boolean | ThemeSelection, +) { const lines = source.length > 0 ? source.split("\n") : []; const serialized = serializeTomlPreferenceValue(value); const assignment = `${key} = ${serialized}`; - let firstTableIndex = lines.findIndex((line) => /^\s*\[/.test(line)); - if (firstTableIndex < 0) { - firstTableIndex = lines.length; - } + const tableIndex = lines.findIndex((line) => /^\s*\[/.test(line)); + const firstTableIndex = tableIndex < 0 ? lines.length : tableIndex; - const keyPattern = new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=`); + const keyPattern = tomlKeyPattern(key); + const matches: number[] = []; for (let index = 0; index < firstTableIndex; index += 1) { if (keyPattern.test(lines[index] ?? "")) { - lines[index] = assignment; - return `${lines.join("\n").replace(/\n*$/, "")}\n`; + matches.push(index); + } + } + + const [first, ...duplicates] = matches; + if (first !== undefined) { + lines[first] = rewriteAssignmentLine(lines[first] ?? "", assignment); + for (const index of duplicates.reverse()) { + lines.splice(index, 1); } + return `${lines.join("\n").replace(/\n*$/, "")}\n`; } let insertAt = firstTableIndex; + if (tableIndex >= 0) { + while (insertAt > 0 && (lines[insertAt - 1] ?? "").trim().startsWith("#")) { + insertAt -= 1; + } + } const hasTableSpacer = insertAt > 0 && lines[insertAt - 1] === ""; if (hasTableSpacer) { insertAt -= 1; @@ -213,6 +293,107 @@ function upsertTopLevelTomlValue(source: string, key: string, value: string | bo return `${lines.join("\n").replace(/\n*$/, "")}\n`; } +function findThemeTableRange(lines: readonly string[]) { + const headerIndex = lines.findIndex((line) => /^\s*\[\s*theme\s*\]\s*(?:#.*)?$/.test(line)); + if (headerIndex < 0) { + return null; + } + + const nextHeaderOffset = lines.slice(headerIndex + 1).findIndex((line) => /^\s*\[/.test(line)); + let end = nextHeaderOffset < 0 ? lines.length : headerIndex + 1 + nextHeaderOffset; + while (end > headerIndex + 1) { + const line = (lines[end - 1] ?? "").trim(); + if (line.length > 0 && !line.startsWith("#")) { + break; + } + end -= 1; + } + + return { headerIndex, end }; +} + +function collapseBlankSeam(lines: string[], index: number) { + let start = index; + while (start > 0 && (lines[start - 1] ?? "").trim().length === 0) { + start -= 1; + } + + let end = index; + while (end < lines.length && (lines[end] ?? "").trim().length === 0) { + end += 1; + } + + const separators = start > 0 && end < lines.length ? [""] : []; + lines.splice(start, end - start, ...separators); +} + +function applyThemeTableKey( + lines: string[], + range: { headerIndex: number; end: number }, + key: string, + value: string | undefined, +) { + const keyPattern = tomlKeyPattern(key); + const existingIndex = lines + .slice(range.headerIndex + 1, range.end) + .findIndex((line) => keyPattern.test(line)); + if (existingIndex >= 0) { + const absolute = range.headerIndex + 1 + existingIndex; + if (value === undefined) { + lines.splice(absolute, 1); + range.end -= 1; + return; + } + lines[absolute] = rewriteAssignmentLine( + lines[absolute] ?? "", + `${key} = ${JSON.stringify(value)}`, + ); + return; + } + + if (value === undefined) { + return; + } + + const indent = (lines[range.end - 1] ?? "").match(/^\s*/)?.[0] ?? ""; + lines.splice(range.end, 0, `${indent}${key} = ${JSON.stringify(value)}`); + range.end += 1; +} + +function upsertThemeTomlValue(source: string, value: ThemeSelection | undefined) { + if (value === undefined) { + return source; + } + + const lines = source.length > 0 ? source.split("\n") : []; + const range = findThemeTableRange(lines); + if (!range) { + return upsertTopLevelTomlValue(source, "theme", value); + } + + if (isAdaptiveThemeSelection(value)) { + for (const key of ADAPTIVE_THEME_SELECTION_KEYS) { + applyThemeTableKey(lines, range, key, value[key]); + } + return `${lines.join("\n").replace(/\n*$/, "")}\n`; + } + + const firstTableIndex = lines.findIndex((line) => /^\s*\[/.test(line)); + if (firstTableIndex === range.headerIndex) { + lines.splice( + range.headerIndex, + range.end - range.headerIndex, + `theme = ${serializeTomlPreferenceValue(value)}`, + ); + return `${lines.join("\n").replace(/\n*$/, "")}\n`; + } + + lines.splice(range.headerIndex, range.end - range.headerIndex); + collapseBlankSeam(lines, range.headerIndex); + const remaining = lines.join("\n").replace(/^\n+/, "").replace(/\n*$/, ""); + return upsertTopLevelTomlValue(remaining.length > 0 ? `${remaining}\n` : "", "theme", value); +} + /** Accept only the layout names Hunk already supports. */ function normalizeLayoutMode(value: unknown): LayoutMode | undefined { return value === "auto" || value === "split" || value === "stack" ? value : undefined; @@ -250,6 +431,19 @@ function normalizeBoolean(value: unknown) { return typeof value === "boolean" ? value : undefined; } +function normalizeThemeSelection(value: unknown, origin: ConfigValueOrigin = {}) { + const read = readThemeSelection(value, `${origin.section ?? ""}theme`); + if (read === undefined) { + return undefined; + } + + if ("issue" in read) { + throw new Error(origin.file ? `${read.issue} (${origin.file})` : read.issue); + } + + return read.selection; +} + /** Accept only plain strings from config files. */ function normalizeString(value: unknown) { return typeof value === "string" && value.length > 0 ? value : undefined; @@ -333,10 +527,12 @@ export const CONFIG_REFERENCE_OPTIONS: readonly ConfigReferenceOption[] = [ { key: "theme", property: "theme", - type: "string", - accepted: "a built-in theme id or `custom`", + type: "string or table", + accepted: + "a built-in theme id, `custom`, `auto`, or a `[theme]` table setting `dark` and `light` (plus an optional `fallback`)", runtimeDefault: DEFAULT_THEME_ID, - description: "Select the active color theme.", + description: + "Select the active color theme, or one theme per terminal background. A `[theme]` table follows the terminal between its `dark` and `light` ids, using `fallback` (else `dark`) when the terminal does not report a background.", }, { key: "watch", @@ -947,8 +1143,17 @@ function resolveExtensionsConfig( }; } +interface ConfigValueOrigin { + file?: string; + section?: string; +} + /** Normalize one cataloged config value according to its runtime property. */ -function normalizeConfigReferenceValue(property: keyof CommonOptions, value: unknown) { +function normalizeConfigReferenceValue( + property: keyof CommonOptions, + value: unknown, + origin: ConfigValueOrigin = {}, +) { switch (property) { case "mode": return normalizeLayoutMode(value); @@ -957,7 +1162,7 @@ function normalizeConfigReferenceValue(property: keyof CommonOptions, value: unk case "vcs": return normalizeVcsMode(value); case "theme": - return normalizeString(value); + return normalizeThemeSelection(value, origin); case "tabWidth": return normalizeTabWidth(value); case "fileGap": @@ -972,7 +1177,10 @@ function normalizeConfigReferenceValue(property: keyof CommonOptions, value: unk } /** Read the view preferences stored at one TOML object level. */ -function readConfigPreferences(source: Record): CommonOptions { +function readConfigPreferences( + source: Record, + origin: ConfigValueOrigin = {}, +): CommonOptions { const preferences: CommonOptions = {}; const mutable = preferences as Record; @@ -983,7 +1191,7 @@ function readConfigPreferences(source: Record): CommonOptions { ]; let normalized: unknown; for (const key of runtimeKeys) { - normalized = normalizeConfigReferenceValue(option.property, source[key]); + normalized = normalizeConfigReferenceValue(option.property, source[key], origin); if (normalized !== undefined) { break; } @@ -1040,17 +1248,27 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti } /** Apply one parsed config object, including command/pager sections, to the current invocation. */ -function resolveConfigLayer(source: Record, input: CliInput): CommonOptions { - let resolved = readConfigPreferences(source); +function resolveConfigLayer( + source: Record, + input: CliInput, + file?: string, +): CommonOptions { + let resolved = readConfigPreferences(source, { file }); const commandSection = CONFIG_COMMAND_SECTIONS[input.kind] ? source[input.kind] : undefined; if (isRecord(commandSection)) { - resolved = mergeOptions(resolved, readConfigPreferences(commandSection)); + resolved = mergeOptions( + resolved, + readConfigPreferences(commandSection, { file, section: `${input.kind}.` }), + ); } const pagerSection = source.pager; if (input.options.pager && isRecord(pagerSection)) { - resolved = mergeOptions(resolved, readConfigPreferences(pagerSection)); + resolved = mergeOptions( + resolved, + readConfigPreferences(pagerSection, { file, section: "pager." }), + ); } return resolved; @@ -1116,7 +1334,10 @@ export function diffPersistedViewPreferences( for (const key of PERSISTED_VIEW_PREFERENCE_KEYS) { const previousValue = key.value(previous); const nextValue = key.value(next); - if (previousValue === nextValue) { + const unchanged = key.equals + ? key.equals(previousValue, nextValue) + : previousValue === nextValue; + if (unchanged) { continue; } @@ -1143,9 +1364,13 @@ export function saveGlobalViewPreferences( let nextSource = readConfigSource(configPath); for (const key of PERSISTED_VIEW_PREFERENCE_KEYS) { const value = key.value(preferences); - if (value !== undefined) { - nextSource = upsertTopLevelTomlValue(nextSource, key.configKey, value); + if (value === undefined) { + continue; } + + nextSource = key.upsert + ? key.upsert(nextSource, value) + : upsertTopLevelTomlValue(nextSource, key.configKey, value); } writeConfigSource(configPath, nextSource); @@ -1266,7 +1491,7 @@ export function resolveConfiguredCliInput( if (userConfigPath && sources.userConfig) { const userConfig = sources.userConfig; - const userLayer = resolveConfigLayer(userConfig, input); + const userLayer = resolveConfigLayer(userConfig, input, userConfigPath); explicitVcsId = userLayer.vcs ?? explicitVcsId; resolvedOptions = mergeOptions(resolvedOptions, userLayer); applyCustomThemeLayer(readCustomThemes(userConfig)); @@ -1276,7 +1501,7 @@ export function resolveConfiguredCliInput( if (repoConfigPath && sources.repoConfig) { const repoConfig = sources.repoConfig; - const repoLayer = resolveConfigLayer(repoConfig, input); + const repoLayer = resolveConfigLayer(repoConfig, input, repoConfigPath); explicitVcsId = repoLayer.vcs ?? explicitVcsId; resolvedOptions = mergeOptions(resolvedOptions, repoLayer); applyCustomThemeLayer(readCustomThemes(repoConfig)); @@ -1313,8 +1538,12 @@ export function resolveConfiguredCliInput( // Only the legacy `custom` id is a hard error: every other unknown id may still name a theme an // extension contributes later, so those fall back to the default theme instead of failing startup. + const themeSelection = resolvedOptions.theme; + const selectedThemeIds = isAdaptiveThemeSelection(themeSelection) + ? ADAPTIVE_THEME_SELECTION_KEYS.map((key) => themeSelection[key]) + : [themeSelection]; if ( - resolvedOptions.theme === LEGACY_CUSTOM_THEME_ID && + selectedThemeIds.includes(LEGACY_CUSTOM_THEME_ID) && !resolvedCustomThemes.some((theme) => theme.id === LEGACY_CUSTOM_THEME_ID) ) { throw new Error('Expected a [custom_theme] table when config selects theme = "custom".'); diff --git a/src/core/theme/selection.test.ts b/src/core/theme/selection.test.ts new file mode 100644 index 000000000..2f1de8900 --- /dev/null +++ b/src/core/theme/selection.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import { + chooseThemeSelectionId, + isAdaptiveThemeSelection, + readThemeSelection, + themeSelectionsEqual, + themeSelectionNeedsTerminalMode, +} from "./selection"; + +describe("readThemeSelection", () => { + test("accepts one theme id and reports nothing for an unset or empty value", () => { + expect(readThemeSelection("nord")).toEqual({ selection: "nord" }); + expect(readThemeSelection("auto")).toEqual({ selection: "auto" }); + expect(readThemeSelection(undefined)).toBeUndefined(); + expect(readThemeSelection("")).toBeUndefined(); + }); + + test("accepts an adaptive pair with and without a fallback", () => { + expect(readThemeSelection({ dark: "catppuccin-mocha", light: "catppuccin-latte" })).toEqual({ + selection: { dark: "catppuccin-mocha", light: "catppuccin-latte" }, + }); + expect( + readThemeSelection({ dark: "vitesse-dark", light: "vitesse-light", fallback: "nord" }), + ).toEqual({ selection: { dark: "vitesse-dark", light: "vitesse-light", fallback: "nord" } }); + }); + + test("requires both backgrounds so neither terminal falls back to a Hunk default", () => { + const read = readThemeSelection({ dark: "nord" }); + expect(read).toEqual({ + issue: "Expected [theme] to set both `dark` and `light` to theme ids.", + }); + }); + + test("rejects unknown keys so a typo surfaces instead of doing nothing", () => { + const read = readThemeSelection({ dark: "nord", light: "one-light", defualt: "nord" }); + expect(read).toEqual({ + issue: "Expected [theme] to contain only dark, light, fallback. Unexpected: `defualt`.", + }); + }); + + test("rejects non-string ids and values that are neither an id nor a table", () => { + expect(readThemeSelection({ dark: "nord", light: "one-light", fallback: 7 })).toEqual({ + issue: "Expected theme.fallback to be a theme id.", + }); + expect(readThemeSelection(["nord"])).toEqual({ + issue: "Expected theme to be a theme id or a table of theme ids.", + }); + }); + + test("names the key path it was given so nested tables explain themselves", () => { + expect(readThemeSelection({ dark: 1 }, "pager.theme")).toEqual({ + issue: "Expected [pager.theme] to set both `dark` and `light` to theme ids.", + }); + }); +}); + +describe("chooseThemeSelectionId", () => { + const adaptive = { dark: "vitesse-dark", light: "vitesse-light" }; + + test("passes a plain id through for every background", () => { + expect(chooseThemeSelectionId("nord", "light")).toBe("nord"); + expect(chooseThemeSelectionId("nord", null)).toBe("nord"); + expect(chooseThemeSelectionId(undefined, "dark")).toBeUndefined(); + }); + + test("follows the detected background across an adaptive pair", () => { + expect(chooseThemeSelectionId(adaptive, "light")).toBe("vitesse-light"); + expect(chooseThemeSelectionId(adaptive, "dark")).toBe("vitesse-dark"); + }); + + test("takes fallback when the terminal never answered, and dark when none is set", () => { + expect(chooseThemeSelectionId({ ...adaptive, fallback: "nord" }, null)).toBe("nord"); + expect(chooseThemeSelectionId(adaptive, null)).toBe("vitesse-dark"); + expect(chooseThemeSelectionId(adaptive, undefined)).toBe("vitesse-dark"); + }); +}); + +describe("selection predicates", () => { + test("probes the terminal only when the answer can change the theme", () => { + expect(themeSelectionNeedsTerminalMode("auto")).toBe(true); + expect(themeSelectionNeedsTerminalMode({ dark: "nord", light: "one-light" })).toBe(true); + expect(themeSelectionNeedsTerminalMode("nord")).toBe(false); + expect(themeSelectionNeedsTerminalMode(undefined)).toBe(false); + }); + + test("narrows adaptive pairs away from ids", () => { + expect(isAdaptiveThemeSelection({ dark: "nord", light: "one-light" })).toBe(true); + expect(isAdaptiveThemeSelection("nord")).toBe(false); + expect(isAdaptiveThemeSelection(undefined)).toBe(false); + }); + + test("compares pairs by value so an untouched preference never looks dirty", () => { + expect( + themeSelectionsEqual( + { dark: "nord", light: "one-light" }, + { dark: "nord", light: "one-light" }, + ), + ).toBe(true); + expect( + themeSelectionsEqual( + { dark: "nord", light: "one-light" }, + { dark: "nord", light: "one-light", fallback: "nord" }, + ), + ).toBe(false); + expect(themeSelectionsEqual("nord", { dark: "nord", light: "one-light" })).toBe(false); + expect(themeSelectionsEqual("nord", "nord")).toBe(true); + }); +}); diff --git a/src/core/theme/selection.ts b/src/core/theme/selection.ts new file mode 100644 index 000000000..733965c03 --- /dev/null +++ b/src/core/theme/selection.ts @@ -0,0 +1,99 @@ +export type ThemeSelectionMode = "light" | "dark"; + +export interface AdaptiveThemeSelection { + dark: string; + light: string; + /** Theme used when the terminal never answered the background probe. Defaults to `dark`. */ + fallback?: string; +} + +export type ThemeSelection = string | AdaptiveThemeSelection; + +export const AUTO_THEME_ID = "auto"; + +export const ADAPTIVE_THEME_SELECTION_KEYS = ["dark", "light", "fallback"] as const; + +export function isAdaptiveThemeSelection( + selection: ThemeSelection | undefined, +): selection is AdaptiveThemeSelection { + return typeof selection === "object" && selection !== null && !Array.isArray(selection); +} + +export function chooseThemeSelectionId( + selection: ThemeSelection | undefined, + mode: ThemeSelectionMode | null | undefined, +): string | undefined { + if (!isAdaptiveThemeSelection(selection)) { + return selection; + } + + if (mode === "light") return selection.light; + if (mode === "dark") return selection.dark; + return selection.fallback ?? selection.dark; +} + +export function themeSelectionNeedsTerminalMode(selection: ThemeSelection | undefined): boolean { + return selection === AUTO_THEME_ID || isAdaptiveThemeSelection(selection); +} + +export function themeSelectionsEqual( + left: ThemeSelection | undefined, + right: ThemeSelection | undefined, +): boolean { + if (isAdaptiveThemeSelection(left) && isAdaptiveThemeSelection(right)) { + return ( + left.dark === right.dark && left.light === right.light && left.fallback === right.fallback + ); + } + + return left === right; +} + +/** Read a `theme` config value into a selection, or explain why the table is unusable. */ +export function readThemeSelection( + value: unknown, + keyPath = "theme", +): { selection: ThemeSelection } | { issue: string } | undefined { + if (value === undefined) { + return undefined; + } + + if (typeof value === "string") { + return value.length > 0 ? { selection: value } : undefined; + } + + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { issue: `Expected ${keyPath} to be a theme id or a table of theme ids.` }; + } + + const table = value as Record; + const unknownKeys = Object.keys(table).filter( + (key) => !(ADAPTIVE_THEME_SELECTION_KEYS as readonly string[]).includes(key), + ); + if (unknownKeys.length > 0) { + return { + issue: `Expected [${keyPath}] to contain only ${ADAPTIVE_THEME_SELECTION_KEYS.join(", ")}. Unexpected: ${unknownKeys + .map((key) => `\`${key}\``) + .join(", ")}.`, + }; + } + + const readId = (key: string) => { + const entry = table[key]; + return typeof entry === "string" && entry.length > 0 ? entry : undefined; + }; + const dark = readId("dark"); + const light = readId("light"); + if (dark === undefined || light === undefined) { + return { + issue: `Expected [${keyPath}] to set both \`dark\` and \`light\` to theme ids.`, + }; + } + + if ("fallback" in table && readId("fallback") === undefined) { + return { issue: `Expected ${keyPath}.fallback to be a theme id.` }; + } + + const fallback = readId("fallback"); + return { selection: fallback === undefined ? { dark, light } : { dark, light, fallback } }; +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 9c5cdd1d1..15d03f2dd 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -234,6 +234,7 @@ export function App({ activeTheme, baseTheme, themeId, + themeSelection, themeSelectorItems, themeSelectorOpen, themeSelectorSelectedIndex, @@ -253,7 +254,7 @@ export function App({ const currentViewPreferences = useMemo( () => ({ mode: layoutMode, - theme: themeId, + theme: themeSelection, showLineNumbers, wrapLines, showHunkHeaders, @@ -270,7 +271,7 @@ export function App({ showHunkHeaders, showLineNumbers, showMenuBar, - themeId, + themeSelection, wrapLines, ], ); @@ -860,7 +861,7 @@ export function App({ sourceLabel: bootstrap.changeset.sourceLabel, view: { layoutMode, - themeId, + themeSelection, showAgentNotes, showHunkHeaders, showLineNumbers, diff --git a/src/ui/currentReviewRefresh.test.ts b/src/ui/currentReviewRefresh.test.ts index 3bb008312..935d05dcf 100644 --- a/src/ui/currentReviewRefresh.test.ts +++ b/src/ui/currentReviewRefresh.test.ts @@ -8,7 +8,7 @@ import { const currentView: CurrentReviewViewOptions = { layoutMode: "split", - themeId: "nord", + themeSelection: "nord", showAgentNotes: false, showHunkHeaders: false, showLineNumbers: false, @@ -41,6 +41,23 @@ describe("current review refresh descriptor", () => { expect(input.options).toEqual({ mode: "stack", theme: "dracula", watch: true, tabWidth: 8 }); }); + test("carries an adaptive theme pair through a refresh instead of freezing one side", () => { + const input: CliInput = { + kind: "diff", + left: "before.ts", + right: "after.ts", + options: { theme: "dracula" }, + }; + const adaptive = { dark: "vitesse-dark", light: "vitesse-light" }; + + const refreshed = withCurrentReviewViewOptions(input, { + ...currentView, + themeSelection: adaptive, + }); + + expect(refreshed.options.theme).toEqual(adaptive); + }); + test("attaches the source path only to VCS inputs", () => { const fileRequest = deriveWorkspaceRefreshRequest({ input: { diff --git a/src/ui/currentReviewRefresh.ts b/src/ui/currentReviewRefresh.ts index debdf5ba9..4ee2a29f5 100644 --- a/src/ui/currentReviewRefresh.ts +++ b/src/ui/currentReviewRefresh.ts @@ -12,12 +12,13 @@ import type { SessionReloadReason } from "../extension-api/types"; import type { CliInput, LayoutMode } from "../core/run/commandInputs"; import { canReloadInput } from "../core/run/inputReload"; +import type { ThemeSelection } from "../core/theme/selection"; import { isVcsReviewInput } from "../core/vcs"; /** Live view settings that must survive an in-session review refresh. */ export interface CurrentReviewViewOptions { layoutMode: LayoutMode; - themeId: string; + themeSelection: ThemeSelection; showAgentNotes: boolean; showHunkHeaders: boolean; showLineNumbers: boolean; @@ -53,7 +54,7 @@ export function withCurrentReviewViewOptions( options: { ...input.options, mode: view.layoutMode, - theme: view.themeId, + theme: view.themeSelection, agentNotes: view.showAgentNotes, hunkHeaders: view.showHunkHeaders, lineNumbers: view.showLineNumbers, diff --git a/src/ui/hooks/useCurrentReviewRefreshController.test.tsx b/src/ui/hooks/useCurrentReviewRefreshController.test.tsx index 13d1c5626..3824ca34a 100644 --- a/src/ui/hooks/useCurrentReviewRefreshController.test.tsx +++ b/src/ui/hooks/useCurrentReviewRefreshController.test.tsx @@ -3,6 +3,7 @@ import { testRender } from "@opentui/react/test-utils"; import { act, useState } from "react"; import { createWatchTestRuntime } from "../../../test/helpers/watchTest"; import type { CliInput } from "../../core/run/commandInputs"; +import type { ThemeSelection } from "../../core/theme/selection"; import type { ReloadSessionOptions, ReloadedSessionResult } from "../../session/types"; import type { WorkspaceRefreshRequest } from "../currentReviewRefresh"; import { @@ -32,12 +33,12 @@ function RefreshHarness({ input: CliInput; onController: (controller: CurrentReviewRefreshController) => void; onRegister: (request: WorkspaceRefreshRequest) => () => void; - onSetTheme?: (setTheme: (themeId: string) => void) => void; + onSetTheme?: (setTheme: (themeSelection: ThemeSelection) => void) => void; onReload: (input: CliInput, options?: ReloadSessionOptions) => Promise; onWatchReloadPending?: () => void; watchRuntime?: Parameters[0]["watchRuntime"]; }) { - const [themeId, setThemeId] = useState("dracula"); + const [themeSelection, setThemeSelection] = useState("dracula"); const controller = useCurrentReviewRefreshController({ input, onRegisterWorkspaceRefreshRequest: onRegister, @@ -47,7 +48,7 @@ function RefreshHarness({ sourceLabel: "/repo", view: { layoutMode: "stack", - themeId, + themeSelection, showAgentNotes: true, showHunkHeaders: true, showLineNumbers: true, @@ -57,11 +58,11 @@ function RefreshHarness({ watchRuntime, }); onController(controller); - onSetTheme?.(setThemeId); + onSetTheme?.(setThemeSelection); return ( - {themeId} + {JSON.stringify(themeSelection)} ); } @@ -79,7 +80,7 @@ describe("useCurrentReviewRefreshController", () => { const cleaned: WorkspaceRefreshRequest[] = []; let active: WorkspaceRefreshRequest | undefined; let controller!: CurrentReviewRefreshController; - let setTheme!: (themeId: string) => void; + let setTheme!: (themeSelection: ThemeSelection) => void; const reloads: Array<{ input: CliInput; options?: ReloadSessionOptions }> = []; const setup = await testRender( { expect(active).toBeUndefined(); }); + test("re-registers an adaptive theme pair as a pair so a refresh still follows the terminal", async () => { + const registered: WorkspaceRefreshRequest[] = []; + let setTheme!: (themeSelection: ThemeSelection) => void; + const adaptive = { dark: "vitesse-dark", light: "vitesse-light" }; + const setup = await testRender( + {}} + onRegister={(request) => { + registered.push(request); + return () => {}; + }} + onSetTheme={(value) => { + setTheme = value; + }} + onReload={async () => reloadedResult} + />, + { width: 20, height: 4 }, + ); + + try { + await act(async () => setup.renderOnce()); + await act(async () => { + setTheme(adaptive); + await setup.renderOnce(); + }); + + expect(registered).toHaveLength(2); + expect(registered[1]?.nextInput.options.theme).toEqual(adaptive); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + test("manual reload reports a rejection while the general operation preserves it", async () => { let controller!: CurrentReviewRefreshController; const failure = new Error("reload failed"); diff --git a/src/ui/hooks/useCurrentReviewRefreshController.ts b/src/ui/hooks/useCurrentReviewRefreshController.ts index 05c78046a..333334fdb 100644 --- a/src/ui/hooks/useCurrentReviewRefreshController.ts +++ b/src/ui/hooks/useCurrentReviewRefreshController.ts @@ -68,7 +68,7 @@ export function useCurrentReviewRefreshController({ view.showHunkHeaders, view.showLineNumbers, view.showMenuBar, - view.themeId, + view.themeSelection, view.wrapLines, ], ); diff --git a/src/ui/hooks/useThemeSelectorController.test.tsx b/src/ui/hooks/useThemeSelectorController.test.tsx index f69dc8e3e..72a8be270 100644 --- a/src/ui/hooks/useThemeSelectorController.test.tsx +++ b/src/ui/hooks/useThemeSelectorController.test.tsx @@ -56,6 +56,58 @@ function customTheme( const noNotice = () => {}; describe("useThemeSelectorController", () => { + test("draws an adaptive pair from the detected background and keeps the pair committed", async () => { + const adaptive = { dark: "vitesse-dark", light: "one-light" }; + const dark = await renderThemeSelectorController({ + initialTheme: adaptive, + initialThemeMode: "dark", + onTransientNotice: noNotice, + transparentBackground: false, + }); + + try { + expect(dark.controller.baseTheme.id).toBe("vitesse-dark"); + expect(dark.controller.themeId).toBe("vitesse-dark"); + expect(dark.controller.themeSelection).toEqual(adaptive); + } finally { + await destroyController(dark.setup); + } + + const light = await renderThemeSelectorController({ + initialTheme: adaptive, + initialThemeMode: "light", + onTransientNotice: noNotice, + transparentBackground: false, + }); + + try { + expect(light.controller.baseTheme.id).toBe("one-light"); + expect(light.controller.themeSelection).toEqual(adaptive); + } finally { + await destroyController(light.setup); + } + }); + + test("commits one picked theme over an adaptive pair", async () => { + const harness = await renderThemeSelectorController({ + initialTheme: { dark: "vitesse-dark", light: "one-light" }, + initialThemeMode: "dark", + onTransientNotice: noNotice, + transparentBackground: false, + }); + + try { + const draculaIndex = availableThemes().findIndex((theme) => theme.id === "dracula"); + await act(async () => harness.controller.acceptThemeSelectorItem(draculaIndex)); + + expect(harness.controller.themeSelection).toBe("dracula"); + expect(harness.controller.themeId).toBe("dracula"); + expect(harness.controller.baseTheme.id).toBe("dracula"); + } finally { + await destroyController(harness.setup); + } + }); + test("resolves auto initialization from the detected light or dark terminal mode", async () => { const light = await renderThemeSelectorController({ initialTheme: "auto", diff --git a/src/ui/hooks/useThemeSelectorController.ts b/src/ui/hooks/useThemeSelectorController.ts index 7db6223da..f76d6944e 100644 --- a/src/ui/hooks/useThemeSelectorController.ts +++ b/src/ui/hooks/useThemeSelectorController.ts @@ -1,11 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { TerminalThemeMode } from "../../core/theme/detection"; +import { + AUTO_THEME_ID, + chooseThemeSelectionId, + type ThemeSelection, +} from "../../core/theme/selection"; import type { NamedCustomThemeConfig } from "../../extension-api/types"; import type { ThemeSelectorItem } from "../components/chrome/ThemeSelectorDialog"; import { availableThemes, resolveTheme, withTransparentSurfaces } from "../themes"; interface ThemeSelectorControllerState { - committedThemeId: string; + committedThemeSelection: ThemeSelection; open: boolean; previewThemeId: string | null; selectedThemeId: string | null; @@ -13,7 +18,7 @@ interface ThemeSelectorControllerState { export interface UseThemeSelectorControllerOptions { customThemes?: readonly NamedCustomThemeConfig[]; - initialTheme?: string; + initialTheme?: ThemeSelection; initialThemeMode?: TerminalThemeMode | null; onTransientNotice: (text: string) => void; transparentBackground: boolean; @@ -31,7 +36,7 @@ export function useThemeSelectorController({ // incoming record, but they must not reinterpret an in-session theme choice. const [detectedThemeMode] = useState(initialThemeMode); const [state, setState] = useState(() => ({ - committedThemeId: resolveTheme(initialTheme, initialThemeMode ?? null, customThemes).id, + committedThemeSelection: initialTheme ?? resolveTheme(undefined, initialThemeMode ?? null).id, open: false, previewThemeId: null, selectedThemeId: null, @@ -39,9 +44,13 @@ export function useThemeSelectorController({ const themeOptions = useMemo(() => availableThemes(customThemes), [customThemes]); const committedTheme = useMemo( - () => resolveTheme(state.committedThemeId, detectedThemeMode ?? null, customThemes), - [customThemes, detectedThemeMode, state.committedThemeId], + () => resolveTheme(state.committedThemeSelection, detectedThemeMode ?? null, customThemes), + [customThemes, detectedThemeMode, state.committedThemeSelection], ); + const committedThemeId = useMemo(() => { + const chosen = chooseThemeSelectionId(state.committedThemeSelection, detectedThemeMode ?? null); + return chosen === undefined || chosen === AUTO_THEME_ID ? committedTheme.id : chosen; + }, [committedTheme.id, detectedThemeMode, state.committedThemeSelection]); const committedIndex = themeOptions.findIndex((theme) => theme.id === committedTheme.id); const storedSelectedIndex = themeOptions.findIndex((theme) => theme.id === state.selectedThemeId); const selectedIndex = @@ -172,7 +181,7 @@ export function useThemeSelectorController({ selectedThemeIdRef.current = item.id; setState((current) => ({ ...current, - committedThemeId: item.id, + committedThemeSelection: item.id, open: false, previewThemeId: null, selectedThemeId: item.id, @@ -200,7 +209,8 @@ export function useThemeSelectorController({ return { activeTheme, baseTheme, - themeId: state.committedThemeId, + themeId: committedThemeId, + themeSelection: state.committedThemeSelection, themeSelectorItems: items, themeSelectorOpen: state.open, themeSelectorSelectedIndex: selectedIndex, diff --git a/src/ui/themes.test.ts b/src/ui/themes.test.ts index 2c6cbdb98..d43076514 100644 --- a/src/ui/themes.test.ts +++ b/src/ui/themes.test.ts @@ -98,6 +98,28 @@ describe("themes", () => { expect(resolveTheme("auto", "light").id).toBe(DEFAULT_LIGHT_THEME_ID); }); + test("follows an adaptive pair across terminal backgrounds", () => { + const adaptive = { dark: "vitesse-dark", light: "one-light" }; + expect(resolveTheme(adaptive, "dark").id).toBe("vitesse-dark"); + expect(resolveTheme(adaptive, "light").id).toBe("one-light"); + expect(resolveTheme(adaptive, null).id).toBe("vitesse-dark"); + expect(resolveTheme({ ...adaptive, fallback: "nord" }, null).id).toBe("nord"); + expect(resolveTheme({ dark: "graphite", light: "paper" }, "light").id).toBe( + DEFAULT_LIGHT_THEME_ID, + ); + expect(resolveTheme({ dark: "nope", light: "one-light" }, "dark").id).toBe( + DEFAULT_DARK_THEME_ID, + ); + }); + + test("resolves a custom theme named by one side of an adaptive pair", () => { + const resolved = resolveTheme({ dark: "ocean", light: "one-light" }, "dark", [ + { id: "ocean", base: "nord", label: "Ocean", accent: "#7fd1ff" }, + ]); + expect(resolved.id).toBe("ocean"); + expect(resolved.accent).toBe("#7fd1ff"); + }); + 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"); diff --git a/src/ui/themes.ts b/src/ui/themes.ts index 13ffaa372..1721583e3 100644 --- a/src/ui/themes.ts +++ b/src/ui/themes.ts @@ -1,5 +1,6 @@ import type { ThemeMode } from "@opentui/core"; import { LEGACY_CUSTOM_THEME_ID } from "../core/theme/customThemes"; +import { chooseThemeSelectionId, type ThemeSelection } from "../core/theme/selection"; import { resolveSyntaxScopeOverrides } from "../core/theme/legacySyntaxScopes"; import type { NamedCustomThemeConfig } from "../extension-api/types"; import { blendHex, contrastRatio, hexColorDistance, relativeLuminance } from "./lib/color"; @@ -365,17 +366,18 @@ export function availableThemes(customThemes: readonly NamedCustomThemeConfig[] } /** - * Resolve a named theme, including terminal-background auto mode and custom themes. + * Resolve a theme selection, including terminal-background auto mode and custom themes. * * Custom themes are matched before bundled ids so a custom theme that reuses a * deprecated built-in alias still resolves to what the user actually defined. */ export function resolveTheme( - requested: string | undefined, + selection: ThemeSelection | undefined, themeMode: ThemeMode | null, customThemes: readonly NamedCustomThemeConfig[] = [], ) { - if (requested === "auto") { + const requested = chooseThemeSelectionId(selection, themeMode); + if (requested === undefined || requested === "auto") { return fallbackTheme(themeMode); } diff --git a/test/pty/chrome.test.ts b/test/pty/chrome.test.ts index 0eea9d935..e2085c774 100644 --- a/test/pty/chrome.test.ts +++ b/test/pty/chrome.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { availableThemes } from "../../src/ui/themes"; @@ -164,6 +164,60 @@ describe("PTY chrome", () => { } }); + test("an adaptive [theme] table stays committed until a theme is picked", async () => { + const configHome = mkdtempSync(join(tmpdir(), "hunk-tuistory-adaptive-theme-")); + const configPath = join(configHome, "hunk", "config.toml"); + mkdirSync(join(configHome, "hunk"), { recursive: true }); + writeFileSync( + configPath, + ["[theme]", 'dark = "vitesse-dark"', 'light = "one-light"', ""].join("\n"), + ); + const fixture = harness.createMultiHunkFilePair(); + const session = await harness.launchHunk({ + args: ["diff", "--files", fixture.before, fixture.after], + cwd: fixture.dir, + cols: 120, + rows: 24, + env: { XDG_CONFIG_HOME: configHome }, + }); + + try { + await session.waitForText(/line60/, { timeout: 15_000 }); + + // The pair resolves to one of its own ids, not a Hunk default. + await session.press("t"); + const selector = await session.waitForText(/Theme selector/, { timeout: 5_000 }); + expect(selector).toMatch(/(vitesse-dark|one-light)\s+active/); + await session.press("escape"); + await harness.waitForSnapshot(session, (text) => !text.includes("Theme selector"), 5_000); + + // Picking a theme collapses the pair, and the prompt shows that before writing. + await session.press("t"); + await session.waitForText(/Theme selector/, { timeout: 5_000 }); + await session.press("down"); + await session.press("enter"); + await harness.waitForSnapshot(session, (text) => !text.includes("Theme selector"), 5_000); + + await session.press("q"); + const prompt = await session.waitForText(/Save view preferences\?/, { timeout: 5_000 }); + expect(prompt).toContain('- theme = { dark = "vitesse-dark", light = "one-light" }'); + + await session.click(/enter\/s save/); + + const deadline = Date.now() + 5_000; + while (Date.now() < deadline && readFileSync(configPath, "utf8").includes("[theme]")) { + await sleep(50); + } + + const saved = readFileSync(configPath, "utf8"); + expect(saved).not.toContain("[theme]"); + expect(saved).toMatch(/^theme = "[a-z0-9-]+"$/m); + } finally { + session.close(); + rmSync(configHome, { recursive: true, force: true }); + } + }); + test("filter focus narrows the visible review stream in the live app", async () => { const fixture = harness.createTwoFileRepoFixture(); const session = await harness.launchHunk({ diff --git a/website/src/content/docs/docs/configure/themes.md b/website/src/content/docs/docs/configure/themes.md index fbc402114..86a3b226d 100644 --- a/website/src/content/docs/docs/configure/themes.md +++ b/website/src/content/docs/docs/configure/themes.md @@ -11,6 +11,21 @@ theme = "github-dark-default" Use `theme = "auto"` to query the terminal background at startup. Hunk chooses `github-light-default` for light terminals, `github-dark-default` for dark terminals, and falls back to dark if the terminal does not answer. +## Follow the terminal between two themes you chose + +Write `theme` as a table to name the theme for each background yourself: + +```toml +[theme] +dark = "catppuccin-mocha" +light = "catppuccin-latte" +fallback = "github-dark-default" +``` + +`dark` and `light` are required. Hunk queries the terminal background the way `auto` does, then draws the matching side. The optional `fallback` covers sessions where Hunk never gets an answer: terminals that ignore the query, and captured pager hosts such as LazyGit, where Hunk never asks. Without it those sessions use `dark`. Both sides accept built-in ids, custom theme ids, and the compatibility aliases. + +A `--theme ` flag overrides the table for one run. Picking a theme in the app replaces the pair with that single id, and the save-on-quit prompt shows the change before writing it. + ## Create a custom theme ```toml diff --git a/website/src/content/docs/docs/reference/config.md b/website/src/content/docs/docs/reference/config.md index b98146a58..f48fe8f85 100644 --- a/website/src/content/docs/docs/reference/config.md +++ b/website/src/content/docs/docs/reference/config.md @@ -52,10 +52,10 @@ Select the version-control adapter explicitly. An explicit id outranks detection **`theme`** -Select the active color theme. +Select the active color theme, or one theme per terminal background. A `[theme]` table follows the terminal between its `dark` and `light` ids, using `fallback` (else `dark`) when the terminal does not report a background. -- **Type:** string -- **Accepted:** a built-in theme id or `custom` +- **Type:** string or table +- **Accepted:** a built-in theme id, `custom`, `auto`, or a `[theme]` table setting `dark` and `light` (plus an optional `fallback`) - **Built-in default:** `github-dark-default` ---