Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions scripts/probe-terminal-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env bun

import fs from "node:fs";
import tty from "node:tty";
import {
detectTerminalThemeModeFromBackground,
parseOsc11BackgroundColor,
themeModeForBackgroundColor,
} from "../src/core/themeDetection";

const inputFd = fs.openSync("/dev/tty", "r");
const input = new tty.ReadStream(inputFd);
const output = process.stdout.isTTY
? process.stdout
: new tty.WriteStream(fs.openSync("/dev/tty", "w"));

let raw = "";
input.on("data", (chunk) => {
raw += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
});

try {
const mode = await detectTerminalThemeModeFromBackground({ input, output, timeoutMs: 500 });
const color = parseOsc11BackgroundColor(raw);
const classified = color ? themeModeForBackgroundColor(color) : null;

process.stderr.write(
JSON.stringify(
{
mode,
color,
classified,
raw: raw.replaceAll("\x1b", "\\e"),
stdoutIsTTY: Boolean(process.stdout.isTTY),
stdinIsTTY: Boolean(process.stdin.isTTY),
},
null,
2,
) + "\n",
);
} finally {
input.destroy();
if (output !== process.stdout) {
output.destroy();
}
}
70 changes: 70 additions & 0 deletions src/core/startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,76 @@ describe("startup planning", () => {
).rejects.toBeInstanceOf(HunkUserError);
});

test("opens the controlling terminal for any app startup with piped stdin", async () => {
const cliInput: CliInput = {
kind: "vcs",
staged: false,
options: {
theme: "graphite",
},
};
const controllingTerminal = { stdin: {} as never, close: () => {} };
let opened = 0;

const plan = await prepareStartupPlan(["bun", "hunk", "diff", "--theme", "graphite"], {
parseCliImpl: async () => cliInput as ParsedCliInput,
resolveRuntimeCliInputImpl: (input) => input,
resolveConfiguredCliInputImpl: (input) => ({ input }) as never,
loadAppBootstrapImpl: async (input) => createBootstrap(input),
openControllingTerminalImpl: () => {
opened += 1;
return controllingTerminal;
},
stdinIsTTY: false,
stdoutIsTTY: true,
});

expect(plan).toMatchObject({
kind: "app",
cliInput,
controllingTerminal,
});
expect(opened).toBe(1);
});

test("detects auto theme through the controlling terminal before app startup", async () => {
const cliInput: CliInput = {
kind: "patch",
file: "-",
options: {
theme: "auto",
pager: true,
},
};
const controllingTerminal = { stdin: {} as never, close: () => {} };
let opened = 0;

const plan = await prepareStartupPlan(["bun", "hunk", "patch", "-", "--theme", "auto"], {
parseCliImpl: async () => cliInput as ParsedCliInput,
resolveRuntimeCliInputImpl: (input) => input,
resolveConfiguredCliInputImpl: (input) => ({ input }) as never,
loadAppBootstrapImpl: async (input) => createBootstrap(input),
openControllingTerminalImpl: () => {
opened += 1;
return controllingTerminal;
},
detectTerminalThemeModeFromBackgroundImpl: async ({ input }) => {
expect(input).toBe(controllingTerminal.stdin);
return "dark";
},
stdinIsTTY: false,
stdoutIsTTY: true,
stdout: { write: () => true } as never,
});

expect(plan).toMatchObject({
kind: "app",
controllingTerminal,
bootstrap: { initialThemeMode: "dark" },
});
expect(opened).toBe(1);
});

