feat(theme): add auto theme detection via OSC 11 background probe#290
Conversation
Add `--theme auto` support that queries the terminal background color using the standard OSC 11 escape sequence and classifies luminance to choose between Paper (light) and Graphite (dark). This approach avoids OpenTUI's built-in theme_mode signal, which reports incorrect results when stdin is piped because the renderer's input/output plumbing changes the effective detection path. Instead, the probe reads the OSC 11 response from the same /dev/tty stream that #198 established for mouse support, keeping renderer output on process.stdout so mouse scrolling continues to work in piped mode. Additionally, any app startup with piped stdin now unconditionally opens /dev/tty for terminal input, fixing a class of issues where non-auto themes with piped stdin left OpenTUI without a real terminal input stream (causing leaked escape responses and broken interactivity). What changed: - New themeDetection module with OSC 11 query, RGB parsing, and luminance-based light/dark classification - resolveTheme() handles "auto" by mapping light→paper, dark→graphite - prepareStartupPlan opens /dev/tty for all piped-stdin app sessions, not just patch mode, then optionally probes theme through it - App initializes theme from bootstrap.initialThemeMode instead of renderer.themeMode to avoid the detection mismatch - Diagnostic script for manual terminal theme probing The default theme remains explicit Graphite. Auto detection only activates when opted in via --theme auto or config. Refs: #198, #200, #238, #241
Greptile SummaryThis PR adds opt-in automatic light/dark theme detection (
Confidence Score: 4/5Safe to merge; the new probe is opt-in, times out gracefully, and falls back to Graphite, so it cannot break existing concrete-theme sessions. The core logic — terminal attachment, OSC 11 probe, luminance classification, and theme resolution — is well-tested and correctly structured. The main concerns are quality issues in the new themeDetection.ts module: TerminalThemeMode is defined in both themeDetection.ts and types.ts (two sources of truth that can silently diverge), the cleanup closure omits clearTimeout (making it fragile to future refactors), and off plus removeListener are both called when one suffices. None of these affect runtime correctness today. src/core/themeDetection.ts has the three style and defensive-coding issues flagged; all other files look clean. Important Files Changed
Sequence DiagramsequenceDiagram
participant CLI as CLI argv
participant Startup as prepareStartupPlan
participant TTY as /dev/tty
participant Terminal as Terminal
participant Bootstrap as loadAppBootstrap
participant App as App.tsx
CLI->>Startup: argv + deps
Startup->>Startup: parseCliInput / resolveConfigured
alt "stdinIsTTY=false AND stdoutIsTTY=true"
Startup->>TTY: openControllingTerminal()
TTY-->>Startup: controllingTerminal stdin+close
end
alt "theme=auto AND stdoutIsTTY"
Startup->>Terminal: write OSC 11 query via stdout
Terminal-->>Startup: OSC 11 response via controllingTerminal.stdin
Startup->>Startup: parseOsc11 then themeModeForBackground
Note right of Startup: initialThemeMode = light or dark or null
end
Startup->>Bootstrap: loadAppBootstrap cliInput
Bootstrap-->>Startup: bootstrap object
Startup->>Startup: set bootstrap.initialThemeMode
Startup-->>App: "StartupPlan kind=app with bootstrap"
App->>App: "themeId = auto or concrete id"
App->>App: "activeTheme = resolveTheme themeId + initialThemeMode"
Prompt To Fix All With AIFix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
src/core/themeDetection.ts:1
`TerminalThemeMode` is already exported from `src/core/types.ts` (also added in this PR). Having two identical type declarations in different modules means they can silently diverge — e.g., if a future PR adds a `"system"` variant to one without updating the other, TypeScript will still accept assignments due to structural equivalence but the semantic contract breaks. `themeDetection.ts` should import the canonical definition from `types.ts`.
```suggestion
export type { TerminalThemeMode } from "./types";
```
### Issue 2 of 3
src/core/themeDetection.ts:90-100
The `cleanup` function removes the data listener but never cancels the `timer`. In the current call graph this is safe — `finish` is only ever reached either from the timer callback (already fired) or after `clearTimeout(timer)` in `onData`. But if a future refactor calls `cleanup` directly (e.g., for an early-abort path), the timer would still fire and attempt a second resolution. Canceling the timer inside `cleanup` makes the invariant explicit.
```suggestion
const cleanup = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
input.off?.("data", onData);
input.removeListener?.("data", onData);
if (wasRaw !== undefined) {
input.setRawMode?.(wasRaw);
}
};
```
### Issue 3 of 3
src/core/themeDetection.ts:95-96
Both `off` and `removeListener` are called on the input in cleanup. In every concrete Node.js stream `off` is just an alias for `removeListener`, so calling both simply tries to remove an already-removed listener (harmless no-op). For a custom `ThemeProbeInput` that provides both as independent methods (unlikely but allowed by the interface), the second call would be surprising. Choosing one side consistently removes the ambiguity — `removeListener` is the older, more widely supported name.
```suggestion
input.removeListener?.("data", onData);
```
Reviews (1): Last reviewed commit: "feat(theme): add auto theme detection vi..." | Re-trigger Greptile |
Problem
#238 requested automatic light/dark theme detection. #241 attempted this by reusing OpenTUI's
renderer.themeModesignal and adding a macOSdefaults readfallback, but that approach reports incorrect results under piped-stdin plumbing — the same plumbing #198 introduced to fix mouse scrolling in pager mode. #200 removed the original implicit theme-mode fallback for this exact reason.Additionally, any piped-stdin app startup (not just
patch -) that didn't go through the pager path was missing a controlling terminal attachment, causing leaked escape responses and broken interactivity.Approach
Instead of relying on OpenTUI's theme-mode signal or OS-level appearance queries, this branch probes the terminal's actual background color using the standard OSC 11 escape sequence and classifies luminance to choose between Paper (light) and Graphite (dark).
Key design decisions:
/dev/ttyinput stream that fix: enable mouse scrolling in pager mode #198 established for mouse support, keeping renderer output onprocess.stdout./dev/ttyfor piped stdin. Any app session with piped stdin and interactive stdout now opens/dev/ttyfor terminal input, not just patch/pager mode. This fixes a class of issues where concrete themes with piped stdin left OpenTUI without a real terminal input stream.--theme autoortheme = "auto"in config.What changed
themeDetectionmodule: OSC 11 query, RGB parsing, luminance-based light/dark classificationresolveTheme()handles"auto"by mapping light → Paper, dark → GraphiteprepareStartupPlanopens/dev/ttyfor all piped-stdin app sessions, then optionally probes theme through it for auto modeAppinitializes theme frombootstrap.initialThemeModeinstead ofrenderer.themeModescripts/probe-terminal-theme.ts) for manual terminal theme probingNot included (future work)
theme_light/theme_dark)Testing
--theme autostill supports mouse wheel scrollingRefs: #198, #200, #238, #241