feat(coding-agent): add automatic theme mode (#5874)
This commit is contained in:
@@ -137,7 +137,7 @@ vim ~/.pi/agent/themes/my-theme.json
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `name` is required and must be unique.
|
- `name` is required, must be unique, and must not contain `/`.
|
||||||
- `vars` is optional. Define reusable colors here, then reference them in `colors`.
|
- `vars` is optional. Define reusable colors here, then reference them in `colors`.
|
||||||
- `colors` must define all 51 required tokens.
|
- `colors` must define all 51 required tokens.
|
||||||
|
|
||||||
|
|||||||
@@ -714,8 +714,15 @@ export class SettingsManager {
|
|||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getThemeSetting(): string | undefined {
|
||||||
|
const value = this.settings.theme;
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
getTheme(): string | undefined {
|
getTheme(): string | undefined {
|
||||||
return this.settings.theme;
|
const theme = this.getThemeSetting();
|
||||||
|
return theme?.includes("/") ? undefined : theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTheme(theme: string): void {
|
setTheme(theme: string): void {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
import type { Transport } from "@earendil-works/pi-ai";
|
import type { Transport } from "@earendil-works/pi-ai";
|
||||||
import {
|
import {
|
||||||
|
type Component,
|
||||||
Container,
|
Container,
|
||||||
getCapabilities,
|
getCapabilities,
|
||||||
type SelectItem,
|
type SelectItem,
|
||||||
@@ -13,7 +14,13 @@ import {
|
|||||||
} from "@earendil-works/pi-tui";
|
} from "@earendil-works/pi-tui";
|
||||||
import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts";
|
import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts";
|
||||||
import type { DefaultProjectTrust, WarningSettings } from "../../../core/settings-manager.ts";
|
import type { DefaultProjectTrust, WarningSettings } from "../../../core/settings-manager.ts";
|
||||||
import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts";
|
import {
|
||||||
|
getSelectListTheme,
|
||||||
|
getSettingsListTheme,
|
||||||
|
parseAutoThemeSetting,
|
||||||
|
type TerminalTheme,
|
||||||
|
theme,
|
||||||
|
} from "../theme/theme.ts";
|
||||||
import { DynamicBorder } from "./dynamic-border.ts";
|
import { DynamicBorder } from "./dynamic-border.ts";
|
||||||
import { keyDisplayText } from "./keybinding-hints.ts";
|
import { keyDisplayText } from "./keybinding-hints.ts";
|
||||||
|
|
||||||
@@ -55,6 +62,7 @@ export interface SettingsConfig {
|
|||||||
thinkingLevel: ThinkingLevel;
|
thinkingLevel: ThinkingLevel;
|
||||||
availableThinkingLevels: ThinkingLevel[];
|
availableThinkingLevels: ThinkingLevel[];
|
||||||
currentTheme: string;
|
currentTheme: string;
|
||||||
|
terminalTheme: TerminalTheme;
|
||||||
availableThemes: string[];
|
availableThemes: string[];
|
||||||
hideThinkingBlock: boolean;
|
hideThinkingBlock: boolean;
|
||||||
collapseChangelog: boolean;
|
collapseChangelog: boolean;
|
||||||
@@ -210,6 +218,249 @@ class SelectSubmenu extends Container {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function themeItems(availableThemes: string[]): SelectItem[] {
|
||||||
|
return availableThemes.map((name) => ({ value: name, label: name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTOMATIC_THEME_VALUE = "/";
|
||||||
|
|
||||||
|
function singleModeThemeItems(availableThemes: string[]): SelectItem[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: AUTOMATIC_THEME_VALUE,
|
||||||
|
label: "Automatic",
|
||||||
|
description: "Use separate themes for light and dark terminal appearance",
|
||||||
|
},
|
||||||
|
...themeItems(availableThemes),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferredTheme(availableThemes: string[], preferred: string | undefined, fallback: string): string {
|
||||||
|
if (preferred && availableThemes.includes(preferred)) return preferred;
|
||||||
|
if (availableThemes.includes(fallback)) return fallback;
|
||||||
|
return availableThemes[0] ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultAutomaticThemes(
|
||||||
|
currentThemeSetting: string,
|
||||||
|
availableThemes: string[],
|
||||||
|
): { lightTheme: string; darkTheme: string } {
|
||||||
|
const autoTheme = parseAutoThemeSetting(currentThemeSetting);
|
||||||
|
if (autoTheme) return autoTheme;
|
||||||
|
|
||||||
|
const currentFixedTheme = currentThemeSetting.includes("/") ? undefined : currentThemeSetting;
|
||||||
|
const themeName = preferredTheme(availableThemes, currentFixedTheme, "dark");
|
||||||
|
return { lightTheme: themeName, darkTheme: themeName };
|
||||||
|
}
|
||||||
|
|
||||||
|
class ThemeSubmenu extends Container {
|
||||||
|
private inputComponent: Component | undefined;
|
||||||
|
private readonly callbacks: SettingsCallbacks;
|
||||||
|
private readonly availableThemes: string[];
|
||||||
|
private readonly terminalTheme: TerminalTheme;
|
||||||
|
private readonly onDone: (selectedValue?: string) => void;
|
||||||
|
private readonly originalThemeSetting: string;
|
||||||
|
private mode: "single" | "automatic";
|
||||||
|
private singleTheme: string;
|
||||||
|
private lightTheme: string;
|
||||||
|
private darkTheme: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
currentThemeSetting: string,
|
||||||
|
terminalTheme: TerminalTheme,
|
||||||
|
availableThemes: string[],
|
||||||
|
callbacks: SettingsCallbacks,
|
||||||
|
onDone: (selectedValue?: string) => void,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.callbacks = callbacks;
|
||||||
|
this.availableThemes = availableThemes;
|
||||||
|
this.terminalTheme = terminalTheme;
|
||||||
|
this.onDone = onDone;
|
||||||
|
this.originalThemeSetting = currentThemeSetting;
|
||||||
|
const autoTheme = parseAutoThemeSetting(currentThemeSetting);
|
||||||
|
const automaticThemes = defaultAutomaticThemes(currentThemeSetting, availableThemes);
|
||||||
|
const fixedTheme = autoTheme || currentThemeSetting.includes("/") ? undefined : currentThemeSetting;
|
||||||
|
this.mode = autoTheme ? "automatic" : "single";
|
||||||
|
this.lightTheme = automaticThemes.lightTheme;
|
||||||
|
this.darkTheme = automaticThemes.darkTheme;
|
||||||
|
this.singleTheme = preferredTheme(
|
||||||
|
availableThemes,
|
||||||
|
fixedTheme ?? (autoTheme ? this.getActiveAutomaticTheme() : undefined),
|
||||||
|
"dark",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.mode === "automatic") {
|
||||||
|
this.showAutomaticMenu();
|
||||||
|
} else {
|
||||||
|
this.showSingleMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInput(data: string): void {
|
||||||
|
this.inputComponent?.handleInput?.(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setContent(renderComponent: Component, inputComponent: Component = renderComponent): void {
|
||||||
|
this.clear();
|
||||||
|
this.addChild(renderComponent);
|
||||||
|
this.inputComponent = inputComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private showSingleMenu(): void {
|
||||||
|
this.mode = "single";
|
||||||
|
const menu = new SelectSubmenu(
|
||||||
|
"Theme",
|
||||||
|
"Select a theme, or choose Automatic to follow terminal appearance.",
|
||||||
|
singleModeThemeItems(this.availableThemes),
|
||||||
|
this.singleTheme,
|
||||||
|
(value) => {
|
||||||
|
if (value === AUTOMATIC_THEME_VALUE) {
|
||||||
|
this.mode = "automatic";
|
||||||
|
this.callbacks.onThemePreview?.(this.getThemeSetting());
|
||||||
|
this.showAutomaticMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.singleTheme = value;
|
||||||
|
this.apply(value);
|
||||||
|
},
|
||||||
|
() => this.cancel(),
|
||||||
|
(value) => {
|
||||||
|
this.callbacks.onThemePreview?.(value === AUTOMATIC_THEME_VALUE ? this.getAutomaticThemeSetting() : value);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.setContent(menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
private showAutomaticMenu(): void {
|
||||||
|
this.mode = "automatic";
|
||||||
|
const content = new Container();
|
||||||
|
content.addChild(new Text(theme.bold(theme.fg("accent", "Automatic Theme")), 0, 0));
|
||||||
|
content.addChild(new Spacer(1));
|
||||||
|
content.addChild(new Text(theme.fg("muted", "Choose themes for terminal light and dark appearance."), 0, 0));
|
||||||
|
content.addChild(new Text(theme.fg("muted", "Light/dark detection requires terminal support."), 0, 0));
|
||||||
|
content.addChild(new Spacer(1));
|
||||||
|
|
||||||
|
const items: SettingItem[] = [
|
||||||
|
{
|
||||||
|
id: "light-theme",
|
||||||
|
label: "Light theme",
|
||||||
|
description: "Theme to use in automatic mode when the terminal is light",
|
||||||
|
currentValue: this.lightTheme,
|
||||||
|
submenu: (currentValue, done) =>
|
||||||
|
this.createThemeSelect(
|
||||||
|
"Light Theme",
|
||||||
|
"Select the theme to use for light terminal appearance",
|
||||||
|
currentValue,
|
||||||
|
done,
|
||||||
|
(value) => {
|
||||||
|
this.lightTheme = value;
|
||||||
|
this.callbacks.onThemePreview?.(this.getThemeSetting());
|
||||||
|
done(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dark-theme",
|
||||||
|
label: "Dark theme",
|
||||||
|
description: "Theme to use in automatic mode when the terminal is dark",
|
||||||
|
currentValue: this.darkTheme,
|
||||||
|
submenu: (currentValue, done) =>
|
||||||
|
this.createThemeSelect(
|
||||||
|
"Dark Theme",
|
||||||
|
"Select the theme to use for dark terminal appearance",
|
||||||
|
currentValue,
|
||||||
|
done,
|
||||||
|
(value) => {
|
||||||
|
this.darkTheme = value;
|
||||||
|
this.callbacks.onThemePreview?.(this.getThemeSetting());
|
||||||
|
done(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "apply",
|
||||||
|
label: "Apply",
|
||||||
|
description: "Save and go back",
|
||||||
|
currentValue: "save and go back",
|
||||||
|
values: ["save and go back"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "single-mode",
|
||||||
|
label: "Change mode",
|
||||||
|
description: "Switch to one theme for light and dark",
|
||||||
|
currentValue: "switch to single theme",
|
||||||
|
values: ["switch to single theme"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const settingsList = new SettingsList(
|
||||||
|
items,
|
||||||
|
Math.min(items.length, 10),
|
||||||
|
getSettingsListTheme(),
|
||||||
|
(id) => {
|
||||||
|
switch (id) {
|
||||||
|
case "single-mode":
|
||||||
|
this.mode = "single";
|
||||||
|
this.singleTheme = this.getActiveAutomaticTheme();
|
||||||
|
this.callbacks.onThemePreview?.(this.singleTheme);
|
||||||
|
this.showSingleMenu();
|
||||||
|
break;
|
||||||
|
case "apply":
|
||||||
|
this.apply(this.getAutomaticThemeSetting());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => this.cancel(),
|
||||||
|
);
|
||||||
|
content.addChild(settingsList);
|
||||||
|
this.setContent(content, settingsList);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createThemeSelect(
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
currentValue: string,
|
||||||
|
done: (selectedValue?: string) => void,
|
||||||
|
onSelect: (value: string) => void,
|
||||||
|
): SelectSubmenu {
|
||||||
|
return new SelectSubmenu(
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
themeItems(this.availableThemes),
|
||||||
|
currentValue,
|
||||||
|
onSelect,
|
||||||
|
() => {
|
||||||
|
this.callbacks.onThemePreview?.(this.getThemeSetting());
|
||||||
|
done();
|
||||||
|
},
|
||||||
|
(value) => this.callbacks.onThemePreview?.(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getThemeSetting(): string {
|
||||||
|
return this.mode === "automatic" ? this.getAutomaticThemeSetting() : this.singleTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getActiveAutomaticTheme(): string {
|
||||||
|
return this.terminalTheme === "light" ? this.lightTheme : this.darkTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAutomaticThemeSetting(): string {
|
||||||
|
return `${this.lightTheme}/${this.darkTheme}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private apply(themeSetting: string): void {
|
||||||
|
this.onDone(themeSetting);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancel(): void {
|
||||||
|
this.callbacks.onThemePreview?.(this.originalThemeSetting);
|
||||||
|
this.onDone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main settings selector component.
|
* Main settings selector component.
|
||||||
*/
|
*/
|
||||||
@@ -353,28 +604,7 @@ export class SettingsSelectorComponent extends Container {
|
|||||||
description: "Color theme for the interface",
|
description: "Color theme for the interface",
|
||||||
currentValue: config.currentTheme,
|
currentValue: config.currentTheme,
|
||||||
submenu: (currentValue, done) =>
|
submenu: (currentValue, done) =>
|
||||||
new SelectSubmenu(
|
new ThemeSubmenu(currentValue, config.terminalTheme, config.availableThemes, callbacks, done),
|
||||||
"Theme",
|
|
||||||
"Select color theme",
|
|
||||||
config.availableThemes.map((t) => ({
|
|
||||||
value: t,
|
|
||||||
label: t,
|
|
||||||
})),
|
|
||||||
currentValue,
|
|
||||||
(value) => {
|
|
||||||
callbacks.onThemeChange(value);
|
|
||||||
done(value);
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
// Restore original theme on cancel
|
|
||||||
callbacks.onThemePreview?.(currentValue);
|
|
||||||
done();
|
|
||||||
},
|
|
||||||
(value) => {
|
|
||||||
// Preview theme on selection change
|
|
||||||
callbacks.onThemePreview?.(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -561,6 +791,9 @@ export class SettingsSelectorComponent extends Container {
|
|||||||
case "terminal-progress":
|
case "terminal-progress":
|
||||||
callbacks.onShowTerminalProgressChange(newValue === "true");
|
callbacks.onShowTerminalProgressChange(newValue === "true");
|
||||||
break;
|
break;
|
||||||
|
case "theme":
|
||||||
|
callbacks.onThemeChange(newValue);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
callbacks.onCancel,
|
callbacks.onCancel,
|
||||||
|
|||||||
@@ -128,22 +128,19 @@ import { UserMessageComponent } from "./components/user-message.ts";
|
|||||||
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
|
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
|
||||||
import { getModelSearchText } from "./model-search.ts";
|
import { getModelSearchText } from "./model-search.ts";
|
||||||
import {
|
import {
|
||||||
detectTerminalBackgroundTheme,
|
|
||||||
getAvailableThemes,
|
getAvailableThemes,
|
||||||
getAvailableThemesWithPaths,
|
getAvailableThemesWithPaths,
|
||||||
getEditorTheme,
|
getEditorTheme,
|
||||||
getMarkdownTheme,
|
getMarkdownTheme,
|
||||||
getThemeByName,
|
getThemeByName,
|
||||||
initTheme,
|
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
setRegisteredThemes,
|
setRegisteredThemes,
|
||||||
setTheme,
|
|
||||||
setThemeInstance,
|
|
||||||
stopThemeWatcher,
|
stopThemeWatcher,
|
||||||
Theme,
|
Theme,
|
||||||
type ThemeColor,
|
type ThemeColor,
|
||||||
theme,
|
theme,
|
||||||
} from "./theme/theme.ts";
|
} from "./theme/theme.ts";
|
||||||
|
import { InteractiveThemeController } from "./theme/theme-controller.ts";
|
||||||
|
|
||||||
/** Interface for components that can be expanded/collapsed */
|
/** Interface for components that can be expanded/collapsed */
|
||||||
interface Expandable {
|
interface Expandable {
|
||||||
@@ -374,6 +371,7 @@ export class InteractiveMode {
|
|||||||
|
|
||||||
private options: InteractiveModeOptions;
|
private options: InteractiveModeOptions;
|
||||||
private autoTrustOnReloadCwd: string | undefined;
|
private autoTrustOnReloadCwd: string | undefined;
|
||||||
|
private themeController: InteractiveThemeController;
|
||||||
|
|
||||||
// Convenience accessors
|
// Convenience accessors
|
||||||
private get session(): AgentSession {
|
private get session(): AgentSession {
|
||||||
@@ -428,26 +426,12 @@ export class InteractiveMode {
|
|||||||
|
|
||||||
// Register themes from resource loader and initialize
|
// Register themes from resource loader and initialize
|
||||||
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
|
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
|
||||||
initTheme(this.settingsManager.getTheme(), true);
|
this.themeController = new InteractiveThemeController(
|
||||||
}
|
this.ui,
|
||||||
|
this.settingsManager,
|
||||||
private async detectThemeIfUnset(): Promise<void> {
|
(message) => this.showError(message),
|
||||||
if (this.settingsManager.getTheme()) {
|
() => this.updateEditorBorderColor(),
|
||||||
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 {
|
||||||
@@ -672,7 +656,7 @@ export class InteractiveMode {
|
|||||||
this.ui.start();
|
this.ui.start();
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
|
|
||||||
await this.detectThemeIfUnset();
|
await this.themeController.applyFromSettings();
|
||||||
|
|
||||||
// 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()) {
|
||||||
@@ -2080,16 +2064,13 @@ export class InteractiveMode {
|
|||||||
getTheme: (name) => getThemeByName(name),
|
getTheme: (name) => getThemeByName(name),
|
||||||
setTheme: (themeOrName) => {
|
setTheme: (themeOrName) => {
|
||||||
if (themeOrName instanceof Theme) {
|
if (themeOrName instanceof Theme) {
|
||||||
setThemeInstance(themeOrName);
|
return this.themeController.setThemeInstance(themeOrName);
|
||||||
this.ui.requestRender();
|
|
||||||
return { success: true };
|
|
||||||
}
|
}
|
||||||
const result = setTheme(themeOrName, true);
|
const result = this.themeController.setThemeName(themeOrName);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (this.settingsManager.getTheme() !== themeOrName) {
|
if (this.settingsManager.getTheme() !== themeOrName) {
|
||||||
this.settingsManager.setTheme(themeOrName);
|
this.settingsManager.setTheme(themeOrName);
|
||||||
}
|
}
|
||||||
this.ui.requestRender();
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
@@ -3378,6 +3359,7 @@ export class InteractiveMode {
|
|||||||
// which the stdout/stderr error handler turns into emergencyTerminalExit;
|
// which the stdout/stderr error handler turns into emergencyTerminalExit;
|
||||||
// the render loop is already idle, so this cannot hot-spin (see #4144).
|
// the render loop is already idle, so this cannot hot-spin (see #4144).
|
||||||
await this.runtimeHost.dispose();
|
await this.runtimeHost.dispose();
|
||||||
|
this.themeController.disableAutoSync();
|
||||||
await this.ui.terminal.drainInput(1000);
|
await this.ui.terminal.drainInput(1000);
|
||||||
this.stop();
|
this.stop();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -3388,6 +3370,7 @@ export class InteractiveMode {
|
|||||||
// the final frame while the process is exiting.
|
// the final frame while the process is exiting.
|
||||||
// Drain any in-flight Kitty key release events before stopping.
|
// Drain any in-flight Kitty key release events before stopping.
|
||||||
// This prevents escape sequences from leaking to the parent shell over slow SSH.
|
// This prevents escape sequences from leaking to the parent shell over slow SSH.
|
||||||
|
this.themeController.disableAutoSync();
|
||||||
await this.ui.terminal.drainInput(1000);
|
await this.ui.terminal.drainInput(1000);
|
||||||
|
|
||||||
this.stop();
|
this.stop();
|
||||||
@@ -3991,7 +3974,8 @@ export class InteractiveMode {
|
|||||||
httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(),
|
httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(),
|
||||||
thinkingLevel: this.session.thinkingLevel,
|
thinkingLevel: this.session.thinkingLevel,
|
||||||
availableThinkingLevels: this.session.getAvailableThinkingLevels(),
|
availableThinkingLevels: this.session.getAvailableThinkingLevels(),
|
||||||
currentTheme: this.settingsManager.getTheme() || "dark",
|
currentTheme: this.settingsManager.getThemeSetting() || "dark",
|
||||||
|
terminalTheme: this.themeController.getTerminalTheme(),
|
||||||
availableThemes: getAvailableThemes(),
|
availableThemes: getAvailableThemes(),
|
||||||
hideThinkingBlock: this.hideThinkingBlock,
|
hideThinkingBlock: this.hideThinkingBlock,
|
||||||
collapseChangelog: this.settingsManager.getCollapseChangelog(),
|
collapseChangelog: this.settingsManager.getCollapseChangelog(),
|
||||||
@@ -4058,21 +4042,11 @@ export class InteractiveMode {
|
|||||||
this.footer.invalidate();
|
this.footer.invalidate();
|
||||||
this.updateEditorBorderColor();
|
this.updateEditorBorderColor();
|
||||||
},
|
},
|
||||||
onThemeChange: (themeName) => {
|
onThemeChange: (themeSetting) => {
|
||||||
const result = setTheme(themeName, true);
|
this.settingsManager.setTheme(themeSetting);
|
||||||
this.settingsManager.setTheme(themeName);
|
void this.themeController.applyFromSettings();
|
||||||
this.ui.invalidate();
|
|
||||||
if (!result.success) {
|
|
||||||
this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onThemePreview: (themeName) => {
|
|
||||||
const result = setTheme(themeName, true);
|
|
||||||
if (result.success) {
|
|
||||||
this.ui.invalidate();
|
|
||||||
this.ui.requestRender();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
onThemePreview: (themeName) => this.themeController.preview(themeName),
|
||||||
onHideThinkingBlockChange: (hidden) => {
|
onHideThinkingBlockChange: (hidden) => {
|
||||||
this.hideThinkingBlock = hidden;
|
this.hideThinkingBlock = hidden;
|
||||||
this.settingsManager.setHideThinkingBlock(hidden);
|
this.settingsManager.setHideThinkingBlock(hidden);
|
||||||
@@ -5109,11 +5083,7 @@ export class InteractiveMode {
|
|||||||
}
|
}
|
||||||
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
|
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
|
||||||
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
|
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
|
||||||
const themeName = this.settingsManager.getTheme();
|
await this.themeController.applyFromSettings();
|
||||||
const themeResult = themeName ? setTheme(themeName, true) : { success: true };
|
|
||||||
if (!themeResult.success) {
|
|
||||||
this.showError(`Failed to load theme "${themeName}": ${themeResult.error}\nFell back to dark theme.`);
|
|
||||||
}
|
|
||||||
const editorPaddingX = this.settingsManager.getEditorPaddingX();
|
const editorPaddingX = this.settingsManager.getEditorPaddingX();
|
||||||
const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
|
const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
|
||||||
this.defaultEditor.setPaddingX(editorPaddingX);
|
this.defaultEditor.setPaddingX(editorPaddingX);
|
||||||
@@ -5754,6 +5724,7 @@ export class InteractiveMode {
|
|||||||
this.loadingAnimation.stop();
|
this.loadingAnimation.stop();
|
||||||
this.loadingAnimation = undefined;
|
this.loadingAnimation = undefined;
|
||||||
}
|
}
|
||||||
|
this.themeController.disableAutoSync();
|
||||||
this.clearExtensionTerminalInputListeners();
|
this.clearExtensionTerminalInputListeners();
|
||||||
this.footer.dispose();
|
this.footer.dispose();
|
||||||
this.footerDataProvider.dispose();
|
this.footerDataProvider.dispose();
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { TUI } from "@earendil-works/pi-tui";
|
||||||
|
import type { SettingsManager } from "../../../core/settings-manager.ts";
|
||||||
|
import {
|
||||||
|
detectTerminalBackgroundFromEnv,
|
||||||
|
detectTerminalBackgroundTheme,
|
||||||
|
initTheme,
|
||||||
|
parseAutoThemeSetting,
|
||||||
|
resolveThemeSetting,
|
||||||
|
setTheme,
|
||||||
|
setThemeInstance,
|
||||||
|
type TerminalTheme,
|
||||||
|
type Theme,
|
||||||
|
} from "./theme.ts";
|
||||||
|
|
||||||
|
type ThemeResult = { success: boolean; error?: string };
|
||||||
|
|
||||||
|
export class InteractiveThemeController {
|
||||||
|
private readonly ui: TUI;
|
||||||
|
private readonly settingsManager: SettingsManager;
|
||||||
|
private readonly showError: (message: string) => void;
|
||||||
|
private readonly onChanged: () => void;
|
||||||
|
private terminalTheme: TerminalTheme = detectTerminalBackgroundFromEnv().theme;
|
||||||
|
private activeThemeName: string | undefined;
|
||||||
|
private autoSyncEnabled = false;
|
||||||
|
|
||||||
|
constructor(ui: TUI, settingsManager: SettingsManager, showError: (message: string) => void, onChanged: () => void) {
|
||||||
|
this.ui = ui;
|
||||||
|
this.settingsManager = settingsManager;
|
||||||
|
this.showError = showError;
|
||||||
|
this.onChanged = onChanged;
|
||||||
|
this.activeThemeName = resolveThemeSetting(this.settingsManager.getThemeSetting(), this.terminalTheme);
|
||||||
|
initTheme(this.activeThemeName, true);
|
||||||
|
this.ui.onTerminalColorSchemeChange((terminalTheme) => this.applyTerminalTheme(terminalTheme));
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyFromSettings(): Promise<void> {
|
||||||
|
const themeSetting = this.settingsManager.getThemeSetting();
|
||||||
|
const autoTheme = parseAutoThemeSetting(themeSetting);
|
||||||
|
if (autoTheme) {
|
||||||
|
this.terminalTheme = await this.detectTerminalThemeForAuto();
|
||||||
|
this.setAutoSync(true);
|
||||||
|
this.applyThemeName(this.terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setAutoSync(false);
|
||||||
|
if (themeSetting !== undefined) {
|
||||||
|
this.applyThemeName(themeSetting, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detection = await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 });
|
||||||
|
this.terminalTheme = detection.theme;
|
||||||
|
if (!this.applyThemeName(detection.theme).success) return;
|
||||||
|
if (detection.confidence === "high") {
|
||||||
|
this.settingsManager.setTheme(detection.theme);
|
||||||
|
await this.settingsManager.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setThemeName(themeName: string, showError = false): ThemeResult {
|
||||||
|
this.setAutoSync(false);
|
||||||
|
return this.applyThemeName(themeName, showError);
|
||||||
|
}
|
||||||
|
|
||||||
|
setThemeInstance(themeInstance: Theme): ThemeResult {
|
||||||
|
this.setAutoSync(false);
|
||||||
|
setThemeInstance(themeInstance);
|
||||||
|
this.activeThemeName = "<in-memory>";
|
||||||
|
this.notifyChanged();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
preview(themeSettingOrName: string): void {
|
||||||
|
const themeName = resolveThemeSetting(themeSettingOrName, this.terminalTheme) ?? this.activeThemeName;
|
||||||
|
if (!themeName) return;
|
||||||
|
if (setTheme(themeName, true).success) {
|
||||||
|
this.ui.invalidate();
|
||||||
|
this.ui.requestRender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disableAutoSync(): void {
|
||||||
|
this.setAutoSync(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTerminalTheme(): TerminalTheme {
|
||||||
|
return this.terminalTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyThemeName(themeName: string, showError = false): ThemeResult {
|
||||||
|
const result = setTheme(themeName, true);
|
||||||
|
this.activeThemeName = result.success ? themeName : "dark";
|
||||||
|
this.notifyChanged();
|
||||||
|
if (!result.success && showError) {
|
||||||
|
this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyChanged(): void {
|
||||||
|
this.ui.invalidate();
|
||||||
|
this.onChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setAutoSync(enabled: boolean): void {
|
||||||
|
if (this.autoSyncEnabled === enabled) return;
|
||||||
|
this.autoSyncEnabled = enabled;
|
||||||
|
this.ui.setTerminalColorSchemeNotifications(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detectTerminalThemeForAuto(): Promise<TerminalTheme> {
|
||||||
|
try {
|
||||||
|
const colorScheme = await this.ui.queryTerminalColorScheme({ timeoutMs: 100 });
|
||||||
|
if (colorScheme) return colorScheme;
|
||||||
|
} catch {
|
||||||
|
// Fall back to OSC 11 / COLORFGBG detection when color-scheme DSR is unsupported.
|
||||||
|
}
|
||||||
|
return (await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 })).theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyTerminalTheme(terminalTheme: TerminalTheme): void {
|
||||||
|
if (!this.autoSyncEnabled) return;
|
||||||
|
this.terminalTheme = terminalTheme;
|
||||||
|
const autoTheme = parseAutoThemeSetting(this.settingsManager.getThemeSetting());
|
||||||
|
if (!autoTheme) {
|
||||||
|
this.setAutoSync(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const themeName = terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme;
|
||||||
|
if (themeName !== this.activeThemeName) {
|
||||||
|
this.applyThemeName(themeName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Theme name"
|
"pattern": "^[^/]+$",
|
||||||
|
"description": "Theme name. Must not contain '/' because it is reserved for automatic light/dark theme settings."
|
||||||
},
|
},
|
||||||
"vars": {
|
"vars": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -503,6 +503,14 @@ function getCustomThemeInfos(): ThemeInfo[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertThemeNameIsValid(name: string): void {
|
||||||
|
if (name.includes("/")) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid theme name "${name}": theme names cannot contain "/" because it is reserved for automatic light/dark theme settings.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseThemeJson(label: string, json: unknown): ThemeJson {
|
function parseThemeJson(label: string, json: unknown): ThemeJson {
|
||||||
if (!validateThemeJson.Check(json)) {
|
if (!validateThemeJson.Check(json)) {
|
||||||
const errors = Array.from(validateThemeJson.Errors(json));
|
const errors = Array.from(validateThemeJson.Errors(json));
|
||||||
@@ -539,7 +547,9 @@ function parseThemeJson(label: string, json: unknown): ThemeJson {
|
|||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return json as ThemeJson;
|
const themeJson = json as ThemeJson;
|
||||||
|
assertThemeNameIsValid(themeJson.name);
|
||||||
|
return themeJson;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseThemeJsonContent(label: string, content: string): ThemeJson {
|
function parseThemeJsonContent(label: string, content: string): ThemeJson {
|
||||||
@@ -625,6 +635,36 @@ export function getThemeByName(name: string): Theme | undefined {
|
|||||||
|
|
||||||
export type TerminalTheme = "dark" | "light";
|
export type TerminalTheme = "dark" | "light";
|
||||||
|
|
||||||
|
export function parseAutoThemeSetting(
|
||||||
|
themeSetting: string | undefined,
|
||||||
|
): { lightTheme: string; darkTheme: string } | undefined {
|
||||||
|
if (!themeSetting) return undefined;
|
||||||
|
const slashIndex = themeSetting.indexOf("/");
|
||||||
|
if (slashIndex === -1 || themeSetting.indexOf("/", slashIndex + 1) !== -1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lightTheme = themeSetting.slice(0, slashIndex).trim();
|
||||||
|
const darkTheme = themeSetting.slice(slashIndex + 1).trim();
|
||||||
|
if (!lightTheme || !darkTheme) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { lightTheme, darkTheme };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveThemeSetting(
|
||||||
|
themeSetting: string | undefined,
|
||||||
|
terminalTheme: TerminalTheme,
|
||||||
|
): string | undefined {
|
||||||
|
const autoTheme = parseAutoThemeSetting(themeSetting);
|
||||||
|
if (autoTheme) {
|
||||||
|
return terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme;
|
||||||
|
}
|
||||||
|
if (themeSetting?.includes("/")) return undefined;
|
||||||
|
if (typeof themeSetting === "string") return themeSetting;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TerminalThemeDetection {
|
export interface TerminalThemeDetection {
|
||||||
theme: TerminalTheme;
|
theme: TerminalTheme;
|
||||||
source: "terminal background" | "COLORFGBG" | "fallback";
|
source: "terminal background" | "COLORFGBG" | "fallback";
|
||||||
@@ -752,6 +792,7 @@ export function setRegisteredThemes(themes: Theme[]): void {
|
|||||||
registeredThemes.clear();
|
registeredThemes.clear();
|
||||||
for (const theme of themes) {
|
for (const theme of themes) {
|
||||||
if (theme.name) {
|
if (theme.name) {
|
||||||
|
assertThemeNameIsValid(theme.name);
|
||||||
registeredThemes.set(theme.name, theme);
|
registeredThemes.set(theme.name, theme);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,13 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
|
|||||||
const fakeThis: any = {
|
const fakeThis: any = {
|
||||||
session: { settingsManager },
|
session: { settingsManager },
|
||||||
settingsManager,
|
settingsManager,
|
||||||
|
themeController: {
|
||||||
|
setThemeInstance: vi.fn(() => ({ success: true })),
|
||||||
|
setThemeName: vi.fn(() => {
|
||||||
|
fakeThis.ui.requestRender();
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
},
|
||||||
ui: { requestRender: vi.fn() },
|
ui: { requestRender: vi.fn() },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -158,6 +165,7 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
|
|||||||
const result = uiContext.setTheme("light");
|
const result = uiContext.setTheme("light");
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
expect(fakeThis.themeController.setThemeName).toHaveBeenCalledWith("light");
|
||||||
expect(settingsManager.setTheme).toHaveBeenCalledWith("light");
|
expect(settingsManager.setTheme).toHaveBeenCalledWith("light");
|
||||||
expect(currentTheme).toBe("light");
|
expect(currentTheme).toBe("light");
|
||||||
expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1);
|
expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1);
|
||||||
@@ -173,6 +181,10 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
|
|||||||
const fakeThis: any = {
|
const fakeThis: any = {
|
||||||
session: { settingsManager },
|
session: { settingsManager },
|
||||||
settingsManager,
|
settingsManager,
|
||||||
|
themeController: {
|
||||||
|
setThemeInstance: vi.fn(() => ({ success: true })),
|
||||||
|
setThemeName: vi.fn(() => ({ success: false, error: "Theme not found" })),
|
||||||
|
},
|
||||||
ui: { requestRender: vi.fn() },
|
ui: { requestRender: vi.fn() },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -180,6 +192,7 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
|
|||||||
const result = uiContext.setTheme("__missing_theme__");
|
const result = uiContext.setTheme("__missing_theme__");
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
|
expect(fakeThis.themeController.setThemeName).toHaveBeenCalledWith("__missing_theme__");
|
||||||
expect(settingsManager.setTheme).not.toHaveBeenCalled();
|
expect(settingsManager.setTheme).not.toHaveBeenCalled();
|
||||||
expect(fakeThis.ui.requestRender).not.toHaveBeenCalled();
|
expect(fakeThis.ui.requestRender).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -198,6 +198,24 @@ describe("SettingsManager", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("theme setting", () => {
|
||||||
|
it("stores slash-separated automatic theme settings separately from fixed theme names", async () => {
|
||||||
|
const settingsPath = join(agentDir, "settings.json");
|
||||||
|
writeFileSync(settingsPath, JSON.stringify({ theme: "light/dark" }));
|
||||||
|
|
||||||
|
const manager = SettingsManager.create(projectDir, agentDir);
|
||||||
|
|
||||||
|
expect(manager.getTheme()).toBeUndefined();
|
||||||
|
expect(manager.getThemeSetting()).toBe("light/dark");
|
||||||
|
|
||||||
|
manager.setTheme("solarized-light/tokyo-night");
|
||||||
|
await manager.flush();
|
||||||
|
|
||||||
|
const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
||||||
|
expect(savedSettings.theme).toBe("solarized-light/tokyo-night");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("error tracking", () => {
|
describe("error tracking", () => {
|
||||||
it("should collect and clear load errors via drainErrors", () => {
|
it("should collect and clear load errors via drainErrors", () => {
|
||||||
const globalSettingsPath = join(agentDir, "settings.json");
|
const globalSettingsPath = join(agentDir, "settings.json");
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type ShutdownThis = {
|
|||||||
unregisterSignalHandlers: () => void;
|
unregisterSignalHandlers: () => void;
|
||||||
runtimeHost: { dispose: () => Promise<void> };
|
runtimeHost: { dispose: () => Promise<void> };
|
||||||
ui: { terminal: { drainInput: (ms: number) => Promise<void> } };
|
ui: { terminal: { drainInput: (ms: number) => Promise<void> } };
|
||||||
|
themeController: { disableAutoSync: () => void };
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
sessionManager: SessionManager;
|
sessionManager: SessionManager;
|
||||||
};
|
};
|
||||||
@@ -81,6 +82,7 @@ function createContext(order: string[], sessionManager = createSessionManager())
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
themeController: { disableAutoSync: vi.fn() },
|
||||||
stop: vi.fn(() => {
|
stop: vi.fn(() => {
|
||||||
order.push("stop");
|
order.push("stop");
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type ShutdownThis = {
|
|||||||
unregisterSignalHandlers: () => void;
|
unregisterSignalHandlers: () => void;
|
||||||
runtimeHost: { dispose: () => Promise<void> };
|
runtimeHost: { dispose: () => Promise<void> };
|
||||||
ui: { terminal: { drainInput: (ms: number) => Promise<void> } };
|
ui: { terminal: { drainInput: (ms: number) => Promise<void> } };
|
||||||
|
themeController: { disableAutoSync: () => void };
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ describe("InteractiveMode SIGTERM shutdown with signal-exit (#5724)", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
themeController: { disableAutoSync: vi.fn() },
|
||||||
stop: vi.fn(() => {
|
stop: vi.fn(() => {
|
||||||
order.push("stop");
|
order.push("stop");
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
detectTerminalBackgroundTheme,
|
detectTerminalBackgroundTheme,
|
||||||
getThemeByName,
|
getThemeByName,
|
||||||
getThemeForRgbColor,
|
getThemeForRgbColor,
|
||||||
|
parseAutoThemeSetting,
|
||||||
|
resolveThemeSetting,
|
||||||
} from "../src/modes/interactive/theme/theme.ts";
|
} from "../src/modes/interactive/theme/theme.ts";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -119,3 +121,13 @@ describe("theme detection from RGB", () => {
|
|||||||
expect(getThemeForRgbColor({ r: 250, g: 250, b: 250 })).toBe("light");
|
expect(getThemeForRgbColor({ r: 250, g: 250, b: 250 })).toBe("light");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("theme setting helpers", () => {
|
||||||
|
it("parses and resolves automatic theme settings", () => {
|
||||||
|
expect(parseAutoThemeSetting("light/dark")).toEqual({ lightTheme: "light", darkTheme: "dark" });
|
||||||
|
expect(resolveThemeSetting("dark", "light")).toBe("dark");
|
||||||
|
expect(resolveThemeSetting("light/dark", "light")).toBe("light");
|
||||||
|
expect(resolveThemeSetting("light/dark", "dark")).toBe("dark");
|
||||||
|
expect(resolveThemeSetting("light/dark/extra", "dark")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -62,7 +62,12 @@ export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "
|
|||||||
// Terminal interface and implementations
|
// Terminal interface and implementations
|
||||||
export { ProcessTerminal, type Terminal } from "./terminal.ts";
|
export { ProcessTerminal, type Terminal } from "./terminal.ts";
|
||||||
// Terminal colors
|
// Terminal colors
|
||||||
export { parseOsc11BackgroundColor, type RgbColor } from "./terminal-colors.ts";
|
export {
|
||||||
|
parseOsc11BackgroundColor,
|
||||||
|
parseTerminalColorSchemeReport,
|
||||||
|
type RgbColor,
|
||||||
|
type TerminalColorScheme,
|
||||||
|
} from "./terminal-colors.ts";
|
||||||
// Terminal image support
|
// Terminal image support
|
||||||
export {
|
export {
|
||||||
allocateImageId,
|
allocateImageId,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export interface RgbColor {
|
|||||||
b: number;
|
b: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TerminalColorScheme = "dark" | "light";
|
||||||
|
|
||||||
function hexToRgb(hex: string): RgbColor {
|
function hexToRgb(hex: string): RgbColor {
|
||||||
const normalized = hex.startsWith("#") ? hex.slice(1) : hex;
|
const normalized = hex.startsWith("#") ? hex.slice(1) : hex;
|
||||||
const r = parseInt(normalized.slice(0, 2), 16);
|
const r = parseInt(normalized.slice(0, 2), 16);
|
||||||
@@ -24,6 +26,7 @@ function parseOscHexChannel(channel: string): number | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN = /^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i;
|
const OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN = /^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i;
|
||||||
|
const COLOR_SCHEME_REPORT_PATTERN = /^\x1b\[\?997;(1|2)n$/;
|
||||||
|
|
||||||
export function isOsc11BackgroundColorResponse(data: string): boolean {
|
export function isOsc11BackgroundColorResponse(data: string): boolean {
|
||||||
return OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN.test(data);
|
return OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN.test(data);
|
||||||
@@ -60,3 +63,11 @@ export function parseOsc11BackgroundColor(data: string): RgbColor | undefined {
|
|||||||
const b = parseOscHexChannel(blue);
|
const b = parseOscHexChannel(blue);
|
||||||
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
|
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseTerminalColorSchemeReport(data: string): TerminalColorScheme | undefined {
|
||||||
|
const match = data.match(COLOR_SCHEME_REPORT_PATTERN);
|
||||||
|
if (!match) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return match[1] === "2" ? "light" : "dark";
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ 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 {
|
||||||
|
isOsc11BackgroundColorResponse,
|
||||||
|
parseOsc11BackgroundColor,
|
||||||
|
parseTerminalColorSchemeReport,
|
||||||
|
type RgbColor,
|
||||||
|
type TerminalColorScheme,
|
||||||
|
} 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";
|
||||||
|
|
||||||
@@ -311,6 +317,8 @@ export class TUI extends Container {
|
|||||||
private stopped = false;
|
private stopped = false;
|
||||||
private pendingOsc11BackgroundReplies = 0;
|
private pendingOsc11BackgroundReplies = 0;
|
||||||
private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = [];
|
private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = [];
|
||||||
|
private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>();
|
||||||
|
private terminalColorSchemeNotificationsEnabled = false;
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -631,6 +639,9 @@ export class TUI extends Container {
|
|||||||
() => this.requestRender(),
|
() => this.requestRender(),
|
||||||
);
|
);
|
||||||
this.terminal.hideCursor();
|
this.terminal.hideCursor();
|
||||||
|
if (this.terminalColorSchemeNotificationsEnabled) {
|
||||||
|
this.terminal.write("\x1b[?2031h");
|
||||||
|
}
|
||||||
this.queryCellSize();
|
this.queryCellSize();
|
||||||
this.requestRender();
|
this.requestRender();
|
||||||
}
|
}
|
||||||
@@ -646,6 +657,23 @@ export class TUI extends Container {
|
|||||||
this.inputListeners.delete(listener);
|
this.inputListeners.delete(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void {
|
||||||
|
this.terminalColorSchemeListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.terminalColorSchemeListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setTerminalColorSchemeNotifications(enabled: boolean): void {
|
||||||
|
if (this.terminalColorSchemeNotificationsEnabled === enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.terminalColorSchemeNotificationsEnabled = enabled;
|
||||||
|
if (!this.stopped) {
|
||||||
|
this.terminal.write(enabled ? "\x1b[?2031h" : "\x1b[?2031l");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private queryCellSize(): void {
|
private queryCellSize(): void {
|
||||||
// Only query if terminal supports images (cell size is only used for image rendering)
|
// Only query if terminal supports images (cell size is only used for image rendering)
|
||||||
if (!getCapabilities().images) {
|
if (!getCapabilities().images) {
|
||||||
@@ -662,6 +690,9 @@ export class TUI extends Container {
|
|||||||
clearTimeout(this.renderTimer);
|
clearTimeout(this.renderTimer);
|
||||||
this.renderTimer = undefined;
|
this.renderTimer = undefined;
|
||||||
}
|
}
|
||||||
|
if (this.terminalColorSchemeNotificationsEnabled) {
|
||||||
|
this.terminal.write("\x1b[?2031l");
|
||||||
|
}
|
||||||
// Move cursor to the end of the content to prevent overwriting/artifacts on exit
|
// Move cursor to the end of the content to prevent overwriting/artifacts on exit
|
||||||
if (this.previousLines.length > 0) {
|
if (this.previousLines.length > 0) {
|
||||||
const targetRow = this.previousLines.length; // Line after the last content
|
const targetRow = this.previousLines.length; // Line after the last content
|
||||||
@@ -731,6 +762,9 @@ export class TUI extends Container {
|
|||||||
if (this.consumeOsc11BackgroundResponse(data)) {
|
if (this.consumeOsc11BackgroundResponse(data)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.consumeTerminalColorSchemeReport(data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.inputListeners.size > 0) {
|
if (this.inputListeners.size > 0) {
|
||||||
let current = data;
|
let current = data;
|
||||||
@@ -824,6 +858,18 @@ export class TUI extends Container {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private consumeTerminalColorSchemeReport(data: string): boolean {
|
||||||
|
const scheme = parseTerminalColorSchemeReport(data);
|
||||||
|
if (!scheme) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const listener of this.terminalColorSchemeListeners) {
|
||||||
|
listener(scheme);
|
||||||
|
}
|
||||||
|
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$/);
|
||||||
@@ -1638,4 +1684,31 @@ export class TUI extends Container {
|
|||||||
this.terminal.write("\x1b]11;?\x07");
|
this.terminal.write("\x1b]11;?\x07");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query the terminal's color-scheme preference with DSR (`CSI ? 996 n`).
|
||||||
|
* Terminals that support the color palette notification protocol reply with
|
||||||
|
* `CSI ? 997 ; 1 n` for dark or `CSI ? 997 ; 2 n` for light.
|
||||||
|
*/
|
||||||
|
queryTerminalColorScheme({ timeoutMs }: { timeoutMs: number }): Promise<TerminalColorScheme | undefined> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
let unsubscribe: () => void = () => {};
|
||||||
|
const settle = (scheme: TerminalColorScheme | undefined) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
}
|
||||||
|
unsubscribe();
|
||||||
|
resolve(scheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
unsubscribe = this.onTerminalColorSchemeChange(settle);
|
||||||
|
timer = setTimeout(() => settle(undefined), timeoutMs);
|
||||||
|
this.terminal.write("\x1b[?996n");
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { type Component, parseOsc11BackgroundColor, type Terminal, TUI } from "../src/index.ts";
|
import {
|
||||||
|
type Component,
|
||||||
|
parseOsc11BackgroundColor,
|
||||||
|
parseTerminalColorSchemeReport,
|
||||||
|
type Terminal,
|
||||||
|
TUI,
|
||||||
|
} from "../src/index.ts";
|
||||||
|
|
||||||
class TestTerminal implements Terminal {
|
class TestTerminal implements Terminal {
|
||||||
private inputHandler?: (data: string) => void;
|
private inputHandler?: (data: string) => void;
|
||||||
@@ -104,6 +110,16 @@ describe("parseOsc11BackgroundColor", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseTerminalColorSchemeReport", () => {
|
||||||
|
it("parses color scheme reports", () => {
|
||||||
|
assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;1n"), "dark");
|
||||||
|
assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;2n"), "light");
|
||||||
|
assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;3n"), undefined);
|
||||||
|
assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?996n"), undefined);
|
||||||
|
assert.strictEqual(parseTerminalColorSchemeReport("x\x1b[?997;1n"), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("TUI.queryTerminalBackgroundColor", () => {
|
describe("TUI.queryTerminalBackgroundColor", () => {
|
||||||
it("writes OSC 11 query and resolves with the parsed RGB reply", async () => {
|
it("writes OSC 11 query and resolves with the parsed RGB reply", async () => {
|
||||||
const terminal = new TestTerminal();
|
const terminal = new TestTerminal();
|
||||||
|
|||||||
Reference in New Issue
Block a user