diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 71de722b..23d7b939 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added first-run interactive theme detection from the terminal background. + ### Fixed - Fixed `pi update` for pnpm global installs whose configured `global-bin-dir` no longer matches the active pnpm home ([#5689](https://github.com/earendil-works/pi/issues/5689)). diff --git a/packages/coding-agent/src/cli/startup-ui.ts b/packages/coding-agent/src/cli/startup-ui.ts index ec7aa5e7..93841304 100644 --- a/packages/coding-agent/src/cli/startup-ui.ts +++ b/packages/coding-agent/src/cli/startup-ui.ts @@ -10,7 +10,7 @@ import { FirstTimeSetupComponent, type FirstTimeSetupResult, } from "../modes/interactive/components/first-time-setup.ts"; -import { detectTerminalBackground, initTheme, setTheme } from "../modes/interactive/theme/theme.ts"; +import { detectTerminalBackgroundTheme, initTheme, setTheme } from "../modes/interactive/theme/theme.ts"; const OFFICIAL_PACKAGE_NAME = "@earendil-works/pi-coding-agent"; const OFFICIAL_APP_NAME = "pi"; @@ -123,19 +123,25 @@ export async function showFirstTimeSetup(settingsManager: SettingsManager): Prom resolve(); }; - const component = new FirstTimeSetupComponent({ - detectedTheme: detectTerminalBackground().theme, - onThemePreview: (themeName) => { - setTheme(themeName); - ui.invalidate(); - ui.requestRender(); - }, - onSubmit: (result) => void finish(result), - onCancel: () => void finish(undefined), - }); - ui.addChild(component); - ui.setFocus(component); - ui.start(); + const showSetup = async () => { + ui.start(); + const detection = await detectTerminalBackgroundTheme({ ui, timeoutMs: 100 }); + setTheme(detection.theme); + const component = new FirstTimeSetupComponent({ + detectedTheme: detection.theme, + onThemePreview: (themeName) => { + setTheme(themeName); + ui.requestRender(); + }, + onSubmit: (result) => void finish(result), + onCancel: () => void finish(undefined), + }); + ui.addChild(component); + ui.setFocus(component); + ui.requestRender(); + }; + + void showSetup(); }); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index fc25792e..b3424a15 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -126,6 +126,7 @@ import { TrustSelectorComponent } from "./components/trust-selector.ts"; import { UserMessageComponent } from "./components/user-message.ts"; import { UserMessageSelectorComponent } from "./components/user-message-selector.ts"; import { + detectTerminalBackgroundTheme, getAvailableThemes, getAvailableThemesWithPaths, getEditorTheme, @@ -428,6 +429,25 @@ export class InteractiveMode { initTheme(this.settingsManager.getTheme(), true); } + private async detectThemeIfUnset(): Promise { + if (this.settingsManager.getTheme()) { + return; + } + + const detection = await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 }); + const result = setTheme(detection.theme, true); + if (!result.success) { + return; + } + + if (detection.confidence === "high") { + this.settingsManager.setTheme(detection.theme); + await this.settingsManager.flush(); + } + this.updateEditorBorderColor(); + this.ui.requestRender(); + } + private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined { if (!sourceInfo) { return undefined; @@ -629,9 +649,28 @@ export class InteractiveMode { console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`)); } - // Add header container as first child + // Add header container as first child. Populate it after detectThemeIfUnset. this.ui.addChild(this.headerContainer); + this.ui.addChild(this.chatContainer); + this.ui.addChild(this.pendingMessagesContainer); + this.ui.addChild(this.statusContainer); + this.renderWidgets(); // Initialize with default spacer + this.ui.addChild(this.widgetContainerAbove); + this.ui.addChild(this.editorContainer); + this.ui.addChild(this.widgetContainerBelow); + this.ui.addChild(this.footer); + this.ui.setFocus(this.editor); + + this.setupKeyHandlers(); + this.setupEditorSubmitHandler(); + + // Start the UI before initializing extensions so session_start handlers can use interactive dialogs + this.ui.start(); + this.isInitialized = true; + + await this.detectThemeIfUnset(); + // Add header with keybindings from config (unless silenced) if (this.options.verbose || !this.settingsManager.getQuietStartup()) { const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`); @@ -692,23 +731,7 @@ export class InteractiveMode { this.builtInHeader = new Text("", 0, 0); this.headerContainer.addChild(this.builtInHeader); } - - this.ui.addChild(this.chatContainer); - this.ui.addChild(this.pendingMessagesContainer); - this.ui.addChild(this.statusContainer); - this.renderWidgets(); // Initialize with default spacer - this.ui.addChild(this.widgetContainerAbove); - this.ui.addChild(this.editorContainer); - this.ui.addChild(this.widgetContainerBelow); - this.ui.addChild(this.footer); - this.ui.setFocus(this.editor); - - this.setupKeyHandlers(); - this.setupEditorSubmitHandler(); - - // Start the UI before initializing extensions so session_start handlers can use interactive dialogs - this.ui.start(); - this.isInitialized = true; + this.ui.requestRender(); // Initialize extensions first so resources are shown before messages await this.rebindCurrentSession(); diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 8bdf4816..778f8028 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -4,6 +4,7 @@ import { type EditorTheme, getCapabilities, type MarkdownTheme, + type RgbColor, type SelectListTheme, type SettingsListTheme, } from "@earendil-works/pi-tui"; @@ -624,12 +625,6 @@ export function getThemeByName(name: string): Theme | undefined { export type TerminalTheme = "dark" | "light"; -export interface RgbColor { - r: number; - g: number; - b: number; -} - export interface TerminalThemeDetection { theme: TerminalTheme; source: "terminal background" | "COLORFGBG" | "fallback"; @@ -641,6 +636,15 @@ export interface TerminalThemeDetectionOptions { env?: NodeJS.ProcessEnv; } +export interface TerminalBackgroundThemeDetector { + queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise; +} + +export interface TerminalBackgroundThemeDetectionOptions extends TerminalThemeDetectionOptions { + ui: TerminalBackgroundThemeDetector; + timeoutMs: number; +} + function getColorFgBgBackgroundIndex(colorfgbg: string): number | undefined { const parts = colorfgbg.split(";"); for (let i = parts.length - 1; i >= 0; i--) { @@ -668,50 +672,7 @@ export function getThemeForRgbColor(rgb: RgbColor): TerminalTheme { return getRgbColorLuminance(rgb) >= 0.5 ? "light" : "dark"; } -function parseOscHexChannel(channel: string): number | undefined { - if (!/^[0-9a-f]+$/i.test(channel)) { - return undefined; - } - const max = 16 ** channel.length - 1; - if (max <= 0) { - return undefined; - } - return Math.round((parseInt(channel, 16) / max) * 255); -} - -export function parseOsc11BackgroundColor(data: string): RgbColor | undefined { - const match = data.match(/^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i); - if (!match) { - return undefined; - } - - const value = match[1].trim(); - if (value.startsWith("#")) { - const hex = value.slice(1); - if (/^[0-9a-f]{6}$/i.test(hex)) { - return hexToRgb(value); - } - if (/^[0-9a-f]{12}$/i.test(hex)) { - const r = parseOscHexChannel(hex.slice(0, 4)); - const g = parseOscHexChannel(hex.slice(4, 8)); - const b = parseOscHexChannel(hex.slice(8, 12)); - return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; - } - return undefined; - } - - const rgbValue = value.replace(/^rgba?:/i, ""); - const [red, green, blue] = rgbValue.split("/"); - if (red === undefined || green === undefined || blue === undefined) { - return undefined; - } - const r = parseOscHexChannel(red); - const g = parseOscHexChannel(green); - const b = parseOscHexChannel(blue); - return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; -} - -export function detectTerminalBackground(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection { +export function detectTerminalBackgroundFromEnv(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection { const env = options.env ?? process.env; const colorfgbg = env.COLORFGBG || ""; const bg = getColorFgBgBackgroundIndex(colorfgbg); @@ -732,8 +693,30 @@ export function detectTerminalBackground(options: TerminalThemeDetectionOptions }; } +export async function detectTerminalBackgroundTheme({ + ui, + timeoutMs, + env, +}: TerminalBackgroundThemeDetectionOptions): Promise { + try { + const rgb = await ui.queryTerminalBackgroundColor({ timeoutMs }); + if (rgb) { + return { + theme: getThemeForRgbColor(rgb), + source: "terminal background", + detail: `OSC 11 background rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`, + confidence: "high", + }; + } + } catch { + // Fall back to environment-based detection when the terminal query fails. + } + + return detectTerminalBackgroundFromEnv({ env }); +} + export function getDefaultTheme(): string { - return detectTerminalBackground().theme; + return detectTerminalBackgroundFromEnv().theme; } // ============================================================================ diff --git a/packages/coding-agent/test/theme-detection.test.ts b/packages/coding-agent/test/theme-detection.test.ts index 7bb280b2..4ad0cf56 100644 --- a/packages/coding-agent/test/theme-detection.test.ts +++ b/packages/coding-agent/test/theme-detection.test.ts @@ -1,24 +1,24 @@ -import { resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui"; +import { type RgbColor, resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui"; import { afterEach, describe, expect, it } from "vitest"; import { - detectTerminalBackground, + detectTerminalBackgroundFromEnv, + detectTerminalBackgroundTheme, getThemeByName, getThemeForRgbColor, - parseOsc11BackgroundColor, } from "../src/modes/interactive/theme/theme.ts"; afterEach(() => { resetCapabilitiesCache(); }); -describe("detectTerminalBackground", () => { +describe("detectTerminalBackgroundFromEnv", () => { it("uses the COLORFGBG background color index", () => { - expect(detectTerminalBackground({ env: { COLORFGBG: "0;15" } })).toMatchObject({ + expect(detectTerminalBackgroundFromEnv({ env: { COLORFGBG: "0;15" } })).toMatchObject({ theme: "light", source: "COLORFGBG", confidence: "high", }); - expect(detectTerminalBackground({ env: { COLORFGBG: "15;0" } })).toMatchObject({ + expect(detectTerminalBackgroundFromEnv({ env: { COLORFGBG: "15;0" } })).toMatchObject({ theme: "dark", source: "COLORFGBG", confidence: "high", @@ -26,11 +26,11 @@ describe("detectTerminalBackground", () => { }); it("uses the last COLORFGBG field as the background", () => { - expect(detectTerminalBackground({ env: { COLORFGBG: "0;7;15" } }).theme).toBe("light"); + expect(detectTerminalBackgroundFromEnv({ env: { COLORFGBG: "0;7;15" } }).theme).toBe("light"); }); it("defaults to dark without terminal background hints", () => { - expect(detectTerminalBackground({ env: {} })).toMatchObject({ + expect(detectTerminalBackgroundFromEnv({ env: {} })).toMatchObject({ theme: "dark", source: "fallback", confidence: "low", @@ -38,6 +38,65 @@ describe("detectTerminalBackground", () => { }); }); +describe("detectTerminalBackgroundTheme", () => { + it("uses the queried terminal background before environment hints", async () => { + let queriedTimeoutMs: number | undefined; + const detection = await detectTerminalBackgroundTheme({ + env: { COLORFGBG: "15;0" }, + timeoutMs: 250, + ui: { + async queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise { + queriedTimeoutMs = timeoutMs; + return { r: 250, g: 250, b: 250 }; + }, + }, + }); + + expect(queriedTimeoutMs).toBe(250); + expect(detection).toMatchObject({ + theme: "light", + source: "terminal background", + confidence: "high", + }); + }); + + it("falls back to environment hints when the terminal query returns no color", async () => { + const detection = await detectTerminalBackgroundTheme({ + env: { COLORFGBG: "15;0" }, + timeoutMs: 250, + ui: { + async queryTerminalBackgroundColor(): Promise { + return undefined; + }, + }, + }); + + expect(detection).toMatchObject({ + theme: "dark", + source: "COLORFGBG", + confidence: "high", + }); + }); + + it("falls back to environment hints when the terminal query fails", async () => { + const detection = await detectTerminalBackgroundTheme({ + env: { COLORFGBG: "0;15" }, + timeoutMs: 250, + ui: { + async queryTerminalBackgroundColor(): Promise { + throw new Error("terminal write failed"); + }, + }, + }); + + expect(detection).toMatchObject({ + theme: "light", + source: "COLORFGBG", + confidence: "high", + }); + }); +}); + describe("theme color mode", () => { it("uses terminal capabilities", () => { setCapabilities({ images: null, trueColor: false, hyperlinks: false }); @@ -54,16 +113,7 @@ describe("theme color mode", () => { }); }); -describe("parseOsc11BackgroundColor", () => { - it("parses 16-bit OSC 11 rgb responses", () => { - expect(parseOsc11BackgroundColor("\x1b]11;rgb:0000/8000/ffff\x07")).toEqual({ r: 0, g: 128, b: 255 }); - }); - - it("parses OSC 11 hex responses", () => { - expect(parseOsc11BackgroundColor("\x1b]11;#ffffff\x1b\\")).toEqual({ r: 255, g: 255, b: 255 }); - expect(parseOsc11BackgroundColor("\x1b]11;#000000\x07")).toEqual({ r: 0, g: 0, b: 0 }); - }); - +describe("theme detection from RGB", () => { it("classifies RGB colors by luminance", () => { expect(getThemeForRgbColor({ r: 8, g: 8, b: 8 })).toBe("dark"); expect(getThemeForRgbColor({ r: 250, g: 250, b: 250 })).toBe("light"); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 69b0529a..b1108dee 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added terminal background color query support for OSC 11 replies. + ## [0.79.3] - 2026-06-13 ## [0.79.2] - 2026-06-12 diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index fe0fed00..47bb5240 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -61,6 +61,8 @@ export { export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts"; // Terminal interface and implementations export { ProcessTerminal, type Terminal } from "./terminal.ts"; +// Terminal colors +export { parseOsc11BackgroundColor, type RgbColor } from "./terminal-colors.ts"; // Terminal image support export { allocateImageId, diff --git a/packages/tui/src/terminal-colors.ts b/packages/tui/src/terminal-colors.ts new file mode 100644 index 00000000..1c383a51 --- /dev/null +++ b/packages/tui/src/terminal-colors.ts @@ -0,0 +1,62 @@ +export interface RgbColor { + r: number; + g: number; + b: number; +} + +function hexToRgb(hex: string): RgbColor { + const normalized = hex.startsWith("#") ? hex.slice(1) : hex; + const r = parseInt(normalized.slice(0, 2), 16); + const g = parseInt(normalized.slice(2, 4), 16); + const b = parseInt(normalized.slice(4, 6), 16); + return { r, g, b }; +} + +function parseOscHexChannel(channel: string): number | undefined { + if (!/^[0-9a-f]+$/i.test(channel)) { + return undefined; + } + const max = 16 ** channel.length - 1; + if (max <= 0) { + return undefined; + } + return Math.round((parseInt(channel, 16) / max) * 255); +} + +const OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN = /^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i; + +export function isOsc11BackgroundColorResponse(data: string): boolean { + return OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN.test(data); +} + +export function parseOsc11BackgroundColor(data: string): RgbColor | undefined { + const match = data.match(OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN); + if (!match) { + return undefined; + } + + const value = match[1].trim(); + if (value.startsWith("#")) { + const hex = value.slice(1); + if (/^[0-9a-f]{6}$/i.test(hex)) { + return hexToRgb(value); + } + if (/^[0-9a-f]{12}$/i.test(hex)) { + const r = parseOscHexChannel(hex.slice(0, 4)); + const g = parseOscHexChannel(hex.slice(4, 8)); + const b = parseOscHexChannel(hex.slice(8, 12)); + return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; + } + return undefined; + } + + const rgbValue = value.replace(/^rgba?:/i, ""); + const [red, green, blue] = rgbValue.split("/"); + if (red === undefined || green === undefined || blue === undefined) { + return undefined; + } + const r = parseOscHexChannel(red); + const g = parseOscHexChannel(green); + const b = parseOscHexChannel(blue); + return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; +} diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 2399564d..2b5d05cd 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -8,6 +8,7 @@ import * as path from "node:path"; import { performance } from "node:perf_hooks"; import { isKeyRelease, matchesKey } from "./keys.ts"; import type { Terminal } from "./terminal.ts"; +import { isOsc11BackgroundColorResponse, parseOsc11BackgroundColor, type RgbColor } from "./terminal-colors.ts"; import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts"; import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts"; @@ -82,6 +83,11 @@ export interface Component { type InputListenerResult = { consume?: boolean; data?: string } | undefined; type InputListener = (data: string) => InputListenerResult; +type PendingOsc11BackgroundQuery = { + settled: boolean; + resolve: ((rgb: RgbColor | undefined) => void) | undefined; + timer: NodeJS.Timeout | undefined; +}; /** * Interface for components that can receive focus and display a hardware cursor. @@ -303,6 +309,8 @@ export class TUI extends Container { private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves private fullRedrawCount = 0; private stopped = false; + private pendingOsc11BackgroundReplies = 0; + private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = []; // Overlay stack for modal components rendered on top of base content private focusOrderCounter = 0; @@ -720,6 +728,10 @@ export class TUI extends Container { } private handleInput(data: string): void { + if (this.consumeOsc11BackgroundResponse(data)) { + return; + } + if (this.inputListeners.size > 0) { let current = data; for (const listener of this.inputListeners) { @@ -788,6 +800,30 @@ export class TUI extends Container { } } + private consumeOsc11BackgroundResponse(data: string): boolean { + if (this.pendingOsc11BackgroundReplies <= 0) { + return false; + } + + if (!isOsc11BackgroundColorResponse(data)) { + return false; + } + + const rgb = parseOsc11BackgroundColor(data); + this.pendingOsc11BackgroundReplies -= 1; + const query = this.pendingOsc11BackgroundQueries.shift(); + if (query && !query.settled) { + query.settled = true; + if (query.timer) { + clearTimeout(query.timer); + query.timer = undefined; + } + query.resolve?.(rgb); + query.resolve = undefined; + } + return true; + } + private consumeCellSizeResponse(data: string): boolean { // Response format: ESC [ 6 ; height ; width t const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/); @@ -1561,4 +1597,32 @@ export class TUI extends Container { this.terminal.hideCursor(); } } + + /** + * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`). + * @param timeoutMs Query timeout in milliseconds. + * @returns Promise containing the parsed RGB color, or undefined if it times out or fails to parse. + */ + queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise { + return new Promise((resolve) => { + const query: PendingOsc11BackgroundQuery = { + settled: false, + resolve, + timer: undefined, + }; + + query.timer = setTimeout(() => { + if (query.settled) { + return; + } + query.settled = true; + query.timer = undefined; + query.resolve?.(undefined); + query.resolve = undefined; + }, timeoutMs); + this.pendingOsc11BackgroundQueries.push(query); + this.pendingOsc11BackgroundReplies += 1; + this.terminal.write("\x1b]11;?\x07"); + }); + } } diff --git a/packages/tui/test/terminal-colors.test.ts b/packages/tui/test/terminal-colors.test.ts new file mode 100644 index 00000000..9ac2a4ee --- /dev/null +++ b/packages/tui/test/terminal-colors.test.ts @@ -0,0 +1,233 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { type Component, parseOsc11BackgroundColor, type Terminal, TUI } from "../src/index.ts"; + +class TestTerminal implements Terminal { + private inputHandler?: (data: string) => void; + private resizeHandler?: () => void; + private readonly columnCount: number; + private readonly rowCount: number; + readonly writes: string[] = []; + + constructor(columnCount = 80, rowCount = 24) { + this.columnCount = columnCount; + this.rowCount = rowCount; + } + + start(onInput: (data: string) => void, onResize: () => void): void { + this.inputHandler = onInput; + this.resizeHandler = onResize; + } + + stop(): void { + this.inputHandler = undefined; + this.resizeHandler = undefined; + } + + async drainInput(_maxMs?: number, _idleMs?: number): Promise {} + + write(data: string): void { + this.writes.push(data); + } + + get columns(): number { + return this.columnCount; + } + + get rows(): number { + return this.rowCount; + } + + get kittyProtocolActive(): boolean { + return false; + } + + moveBy(_lines: number): void {} + + hideCursor(): void {} + + showCursor(): void {} + + clearLine(): void {} + + clearFromCursor(): void {} + + clearScreen(): void {} + + setTitle(_title: string): void {} + + setProgress(_active: boolean): void {} + + sendInput(data: string): void { + this.inputHandler?.(data); + } + + sendResize(): void { + this.resizeHandler?.(); + } +} + +class InputRecorder implements Component { + readonly inputs: string[] = []; + + render(_width: number): string[] { + return []; + } + + handleInput(data: string): void { + this.inputs.push(data); + } + + invalidate(): void {} +} + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe("parseOsc11BackgroundColor", () => { + it("parses 16-bit OSC 11 rgb responses", () => { + assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;rgb:0000/8000/ffff\x07"), { + r: 0, + g: 128, + b: 255, + }); + }); + + it("parses OSC 11 hex responses", () => { + assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;#ffffff\x1b\\"), { r: 255, g: 255, b: 255 }); + assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;#000000\x07"), { r: 0, g: 0, b: 0 }); + }); + + it("rejects non-strict OSC 11 responses", () => { + assert.strictEqual(parseOsc11BackgroundColor(`x\x1b]11;#ffffff\x07`), undefined); + assert.strictEqual(parseOsc11BackgroundColor("\x1b]10;#ffffff\x07"), undefined); + assert.strictEqual(parseOsc11BackgroundColor("\x1b]11;#ffffff\x07x"), undefined); + }); +}); + +describe("TUI.queryTerminalBackgroundColor", () => { + it("writes OSC 11 query and resolves with the parsed RGB reply", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + tui.start(); + try { + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }); + assert.ok(terminal.writes.includes("\x1b]11;?\x07")); + + terminal.sendInput("\x1b]11;#ffffff\x07"); + + assert.deepStrictEqual(await query, { r: 255, g: 255, b: 255 }); + } finally { + tui.stop(); + } + }); + + it("consumes OSC 11 replies before input listeners and focused component dispatch", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }); + + terminal.sendInput("\x1b]11;#000000\x07"); + + assert.deepStrictEqual(await query, { r: 0, g: 0, b: 0 }); + assert.deepStrictEqual(listenerInputs, []); + assert.deepStrictEqual(component.inputs, []); + } finally { + tui.stop(); + } + }); + + it("consumes unparseable strict OSC 11 replies and resolves undefined", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }); + + terminal.sendInput("\x1b]11;not-a-color\x07"); + + assert.strictEqual(await query, undefined); + assert.deepStrictEqual(listenerInputs, []); + assert.deepStrictEqual(component.inputs, []); + } finally { + tui.stop(); + } + }); + + it("dispatches non-matching input normally while waiting for an OSC 11 reply", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + let settled = false; + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }).then((rgb) => { + settled = true; + return rgb; + }); + + terminal.sendInput("x"); + await Promise.resolve(); + + assert.strictEqual(settled, false); + assert.deepStrictEqual(listenerInputs, ["x"]); + assert.deepStrictEqual(component.inputs, ["x"]); + + terminal.sendInput("\x1b]11;#ffffff\x07"); + assert.deepStrictEqual(await query, { r: 255, g: 255, b: 255 }); + } finally { + tui.stop(); + } + }); + + it("keeps consuming a late OSC 11 reply after timeout", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1 }); + await wait(5); + + assert.strictEqual(await query, undefined); + + terminal.sendInput("\x1b]11;#ffffff\x07"); + + assert.deepStrictEqual(listenerInputs, []); + assert.deepStrictEqual(component.inputs, []); + } finally { + tui.stop(); + } + }); +});