From a169322da45604c09457972c84cb427a2ab059a5 Mon Sep 17 00:00:00 2001 From: Mason McElvain <52104630+masonmcelvain@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:16:09 -0600 Subject: [PATCH 1/4] feat(theme): accept a light/dark theme table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `theme = "auto"` only ever chose between github-light-default and github-dark-default, so a reader who wanted one-light and one-dark-pro had no way to follow their terminal. Accept a `[theme]` table naming both sides instead, with an optional `fallback` for terminals that never answer the background probe, following Helix's config shape. The committed preference now stays whatever config asked for until someone picks a theme in the selector, so quitting without touching themes no longer rewrites `auto` — or a pair — into the one id it happened to resolve to. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013Y3vNR6iJEH4epjQo8JARe --- .changeset/tidy-hounds-repeat.md | 5 + docs/themes.md | 19 ++ src/app/startup.test.ts | 57 +++++ src/app/startup.ts | 3 +- src/core/bootstrap.ts | 3 +- src/core/run/commandInputs.ts | 3 +- src/core/run/config.test.ts | 214 ++++++++++++++++++ src/core/run/config.ts | 152 +++++++++++-- src/core/theme/selection.test.ts | 108 +++++++++ src/core/theme/selection.ts | 99 ++++++++ src/ui/App.tsx | 5 +- .../hooks/useThemeSelectorController.test.tsx | 52 +++++ src/ui/hooks/useThemeSelectorController.ts | 24 +- src/ui/themes.test.ts | 22 ++ src/ui/themes.ts | 8 +- test/pty/chrome.test.ts | 56 ++++- .../src/content/docs/docs/configure/themes.md | 15 ++ .../src/content/docs/docs/reference/config.md | 6 +- 18 files changed, 818 insertions(+), 33 deletions(-) create mode 100644 .changeset/tidy-hounds-repeat.md create mode 100644 src/core/theme/selection.test.ts create mode 100644 src/core/theme/selection.ts 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..4aa2ce3ea 100644 --- a/src/core/run/config.test.ts +++ b/src/core/run/config.test.ts @@ -163,6 +163,220 @@ 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]"); + }); + + 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..ec51f1023 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,17 +193,28 @@ 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); } /** 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}`; @@ -213,6 +245,72 @@ 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)); + const end = nextHeaderOffset < 0 ? lines.length : headerIndex + 1 + nextHeaderOffset; + return { headerIndex, end }; +} + +function applyThemeTableKey( + lines: string[], + range: { headerIndex: number; end: number }, + key: string, + value: string | undefined, +) { + const keyPattern = new RegExp(`^\\s*${key}\\s*=`); + 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] = `${key} = ${JSON.stringify(value)}`; + return; + } + + if (value === undefined) { + return; + } + + let insertAt = range.end; + while (insertAt > range.headerIndex + 1 && (lines[insertAt - 1] ?? "").trim().length === 0) { + insertAt -= 1; + } + lines.splice(insertAt, 0, `${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`; + } + + lines.splice(range.headerIndex, range.end - range.headerIndex); + return upsertTopLevelTomlValue(`${lines.join("\n").replace(/\n*$/, "")}\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 +348,19 @@ function normalizeBoolean(value: unknown) { return typeof value === "boolean" ? value : undefined; } +function normalizeThemeSelection(value: unknown) { + const read = readThemeSelection(value); + if (read === undefined) { + return undefined; + } + + if ("issue" in read) { + throw new Error(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 +444,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", @@ -957,7 +1070,7 @@ function normalizeConfigReferenceValue(property: keyof CommonOptions, value: unk case "vcs": return normalizeVcsMode(value); case "theme": - return normalizeString(value); + return normalizeThemeSelection(value); case "tabWidth": return normalizeTabWidth(value); case "fileGap": @@ -1116,7 +1229,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 +1259,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); @@ -1313,8 +1433,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..1b5f932fc 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, ], ); 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` --- From a18753659f8956371642cb299e1a28dd1fc3d8de Mon Sep 17 00:00:00 2001 From: Mason McElvain <52104630+masonmcelvain@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:49:11 -0600 Subject: [PATCH 2/4] fix(config): don't corrupt config.toml on save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theme writer only recognized the `theme = ` and `[theme]` spellings, while the reader takes whatever Bun.TOML.parse produced. A config written as `theme.dark = "nord"` read fine, then gained a second `theme = "..."` assignment on the next preference save, after which Hunk refused to start with `BuildMessage: Cannot redefine key 'theme'` — an error naming neither the file nor the key. A quoted key inside `[theme]` failed the same way. Match every spelling TOML accepts for a key, and drop the extra lines a dotted key spreads a value over. Saves also deleted comments. The `[theme]` range ran to the next section header, so collapsing the table took the blank lines and comments that introduce whatever follows, and every rewritten key lost its trailing comment. Since one save rewrites all nine preferences, toggling `wrap_lines` stripped comments out of an untouched `[theme]` table. Comments now stay attached to the key or section they introduce, which is also why a collapsed table is written over its own header rather than appended — but only while `[theme]` is the first table, since a top-level key at any later position would scope into the table above it. Theme errors named `theme` whichever section or file held the bad table. That matters more now that a checked-in `.hunk/config.toml` can hard-fail startup for everyone in the repo, so they name both. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BLvy5LioGNhwJHzDPb7xxy --- src/core/run/config.test.ts | 134 ++++++++++++++++++++++++++++++++++ src/core/run/config.ts | 139 +++++++++++++++++++++++++++++------- 2 files changed, 248 insertions(+), 25 deletions(-) diff --git a/src/core/run/config.test.ts b/src/core/run/config.test.ts index 4aa2ce3ea..99207c6e2 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, @@ -352,6 +353,139 @@ describe("adaptive theme config", () => { 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("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"]); diff --git a/src/core/run/config.ts b/src/core/run/config.ts index ec51f1023..181d22ec0 100644 --- a/src/core/run/config.ts +++ b/src/core/run/config.ts @@ -209,6 +209,42 @@ function serializeTomlPreferenceValue(value: string | boolean | ThemeSelection) 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, @@ -223,15 +259,27 @@ function upsertTopLevelTomlValue( firstTableIndex = lines.length; } - 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; + while (insertAt > 0 && (lines[insertAt - 1] ?? "").trim().startsWith("#")) { + insertAt -= 1; + } const hasTableSpacer = insertAt > 0 && lines[insertAt - 1] === ""; if (hasTableSpacer) { insertAt -= 1; @@ -252,7 +300,15 @@ function findThemeTableRange(lines: readonly string[]) { } const nextHeaderOffset = lines.slice(headerIndex + 1).findIndex((line) => /^\s*\[/.test(line)); - const end = nextHeaderOffset < 0 ? lines.length : headerIndex + 1 + nextHeaderOffset; + 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 }; } @@ -262,7 +318,7 @@ function applyThemeTableKey( key: string, value: string | undefined, ) { - const keyPattern = new RegExp(`^\\s*${key}\\s*=`); + const keyPattern = tomlKeyPattern(key); const existingIndex = lines .slice(range.headerIndex + 1, range.end) .findIndex((line) => keyPattern.test(line)); @@ -273,7 +329,10 @@ function applyThemeTableKey( range.end -= 1; return; } - lines[absolute] = `${key} = ${JSON.stringify(value)}`; + lines[absolute] = rewriteAssignmentLine( + lines[absolute] ?? "", + `${key} = ${JSON.stringify(value)}`, + ); return; } @@ -281,11 +340,8 @@ function applyThemeTableKey( return; } - let insertAt = range.end; - while (insertAt > range.headerIndex + 1 && (lines[insertAt - 1] ?? "").trim().length === 0) { - insertAt -= 1; - } - lines.splice(insertAt, 0, `${key} = ${JSON.stringify(value)}`); + const indent = (lines[range.end - 1] ?? "").match(/^\s*/)?.[0] ?? ""; + lines.splice(range.end, 0, `${indent}${key} = ${JSON.stringify(value)}`); range.end += 1; } @@ -307,8 +363,19 @@ function upsertThemeTomlValue(source: string, value: ThemeSelection | undefined) 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); - return upsertTopLevelTomlValue(`${lines.join("\n").replace(/\n*$/, "")}\n`, "theme", value); + 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. */ @@ -348,14 +415,14 @@ function normalizeBoolean(value: unknown) { return typeof value === "boolean" ? value : undefined; } -function normalizeThemeSelection(value: unknown) { - const read = readThemeSelection(value); +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(read.issue); + throw new Error(origin.file ? `${read.issue} (${origin.file})` : read.issue); } return read.selection; @@ -1060,8 +1127,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); @@ -1070,7 +1146,7 @@ function normalizeConfigReferenceValue(property: keyof CommonOptions, value: unk case "vcs": return normalizeVcsMode(value); case "theme": - return normalizeThemeSelection(value); + return normalizeThemeSelection(value, origin); case "tabWidth": return normalizeTabWidth(value); case "fileGap": @@ -1085,7 +1161,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; @@ -1096,7 +1175,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; } @@ -1153,17 +1232,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; @@ -1386,7 +1475,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)); @@ -1396,7 +1485,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)); From ef50aa4d8a9b8cb064efb760592dffb70787bf82 Mon Sep 17 00:00:00 2001 From: Mason McElvain <52104630+masonmcelvain@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:21:35 -0600 Subject: [PATCH 3/4] fix(config): tidy the blank lines a config save leaves Saving view preferences could leave a config file slightly messier than it found it. Collapsing a [theme] table that sat between two other tables left the blank line from each side, and a file with no tables at all had new keys inserted above its trailing comment, because the backtrack that keeps a comment attached to the table it documents could not tell a real table index from the clamp used when no table exists. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NJVQRku6dLXkxiACCy177U --- src/core/run/config.test.ts | 66 +++++++++++++++++++++++++++++++++++++ src/core/run/config.ts | 28 ++++++++++++---- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/src/core/run/config.test.ts b/src/core/run/config.test.ts index 99207c6e2..b05c8baf7 100644 --- a/src/core/run/config.test.ts +++ b/src/core/run/config.test.ts @@ -131,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", @@ -473,6 +512,33 @@ describe("adaptive theme config", () => { }); }); + 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, [ diff --git a/src/core/run/config.ts b/src/core/run/config.ts index 181d22ec0..3fb233a15 100644 --- a/src/core/run/config.ts +++ b/src/core/run/config.ts @@ -254,10 +254,8 @@ function upsertTopLevelTomlValue( 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 = tomlKeyPattern(key); const matches: number[] = []; @@ -277,8 +275,10 @@ function upsertTopLevelTomlValue( } let insertAt = firstTableIndex; - while (insertAt > 0 && (lines[insertAt - 1] ?? "").trim().startsWith("#")) { - insertAt -= 1; + if (tableIndex >= 0) { + while (insertAt > 0 && (lines[insertAt - 1] ?? "").trim().startsWith("#")) { + insertAt -= 1; + } } const hasTableSpacer = insertAt > 0 && lines[insertAt - 1] === ""; if (hasTableSpacer) { @@ -312,6 +312,21 @@ function findThemeTableRange(lines: readonly string[]) { 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 }, @@ -374,6 +389,7 @@ function upsertThemeTomlValue(source: string, value: ThemeSelection | undefined) } 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); } From c66827f083050e8a51b1c428d58d30ec8562de87 Mon Sep 17 00:00:00 2001 From: Mason McElvain <52104630+masonmcelvain@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:26:35 -0600 Subject: [PATCH 4/4] fix(theme): refresh a review with the theme pair An in-session refresh rebuilt its reload input from the theme id the selection currently resolved to, so an adaptive pair arrived at the reload as whichever side the terminal happened to be on. Nothing collapses today because every refresh path keeps the mounted App, but the descriptor is the wrong thing to freeze. Carry the committed selection instead, and leave the resolved id to extension events, which want a concrete theme. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NJVQRku6dLXkxiACCy177U --- src/ui/App.tsx | 2 +- src/ui/currentReviewRefresh.test.ts | 19 +++++++- src/ui/currentReviewRefresh.ts | 5 +- ...useCurrentReviewRefreshController.test.tsx | 47 ++++++++++++++++--- .../useCurrentReviewRefreshController.ts | 2 +- 5 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 1b5f932fc..15d03f2dd 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -861,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, ], );