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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ 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
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
17 changes: 17 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
10 changes: 10 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { CliInput, CommonOptions, LayoutMode, PersistedViewPreferences } fr

const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = {
mode: "auto",
themeLight: "paper",
themeDark: "graphite",
showLineNumbers: true,
wrapLines: false,
showHunkHeaders: true,
Expand Down Expand Up @@ -46,6 +48,8 @@ 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),
Expand All @@ -60,6 +64,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 Down Expand Up @@ -134,6 +140,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 Down Expand Up @@ -166,6 +174,8 @@ 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,
Expand Down
4 changes: 4 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,6 +586,9 @@ 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,
Expand Down
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;

Comment on lines +120 to +135
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 macOS-specific tests are skipped on non-macOS CI despite using injectable deps

The three macOS detection tests each open with if (!isDarwin) return, which causes them to silently pass on Linux CI without executing any assertions. Because detectSystemThemeMode accepts an injected spawnSync, the tests can actually run on any platform without needing a real defaults binary. Only the final test ("returns undefined on non-macOS platforms") genuinely requires the real platform branch. The first three tests would be more reliable as unconditional tests that always inject the mock.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/terminal.test.ts
Line: 120-135

Comment:
**macOS-specific tests are skipped on non-macOS CI despite using injectable deps**

The three macOS detection tests each open with `if (!isDarwin) return`, which causes them to silently pass on Linux CI without executing any assertions. Because `detectSystemThemeMode` accepts an injected `spawnSync`, the tests can actually run on any platform without needing a real `defaults` binary. Only the final test ("returns undefined on non-macOS platforms") genuinely requires the real platform branch. The first three tests would be more reliable as unconditional tests that always inject the mock.

How can I resolve this? If you propose a fix, please make it concise.

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;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know why the pager/piped flow works on Linux but it somehow does lol

}

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"]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this idea a bit tricky. Some people use macOS in a light theme but still want a dark theme for the terminal. To me, the terminal should be the main source of information to keep things clear. For instance, you could use CSI to notify the tool when the colour palette changes. OpenTUI support it via the theme mode.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, but then you could just not use the auto theme, no?

