feat: detect first-run terminal theme (#5385)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
62
packages/tui/src/terminal-colors.ts
Normal file
62
packages/tui/src/terminal-colors.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<RgbColor | undefined> {
|
||||
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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
233
packages/tui/test/terminal-colors.test.ts
Normal file
233
packages/tui/test/terminal-colors.test.ts
Normal file
@@ -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<void> {}
|
||||
|
||||
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<void>((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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user