test("opens the controlling terminal for piped patch startup", async () => {
const cliInput: CliInput = {
kind: "patch",
Expand Down
27 changes: 27 additions & 0 deletions src/core/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { resolveConfiguredCliInput } from "./config";
import { HunkUserError } from "./errors";
import { loadAppBootstrap } from "./loaders";
import { looksLikePatchInput } from "./pager";
import { detectTerminalThemeModeFromBackground } from "./themeDetection";
import {
openControllingTerminal,
resolveRuntimeCliInput,
Expand Down Expand Up @@ -62,7 +63,10 @@ export interface StartupDeps {
loadAppBootstrapImpl?: typeof loadAppBootstrap;
usesPipedPatchInputImpl?: typeof usesPipedPatchInput;
openControllingTerminalImpl?: typeof openControllingTerminal;
detectTerminalThemeModeFromBackgroundImpl?: typeof detectTerminalThemeModeFromBackground;
stdinIsTTY?: boolean;
stdoutIsTTY?: boolean;
stdout?: NodeJS.WriteStream;
env?: NodeJS.ProcessEnv;
}

Expand All @@ -80,7 +84,11 @@ export async function prepareStartupPlan(
const loadAppBootstrapImpl = deps.loadAppBootstrapImpl ?? loadAppBootstrap;
const usesPipedPatchInputImpl = deps.usesPipedPatchInputImpl ?? usesPipedPatchInput;
const openControllingTerminalImpl = deps.openControllingTerminalImpl ?? openControllingTerminal;
const detectTerminalThemeModeFromBackgroundImpl =
deps.detectTerminalThemeModeFromBackgroundImpl ?? detectTerminalThemeModeFromBackground;
const stdinIsTTY = deps.stdinIsTTY ?? Boolean(process.stdin.isTTY);
const stdoutIsTTY = deps.stdoutIsTTY ?? Boolean(process.stdout.isTTY);
const stdout = deps.stdout ?? process.stdout;
const env = deps.env ?? process.env;

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

// Any app session launched with piped stdin still needs a real terminal input stream for
// keyboard, mouse, and terminal query responses. Auto-theme happened to open this path during
// probing; make it unconditional so concrete themes behave the same way.
if (!controllingTerminal && !stdinIsTTY && stdoutIsTTY) {
controllingTerminal = openControllingTerminalImpl();
}

let initialThemeMode: AppBootstrap["initialThemeMode"];
if (cliInput.options.theme === "auto" && stdoutIsTTY) {
const themeInput = controllingTerminal?.stdin ?? (stdinIsTTY ? process.stdin : null);
if (themeInput) {
initialThemeMode =
(await detectTerminalThemeModeFromBackgroundImpl({ input: themeInput, output: stdout })) ??
undefined;
}
}

if (cliInput.options.watch && !canReloadInput(cliInput)) {
throw new HunkUserError(
"`--watch` requires a file- or Git-backed input that Hunk can reopen.",
Expand All @@ -194,6 +219,8 @@ export async function prepareStartupPlan(
throw error;
}

bootstrap.initialThemeMode = initialThemeMode ?? bootstrap.initialThemeMode;

controllingTerminal ??= usesPipedPatchInputImpl(cliInput) ? openControllingTerminalImpl() : null;

return {
Expand Down
53 changes: 53 additions & 0 deletions src/core/themeDetection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, test } from "bun:test";
import { EventEmitter } from "node:events";
import {
detectTerminalThemeModeFromBackground,
parseOsc11BackgroundColor,
themeModeForBackgroundColor,
} from "./themeDetection";

class FakeThemeInput extends EventEmitter {
isRaw = false;
setRawMode(mode: boolean) {
this.isRaw = mode;
}
resume() {}
}

/** Unit coverage for the terminal background probe used by auto theme POC. */
describe("terminal theme detection", () => {
test("parses OSC 11 rgb responses", () => {
expect(parseOsc11BackgroundColor("\x1b]11;rgb:0000/1111/2222\x1b\\")).toEqual({
red: 0,
green: 17,
blue: 34,
});
expect(parseOsc11BackgroundColor("\x1b]11;#ffffff\x07")).toEqual({
red: 255,
green: 255,
blue: 255,
});
});

test("classifies dark and light backgrounds", () => {
expect(themeModeForBackgroundColor({ red: 12, green: 12, blue: 12 })).toBe("dark");
expect(themeModeForBackgroundColor({ red: 245, green: 245, blue: 245 })).toBe("light");
});

test("detects terminal mode from the queried input stream", async () => {
const input = new FakeThemeInput();
let query = "";
const output = {
write(chunk: string) {
query += chunk;
queueMicrotask(() => input.emit("data", "\x1b]11;rgb:0000/0000/0000\x1b\\"));
},
};

await expect(
detectTerminalThemeModeFromBackground({ input, output, timeoutMs: 50 }),
).resolves.toBe("dark");
expect(query).toBe("\x1b]11;?\x1b\\");
expect(input.isRaw).toBe(false);
});
});
122 changes: 122 additions & 0 deletions src/core/themeDetection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { TerminalThemeMode } from "./types";

export type { TerminalThemeMode } from "./types";

export interface RgbColor {
red: number;
green: number;
blue: number;
}

interface ThemeProbeInput {
on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
removeListener(event: "data", listener: (chunk: Buffer | string) => void): unknown;
resume?(): unknown;
pause?(): unknown;
setRawMode?(mode: boolean): unknown;
isRaw?: boolean;
}

interface ThemeProbeOutput {
write(chunk: string): unknown;
}

export interface DetectTerminalThemeOptions {
input: ThemeProbeInput;
output: ThemeProbeOutput;
timeoutMs?: number;
}

const OSC_11_BACKGROUND_QUERY = "\x1b]11;?\x1b\\";

/** Convert xterm-style OSC 11 color channels into 8-bit RGB. */
function parseHexChannel(channel: string) {
const value = Number.parseInt(channel, 16);
if (Number.isNaN(value)) {
return null;
}

const max = 16 ** channel.length - 1;
return Math.round((value / max) * 255);
}

/** Parse common OSC 11 background-color responses into RGB. */
export function parseOsc11BackgroundColor(sequence: string): RgbColor | null {
const rgbMatch =
/\x1b\]11;rgb:([0-9a-f]{2,4})\/([0-9a-f]{2,4})\/([0-9a-f]{2,4})(?:\x07|\x1b\\)/i.exec(sequence);
if (rgbMatch) {
const red = parseHexChannel(rgbMatch[1]!);
const green = parseHexChannel(rgbMatch[2]!);
const blue = parseHexChannel(rgbMatch[3]!);
return red === null || green === null || blue === null ? null : { red, green, blue };
}

const hexMatch = /\x1b\]11;#([0-9a-f]{6})(?:\x07|\x1b\\)/i.exec(sequence);
if (!hexMatch) {
return null;
}

const [, hex] = hexMatch;
return {
red: Number.parseInt(hex!.slice(0, 2), 16),
green: Number.parseInt(hex!.slice(2, 4), 16),
blue: Number.parseInt(hex!.slice(4, 6), 16),
};
}

/** Classify a background color using relative luminance. */
export function themeModeForBackgroundColor({ red, green, blue }: RgbColor): TerminalThemeMode {
const linear = [red, green, blue].map((component) => {
const normalized = component / 255;
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
});
const luminance = 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]!;
return luminance > 0.5 ? "light" : "dark";
}

/**
* Probe the terminal background via OSC 11 using the same input stream OpenTUI uses for mouse.
* This avoids treating piped diff stdin as terminal input while leaving renderer stdout unchanged.
*/
export async function detectTerminalThemeModeFromBackground({
input,
output,
timeoutMs = 150,
}: DetectTerminalThemeOptions): Promise<TerminalThemeMode | null> {
const wasRaw = input.isRaw;
let settled = false;
let buffer = "";

return await new Promise<TerminalThemeMode | null>((resolve) => {
const cleanup = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
input.removeListener("data", onData);
if (wasRaw !== undefined) {
input.setRawMode?.(wasRaw);
}
};
Comment thread
elucid marked this conversation as resolved.

const finish = (mode: TerminalThemeMode | null) => {
cleanup();
resolve(mode);
};

const timer = setTimeout(() => finish(null), timeoutMs);
const onData = (chunk: Buffer | string) => {
buffer += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
const color = parseOsc11BackgroundColor(buffer);
if (color) {
finish(themeModeForBackgroundColor(color));
}
};

input.setRawMode?.(true);
input.resume?.();
input.on("data", onData);
output.write(OSC_11_BACKGROUND_QUERY);
});
}
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { FileDiffMetadata } from "@pierre/diffs";

export type LayoutMode = "auto" | "split" | "stack";
export type VcsMode = "git" | "jj";
export type TerminalThemeMode = "light" | "dark";

export interface AgentAnnotation {
id?: string;
Expand Down Expand Up @@ -275,6 +276,7 @@ export interface AppBootstrap {
changeset: Changeset;
initialMode: LayoutMode;
initialTheme?: string;
initialThemeMode?: TerminalThemeMode;
initialShowLineNumbers?: boolean;
initialWrapLines?: boolean;
initialShowHunkHeaders?: boolean;
Expand Down
Loading
Loading