feat: detect first-run terminal theme (#5385)

This commit is contained in:
Vegard Stikbakke
2026-06-14 07:14:42 +02:00
committed by GitHub
parent 3fcfb7abf7
commit f0989800cb
10 changed files with 532 additions and 101 deletions

View File

@@ -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,

View 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;
}

View File

@@ -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");
});
}
}