Skip to content

Commit e9ba014

Browse files
authored
feat(theme): add auto theme detection via OSC 11 background probe (#290)
1 parent 493619e commit e9ba014

10 files changed

Lines changed: 387 additions & 5 deletions

File tree

scripts/probe-terminal-theme.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/usr/bin/env bun
2+
3+
import fs from "node:fs";
4+
import tty from "node:tty";
5+
import {
6+
detectTerminalThemeModeFromBackground,
7+
parseOsc11BackgroundColor,
8+
themeModeForBackgroundColor,
9+
} from "../src/core/themeDetection";
10+
11+
const inputFd = fs.openSync("/dev/tty", "r");
12+
const input = new tty.ReadStream(inputFd);
13+
const output = process.stdout.isTTY
14+
? process.stdout
15+
: new tty.WriteStream(fs.openSync("/dev/tty", "w"));
16+
17+
let raw = "";
18+
input.on("data", (chunk) => {
19+
raw += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
20+
});
21+
22+
try {
23+
const mode = await detectTerminalThemeModeFromBackground({ input, output, timeoutMs: 500 });
24+
const color = parseOsc11BackgroundColor(raw);
25+
const classified = color ? themeModeForBackgroundColor(color) : null;
26+
27+
process.stderr.write(
28+
JSON.stringify(
29+
{
30+
mode,
31+
color,
32+
classified,
33+
raw: raw.replaceAll("\x1b", "\\e"),
34+
stdoutIsTTY: Boolean(process.stdout.isTTY),
35+
stdinIsTTY: Boolean(process.stdin.isTTY),
36+
},
37+
null,
38+
2,
39+
) + "\n",
40+
);
41+
} finally {
42+
input.destroy();
43+
if (output !== process.stdout) {
44+
output.destroy();
45+
}
46+
}

src/core/startup.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,76 @@ describe("startup planning", () => {
237237
).rejects.toBeInstanceOf(HunkUserError);
238238
});
239239

