|
| 1 | +/** |
| 2 | + * Terminal Color Detection using OSC 10/11 Escape Sequences |
| 3 | + * |
| 4 | + * This module provides utilities for detecting terminal theme (dark/light) by querying |
| 5 | + * the terminal's foreground and background colors using OSC (Operating System Command) |
| 6 | + * escape sequences. |
| 7 | + * |
| 8 | + * OSC 10: Query foreground (text) color |
| 9 | + * OSC 11: Query background color |
| 10 | + */ |
| 11 | + |
| 12 | +import { openSync, closeSync, writeSync, createReadStream } from 'fs' |
| 13 | +import { Readable } from 'stream' |
| 14 | + |
| 15 | +/** |
| 16 | + * Check if the current terminal supports OSC color queries |
| 17 | + */ |
| 18 | +export function terminalSupportsOSC(): boolean { |
| 19 | + const term = process.env.TERM || '' |
| 20 | + const termProgram = process.env.TERM_PROGRAM || '' |
| 21 | + |
| 22 | + // Known compatible terminals |
| 23 | + const supportedPrograms = [ |
| 24 | + 'iTerm.app', |
| 25 | + 'Apple_Terminal', |
| 26 | + 'WezTerm', |
| 27 | + 'Alacritty', |
| 28 | + 'kitty', |
| 29 | + 'Ghostty', |
| 30 | + 'vscode', |
| 31 | + ] |
| 32 | + |
| 33 | + if (supportedPrograms.some((p) => termProgram.includes(p))) { |
| 34 | + return true |
| 35 | + } |
| 36 | + |
| 37 | + const supportedTerms = [ |
| 38 | + 'xterm-256color', |
| 39 | + 'xterm-kitty', |
| 40 | + 'alacritty', |
| 41 | + 'wezterm', |
| 42 | + 'ghostty', |
| 43 | + ] |
| 44 | + |
| 45 | + if (supportedTerms.some((t) => term.includes(t))) { |
| 46 | + return true |
| 47 | + } |
| 48 | + |
| 49 | + // Check if we have a TTY |
| 50 | + return process.stdin.isTTY === true |
| 51 | +} |
| 52 | + |
| 53 | +/** |
| 54 | + * Build OSC query with proper wrapping for terminal multiplexers |
| 55 | + * @param oscCode - The OSC code (10 for foreground, 11 for background) |
| 56 | + */ |
| 57 | +function buildOscQuery(oscCode: number): string { |
| 58 | + const base = `\x1b]${oscCode};?\x07` |
| 59 | + |
| 60 | + // tmux requires double-escaping |
| 61 | + if (process.env.TMUX) { |
| 62 | + return `\x1bPtmux;${base.replace(/\x1b/g, '\x1b\x1b')}\x1b\\` |
| 63 | + } |
| 64 | + |
| 65 | + // screen/byobu wrapping |
| 66 | + if (process.env.STY) { |
| 67 | + return `\x1bP${base}\x1b\\` |
| 68 | + } |
| 69 | + |
| 70 | + return base |
| 71 | +} |
| 72 | + |
| 73 | +/** |
| 74 | + * Query the terminal for OSC color information via /dev/tty |
| 75 | + * @param oscCode - The OSC code (10 for foreground, 11 for background) |
| 76 | + * @returns The raw response string or null if query failed |
| 77 | + */ |
| 78 | +export async function queryTerminalOSC( |
| 79 | + oscCode: number, |
| 80 | +): Promise<string | null> { |
| 81 | + return new Promise((resolve) => { |
| 82 | + const ttyPath = process.platform === 'win32' ? 'CON' : '/dev/tty' |
| 83 | + |
| 84 | + let ttyReadFd: number | null = null |
| 85 | + let ttyWriteFd: number | null = null |
| 86 | + let timeout: NodeJS.Timeout | null = null |
| 87 | + let readStream: Readable | null = null |
| 88 | + |
| 89 | + const cleanup = () => { |
| 90 | + if (timeout) { |
| 91 | + clearTimeout(timeout) |
| 92 | + timeout = null |
| 93 | + } |
| 94 | + if (readStream) { |
| 95 | + readStream.removeAllListeners() |
| 96 | + readStream.destroy() |
| 97 | + readStream = null |
| 98 | + } |
| 99 | + if (ttyWriteFd !== null) { |
| 100 | + try { |
| 101 | + closeSync(ttyWriteFd) |
| 102 | + } catch { |
| 103 | + // Ignore close errors |
| 104 | + } |
| 105 | + ttyWriteFd = null |
| 106 | + } |
| 107 | + // ttyReadFd is managed by the stream, so we don't close it separately |
| 108 | + } |
| 109 | + |
| 110 | + try { |
| 111 | + // Open TTY for reading and writing |
| 112 | + try { |
| 113 | + ttyReadFd = openSync(ttyPath, 'r') |
| 114 | + ttyWriteFd = openSync(ttyPath, 'w') |
| 115 | + } catch { |
| 116 | + // Not in a TTY environment |
| 117 | + resolve(null) |
| 118 | + return |
| 119 | + } |
| 120 | + |
| 121 | + // Set timeout for terminal response |
| 122 | + timeout = setTimeout(() => { |
| 123 | + cleanup() |
| 124 | + resolve(null) |
| 125 | + }, 1000) // 1 second timeout |
| 126 | + |
| 127 | + // Create read stream to capture response |
| 128 | + readStream = createReadStream(ttyPath, { |
| 129 | + fd: ttyReadFd, |
| 130 | + encoding: 'utf8', |
| 131 | + autoClose: true, |
| 132 | + }) |
| 133 | + |
| 134 | + let response = '' |
| 135 | + |
| 136 | + readStream.on('data', (chunk: Buffer | string) => { |
| 137 | + response += chunk.toString() |
| 138 | + |
| 139 | + // Check for complete response |
| 140 | + const hasBEL = response.includes('\x07') |
| 141 | + const hasST = response.includes('\x1b\\') |
| 142 | + const hasRGB = |
| 143 | + /rgb:[0-9a-fA-F]{2,4}\/[0-9a-fA-F]{2,4}\/[0-9a-fA-F]{2,4}/.test( |
| 144 | + response, |
| 145 | + ) |
| 146 | + |
| 147 | + if (hasBEL || hasST || hasRGB) { |
| 148 | + cleanup() |
| 149 | + resolve(response) |
| 150 | + } |
| 151 | + }) |
| 152 | + |
| 153 | + readStream.on('error', () => { |
| 154 | + cleanup() |
| 155 | + resolve(null) |
| 156 | + }) |
| 157 | + |
| 158 | + readStream.on('close', () => { |
| 159 | + // If stream closes before we get a complete response |
| 160 | + if (timeout) { |
| 161 | + cleanup() |
| 162 | + resolve(null) |
| 163 | + } |
| 164 | + }) |
| 165 | + |
| 166 | + // Send OSC query |
| 167 | + const query = buildOscQuery(oscCode) |
| 168 | + writeSync(ttyWriteFd, query) |
| 169 | + } catch { |
| 170 | + cleanup() |
| 171 | + resolve(null) |
| 172 | + } |
| 173 | + }) |
| 174 | +} |
| 175 | + |
| 176 | +/** |
| 177 | + * Parse RGB values from OSC response |
| 178 | + * @param response - The raw OSC response string |
| 179 | + * @returns RGB tuple [r, g, b] normalized to 0-255, or null if parsing failed |
| 180 | + */ |
| 181 | +export function parseOSCResponse( |
| 182 | + response: string, |
| 183 | +): [number, number, number] | null { |
| 184 | + // Extract RGB values from response |
| 185 | + const match = response.match( |
| 186 | + /rgb:([0-9a-fA-F]{2,4})\/([0-9a-fA-F]{2,4})\/([0-9a-fA-F]{2,4})/, |
| 187 | + ) |
| 188 | + |
| 189 | + if (!match) return null |
| 190 | + |
| 191 | + const [, rHex, gHex, bHex] = match |
| 192 | + if (!rHex || !gHex || !bHex) return null |
| 193 | + |
| 194 | + // Convert hex to decimal |
| 195 | + let r = parseInt(rHex, 16) |
| 196 | + let g = parseInt(gHex, 16) |
| 197 | + let b = parseInt(bHex, 16) |
| 198 | + |
| 199 | + // Normalize 16-bit (4 hex digits) to 8-bit |
| 200 | + if (rHex.length === 4) { |
| 201 | + r = Math.floor(r / 257) |
| 202 | + g = Math.floor(g / 257) |
| 203 | + b = Math.floor(b / 257) |
| 204 | + } |
| 205 | + |
| 206 | + return [r, g, b] |
| 207 | +} |
| 208 | + |
| 209 | +/** |
| 210 | + * Calculate brightness using ITU-R BT.709 luminance formula |
| 211 | + * @param rgb - RGB tuple [r, g, b] in 0-255 range |
| 212 | + * @returns Brightness value 0-255 |
| 213 | + */ |
| 214 | +export function calculateBrightness([r, g, b]: [ |
| 215 | + number, |
| 216 | + number, |
| 217 | + number, |
| 218 | +]): number { |
| 219 | + // Relative luminance coefficients (ITU-R BT.709) |
| 220 | + const LUMINANCE_RED = 0.2126 |
| 221 | + const LUMINANCE_GREEN = 0.7152 |
| 222 | + const LUMINANCE_BLUE = 0.0722 |
| 223 | + |
| 224 | + return Math.floor(LUMINANCE_RED * r + LUMINANCE_GREEN * g + LUMINANCE_BLUE * b) |
| 225 | +} |
| 226 | + |
| 227 | +/** |
| 228 | + * Determine theme from background color |
| 229 | + * @param rgb - RGB tuple [r, g, b] |
| 230 | + * @returns 'dark' if background is dark, 'light' if background is light |
| 231 | + */ |
| 232 | +export function themeFromBgColor(rgb: [number, number, number]): 'dark' | 'light' { |
| 233 | + const brightness = calculateBrightness(rgb) |
| 234 | + const THRESHOLD = 128 // Middle of 0-255 range |
| 235 | + |
| 236 | + return brightness > THRESHOLD ? 'light' : 'dark' |
| 237 | +} |
| 238 | + |
| 239 | +/** |
| 240 | + * Determine theme from foreground color (inverted logic) |
| 241 | + * @param rgb - RGB tuple [r, g, b] |
| 242 | + * @returns 'dark' if foreground is bright (dark background), 'light' if foreground is dark |
| 243 | + */ |
| 244 | +export function themeFromFgColor(rgb: [number, number, number]): 'dark' | 'light' { |
| 245 | + const brightness = calculateBrightness(rgb) |
| 246 | + // Bright foreground = dark background theme |
| 247 | + return brightness > 128 ? 'dark' : 'light' |
| 248 | +} |
| 249 | + |
| 250 | +/** |
| 251 | + * Detect terminal theme by querying OSC 10/11 |
| 252 | + * @returns 'dark', 'light', or null if detection failed |
| 253 | + */ |
| 254 | +export async function detectTerminalTheme(): Promise<'dark' | 'light' | null> { |
| 255 | + // Check if terminal supports OSC |
| 256 | + if (!terminalSupportsOSC()) { |
| 257 | + return null |
| 258 | + } |
| 259 | + |
| 260 | + try { |
| 261 | + // Try background color first (OSC 11) - more reliable |
| 262 | + const bgResponse = await queryTerminalOSC(11) |
| 263 | + if (bgResponse) { |
| 264 | + const bgRgb = parseOSCResponse(bgResponse) |
| 265 | + if (bgRgb) { |
| 266 | + return themeFromBgColor(bgRgb) |
| 267 | + } |
| 268 | + } |
| 269 | + |
| 270 | + // Fallback to foreground color (OSC 10) |
| 271 | + const fgResponse = await queryTerminalOSC(10) |
| 272 | + if (fgResponse) { |
| 273 | + const fgRgb = parseOSCResponse(fgResponse) |
| 274 | + if (fgRgb) { |
| 275 | + return themeFromFgColor(fgRgb) |
| 276 | + } |
| 277 | + } |
| 278 | + |
| 279 | + return null // Detection failed |
| 280 | + } catch { |
| 281 | + return null |
| 282 | + } |
| 283 | +} |
| 284 | + |
0 commit comments