Skip to content
7 changes: 7 additions & 0 deletions .changeset/tidy-hounds-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"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.

Saving view preferences from the app now rewrites only the keys you changed, keeps the comments around them, writes a key back into the `[pager]` or command table that defined it, and refuses to touch `config.toml` at all if the result would not read back as the same file with just those keys changed.
23 changes: 23 additions & 0 deletions docs/themes.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,29 @@ 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 <id>` 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. The save
rewrites only the keys you changed, keeps the comments around them, and writes
a key back into the command table or `[pager]` table that defined it, so the
next run of that command sees your pick. If the file cannot be updated without
changing anything else, Hunk leaves it untouched and asks you to edit it by hand.

Older theme ids such as `graphite` and `paper` remain accepted as compatibility
aliases.

Expand Down
108 changes: 72 additions & 36 deletions packages/hunk/src/app/historyBootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,44 @@ const input: HistoryCommandInput = {
extensionPaths: [],
};

/** Build one history-capable adapter whose cursor open/close counts are observable. */
function createTestHistoryCatalog(cwd: string) {
const closeCounts: number[] = [];
let opens = 0;
const makeSource = (): VcsHistorySource => {
const index = opens++;
closeCounts[index] = 0;
return {
async read() {
return { commits: [], done: true };
},
async close() {
closeCounts[index]! += 1;
},
};
};
const adapter: VcsAdapter = {
id: "test",
name: "Test",
detect: () => ({ id: "test", repoRoot: cwd }),
operations: {},
history: {
async open() {
return makeSource();
},
async planReview(commit) {
return { kind: "revision-show", revisionId: commit.revisionId };
},
},
};
const catalog: VcsCatalog = {
adapters: [adapter],
defaultAdapterId: "test",
reservedIds: new Set(["test"]),
};
return { catalog, closeCounts, opens: () => opens };
}

describe("history bootstrap cursor ownership", () => {
test("cancels refresh before opening and closes each active provider cursor once", async () => {
const cwd = mkdtempSync(join(tmpdir(), "hunk-history-bootstrap-"));
Expand All @@ -27,39 +65,7 @@ describe("history bootstrap cursor ownership", () => {
configPath,
'theme = "github-dark-dimmed"\nline_numbers = false\nprompt_save_view_preferences = false\n',
);
const closeCounts: number[] = [];
let opens = 0;
const makeSource = (): VcsHistorySource => {
const index = opens++;
closeCounts[index] = 0;
return {
async read() {
return { commits: [], done: true };
},
async close() {
closeCounts[index]! += 1;
},
};
};
const adapter: VcsAdapter = {
id: "test",
name: "Test",
detect: () => ({ id: "test", repoRoot: cwd }),
operations: {},
history: {
async open() {
return makeSource();
},
async planReview(commit) {
return { kind: "revision-show", revisionId: commit.revisionId };
},
},
};
const catalog: VcsCatalog = {
adapters: [adapter],
defaultAdapterId: "test",
reservedIds: new Set(["test"]),
};
const { catalog, closeCounts, opens } = createTestHistoryCatalog(cwd);

try {
const bootstrap = await loadHistoryBootstrap({
Expand All @@ -68,7 +74,8 @@ describe("history bootstrap cursor ownership", () => {
env: { ...process.env, XDG_CONFIG_HOME: configHome },
baseVcsCatalog: catalog,
});
expect(bootstrap.input.theme).toBe("github-dark-dimmed");
expect(bootstrap.input.theme).toBeUndefined();
expect(bootstrap.themeSelection).toBe("github-dark-dimmed");
expect(bootstrap.initialViewPreferences).toMatchObject({
theme: "github-dark-dimmed",
showLineNumbers: false,
Expand All @@ -79,10 +86,10 @@ describe("history bootstrap cursor ownership", () => {
const cancelled = new AbortController();
cancelled.abort();
await expect(bootstrap.reopenSource(cancelled.signal)).rejects.toThrow();
expect(opens).toBe(1);
expect(opens()).toBe(1);

await bootstrap.reopenSource();
expect(opens).toBe(2);
expect(opens()).toBe(2);
expect(closeCounts).toEqual([1, 0]);
await bootstrap.close();
await bootstrap.close();
Expand All @@ -96,4 +103,33 @@ describe("history bootstrap cursor ownership", () => {
rmSync(configHome, { recursive: true, force: true });
}
});

test("keeps a configured [theme] pair intact for the history surface", async () => {
const cwd = mkdtempSync(join(tmpdir(), "hunk-history-bootstrap-"));
const configHome = mkdtempSync(join(tmpdir(), "hunk-history-config-"));
mkdirSync(join(configHome, "hunk"), { recursive: true });
writeFileSync(
join(configHome, "hunk", "config.toml"),
'[theme]\ndark = "github-dark-dimmed"\nlight = "github-light-default"\n',
);
const { catalog } = createTestHistoryCatalog(cwd);

try {
const bootstrap = await loadHistoryBootstrap({
input,
cwd,
env: { ...process.env, XDG_CONFIG_HOME: configHome },
baseVcsCatalog: catalog,
});
const pair = { dark: "github-dark-dimmed", light: "github-light-default" };
expect(bootstrap.input.theme).toBeUndefined();
expect(bootstrap.themeSelection).toEqual(pair);
expect(bootstrap.initialViewPreferences.theme).toEqual(pair);
await bootstrap.close();
await bootstrap.extensionSession.shutdown();
} finally {
rmSync(cwd, { recursive: true, force: true });
rmSync(configHome, { recursive: true, force: true });
}
});
});
9 changes: 7 additions & 2 deletions packages/hunk/src/app/historyBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import type { HistoryCommandInput } from "../core/run/commandInputs";
import {
persistedViewPreferencesFromOptions,
type PersistedViewPreferences,
type ViewPreferenceScope,
} from "../core/run/config";
import { collectSessionCustomThemes } from "../core/theme/customThemes";
import type { ThemeSelection } from "../core/theme/selection";
import type {
ExtensionVcsHistoryCommit,
ExtensionVcsHistoryReviewAction,
Expand Down Expand Up @@ -38,9 +40,11 @@ export interface HistoryBootstrap {
extensionSession: ExtensionSession;
notices: readonly string[];
customThemes: readonly NamedCustomThemeConfig[];
themeSelection?: ThemeSelection;
/** Launch baseline retained so the owning history surface can persist theme changes on quit. */
initialViewPreferences: PersistedViewPreferences;
viewPreferencesConfigPath?: string;
viewPreferenceScope?: ViewPreferenceScope;
promptSaveViewPreferences: boolean;
planReview(
commit: ExtensionVcsHistoryCommit,
Expand Down Expand Up @@ -133,10 +137,10 @@ export async function loadHistoryBootstrap({
throw error;
}

const resolvedTheme = resolved.configured.input.options.theme;
let closed = false;
return {
input: resolvedTheme ? { ...input, theme: resolvedTheme } : input,
input,
themeSelection: resolved.configured.input.options.theme,
source,
providerId: sanitizeTerminalLine(adapter.id),
providerName: sanitizeTerminalLine(adapter.name),
Expand All @@ -146,6 +150,7 @@ export async function loadHistoryBootstrap({
customThemes: sessionThemes.themes,
initialViewPreferences: persistedViewPreferencesFromOptions(resolved.configured.input.options),
viewPreferencesConfigPath: resolved.configured.viewPreferencesConfigPath,
viewPreferenceScope: resolved.configured.viewPreferenceScope,
promptSaveViewPreferences:
resolved.configured.input.options.promptSaveViewPreferences !== false,
notices: [
Expand Down
1 change: 1 addition & 0 deletions packages/hunk/src/app/sessionBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export async function loadConfiguredSessionBootstrap({
bootstrap.initialThemeMode = initialThemeMode ?? bootstrap.initialThemeMode;
bootstrap.extensions = extensions;
bootstrap.viewPreferencesConfigPath = configured.viewPreferencesConfigPath;
bootstrap.viewPreferenceScope = configured.viewPreferenceScope;
bootstrap.keybindings = configured.keybindings;

return { applied, bootstrap, input, previousFileLanguages, sessionThemes, sessionVcs };
Expand Down
117 changes: 117 additions & 0 deletions packages/hunk/src/app/startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,123 @@ describe("startup planning", () => {
expect(detected).toBe(0);
});

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("probes the terminal for a pair that only extension-discovered repo config introduces", async () => {
const cliInput: CliInput = {
kind: "patch",
file: "-",
options: { theme: "github-dark-default", pager: true },
};
// A user extension recognizes the checkout the bundled catalog could not, so config resolves
// a second time against that repo's `.hunk/config.toml`, which is where the pair lives.
const extensionResult = createEmptyExtensionLoadResult();
extensionResult.registry.vcsAdapters.push({
extensionId: "probe",
adapter: {
id: "probe",
name: "Probe",
detect: (cwd) => ({ id: "probe", repoRoot: cwd }),
operations: {},
},
});
let resolutions = 0;
let probes = 0;

const plan = await prepareStartupPlan(["bun", "hunk", "patch", "-"], {
parseCliImpl: async () => cliInput as ParsedCliInput,
resolveRuntimeCliInputImpl: (input) => input,
resolveConfiguredCliInputImpl: (input) => {
resolutions += 1;
return createTestConfigResolution(
resolutions === 1
? input
: {
...input,
options: { ...input.options, theme: { dark: "vitesse-dark", light: "one-light" } },
},
{ extensions: { enabled: true, paths: [], repoPaths: [], extensionConfigs: {} } },
);
},
loadStartupExtensionsImpl: async () => extensionResult,
loadAppBootstrapImpl: async (input) => createBootstrap(input),
openControllingTerminalImpl: () => ({ stdin: {} as never, close: () => {} }),
detectTerminalThemeModeFromBackgroundImpl: async () => {
probes += 1;
return "light";
},
usesPipedPatchInputImpl: () => false,
stdinIsTTY: false,
stdoutIsTTY: true,
stdout: { write: () => true } as never,
});

expect(resolutions).toBe(2);
expect(probes).toBe(1);
expect(plan).toMatchObject({
kind: "app",
bootstrap: {
initialThemeMode: "light",
input: { options: { theme: { dark: "vitesse-dark", light: "one-light" } } },
},
});
});

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",
Expand Down
Loading
Loading