Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ All notable user-visible changes to Hunk are documented in this file.

### Added

- Added automatic switching between dark and light mode themes based on system preferences.
- Added a "Follow system" option to the theme menu.

### Changed

### Fixed

- Improved theme-mode detection for pager and patch flows by falling back to system preferences when stdin is redirected.

## [0.10.0] - 2026-04-21

### Added
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,15 @@ You can persist preferences to a config file:
Example:

```toml
theme = "graphite" # graphite, midnight, paper, ember
theme = "graphite" # graphite, midnight, paper, ember, auto (based on system pref.)
theme_light = "paper" # default light theme for auto theme
theme_dark = "graphite" # default dark theme for auto theme
mode = "auto" # auto, split, stack
exclude_untracked = false
line_numbers = true
wrap_lines = false
agent_notes = false
transparent_bg = false # use terminal background instead of theme backgrounds
```

`exclude_untracked` affects working-tree `hunk diff` sessions only.
Expand Down
26 changes: 13 additions & 13 deletions docs/opentui-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,19 +99,19 @@ if (!metadata) {

## Props

| Prop | Type | Default | Notes |
| ------------------- | ------------------------------------------------ | ------------ | ------------------------------------------------------------------------- |
| `diff` | `HunkDiffFile` | `undefined` | File to render. When omitted, the component shows an empty-state message. |
| `layout` | `"split" \| "stack"` | `"split"` | Chooses side-by-side or stacked rendering. |
| `width` | `number` | — | Required content width in terminal columns. |
| `theme` | `"graphite" \| "midnight" \| "paper" \| "ember"` | `"graphite"` | Matches Hunk's built-in themes. |
| `showLineNumbers` | `boolean` | `true` | Toggles line-number columns. |
| `showHunkHeaders` | `boolean` | `true` | Toggles `@@ ... @@` hunk header rows. |
| `wrapLines` | `boolean` | `false` | Wraps long lines instead of clipping horizontally. |
| `horizontalOffset` | `number` | `0` | Scroll offset for non-wrapped code rows. |
| `highlight` | `boolean` | `true` | Enables syntax highlighting. |
| `scrollable` | `boolean` | `true` | Set to `false` if your parent view owns scrolling. |
| `selectedHunkIndex` | `number` | `0` | Highlights one hunk as the active target. |
| Prop | Type | Default | Notes |
| ------------------- | ---------------------------------------------------------- | ------------ | ------------------------------------------------------------------------- |
| `diff` | `HunkDiffFile` | `undefined` | File to render. When omitted, the component shows an empty-state message. |
| `layout` | `"split" \| "stack"` | `"split"` | Chooses side-by-side or stacked rendering. |
| `width` | `number` | — | Required content width in terminal columns. |
| `theme` | `"auto" \| "graphite" \| "midnight" \| "paper" \| "ember"` | `"graphite"` | Matches Hunk's built-in themes. |
| `showLineNumbers` | `boolean` | `true` | Toggles line-number columns. |
| `showHunkHeaders` | `boolean` | `true` | Toggles `@@ ... @@` hunk header rows. |
| `wrapLines` | `boolean` | `false` | Wraps long lines instead of clipping horizontally. |
| `horizontalOffset` | `number` | `0` | Scroll offset for non-wrapped code rows. |
| `highlight` | `boolean` | `true` | Enables syntax highlighting. |
| `scrollable` | `boolean` | `true` | Set to `false` if your parent view owns scrolling. |
| `selectedHunkIndex` | `number` | `0` | Highlights one hunk as the active target. |

## Other exports

Expand Down
31 changes: 31 additions & 0 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,23 @@ describe("config resolution", () => {
expect(resolved.input.options.theme).toBe("graphite");
});

test("resolves custom light and dark theme preferences", () => {
const home = createTempDir("hunk-config-home-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(
join(home, ".config", "hunk", "config.toml"),
['theme_light = "ember"', 'theme_dark = "midnight"'].join("\n"),
);

const resolved = resolveConfiguredCliInput(createPatchPagerInput(), {
cwd: createTempDir("hunk-config-cwd-"),
env: { HOME: home },
});

expect(resolved.input.options.themeLight).toBe("ember");
expect(resolved.input.options.themeDark).toBe("midnight");
});

test("command-specific config sections also apply to show mode", () => {
const home = createTempDir("hunk-config-home-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
Expand Down Expand Up @@ -196,6 +213,20 @@ describe("config resolution", () => {
expect(bootstrap.initialWrapLines).toBe(true);
expect(bootstrap.initialShowHunkHeaders).toBe(false);
expect(bootstrap.initialShowAgentNotes).toBe(true);
expect(bootstrap.initialTransparentBg).toBe(false);
});

test("resolves custom transparent_bg preference", () => {
const home = createTempDir("hunk-config-home-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(join(home, ".config", "hunk", "config.toml"), "transparent_bg = false\n");

const resolved = resolveConfiguredCliInput(createPatchPagerInput(), {
cwd: createTempDir("hunk-config-cwd-"),
env: { HOME: home },
});

expect(resolved.input.options.transparentBg).toBe(false);
});

test("loadAppBootstrap exposes graphite when no theme is configured", async () => {
Expand Down
15 changes: 15 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import type { CliInput, CommonOptions, LayoutMode, PersistedViewPreferences } fr

const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = {
mode: "auto",
themeLight: "paper",
themeDark: "graphite",
showLineNumbers: true,
wrapLines: false,
showHunkHeaders: true,
showAgentNotes: false,
transparentBg: false,
};

interface ConfigResolutionOptions {
Expand Down Expand Up @@ -46,11 +49,14 @@ function readConfigPreferences(source: Record<string, unknown>): CommonOptions {
return {
mode: normalizeLayoutMode(source.mode),
theme: normalizeString(source.theme),
themeLight: normalizeString(source.theme_light),
themeDark: normalizeString(source.theme_dark),
excludeUntracked: normalizeBoolean(source.exclude_untracked),
lineNumbers: normalizeBoolean(source.line_numbers),
wrapLines: normalizeBoolean(source.wrap_lines),
hunkHeaders: normalizeBoolean(source.hunk_headers),
agentNotes: normalizeBoolean(source.agent_notes),
transparentBg: normalizeBoolean(source.transparent_bg),
};
}

Expand All @@ -60,6 +66,8 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti
...base,
mode: overrides.mode ?? base.mode,
theme: overrides.theme ?? base.theme,
themeLight: overrides.themeLight ?? base.themeLight,
themeDark: overrides.themeDark ?? base.themeDark,
agentContext: overrides.agentContext ?? base.agentContext,
pager: overrides.pager ?? base.pager,
watch: overrides.watch ?? base.watch,
Expand All @@ -68,6 +76,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti
wrapLines: overrides.wrapLines ?? base.wrapLines,
hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders,
agentNotes: overrides.agentNotes ?? base.agentNotes,
transparentBg: overrides.transparentBg ?? base.transparentBg,
};
}

Expand Down Expand Up @@ -134,6 +143,8 @@ export function resolveConfiguredCliInput(
// Keep the built-in theme default explicit so stdin-backed startup paths do not depend on
// renderer theme-mode detection for their initial palette.
theme: "graphite",
themeLight: DEFAULT_VIEW_PREFERENCES.themeLight,
themeDark: DEFAULT_VIEW_PREFERENCES.themeDark,
agentContext: input.options.agentContext,
pager: input.options.pager ?? false,
watch: input.options.watch ?? false,
Expand All @@ -142,6 +153,7 @@ export function resolveConfiguredCliInput(
wrapLines: DEFAULT_VIEW_PREFERENCES.wrapLines,
hunkHeaders: DEFAULT_VIEW_PREFERENCES.showHunkHeaders,
agentNotes: DEFAULT_VIEW_PREFERENCES.showAgentNotes,
transparentBg: DEFAULT_VIEW_PREFERENCES.transparentBg,
};

if (userConfigPath) {
Expand All @@ -166,10 +178,13 @@ export function resolveConfiguredCliInput(
watch: input.options.watch ?? false,
excludeUntracked: resolvedOptions.excludeUntracked ?? false,
mode: resolvedOptions.mode ?? DEFAULT_VIEW_PREFERENCES.mode,
themeLight: resolvedOptions.themeLight ?? DEFAULT_VIEW_PREFERENCES.themeLight,
themeDark: resolvedOptions.themeDark ?? DEFAULT_VIEW_PREFERENCES.themeDark,
lineNumbers: resolvedOptions.lineNumbers ?? DEFAULT_VIEW_PREFERENCES.showLineNumbers,
wrapLines: resolvedOptions.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines,
hunkHeaders: resolvedOptions.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders,
agentNotes: resolvedOptions.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes,
transparentBg: resolvedOptions.transparentBg ?? DEFAULT_VIEW_PREFERENCES.transparentBg,
};

return {
Expand Down
5 changes: 5 additions & 0 deletions src/core/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
ShowCommandInput,
StashShowCommandInput,
} from "./types";
import { detectSystemThemeMode, usesPipedPatchInput } from "./terminal";

interface LoadAppBootstrapOptions {
cwd?: string;
Expand Down Expand Up @@ -585,9 +586,13 @@ export async function loadAppBootstrap(
changeset,
initialMode: input.options.mode ?? "auto",
initialTheme: input.options.theme,
initialThemeLight: input.options.themeLight,
initialThemeDark: input.options.themeDark,
initialThemeMode: usesPipedPatchInput(input) ? detectSystemThemeMode() : undefined,
initialShowLineNumbers: input.options.lineNumbers ?? true,
initialWrapLines: input.options.wrapLines ?? false,
initialShowHunkHeaders: input.options.hunkHeaders ?? true,
initialShowAgentNotes: input.options.agentNotes ?? false,
initialTransparentBg: input.options.transparentBg ?? false,
};
}
43 changes: 43 additions & 0 deletions src/core/terminal.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import type { CliInput } from "./types";
import {
detectSystemThemeMode,
openControllingTerminal,
resolveRuntimeCliInput,
shouldUseMouseForApp,
Expand Down Expand Up @@ -114,3 +115,45 @@ describe("controlling terminal attachment", () => {
expect(controllingTerminal).toBeNull();
});
});

describe("system theme detection", () => {
const isDarwin = process.platform === "darwin";

test("detects dark mode on macOS", () => {
if (!isDarwin) return;

const spawnSync = ((args: string[]) => {
expect(args).toEqual(["defaults", "read", "-g", "AppleInterfaceStyle"]);
return { stdout: Buffer.from("Dark\n") };
}) as typeof Bun.spawnSync;

expect(detectSystemThemeMode({ spawnSync })).toBe("dark");
});

test("detects light mode on macOS", () => {
if (!isDarwin) return;

const spawnSync = ((args: string[]) => {
expect(args).toEqual(["defaults", "read", "-g", "AppleInterfaceStyle"]);
return { stdout: Buffer.from("\n") };
}) as typeof Bun.spawnSync;

expect(detectSystemThemeMode({ spawnSync })).toBe("light");
});

test("falls back to light mode on macOS when the command fails", () => {
if (!isDarwin) return;

const spawnSync = (() => {
throw new Error("Command failed");
}) as typeof Bun.spawnSync;

expect(detectSystemThemeMode({ spawnSync })).toBe("light");
});

test("returns undefined on non-macOS platforms", () => {
if (isDarwin) return;

expect(detectSystemThemeMode()).toBeUndefined();
});
});
20 changes: 20 additions & 0 deletions src/core/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ export function shouldUseMouseForApp({
return stdinIsTTY || hasControllingTerminal;
}

/** Detect the system theme mode as a fallback when terminal-based detection is unavailable. */
export function detectSystemThemeMode(
deps: { spawnSync: typeof Bun.spawnSync } = { spawnSync: Bun.spawnSync },
): "light" | "dark" | undefined {
if (process.platform !== "darwin") {
return undefined;
}

try {
// defaults read -g AppleInterfaceStyle returns "Dark" in dark mode.
// It exits with code 1 if the key doesn't exist (which means light mode).
const proc = deps.spawnSync(["defaults", "read", "-g", "AppleInterfaceStyle"]);
const output = proc.stdout.toString().trim();
return output === "Dark" ? "dark" : "light";
} catch {
// On macOS, if the key is missing, it's light mode.
return "light";
}
}

export interface ControllingTerminal {
stdin: tty.ReadStream;
close: () => void;
Expand Down
10 changes: 10 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export interface Changeset {
export interface CommonOptions {
mode?: LayoutMode;
theme?: string;
themeLight?: string;
themeDark?: string;
agentContext?: string;
pager?: boolean;
watch?: boolean;
Expand All @@ -63,15 +65,19 @@ export interface CommonOptions {
wrapLines?: boolean;
hunkHeaders?: boolean;
agentNotes?: boolean;
transparentBg?: boolean;
}

export interface PersistedViewPreferences {
mode: LayoutMode;
theme?: string;
themeLight?: string;
themeDark?: string;
showLineNumbers: boolean;
wrapLines: boolean;
showHunkHeaders: boolean;
showAgentNotes: boolean;
transparentBg: boolean;
}

export interface HelpCommandInput {
Expand Down Expand Up @@ -271,8 +277,12 @@ export interface AppBootstrap {
changeset: Changeset;
initialMode: LayoutMode;
initialTheme?: string;
initialThemeLight?: string;
initialThemeDark?: string;
initialThemeMode?: "light" | "dark";
initialShowLineNumbers?: boolean;
initialWrapLines?: boolean;
initialShowHunkHeaders?: boolean;
initialShowAgentNotes?: boolean;
initialTransparentBg?: boolean;
}
2 changes: 1 addition & 1 deletion src/opentui/HunkDiffView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,6 @@ describe("HunkDiffView", () => {
});

test("exports the documented built-in theme names", () => {
expect(HUNK_DIFF_THEME_NAMES).toEqual(["graphite", "midnight", "paper", "ember"]);
expect(HUNK_DIFF_THEME_NAMES).toEqual(["auto", "graphite", "midnight", "paper", "ember"]);
});
});
2 changes: 1 addition & 1 deletion src/opentui/themes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export const HUNK_DIFF_THEME_NAMES = ["graphite", "midnight", "paper", "ember"] as const;
export const HUNK_DIFF_THEME_NAMES = ["auto", "graphite", "midnight", "paper", "ember"] as const;

export type HunkDiffThemeName = (typeof HUNK_DIFF_THEME_NAMES)[number];
Loading