Skip to content

feat(ui): add new theme "auto" that automatically switches between da…#241

Draft
m4r1vs wants to merge 1 commit intomodem-dev:mainfrom
m4r1vs:auto-theme
Draft

feat(ui): add new theme "auto" that automatically switches between da…#241
m4r1vs wants to merge 1 commit intomodem-dev:mainfrom
m4r1vs:auto-theme

Conversation

@m4r1vs
Copy link
Copy Markdown

@m4r1vs m4r1vs commented May 7, 2026

fixes #238

  • Introduced a new auto theme
  • Added a "Follow system" toggle in the UI theme menu.
  • Implemented a theme resolution strategy that selects between a designated theme_light and theme_dark based on the detected terminal or system mode (with support for pipe-mode, e.g. when git diff | hunk patch - is used.
  • Tested on MacOS and Linux Desktop
image image

Technical details

  • Added detectSystemThemeMode to fall back to OS-level settings (starting with macOS support via defaults read -g AppleInterfaceStyle) when terminal-based detection is inconclusive (e.g., in piped/pager flows).
  • Integrated ThemeMode listeners in the React App shell to respond to live system theme changes.
  • Updated README.md and component documentation to reflect the new theme options.

Comment thread src/core/terminal.ts
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

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 7, 2026

Greptile Summary

Introduces an "auto" theme that dynamically selects between a configurable theme_light and theme_dark based on terminal or system-level color scheme detection, and adds a "Follow system" toggle to the theme menu.

  • Config layer: themeLight/themeDark preferences (defaulting to \"paper\"/\"graphite\") are read from config.toml, threaded through AppBootstrap, and used in resolveTheme to pick the correct palette at runtime.
  • Live detection: A \"theme_mode\" event listener on the renderer updates systemThemeMode in real time; for piped/pager flows on macOS, detectSystemThemeMode queries defaults read -g AppleInterfaceStyle synchronously as a fallback.
  • Menu UX: The "Follow system" toggle and the currently-resolved concrete theme are both shown as checked in the menu when themeId === \"auto\", and toggling off restores the last resolved theme ID.

Confidence Score: 4/5

Safe to merge; existing theme names are unaffected and the new auto-detection path is gated behind a feature flag.

The core detection and menu logic work correctly. The main concern is that 'auto' is now advertised in the public HunkDiffView type and docs but always silently resolves to graphite in that component since themeMode is hardcoded to null.

src/opentui/HunkDiffView.tsx and src/ui/themes.ts — the former advertises auto as a valid theme without a way to supply themeMode, and the latter silently falls to the dark palette whenever themeMode is null.

Important Files Changed

Filename Overview
src/ui/themes.ts Adds "auto" theme resolution via themeMode; null themeMode always picks dark with no fallback signal.
src/opentui/HunkDiffView.tsx Unchanged internally but resolveTheme is called with null themeMode, making "auto" silently resolve to graphite in the public component.
src/core/terminal.ts Adds detectSystemThemeMode using Bun.spawnSync to query macOS defaults; returns undefined on non-macOS with correct try/catch for command failures.
src/core/terminal.test.ts Adds tests for detectSystemThemeMode but macOS-specific tests are guarded by isDarwin and silently skip on Linux CI despite using injectable mocks.
src/ui/App.tsx Wires up themeLight/themeDark state and a renderer theme_mode listener; cleanup is correctly handled in the useEffect return.
src/ui/lib/appMenus.ts Adds Follow system toggle with dual-checked display; toggle action correctly falls back to activeThemeId when disabling auto.
src/core/config.ts Correctly adds themeLight/themeDark to config reading, merging, and default handling with no issues found.
src/core/loaders.ts Passes initialThemeLight/Dark and calls detectSystemThemeMode only for piped patch inputs; correct conditional usage.
src/opentui/themes.ts Adds auto to HUNK_DIFF_THEME_NAMES constant and type; straightforward change.
src/core/types.ts Adds optional themeLight/themeDark fields to CommonOptions, PersistedViewPreferences, and AppBootstrap; clean, consistent additions.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[App starts] --> B{usesPipedPatchInput?}
    B -- yes --> C[detectSystemThemeMode]
    C -- darwin --> D[spawnSync defaults read]
    D -- stdout == Dark --> E[initialThemeMode = dark]
    D -- other/empty --> F[initialThemeMode = light]
    D -- throws --> F
    C -- non-darwin --> G[initialThemeMode = undefined]
    B -- no --> H[initialThemeMode = undefined]
    E & F & G & H --> I[App.useState: systemThemeMode = initialThemeMode ?? renderer.themeMode]
    I --> J[renderer emits theme_mode]
    J --> K[setSystemThemeMode]
    K --> L{themeId === auto?}
    I --> L
    L -- yes --> M{systemThemeMode === light?}
    M -- yes --> N[resolveTheme to themeLight]
    M -- no/null --> O[resolveTheme to themeDark]
    L -- no --> P[resolveTheme to exact match or graphite]
Loading

Comments Outside Diff (1)

  1. src/opentui/HunkDiffView.tsx, line 62 (link)

    P2 "auto" silently degrades to graphite in the public component

    HunkDiffView calls resolveTheme(theme, null) with themeMode hardcoded to null (line 62). The resolveTheme implementation now treats "auto" as themeMode === "light" ? themeLight : themeDark — when themeMode is null the equality is always false, so any caller that passes theme="auto" always receives the graphite (dark) palette regardless of the host environment. Since "auto" is now listed in HUNK_DIFF_THEME_NAMES, it appears as a fully-supported value in the TypeScript type and in the docs, but its observable effect is identical to hardcoding "graphite". The component would need either a themeMode prop or a note in the docs clarifying that "auto" performs no detection inside HunkDiffView.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/opentui/HunkDiffView.tsx
    Line: 62
    
    Comment:
    **`"auto"` silently degrades to graphite in the public component**
    
    `HunkDiffView` calls `resolveTheme(theme, null)` with `themeMode` hardcoded to `null` (line 62). The `resolveTheme` implementation now treats `"auto"` as `themeMode === "light" ? themeLight : themeDark` — when `themeMode` is `null` the equality is always `false`, so any caller that passes `theme="auto"` always receives the graphite (dark) palette regardless of the host environment. Since `"auto"` is now listed in `HUNK_DIFF_THEME_NAMES`, it appears as a fully-supported value in the TypeScript type and in the docs, but its observable effect is identical to hardcoding `"graphite"`. The component would need either a `themeMode` prop or a note in the docs clarifying that `"auto"` performs no detection inside `HunkDiffView`.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
src/opentui/HunkDiffView.tsx:62
**`"auto"` silently degrades to graphite in the public component**

`HunkDiffView` calls `resolveTheme(theme, null)` with `themeMode` hardcoded to `null` (line 62). The `resolveTheme` implementation now treats `"auto"` as `themeMode === "light" ? themeLight : themeDark` — when `themeMode` is `null` the equality is always `false`, so any caller that passes `theme="auto"` always receives the graphite (dark) palette regardless of the host environment. Since `"auto"` is now listed in `HUNK_DIFF_THEME_NAMES`, it appears as a fully-supported value in the TypeScript type and in the docs, but its observable effect is identical to hardcoding `"graphite"`. The component would need either a `themeMode` prop or a note in the docs clarifying that `"auto"` performs no detection inside `HunkDiffView`.

### Issue 2 of 3
src/core/terminal.test.ts:120-135
**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.

### Issue 3 of 3
src/ui/themes.ts:296-298
**`null` themeMode always routes "auto" to the dark theme with no signal**

`resolveTheme` uses `themeMode === "light"` to pick between `themeLight` and `themeDark`. When `themeMode` is `null` (renderer cannot report terminal color scheme), the condition is always `false`, so "auto" invariably resolves to `themeDark`. A user on a light terminal whose color-scheme detection is unsupported will permanently receive the dark palette with no indication or warning. The old code resolved the same way (to graphite), but only because `themeMode` was never read; now that the parameter is meaningful, a `null` value effectively forces dark mode in a way that may surprise light-terminal users who enable "Follow system".

Reviews (1): Last reviewed commit: "feat(ui): add new theme "auto" that auto..." | Re-trigger Greptile

Comment thread src/core/terminal.test.ts
Comment on lines +120 to +135
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;

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.

Comment thread src/ui/themes.ts
Comment on lines +296 to +298
if (requested === "auto" || !requested) {
const preferred = themeMode === "light" ? themeLight : themeDark;
return THEMES.find((theme) => theme.id === preferred) ?? THEMES[0]!;
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 null themeMode always routes "auto" to the dark theme with no signal

resolveTheme uses themeMode === "light" to pick between themeLight and themeDark. When themeMode is null (renderer cannot report terminal color scheme), the condition is always false, so "auto" invariably resolves to themeDark. A user on a light terminal whose color-scheme detection is unsupported will permanently receive the dark palette with no indication or warning. The old code resolved the same way (to graphite), but only because themeMode was never read; now that the parameter is meaningful, a null value effectively forces dark mode in a way that may surprise light-terminal users who enable "Follow system".

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ui/themes.ts
Line: 296-298

Comment:
**`null` themeMode always routes "auto" to the dark theme with no signal**

`resolveTheme` uses `themeMode === "light"` to pick between `themeLight` and `themeDark`. When `themeMode` is `null` (renderer cannot report terminal color scheme), the condition is always `false`, so "auto" invariably resolves to `themeDark`. A user on a light terminal whose color-scheme detection is unsupported will permanently receive the dark palette with no indication or warning. The old code resolved the same way (to graphite), but only because `themeMode` was never read; now that the parameter is meaningful, a `null` value effectively forces dark mode in a way that may surprise light-terminal users who enable "Follow system".

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

@m4r1vs m4r1vs marked this pull request as draft May 8, 2026 13:21
@m4r1vs
Copy link
Copy Markdown
Author

m4r1vs commented May 8, 2026

Ran into an issue where it would use the dark theme when opening it immediately after switching from dark to light in system preferences on MacOS. I'll undraft when I've fixed it.

Comment thread src/core/terminal.ts
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Automatic light/dark theme switching

2 participants