Skip to content

feat(theme): add auto theme detection via OSC 11 background probe#290

Merged
elucid merged 2 commits into
mainfrom
spike/auto-theme-tty-probe
May 11, 2026
Merged

feat(theme): add auto theme detection via OSC 11 background probe#290
elucid merged 2 commits into
mainfrom
spike/auto-theme-tty-probe

Conversation

@elucid
Copy link
Copy Markdown
Member

@elucid elucid commented May 11, 2026

Problem

#238 requested automatic light/dark theme detection. #241 attempted this by reusing OpenTUI's renderer.themeMode signal and adding a macOS defaults read fallback, 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:

  • Direct terminal probe over renderer signal. Reads the OSC 11 response from the same /dev/tty input stream that fix: enable mouse scrolling in pager mode #198 established for mouse support, keeping renderer output on process.stdout.
  • Unconditional /dev/tty for piped stdin. Any app session with piped stdin and interactive stdout now opens /dev/tty for 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.
  • Opt-in only. The default theme remains explicit Graphite. Auto detection activates via --theme auto or theme = "auto" in config.

What changed

  • New themeDetection module: OSC 11 query, RGB parsing, luminance-based light/dark classification
  • resolveTheme() handles "auto" by mapping light → Paper, dark → Graphite
  • prepareStartupPlan opens /dev/tty for all piped-stdin app sessions, then optionally probes theme through it for auto mode
  • App initializes theme from bootstrap.initialThemeMode instead of renderer.themeMode
  • Diagnostic script (scripts/probe-terminal-theme.ts) for manual terminal theme probing

Not included (future work)

  • Live theme switching when the terminal changes light/dark mid-session
  • Configurable light/dark theme pair (theme_light / theme_dark)

Testing

  • Unit tests for OSC 11 parsing, luminance classification, and async probe
  • Startup unit tests for controlling-terminal attachment with piped stdin and auto-theme detection
  • PTY integration tests:
    • piped stdin + concrete theme still accepts terminal input
    • piped stdin + --theme auto still supports mouse wheel scrolling
  • Manual QA across all startup modes (diff, patch, pager) × (auto, concrete theme) × (piped stdin, TTY stdin) × (system dark, system light)

Refs: #198, #200, #238, #241

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-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 11, 2026

Greptile Summary

This PR adds opt-in automatic light/dark theme detection (--theme auto) using the standard OSC 11 terminal background query, and simultaneously fixes a broader class of piped-stdin startup bugs by unconditionally attaching /dev/tty for all interactive app sessions that have piped stdin.

  • src/core/themeDetection.ts — new module that sends the OSC 11 escape, reads the response from the same /dev/tty stream used for mouse input, converts the RGB color to luminance, and returns "light" or "dark". The timeout/cleanup logic is sound; three minor issues are flagged (duplicate type, missing clearTimeout in cleanup, redundant off+removeListener).
  • src/core/startup.ts — moves controlling-terminal attachment earlier and makes it unconditional for piped-stdin sessions; the optional OSC 11 probe runs before loadAppBootstrap so bootstrap.initialThemeMode can be set once and passed forward cleanly.
  • src/ui/App.tsx + src/ui/themes.tsresolveTheme gains an "auto" branch; themeId state stores the literal "auto" so session reload preserves auto mode, while activeTheme.id always returns the concrete resolved theme (used by cycleTheme and menus correctly).

Confidence Score: 4/5

Safe 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

Filename Overview
src/core/themeDetection.ts New module implementing OSC 11 terminal background probe, RGB parsing, and luminance classification; contains a duplicate TerminalThemeMode type definition and minor cleanup issues (no timer cancel in cleanup, redundant off+removeListener calls).
src/core/startup.ts Unconditional /dev/tty attachment for all piped-stdin app sessions, plus optional OSC 11 theme probe before bootstrap load; ordering and null-guard logic look correct, ??= at the end prevents double-open.
src/ui/App.tsx Theme initialization switches from renderer.themeMode (formerly unused/underscored) to static bootstrap.initialThemeMode; themeId stores "auto" literal and cycleTheme correctly derives index from activeTheme.id, so theme cycling and session reload both work as expected.
src/ui/themes.ts Adds "auto" branch to resolveTheme mapping light→paper and dark→graphite; fallback to THEMES[0] is safe given the hardcoded theme list.
src/core/types.ts Adds TerminalThemeMode and initialThemeMode to AppBootstrap; the new type duplicates the one exported from themeDetection.ts.
src/core/themeDetection.test.ts Good unit coverage for OSC 11 parsing, luminance classification, and async probe lifecycle including raw-mode restoration.
src/core/startup.test.ts New tests cover both the unconditional controlling-terminal attachment and the auto-theme detection path; mock injection via deps pattern is well-used.
test/pty/ui-integration.test.ts PTY integration tests added for piped-stdin concrete-theme startup and piped-stdin auto-theme mouse scrolling; covers the two regressions the PR was designed to fix.
scripts/probe-terminal-theme.ts Diagnostic script for manual terminal background probing; correctly opens /dev/tty when stdout is not a TTY, destroys streams in finally.

Sequence Diagram

sequenceDiagram
    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"
Loading
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/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

Comment thread src/core/themeDetection.ts Outdated
Comment thread src/core/themeDetection.ts
Comment thread src/core/themeDetection.ts Outdated
@elucid elucid merged commit e9ba014 into main May 11, 2026
4 checks passed
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.

1 participant