feat: detect first-run terminal theme (#5385)
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added first-run interactive theme detection from the terminal background.
|
||||||
|
|
||||||
### Fixed
|
### 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)).
|
- 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)).
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
FirstTimeSetupComponent,
|
FirstTimeSetupComponent,
|
||||||
type FirstTimeSetupResult,
|
type FirstTimeSetupResult,
|
||||||
} from "../modes/interactive/components/first-time-setup.ts";
|
} 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_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
|
||||||
const OFFICIAL_APP_NAME = "pi";
|
const OFFICIAL_APP_NAME = "pi";
|
||||||
@@ -123,19 +123,25 @@ export async function showFirstTimeSetup(settingsManager: SettingsManager): Prom
|
|||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
const component = new FirstTimeSetupComponent({
|
const showSetup = async () => {
|
||||||
detectedTheme: detectTerminalBackground().theme,
|
ui.start();
|
||||||
onThemePreview: (themeName) => {
|
const detection = await detectTerminalBackgroundTheme({ ui, timeoutMs: 100 });
|
||||||
setTheme(themeName);
|
setTheme(detection.theme);
|
||||||
ui.invalidate();
|
const component = new FirstTimeSetupComponent({
|
||||||
ui.requestRender();
|
detectedTheme: detection.theme,
|
||||||
},
|
onThemePreview: (themeName) => {
|
||||||
onSubmit: (result) => void finish(result),
|
setTheme(themeName);
|
||||||
onCancel: () => void finish(undefined),
|
ui.requestRender();
|
||||||
});
|
},
|
||||||
ui.addChild(component);
|
onSubmit: (result) => void finish(result),
|
||||||
ui.setFocus(component);
|
onCancel: () => void finish(undefined),
|
||||||
ui.start();
|
});
|
||||||
|
ui.addChild(component);
|
||||||
|
ui.setFocus(component);
|
||||||
|
ui.requestRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
void showSetup();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ import { TrustSelectorComponent } from "./components/trust-selector.ts";
|
|||||||
import { UserMessageComponent } from "./components/user-message.ts";
|
import { UserMessageComponent } from "./components/user-message.ts";
|
||||||
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
|
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
|
||||||
import {
|
import {
|
||||||
|
detectTerminalBackgroundTheme,
|
||||||
getAvailableThemes,
|
getAvailableThemes,
|
||||||
getAvailableThemesWithPaths,
|
getAvailableThemesWithPaths,
|
||||||
getEditorTheme,
|
getEditorTheme,
|
||||||
@@ -428,6 +429,25 @@ export class InteractiveMode {
|
|||||||
initTheme(this.settingsManager.getTheme(), true);
|
initTheme(this.settingsManager.getTheme(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async detectThemeIfUnset(): Promise<void> {
|
||||||
|
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 {
|
private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined {
|
||||||
if (!sourceInfo) {
|
if (!sourceInfo) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -629,9 +649,28 @@ export class InteractiveMode {
|
|||||||
console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
|
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.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)
|
// Add header with keybindings from config (unless silenced)
|
||||||
if (this.options.verbose || !this.settingsManager.getQuietStartup()) {
|
if (this.options.verbose || !this.settingsManager.getQuietStartup()) {
|
||||||
const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
|
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.builtInHeader = new Text("", 0, 0);
|
||||||
this.headerContainer.addChild(this.builtInHeader);
|
this.headerContainer.addChild(this.builtInHeader);
|
||||||
}
|
}
|
||||||
|
this.ui.requestRender();
|
||||||
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;
|
|
||||||
|
|
||||||
// Initialize extensions first so resources are shown before messages
|
// Initialize extensions first so resources are shown before messages
|
||||||
await this.rebindCurrentSession();
|
await this.rebindCurrentSession();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type EditorTheme,
|
type EditorTheme,
|
||||||
getCapabilities,
|
getCapabilities,
|
||||||
type MarkdownTheme,
|
type MarkdownTheme,
|
||||||
|
type RgbColor,
|
||||||
type SelectListTheme,
|
type SelectListTheme,
|
||||||
type SettingsListTheme,
|
type SettingsListTheme,
|
||||||
} from "@earendil-works/pi-tui";
|
} from "@earendil-works/pi-tui";
|
||||||
@@ -624,12 +625,6 @@ export function getThemeByName(name: string): Theme | undefined {
|
|||||||
|
|
||||||
export type TerminalTheme = "dark" | "light";
|
export type TerminalTheme = "dark" | "light";
|
||||||
|
|
||||||
export interface RgbColor {
|
|
||||||
r: number;
|
|
||||||
g: number;
|
|
||||||
b: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TerminalThemeDetection {
|
export interface TerminalThemeDetection {
|
||||||
theme: TerminalTheme;
|
theme: TerminalTheme;
|
||||||
source: "terminal background" | "COLORFGBG" | "fallback";
|
source: "terminal background" | "COLORFGBG" | "fallback";
|
||||||
@@ -641,6 +636,15 @@ export interface TerminalThemeDetectionOptions {
|
|||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TerminalBackgroundThemeDetector {
|
||||||
|
queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise<RgbColor | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalBackgroundThemeDetectionOptions extends TerminalThemeDetectionOptions {
|
||||||
|
ui: TerminalBackgroundThemeDetector;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
function getColorFgBgBackgroundIndex(colorfgbg: string): number | undefined {
|
function getColorFgBgBackgroundIndex(colorfgbg: string): number | undefined {
|
||||||
const parts = colorfgbg.split(";");
|
const parts = colorfgbg.split(";");
|
||||||
for (let i = parts.length - 1; i >= 0; i--) {
|
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";
|
return getRgbColorLuminance(rgb) >= 0.5 ? "light" : "dark";
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseOscHexChannel(channel: string): number | undefined {
|
export function detectTerminalBackgroundFromEnv(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection {
|
||||||
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 {
|
|
||||||
const env = options.env ?? process.env;
|
const env = options.env ?? process.env;
|
||||||
const colorfgbg = env.COLORFGBG || "";
|
const colorfgbg = env.COLORFGBG || "";
|
||||||
const bg = getColorFgBgBackgroundIndex(colorfgbg);
|
const bg = getColorFgBgBackgroundIndex(colorfgbg);
|
||||||
@@ -732,8 +693,30 @@ export function detectTerminalBackground(options: TerminalThemeDetectionOptions
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function detectTerminalBackgroundTheme({
|
||||||
|
ui,
|
||||||
|
timeoutMs,
|
||||||
|
env,
|
||||||
|
}: TerminalBackgroundThemeDetectionOptions): Promise<TerminalThemeDetection> {
|
||||||
|
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 {
|
export function getDefaultTheme(): string {
|
||||||
return detectTerminalBackground().theme;
|
return detectTerminalBackgroundFromEnv().theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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 { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
detectTerminalBackground,
|
detectTerminalBackgroundFromEnv,
|
||||||
|
detectTerminalBackgroundTheme,
|
||||||
getThemeByName,
|
getThemeByName,
|
||||||
getThemeForRgbColor,
|
getThemeForRgbColor,
|
||||||
parseOsc11BackgroundColor,
|
|
||||||
} from "../src/modes/interactive/theme/theme.ts";
|
} from "../src/modes/interactive/theme/theme.ts";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
resetCapabilitiesCache();
|
resetCapabilitiesCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("detectTerminalBackground", () => {
|
describe("detectTerminalBackgroundFromEnv", () => {
|
||||||
it("uses the COLORFGBG background color index", () => {
|
it("uses the COLORFGBG background color index", () => {
|
||||||
expect(detectTerminalBackground({ env: { COLORFGBG: "0;15" } })).toMatchObject({
|
expect(detectTerminalBackgroundFromEnv({ env: { COLORFGBG: "0;15" } })).toMatchObject({
|
||||||
theme: "light",
|
theme: "light",
|
||||||
source: "COLORFGBG",
|
source: "COLORFGBG",
|
||||||
confidence: "high",
|
confidence: "high",
|
||||||
});
|
});
|
||||||
expect(detectTerminalBackground({ env: { COLORFGBG: "15;0" } })).toMatchObject({
|
expect(detectTerminalBackgroundFromEnv({ env: { COLORFGBG: "15;0" } })).toMatchObject({
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
source: "COLORFGBG",
|
source: "COLORFGBG",
|
||||||
confidence: "high",
|
confidence: "high",
|
||||||
@@ -26,11 +26,11 @@ describe("detectTerminalBackground", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses the last COLORFGBG field as the background", () => {
|
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", () => {
|
it("defaults to dark without terminal background hints", () => {
|
||||||
expect(detectTerminalBackground({ env: {} })).toMatchObject({
|
expect(detectTerminalBackgroundFromEnv({ env: {} })).toMatchObject({
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
source: "fallback",
|
source: "fallback",
|
||||||
confidence: "low",
|
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<RgbColor | undefined> {
|
||||||
|
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<RgbColor | undefined> {
|
||||||
|
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<RgbColor | undefined> {
|
||||||
|
throw new Error("terminal write failed");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detection).toMatchObject({
|
||||||
|
theme: "light",
|
||||||
|
source: "COLORFGBG",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("theme color mode", () => {
|
describe("theme color mode", () => {
|
||||||
it("uses terminal capabilities", () => {
|
it("uses terminal capabilities", () => {
|
||||||
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
||||||
@@ -54,16 +113,7 @@ describe("theme color mode", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseOsc11BackgroundColor", () => {
|
describe("theme detection from RGB", () => {
|
||||||
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 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies RGB colors by luminance", () => {
|
it("classifies RGB colors by luminance", () => {
|
||||||
expect(getThemeForRgbColor({ r: 8, g: 8, b: 8 })).toBe("dark");
|
expect(getThemeForRgbColor({ r: 8, g: 8, b: 8 })).toBe("dark");
|
||||||
expect(getThemeForRgbColor({ r: 250, g: 250, b: 250 })).toBe("light");
|
expect(getThemeForRgbColor({ r: 250, g: 250, b: 250 })).toBe("light");
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added terminal background color query support for OSC 11 replies.
|
||||||
|
|
||||||
## [0.79.3] - 2026-06-13
|
## [0.79.3] - 2026-06-13
|
||||||
|
|
||||||
## [0.79.2] - 2026-06-12
|
## [0.79.2] - 2026-06-12
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export {
|
|||||||
export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts";
|
export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts";
|
||||||
// Terminal interface and implementations
|
// Terminal interface and implementations
|
||||||
export { ProcessTerminal, type Terminal } from "./terminal.ts";
|
export { ProcessTerminal, type Terminal } from "./terminal.ts";
|
||||||
|
// Terminal colors
|
||||||
|
export { parseOsc11BackgroundColor, type RgbColor } from "./terminal-colors.ts";
|
||||||
// Terminal image support
|
// Terminal image support
|
||||||
export {
|
export {
|
||||||
allocateImageId,
|
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 { performance } from "node:perf_hooks";
|
||||||
import { isKeyRelease, matchesKey } from "./keys.ts";
|
import { isKeyRelease, matchesKey } from "./keys.ts";
|
||||||
import type { Terminal } from "./terminal.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 { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts";
|
||||||
import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.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 InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
||||||
type InputListener = (data: string) => InputListenerResult;
|
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.
|
* 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 previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
|
||||||
private fullRedrawCount = 0;
|
private fullRedrawCount = 0;
|
||||||
private stopped = false;
|
private stopped = false;
|
||||||
|
private pendingOsc11BackgroundReplies = 0;
|
||||||
|
private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = [];
|
||||||
|
|
||||||
// Overlay stack for modal components rendered on top of base content
|
// Overlay stack for modal components rendered on top of base content
|
||||||
private focusOrderCounter = 0;
|
private focusOrderCounter = 0;
|
||||||
@@ -720,6 +728,10 @@ export class TUI extends Container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleInput(data: string): void {
|
private handleInput(data: string): void {
|
||||||
|
if (this.consumeOsc11BackgroundResponse(data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.inputListeners.size > 0) {
|
if (this.inputListeners.size > 0) {
|
||||||
let current = data;
|
let current = data;
|
||||||
for (const listener of this.inputListeners) {
|
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 {
|
private consumeCellSizeResponse(data: string): boolean {
|
||||||
// Response format: ESC [ 6 ; height ; width t
|
// Response format: ESC [ 6 ; height ; width t
|
||||||
const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
|
const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
|
||||||
@@ -1561,4 +1597,32 @@ export class TUI extends Container {
|
|||||||
this.terminal.hideCursor();
|
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