|
| 1 | +import type { TerminalThemeMode } from "./types"; |
| 2 | + |
| 3 | +export type { TerminalThemeMode } from "./types"; |
| 4 | + |
| 5 | +export interface RgbColor { |
| 6 | + red: number; |
| 7 | + green: number; |
| 8 | + blue: number; |
| 9 | +} |
| 10 | + |
| 11 | +interface ThemeProbeInput { |
| 12 | + on(event: "data", listener: (chunk: Buffer | string) => void): unknown; |
| 13 | + removeListener(event: "data", listener: (chunk: Buffer | string) => void): unknown; |
| 14 | + resume?(): unknown; |
| 15 | + pause?(): unknown; |
| 16 | + setRawMode?(mode: boolean): unknown; |
| 17 | + isRaw?: boolean; |
| 18 | +} |
| 19 | + |
| 20 | +interface ThemeProbeOutput { |
| 21 | + write(chunk: string): unknown; |
| 22 | +} |
| 23 | + |
| 24 | +export interface DetectTerminalThemeOptions { |
| 25 | + input: ThemeProbeInput; |
| 26 | + output: ThemeProbeOutput; |
| 27 | + timeoutMs?: number; |
| 28 | +} |
| 29 | + |
| 30 | +const OSC_11_BACKGROUND_QUERY = "\x1b]11;?\x1b\\"; |
| 31 | + |
| 32 | +/** Convert xterm-style OSC 11 color channels into 8-bit RGB. */ |
| 33 | +function parseHexChannel(channel: string) { |
| 34 | + const value = Number.parseInt(channel, 16); |
| 35 | + if (Number.isNaN(value)) { |
| 36 | + return null; |
| 37 | + } |
| 38 | + |
| 39 | + const max = 16 ** channel.length - 1; |
| 40 | + return Math.round((value / max) * 255); |
| 41 | +} |
| 42 | + |
| 43 | +/** Parse common OSC 11 background-color responses into RGB. */ |
| 44 | +export function parseOsc11BackgroundColor(sequence: string): RgbColor | null { |
| 45 | + const rgbMatch = |
| 46 | + /\x1b\]11;rgb:([0-9a-f]{2,4})\/([0-9a-f]{2,4})\/([0-9a-f]{2,4})(?:\x07|\x1b\\)/i.exec(sequence); |
| 47 | + if (rgbMatch) { |
| 48 | + const red = parseHexChannel(rgbMatch[1]!); |
| 49 | + const green = parseHexChannel(rgbMatch[2]!); |
| 50 | + const blue = parseHexChannel(rgbMatch[3]!); |
| 51 | + return red === null || green === null || blue === null ? null : { red, green, blue }; |
| 52 | + } |
| 53 | + |
| 54 | + const hexMatch = /\x1b\]11;#([0-9a-f]{6})(?:\x07|\x1b\\)/i.exec(sequence); |
| 55 | + if (!hexMatch) { |
| 56 | + return null; |
| 57 | + } |
| 58 | + |
| 59 | + const [, hex] = hexMatch; |
| 60 | + return { |
| 61 | + red: Number.parseInt(hex!.slice(0, 2), 16), |
| 62 | + green: Number.parseInt(hex!.slice(2, 4), 16), |
| 63 | + blue: Number.parseInt(hex!.slice(4, 6), 16), |
| 64 | + }; |
| 65 | +} |
| 66 | + |
| 67 | +/** Classify a background color using relative luminance. */ |
| 68 | +export function themeModeForBackgroundColor({ red, green, blue }: RgbColor): TerminalThemeMode { |
| 69 | + const linear = [red, green, blue].map((component) => { |
| 70 | + const normalized = component / 255; |
| 71 | + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; |
| 72 | + }); |
| 73 | + const luminance = 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]!; |
| 74 | + return luminance > 0.5 ? "light" : "dark"; |
| 75 | +} |
| 76 | + |
| 77 | +/** |
| 78 | + * Probe the terminal background via OSC 11 using the same input stream OpenTUI uses for mouse. |
| 79 | + * This avoids treating piped diff stdin as terminal input while leaving renderer stdout unchanged. |
| 80 | + */ |
| 81 | +export async function detectTerminalThemeModeFromBackground({ |
| 82 | + input, |
| 83 | + output, |
| 84 | + timeoutMs = 150, |
| 85 | +}: DetectTerminalThemeOptions): Promise<TerminalThemeMode | null> { |
| 86 | + const wasRaw = input.isRaw; |
| 87 | + let settled = false; |
| 88 | + let buffer = ""; |
| 89 | + |
| 90 | + return await new Promise<TerminalThemeMode | null>((resolve) => { |
| 91 | + const cleanup = () => { |
| 92 | + if (settled) { |
| 93 | + return; |
| 94 | + } |
| 95 | + settled = true; |
| 96 | + clearTimeout(timer); |
| 97 | + input.removeListener("data", onData); |
| 98 | + if (wasRaw !== undefined) { |
| 99 | + input.setRawMode?.(wasRaw); |
| 100 | + } |
| 101 | + }; |
| 102 | + |
| 103 | + const finish = (mode: TerminalThemeMode | null) => { |
| 104 | + cleanup(); |
| 105 | + resolve(mode); |
| 106 | + }; |
| 107 | + |
| 108 | + const timer = setTimeout(() => finish(null), timeoutMs); |
| 109 | + const onData = (chunk: Buffer | string) => { |
| 110 | + buffer += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk; |
| 111 | + const color = parseOsc11BackgroundColor(buffer); |
| 112 | + if (color) { |
| 113 | + finish(themeModeForBackgroundColor(color)); |
| 114 | + } |
| 115 | + }; |
| 116 | + |
| 117 | + input.setRawMode?.(true); |
| 118 | + input.resume?.(); |
| 119 | + input.on("data", onData); |
| 120 | + output.write(OSC_11_BACKGROUND_QUERY); |
| 121 | + }); |
| 122 | +} |
0 commit comments