Skip to content

feat(tui): add Mode 2031 theme detection with tri-state appearance mode#15089

Open
kavhnr wants to merge 1 commit intoanomalyco:devfrom
kavhnr:feat/mode-2031-theme-detection
Open

feat(tui): add Mode 2031 theme detection with tri-state appearance mode#15089
kavhnr wants to merge 1 commit intoanomalyco:devfrom
kavhnr:feat/mode-2031-theme-detection

Conversation

@kavhnr
Copy link
Contributor

@kavhnr kavhnr commented Feb 25, 2026

mode-2031-theme-detection.mp4

Issue for this PR

Closes #13232

Supersedes #13233 (closed to rebase on current dev).

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

OpenTUI v0.1.78 added Mode 2031 dark/light theme detection (opentui#657). OpenCode is already on v0.1.79 (#13036) which includes these APIs, but doesn't use them. This PR integrates renderer.themeMode / renderer.on("theme_mode", ...) into OpenCode's theme system.

Three changes:

  1. Tri-state appearance mode (auto/dark/light) — replaces the binary toggle. auto follows OS via Mode 2031, falling back to OSC 11 luminance on unsupported terminals. dark/light are manual overrides.
  2. Per-mode theme config"theme": { "light": "github", "dark": "catppuccin" } alongside existing "theme": "opencode" (backward compatible).
  3. Appearance selector in the theme dialog — ←/→ arrow keys cycle modes, clickable, reverts on cancel.

Mode 2031 is the same approach used by Neovim and Helix. Event-driven, zero overhead. Unsupported terminals silently ignore the sequences — no degradation.

Why Mode 2031 over the approaches in existing open PRs:

Approach PR Problem
Poll terminal bg every 5s #7182, #7162 Wastes CPU, delayed detection, races with palette queries
execSync("defaults read") / gsettings every 3s #9719 Platform-specific, breaks in SSH/containers/tmux, spawns child processes
Mode 2031 (this PR) Terminal-native, event-driven, zero overhead

Edge cases handled:

  • Unsupported terminals: detectedMode stays at initial OSC 11 value. No degradation.
  • Custom themes without variants: activeThemeMode() falls back to "dark". Existing custom themes work identically.
  • Cancel in dialog: both theme and mode revert to initial values.
  • Event cleanup: onCleanup(() => renderer.off("theme_mode", ...)) prevents leaks.

How did you verify your code works?

  • bun run typecheck passes (16/16 packages, zero errors)
  • Tested on Ghostty (Mode 2031 supported): toggling macOS appearance instantly switches theme variants
  • Tested on Terminal.app (Mode 2031 unsupported): falls back to existing behavior, manual mode selector works, no errors
  • Backward compatible: "theme": "opencode" works, "theme": { "light": "github", "dark": "catppuccin" } works
  • All 31 dual-variant built-in themes resolve correctly in both modes
  • aura and ayu (single-palette) unaffected by mode changes

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@kavhnr kavhnr force-pushed the feat/mode-2031-theme-detection branch from 1c78a6c to daa9a8a Compare February 25, 2026 16:36
@kavhnr kavhnr marked this pull request as ready for review February 25, 2026 16:47
@kavhnr
Copy link
Contributor Author

kavhnr commented Feb 25, 2026

@RhysSullivan this is similar to your PR#7162 so might be up your ally to review/hopefully merge for people that use different modes.

…er-mode theme config

- Detect OS dark/light mode changes in real-time via Mode 2031 (Ghostty, kitty, etc.)
- Add tri-state appearance selector (auto/dark/light) to theme dialog with arrow key cycling
- Support per-mode theme config in tui.json: { dark: "tokyonight", light: "github" }
- Extend DialogSelect with header and onKeyboard props
- Remove standalone Toggle appearance command (now integrated into theme dialog)
@aspiers
Copy link
Contributor

aspiers commented Mar 2, 2026

I've tested this and it works great! Thanks!

@lederniermagicien
Copy link

lederniermagicien commented Mar 11, 2026

Hi @kavhnr. Tested it and I agree, it works really well in Ghostty. I also tested it on Warp though, and it didn't work there because Mode 2031 never fires on Warp, Alacritty, WezTerm, or iTerm2, so detectedMode stays at the initial props.mode value and never updates.

I wanted to extend it a bit to cover those terminals. Tried renderer.getPalette() as the fallback but it's a dead end on Warp too: TerminalPalette.detect() gates every call on detectOSCSupport(), which probes with OSC 4. Warp supports OSC 11 but ignores OSC 4, so the probe times out and defaultBackground comes back null before OSC 11 is ever attempted. process.stdout.write is also out since OpenTUI intercepts it before it reaches the PTY.

What does work: renderer.subscribeOsc() to receive the response, renderer.writeOut() to send the query directly to the PTY. Poll every 2s, self-disables on the first Mode 2031 event so there's zero overhead on Ghostty/kitty.

Two places to change in theme.tsx. First, just before your const handler block, add the tracker variable, and one line inside the handler itself:

+    let mode2031Active = !!renderer.themeMode
     const handler = (mode: "dark" | "light") => {
+      mode2031Active = true
       setStore("detectedMode", mode)
       if (active() === "system") {
         renderer.clearPaletteCache()
         resolveSystemTheme()
       }
     }
     renderer.on("theme_mode", handler)
     onCleanup(() => renderer.off("theme_mode", handler))

Then after your existing if (renderer.themeMode) sync block, add the poll:

     // Sync initial mode if terminal already responded to Mode 2031 query.
     if (renderer.themeMode) {
       setStore("detectedMode", renderer.themeMode)
     }
+
+    const osc11 = /\x1b\]11;rgba?:([0-9a-fA-F]+)\/([0-9a-fA-F]+)\/([0-9a-fA-F]+)/
+    const norm = (h: string) => parseInt(h, 16) / ((1 << (h.length * 4)) - 1)
+    let pending = false
+    const queryOsc11 = () => {
+      if (mode2031Active || store.mode !== "auto" || pending) return
+      pending = true
+      const unsub = renderer.subscribeOsc((seq: string) => {
+        const m = seq.match(osc11)
+        if (!m) return
+        unsub()
+        pending = false
+        const luminance = 0.299 * norm(m[1]) + 0.587 * norm(m[2]) + 0.114 * norm(m[3])
+        const detected: "dark" | "light" = luminance > 0.5 ? "light" : "dark"
+        if (detected === store.detectedMode) return
+        setStore("detectedMode", detected)
+        if (active() === "system") {
+          renderer.clearPaletteCache()
+          resolveSystemTheme()
+        }
+      })
+      ;(renderer as any).writeOut("\x1b]11;?\x07")
+      const t = setTimeout(() => { unsub(); pending = false }, 1000)
+      t.unref()
+    }
+    const poll = setInterval(queryOsc11, 2000)
+    poll.unref()
+    onCleanup(() => clearInterval(poll))

The ordering doesn't matter functionally — the sync block runs synchronously at mount, the poll fires 2s later at the earliest. But placing it after keeps the intent clear: Mode 2031 path first, OSC 11 fallback below it.

The only rough edge is the (renderer as any).writeOut cast since writeOut is private in the type declaration. It works fine at runtime, but if you'd rather not land that, it's worth opening an issue with the OpenTUI maintainers to expose a public sendToTerminal(seq: string) method.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE]: Add support for OpenTUI's new themeMode detection (Mode 2031)

3 participants