Skip to content

Commit c5f3c12

Browse files
[feat]: osc 10/11 for light/dark mode fallback (#359)
1 parent dcd22ff commit c5f3c12

File tree

4 files changed

+348
-21
lines changed

4 files changed

+348
-21
lines changed

bun.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/src/components/login-modal.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ export const LoginModal = ({
283283
width: modalWidth,
284284
height: modalHeight,
285285
maxHeight: modalHeight,
286-
backgroundColor: theme.background,
286+
backgroundColor: theme.surface,
287287
padding: 0,
288288
flexDirection: 'column',
289289
}}
@@ -316,7 +316,6 @@ export const LoginModal = ({
316316
alignItems: 'center',
317317
width: '100%',
318318
height: '100%',
319-
backgroundColor: theme.background,
320319
padding: containerPadding,
321320
gap: 0,
322321
}}
@@ -449,9 +448,7 @@ export const LoginModal = ({
449448
<text style={{ wrapMode: 'none' }}>
450449
<span
451450
fg={
452-
copyMessage.startsWith('✓')
453-
? theme.success
454-
: theme.error
451+
copyMessage.startsWith('✓') ? theme.success : theme.error
455452
}
456453
>
457454
{copyMessage}
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
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

Comments
 (0)