diff --git a/scripts/probe-terminal-theme.ts b/scripts/probe-terminal-theme.ts new file mode 100644 index 00000000..235fc1dd --- /dev/null +++ b/scripts/probe-terminal-theme.ts @@ -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(); + } +} diff --git a/src/core/startup.test.ts b/src/core/startup.test.ts index 66a35698..ff4e45b5 100644 --- a/src/core/startup.test.ts +++ b/src/core/startup.test.ts @@ -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", diff --git a/src/core/startup.ts b/src/core/startup.ts index d8a23eaa..e046530c 100644 --- a/src/core/startup.ts +++ b/src/core/startup.ts @@ -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, @@ -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; } @@ -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); @@ -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.", @@ -194,6 +219,8 @@ export async function prepareStartupPlan( throw error; } + bootstrap.initialThemeMode = initialThemeMode ?? bootstrap.initialThemeMode; + controllingTerminal ??= usesPipedPatchInputImpl(cliInput) ? openControllingTerminalImpl() : null; return { diff --git a/src/core/themeDetection.test.ts b/src/core/themeDetection.test.ts new file mode 100644 index 00000000..78c41cdd --- /dev/null +++ b/src/core/themeDetection.test.ts @@ -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); + }); +}); diff --git a/src/core/themeDetection.ts b/src/core/themeDetection.ts new file mode 100644 index 00000000..7992ac4f --- /dev/null +++ b/src/core/themeDetection.ts @@ -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 { + const wasRaw = input.isRaw; + let settled = false; + let buffer = ""; + + return await new Promise((resolve) => { + const cleanup = () => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + input.removeListener("data", onData); + if (wasRaw !== undefined) { + input.setRawMode?.(wasRaw); + } + }; + + 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); + }); +} diff --git a/src/core/types.ts b/src/core/types.ts index b79fa395..51442aaf 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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; @@ -275,6 +276,7 @@ export interface AppBootstrap { changeset: Changeset; initialMode: LayoutMode; initialTheme?: string; + initialThemeMode?: TerminalThemeMode; initialShowLineNumbers?: boolean; initialWrapLines?: boolean; initialShowHunkHeaders?: boolean; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 56b71523..87dba698 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -102,8 +102,10 @@ export function App({ const layoutToggleScrollTopRef = useRef(null); const [layoutToggleRequestId, setLayoutToggleRequestId] = useState(0); const [layoutMode, setLayoutMode] = useState(bootstrap.initialMode); - const [themeId, setThemeId] = useState( - () => resolveTheme(bootstrap.initialTheme, renderer.themeMode).id, + const [themeId, setThemeId] = useState(() => + bootstrap.initialTheme === "auto" + ? "auto" + : resolveTheme(bootstrap.initialTheme, bootstrap.initialThemeMode ?? null).id, ); const [showAgentNotes, setShowAgentNotes] = useState(bootstrap.initialShowAgentNotes ?? false); const [showLineNumbers, setShowLineNumbers] = useState(bootstrap.initialShowLineNumbers ?? true); @@ -118,7 +120,7 @@ export function App({ const [resizeDragOriginX, setResizeDragOriginX] = useState(null); const [resizeStartWidth, setResizeStartWidth] = useState(null); - const activeTheme = resolveTheme(themeId, renderer.themeMode); + const activeTheme = resolveTheme(themeId, bootstrap.initialThemeMode ?? null); const review = useReviewController({ files: bootstrap.changeset.files }); const filteredFiles = review.visibleFiles; const selectedFile = review.selectedFile; diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 98c77b8f..14136c59 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -363,10 +363,14 @@ describe("ui helpers", () => { const midnight = resolveTheme("midnight", null); const missingLight = resolveTheme("missing", "light"); const missingDark = resolveTheme("missing", "dark"); + const autoLight = resolveTheme("auto", "light"); + const autoDark = resolveTheme("auto", "dark"); expect(midnight.id).toBe("midnight"); expect(missingLight.id).toBe("graphite"); expect(missingDark.id).toBe("graphite"); + expect(autoLight.id).toBe("paper"); + expect(autoDark.id).toBe("graphite"); expect(resolveTheme("ember", null).syntaxStyle).toBeDefined(); }); }); diff --git a/src/ui/themes.ts b/src/ui/themes.ts index 8bf95c3d..38ad3883 100644 --- a/src/ui/themes.ts +++ b/src/ui/themes.ts @@ -286,8 +286,13 @@ export const THEMES: AppTheme[] = [ ), ]; -/** Resolve a named theme or fall back to Hunk's explicit built-in default. */ -export function resolveTheme(requested: string | undefined, _themeMode: ThemeMode | null) { +/** Resolve a named theme, including explicit terminal-background auto mode. */ +export function resolveTheme(requested: string | undefined, themeMode: ThemeMode | null) { + if (requested === "auto") { + const preferred = themeMode === "light" ? "paper" : "graphite"; + return THEMES.find((theme) => theme.id === preferred) ?? THEMES[0]!; + } + const exact = THEMES.find((theme) => theme.id === requested); if (exact) { return exact; diff --git a/test/pty/ui-integration.test.ts b/test/pty/ui-integration.test.ts index cd769913..1696b963 100644 --- a/test/pty/ui-integration.test.ts +++ b/test/pty/ui-integration.test.ts @@ -672,6 +672,28 @@ describe("live UI integration", () => { } }); + test("piped stdin still allows concrete-theme app startup to read terminal input", async () => { + const fixture = harness.createTwoFileRepoFixture(); + const session = await harness.launchShellCommand({ + command: `printf ignored | ${harness.buildHunkCommand(["diff", "--theme", "graphite"])}`, + cwd: fixture.dir, + cols: 120, + rows: 14, + }); + + try { + const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, { + timeout: 15_000, + }); + expect(initial).toContain("alpha.ts"); + + await session.press("q"); + await session.waitIdle({ timeout: 500 }); + } finally { + session.close(); + } + }); + test("stdin patch mode enables mouse wheel scrolling in pager UI", async () => { const fixture = harness.createPagerPatchFixture(60); const session = await harness.launchHunkWithFileBackedStdin({ @@ -714,6 +736,35 @@ describe("live UI integration", () => { } }); + test("stdin patch auto theme still enables mouse wheel scrolling", async () => { + const fixture = harness.createPagerPatchFixture(60); + const session = await harness.launchHunkWithFileBackedStdin({ + stdinFile: fixture.patchFile, + args: ["patch", "-", "--theme", "auto"], + cols: 120, + rows: 12, + }); + + try { + const initial = await session.waitForText(/scroll\.ts/, { timeout: 15_000 }); + + expect(initial).toContain("before_01"); + expect(initial).not.toContain("before_12"); + + await session.waitIdle({ timeout: 200 }); + await session.scrollDown(10); + const scrolled = await harness.waitForSnapshot( + session, + (text) => !text.includes("before_01") && text.includes("before_12"), + 5_000, + ); + + expect(scrolled).toContain("before_12"); + } finally { + session.close(); + } + }); + test("general pager mode enables mouse wheel scrolling for diff-like stdin", async () => { const fixture = harness.createPagerPatchFixture(60); const session = await harness.launchHunkWithFileBackedStdin({