The issue with the OpenTUI theme mode is that it does not work when Hunk is used as a pager because stdin is redirected

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough. It is just odd that Hunk will not follow the terminal when I change the light/dark themes outside of the OS general settings.

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
7 changes: 7 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 @@ -68,6 +70,8 @@ export interface CommonOptions {
export interface PersistedViewPreferences {
mode: LayoutMode;
theme?: string;
themeLight?: string;
themeDark?: string;
showLineNumbers: boolean;
wrapLines: boolean;
showHunkHeaders: boolean;
Expand Down Expand Up @@ -271,6 +275,9 @@ export interface AppBootstrap {
changeset: Changeset;
initialMode: LayoutMode;
initialTheme?: string;
initialThemeLight?: string;
initialThemeDark?: string;
initialThemeMode?: "light" | "dark";
initialShowLineNumbers?: boolean;
initialWrapLines?: boolean;
initialShowHunkHeaders?: boolean;
Expand Down
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];
28 changes: 25 additions & 3 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
MouseButton,
type MouseEvent as TuiMouseEvent,
type ScrollBoxRenderable,
type ThemeMode,
} from "@opentui/core";
import { useRenderer, useTerminalDimensions } from "@opentui/react";
import { Suspense, lazy, useCallback, useEffect, useMemo, useState, useRef } from "react";
Expand Down Expand Up @@ -50,6 +51,8 @@ function withCurrentViewOptions(
view: {
layoutMode: LayoutMode;
themeId: string;
themeLight: string;
themeDark: string;
showAgentNotes: boolean;
showHunkHeaders: boolean;
showLineNumbers: boolean;
Expand All @@ -62,6 +65,8 @@ function withCurrentViewOptions(
...input.options,
mode: view.layoutMode,
theme: view.themeId,
themeLight: view.themeLight,
themeDark: view.themeDark,
agentNotes: view.showAgentNotes,
hunkHeaders: view.showHunkHeaders,
lineNumbers: view.showLineNumbers,
Expand Down Expand Up @@ -101,8 +106,11 @@ export function App({
const layoutToggleScrollTopRef = useRef<number | null>(null);
const [layoutToggleRequestId, setLayoutToggleRequestId] = useState(0);
const [layoutMode, setLayoutMode] = useState<LayoutMode>(bootstrap.initialMode);
const [themeId, setThemeId] = useState(
() => resolveTheme(bootstrap.initialTheme, renderer.themeMode).id,
const [themeId, setThemeId] = useState(bootstrap.initialTheme ?? "auto");
const [themeLight, _setThemeLight] = useState(bootstrap.initialThemeLight ?? "paper");
const [themeDark, _setThemeDark] = useState(bootstrap.initialThemeDark ?? "graphite");
const [systemThemeMode, setSystemThemeMode] = useState(
bootstrap.initialThemeMode ?? renderer.themeMode,
);
const [showAgentNotes, setShowAgentNotes] = useState(bootstrap.initialShowAgentNotes ?? false);
const [showLineNumbers, setShowLineNumbers] = useState(bootstrap.initialShowLineNumbers ?? true);
Expand All @@ -118,7 +126,17 @@ export function App({
const [resizeStartWidth, setResizeStartWidth] = useState<number | null>(null);

const pagerMode = Boolean(bootstrap.input.options.pager);
const activeTheme = resolveTheme(themeId, renderer.themeMode);
const activeTheme = resolveTheme(themeId, systemThemeMode, themeLight, themeDark);

useEffect(() => {
const handleThemeMode = (mode: ThemeMode) => {
setSystemThemeMode(mode);
};
renderer.on("theme_mode", handleThemeMode);
return () => {
renderer.off("theme_mode", handleThemeMode);
};
}, [renderer]);
const review = useReviewController({ files: bootstrap.changeset.files });
const filteredFiles = review.visibleFiles;
const selectedFile = review.selectedFile;
Expand Down Expand Up @@ -346,6 +364,8 @@ export function App({
const nextInput = withCurrentViewOptions(bootstrap.input, {
layoutMode,
themeId,
themeLight,
themeDark,
showAgentNotes,
showHunkHeaders,
showLineNumbers,
Expand Down Expand Up @@ -471,6 +491,7 @@ export function App({
const menus = useMemo(
() =>
buildAppMenus({
themeId,
activeThemeId: activeTheme.id,
canRefreshCurrentInput,
focusFilter,
Expand All @@ -497,6 +518,7 @@ export function App({
wrapLines,
}),
[
themeId,
activeTheme.id,
canRefreshCurrentInput,
focusFilter,
Expand Down
23 changes: 17 additions & 6 deletions src/ui/lib/appMenus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { MenuEntry, MenuId } from "../components/chrome/menu";
import { THEMES } from "../themes";

export interface BuildAppMenusOptions {
themeId: string;
activeThemeId: string;
canRefreshCurrentInput: boolean;
focusFilter: () => void;
Expand Down Expand Up @@ -31,6 +32,7 @@ export interface BuildAppMenusOptions {

/** Build the top-level app menus from the current app state and actions. */
export function buildAppMenus({
themeId,
activeThemeId,
canRefreshCurrentInput,
focusFilter,
Expand All @@ -56,12 +58,21 @@ export function buildAppMenus({
toggleSidebar,
wrapLines,
}: BuildAppMenusOptions): Record<MenuId, MenuEntry[]> {
const themeMenuEntries: MenuEntry[] = THEMES.map((theme) => ({
kind: "item",
label: theme.label,
checked: theme.id === activeThemeId,
action: () => selectThemeId(theme.id),
}));
const themeMenuEntries: MenuEntry[] = [
{
kind: "item",
label: "Follow system",
checked: themeId === "auto",
action: () => selectThemeId(themeId === "auto" ? activeThemeId : "auto"),
},
{ kind: "separator" },
...THEMES.map((theme) => ({
kind: "item" as const,
label: theme.label,
checked: theme.id === themeId || (themeId === "auto" && theme.id === activeThemeId),
action: () => selectThemeId(theme.id),
})),
];

const fileMenuEntries: MenuEntry[] = [
{
Expand Down
Loading