source dump of claude code
at main 119 lines 4.2 kB view raw
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 14import type { ThemeName, ThemeSetting } from './theme.js' 15 16export type SystemTheme = 'dark' | 'light' 17 18let 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 */ 24export 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 */ 35export function setCachedSystemTheme(theme: SystemTheme): void { 36 cachedSystemTheme = theme 37} 38 39/** 40 * Resolve a ThemeSetting (which may be 'auto') to a concrete ThemeName. 41 */ 42export 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 */ 60export 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 68type Rgb = { r: number; g: number; b: number } 69 70function 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]. */ 97function 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 */ 109function 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}