240+
test("opens the controlling terminal for any app startup with piped stdin", async () => {
241+
const cliInput: CliInput = {
242+
kind: "vcs",
243+
staged: false,
244+
options: {
245+
theme: "graphite",
246+
},
247+
};
248+
const controllingTerminal = { stdin: {} as never, close: () => {} };
249+
let opened = 0;
250+
251+
const plan = await prepareStartupPlan(["bun", "hunk", "diff", "--theme", "graphite"], {
252+
parseCliImpl: async () => cliInput as ParsedCliInput,
253+
resolveRuntimeCliInputImpl: (input) => input,
254+
resolveConfiguredCliInputImpl: (input) => ({ input }) as never,
255+
loadAppBootstrapImpl: async (input) => createBootstrap(input),
256+
openControllingTerminalImpl: () => {
257+
opened += 1;
258+
return controllingTerminal;
259+
},
260+
stdinIsTTY: false,
261+
stdoutIsTTY: true,
262+
});
263+
264+
expect(plan).toMatchObject({
265+
kind: "app",
266+
cliInput,
267+
controllingTerminal,
268+
});
269+
expect(opened).toBe(1);
270+
});
271+
272+
test("detects auto theme through the controlling terminal before app startup", async () => {
273+
const cliInput: CliInput = {
274+
kind: "patch",
275+
file: "-",
276+
options: {
277+
theme: "auto",
278+
pager: true,
279+
},
280+
};
281+
const controllingTerminal = { stdin: {} as never, close: () => {} };
282+
let opened = 0;
283+
284+
const plan = await prepareStartupPlan(["bun", "hunk", "patch", "-", "--theme", "auto"], {
285+
parseCliImpl: async () => cliInput as ParsedCliInput,
286+
resolveRuntimeCliInputImpl: (input) => input,
287+
resolveConfiguredCliInputImpl: (input) => ({ input }) as never,
288+
loadAppBootstrapImpl: async (input) => createBootstrap(input),
289+
openControllingTerminalImpl: () => {
290+
opened += 1;
291+
return controllingTerminal;
292+
},
293+
detectTerminalThemeModeFromBackgroundImpl: async ({ input }) => {
294+
expect(input).toBe(controllingTerminal.stdin);
295+
return "dark";
296+
},
297+
stdinIsTTY: false,
298+
stdoutIsTTY: true,
299+
stdout: { write: () => true } as never,
300+
});
301+
302+
expect(plan).toMatchObject({
303+
kind: "app",
304+
controllingTerminal,
305+
bootstrap: { initialThemeMode: "dark" },
306+
});
307+
expect(opened).toBe(1);
308+
});
309+
240310
test("opens the controlling terminal for piped patch startup", async () => {
241311
const cliInput: CliInput = {
242312
kind: "patch",

src/core/startup.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { resolveConfiguredCliInput } from "./config";
22
import { HunkUserError } from "./errors";
33
import { loadAppBootstrap } from "./loaders";
44
import { looksLikePatchInput } from "./pager";
5+
import { detectTerminalThemeModeFromBackground } from "./themeDetection";
56
import {
67
openControllingTerminal,
78
resolveRuntimeCliInput,
@@ -62,7 +63,10 @@ export interface StartupDeps {
6263
loadAppBootstrapImpl?: typeof loadAppBootstrap;
6364
usesPipedPatchInputImpl?: typeof usesPipedPatchInput;
6465
openControllingTerminalImpl?: typeof openControllingTerminal;
66+
detectTerminalThemeModeFromBackgroundImpl?: typeof detectTerminalThemeModeFromBackground;
67+
stdinIsTTY?: boolean;
6568
stdoutIsTTY?: boolean;
69+
stdout?: NodeJS.WriteStream;
6670
env?: NodeJS.ProcessEnv;
6771
}
6872

@@ -80,7 +84,11 @@ export async function prepareStartupPlan(
8084
const loadAppBootstrapImpl = deps.loadAppBootstrapImpl ?? loadAppBootstrap;
8185
const usesPipedPatchInputImpl = deps.usesPipedPatchInputImpl ?? usesPipedPatchInput;
8286
const openControllingTerminalImpl = deps.openControllingTerminalImpl ?? openControllingTerminal;
87+
const detectTerminalThemeModeFromBackgroundImpl =
88+
deps.detectTerminalThemeModeFromBackgroundImpl ?? detectTerminalThemeModeFromBackground;
89+
const stdinIsTTY = deps.stdinIsTTY ?? Boolean(process.stdin.isTTY);
8390
const stdoutIsTTY = deps.stdoutIsTTY ?? Boolean(process.stdout.isTTY);
91+
const stdout = deps.stdout ?? process.stdout;
8492
const env = deps.env ?? process.env;
8593

8694
let parsedCliInput = await parseCliImpl(argv);
@@ -177,6 +185,23 @@ export async function prepareStartupPlan(
177185
const configured = resolveConfiguredCliInputImpl(runtimeCliInput);
178186
const cliInput = configured.input;
179187

188+
// Any app session launched with piped stdin still needs a real terminal input stream for
189+
// keyboard, mouse, and terminal query responses. Auto-theme happened to open this path during
190+
// probing; make it unconditional so concrete themes behave the same way.
191+
if (!controllingTerminal && !stdinIsTTY && stdoutIsTTY) {
192+
controllingTerminal = openControllingTerminalImpl();
193+
}
194+
195+
let initialThemeMode: AppBootstrap["initialThemeMode"];
196+
if (cliInput.options.theme === "auto" && stdoutIsTTY) {
197+
const themeInput = controllingTerminal?.stdin ?? (stdinIsTTY ? process.stdin : null);
198+
if (themeInput) {
199+
initialThemeMode =
200+
(await detectTerminalThemeModeFromBackgroundImpl({ input: themeInput, output: stdout })) ??
201+
undefined;
202+
}
203+
}
204+
180205
if (cliInput.options.watch && !canReloadInput(cliInput)) {
181206
throw new HunkUserError(
182207
"`--watch` requires a file- or Git-backed input that Hunk can reopen.",
@@ -194,6 +219,8 @@ export async function prepareStartupPlan(
194219
throw error;
195220
}
196221

222+
bootstrap.initialThemeMode = initialThemeMode ?? bootstrap.initialThemeMode;
223+
197224
controllingTerminal ??= usesPipedPatchInputImpl(cliInput) ? openControllingTerminalImpl() : null;
198225

199226
return {

src/core/themeDetection.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { EventEmitter } from "node:events";
3+
import {
4+
detectTerminalThemeModeFromBackground,
5+
parseOsc11BackgroundColor,
6+
themeModeForBackgroundColor,
7+
} from "./themeDetection";
8+
9+
class FakeThemeInput extends EventEmitter {
10+
isRaw = false;
11+
setRawMode(mode: boolean) {
12+
this.isRaw = mode;
13+
}
14+
resume() {}
15+
}
16+
17+
/** Unit coverage for the terminal background probe used by auto theme POC. */
18+
describe("terminal theme detection", () => {
19+
test("parses OSC 11 rgb responses", () => {
20+
expect(parseOsc11BackgroundColor("\x1b]11;rgb:0000/1111/2222\x1b\\")).toEqual({
21+
red: 0,
22+
green: 17,
23+
blue: 34,
24+
});
25+
expect(parseOsc11BackgroundColor("\x1b]11;#ffffff\x07")).toEqual({
26+
red: 255,
27+
green: 255,
28+
blue: 255,
29+
});
30+
});
31+
32+
test("classifies dark and light backgrounds", () => {
33+
expect(themeModeForBackgroundColor({ red: 12, green: 12, blue: 12 })).toBe("dark");
34+
expect(themeModeForBackgroundColor({ red: 245, green: 245, blue: 245 })).toBe("light");
35+
});
36+
37+
test("detects terminal mode from the queried input stream", async () => {
38+
const input = new FakeThemeInput();
39+
let query = "";
40+
const output = {
41+
write(chunk: string) {
42+
query += chunk;
43+
queueMicrotask(() => input.emit("data", "\x1b]11;rgb:0000/0000/0000\x1b\\"));
44+
},
45+
};
46+
47+
await expect(
48+
detectTerminalThemeModeFromBackground({ input, output, timeoutMs: 50 }),
49+
).resolves.toBe("dark");
50+
expect(query).toBe("\x1b]11;?\x1b\\");
51+
expect(input.isRaw).toBe(false);
52+
});
53+
});

src/core/themeDetection.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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+
}

src/core/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { FileDiffMetadata } from "@pierre/diffs";
22

33
export type LayoutMode = "auto" | "split" | "stack";
44
export type VcsMode = "git" | "jj";
5+
export type TerminalThemeMode = "light" | "dark";
56

67
export interface AgentAnnotation {
78
id?: string;
@@ -275,6 +276,7 @@ export interface AppBootstrap {
275276
changeset: Changeset;
276277
initialMode: LayoutMode;
277278
initialTheme?: string;
279+
initialThemeMode?: TerminalThemeMode;
278280
initialShowLineNumbers?: boolean;
279281
initialWrapLines?: boolean;
280282
initialShowHunkHeaders?: boolean;

0 commit comments

Comments
 (0)