/ utils / systemTheme.ts
systemTheme.ts
  1  /**
  2   * Terminal dark/light mode detection for the 'auto' theme setting.
  3   *
  4   * Detection is based on the terminal's actual background color (queried via
  5   * OSC 11 by systemThemeWatcher.ts) rather than the OS appearance setting —
  6   * a dark terminal on a light-mode OS should still resolve to 'dark'.
  7   *
  8   * The detected theme is cached module-level so callers can resolve 'auto'
  9   * without awaiting the async OSC round-trip. The cache is seeded from
 10   * $COLORFGBG (synchronous, set by some terminals at launch) and then
 11   * updated by the watcher once the OSC 11 response arrives.
 12   */
 13  
 14  import type { ThemeName, ThemeSetting } from './theme.js'
 15  
 16  export type SystemTheme = 'dark' | 'light'
 17  
 18  let cachedSystemTheme: SystemTheme | undefined
 19  
 20  /**
 21   * Get the current terminal theme. Cached after first detection; the watcher
 22   * updates the cache on live changes.
 23   */
 24  export function getSystemThemeName(): SystemTheme {
 25    if (cachedSystemTheme === undefined) {
 26      cachedSystemTheme = detectFromColorFgBg() ?? 'dark'
 27    }
 28    return cachedSystemTheme
 29  }
 30  
 31  /**
 32   * Update the cached terminal theme. Called by the watcher when the OSC 11
 33   * query returns so non-React call sites stay in sync.
 34   */
 35  export function setCachedSystemTheme(theme: SystemTheme): void {
 36    cachedSystemTheme = theme
 37  }
 38  
 39  /**
 40   * Resolve a ThemeSetting (which may be 'auto') to a concrete ThemeName.
 41   */
 42  export function resolveThemeSetting(setting: ThemeSetting): ThemeName {
 43    if (setting === 'auto') {
 44      return getSystemThemeName()
 45    }
 46    return setting
 47  }
 48  
 49  /**
 50   * Parse an OSC color response data string into a theme.
 51   *
 52   * Accepts XParseColor formats returned by OSC 10/11 queries:
 53   * - `rgb:R/G/B` where each component is 1–4 hex digits (each scaled to
 54   *   [0, 16^n - 1] for n digits). This is what xterm, iTerm2, Terminal.app,
 55   *   Ghostty, kitty, Alacritty, etc. return.
 56   * - `#RRGGBB` / `#RRRRGGGGBBBB` (rare, but cheap to accept).
 57   *
 58   * Returns undefined for unrecognized formats so callers can fall back.
 59   */
 60  export function themeFromOscColor(data: string): SystemTheme | undefined {
 61    const rgb = parseOscRgb(data)
 62    if (!rgb) return undefined
 63    // ITU-R BT.709 relative luminance. Midpoint split: > 0.5 is light.
 64    const luminance = 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b
 65    return luminance > 0.5 ? 'light' : 'dark'
 66  }
 67  
 68  type Rgb = { r: number; g: number; b: number }
 69  
 70  function parseOscRgb(data: string): Rgb | undefined {
 71    // rgb:RRRR/GGGG/BBBB — each component is 1–4 hex digits.
 72    // Some terminals append an alpha component (rgba:…/…/…/…); ignore it.
 73    const rgbMatch =
 74      /^rgba?:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})/i.exec(data)
 75    if (rgbMatch) {
 76      return {
 77        r: hexComponent(rgbMatch[1]!),
 78        g: hexComponent(rgbMatch[2]!),
 79        b: hexComponent(rgbMatch[3]!),
 80      }
 81    }
 82    // #RRGGBB or #RRRRGGGGBBBB — split into three equal hex runs.
 83    const hashMatch = /^#([0-9a-f]+)$/i.exec(data)
 84    if (hashMatch && hashMatch[1]!.length % 3 === 0) {
 85      const hex = hashMatch[1]!
 86      const n = hex.length / 3
 87      return {
 88        r: hexComponent(hex.slice(0, n)),
 89        g: hexComponent(hex.slice(n, 2 * n)),
 90        b: hexComponent(hex.slice(2 * n)),
 91      }
 92    }
 93    return undefined
 94  }
 95  
 96  /** Normalize a 1–4 digit hex component to [0, 1]. */
 97  function hexComponent(hex: string): number {
 98    const max = 16 ** hex.length - 1
 99    return parseInt(hex, 16) / max
100  }
101  
102  /**
103   * Read $COLORFGBG for a synchronous initial guess before the OSC 11
104   * round-trip completes. Format is `fg;bg` (or `fg;other;bg`) where values
105   * are ANSI color indices. rxvt convention: bg 0–6 or 8 are dark; bg 7
106   * and 9–15 are light. Only set by some terminals (rxvt-family, Konsole,
107   * iTerm2 with the option enabled), so this is a best-effort hint.
108   */
109  function detectFromColorFgBg(): SystemTheme | undefined {
110    const colorfgbg = process.env['COLORFGBG']
111    if (!colorfgbg) return undefined
112    const parts = colorfgbg.split(';')
113    const bg = parts[parts.length - 1]
114    if (bg === undefined || bg === '') return undefined
115    const bgNum = Number(bg)
116    if (!Number.isInteger(bgNum) || bgNum < 0 || bgNum > 15) return undefined
117    // 0–6 and 8 are dark ANSI colors; 7 (white) and 9–15 (bright) are light.
118    return bgNum <= 6 || bgNum === 8 ? 'dark' : 'light'
119  }