feat(coding-agent): add first-run theme selector

Fixes #4185
This commit is contained in:
Armin Ronacher
2026-05-18 18:37:12 +02:00
Unverified
parent f10cf57e96
commit a5ceff055d
5 changed files with 319 additions and 54 deletions
+1
View File
@@ -326,6 +326,7 @@ export {
ShowImagesSelectorComponent,
SkillInvocationMessageComponent,
ThemeSelectorComponent,
type ThemeSelectorOptions,
ThinkingSelectorComponent,
ToolExecutionComponent,
type ToolExecutionOptions,
@@ -23,7 +23,7 @@ export { SessionSelectorComponent } from "./session-selector.js";
export { type SettingsCallbacks, type SettingsConfig, SettingsSelectorComponent } from "./settings-selector.js";
export { ShowImagesSelectorComponent } from "./show-images-selector.js";
export { SkillInvocationMessageComponent } from "./skill-invocation-message.js";
export { ThemeSelectorComponent } from "./theme-selector.js";
export { ThemeSelectorComponent, type ThemeSelectorOptions } from "./theme-selector.js";
export { ThinkingSelectorComponent } from "./thinking-selector.js";
export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution.js";
export { TreeSelectorComponent } from "./tree-selector.js";
@@ -15,6 +15,7 @@ import type { WarningSettings } from "../../../core/settings-manager.js";
import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { keyDisplayText } from "./keybinding-hints.js";
import { ThemeSelectorComponent } from "./theme-selector.js";
const SETTINGS_SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
minPrimaryColumnWidth: 12,
@@ -323,13 +324,7 @@ export class SettingsSelectorComponent extends Container {
description: "Color theme for the interface",
currentValue: config.currentTheme,
submenu: (currentValue, done) =>
new SelectSubmenu(
"Theme",
"Select color theme",
config.availableThemes.map((t) => ({
value: t,
label: t,
})),
new ThemeSelectorComponent(
currentValue,
(value) => {
callbacks.onThemeChange(value);
@@ -344,6 +339,12 @@ export class SettingsSelectorComponent extends Container {
// Preview theme on selection change
callbacks.onThemePreview?.(value);
},
{
title: "Theme",
description: "Select a readable color theme for this terminal.",
availableThemes: config.availableThemes,
border: false,
},
),
},
];
@@ -1,5 +1,15 @@
import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@earendil-works/pi-tui";
import { getAvailableThemes, getSelectListTheme } from "../theme/theme.js";
import {
Box,
Container,
type SelectItem,
SelectList,
type SelectListLayoutOptions,
Spacer,
Text,
truncateToWidth,
wrapTextWithAnsi,
} from "@earendil-works/pi-tui";
import { getAvailableThemes, getSelectListTheme, theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
const THEME_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
@@ -7,37 +17,118 @@ const THEME_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
maxPrimaryColumnWidth: 32,
};
export interface ThemeSelectorOptions {
title?: string;
description?: string;
detectedTheme?: string;
detectedThemeLabel?: string;
detectionDescription?: string;
availableThemes?: string[];
border?: boolean;
maxVisible?: number;
hint?: string;
}
class ThemeDemoComponent {
constructor(private readonly getSelectedTheme: () => string) {}
invalidate(): void {
// No cached state to invalidate.
}
render(width: number): string[] {
const lines: string[] = [];
const push = (line = "") => lines.push(truncateToWidth(line, width, ""));
push(` ${theme.fg("mdHeading", theme.bold("Preview"))} ${theme.fg("dim", `(${this.getSelectedTheme()})`)}`);
push();
const userBox = new Box(1, 1, (text: string) => theme.bg("userMessageBg", text));
userBox.addChild(new Text(theme.fg("userMessageText", "Fix the config parser regression."), 0, 0));
lines.push(...userBox.render(width));
push();
push(" I found the issue and will patch it.");
push();
const toolBox = new Box(1, 1, (text: string) => theme.bg("toolSuccessBg", text));
toolBox.addChild(
new Text(`${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", "src/config.ts")}`, 0, 0),
);
toolBox.addChild(new Spacer(1));
toolBox.addChild(
new Text(
[
theme.fg("toolDiffContext", " ..."),
theme.fg("toolDiffContext", " 41 const raw = readConfig();"),
theme.fg("toolDiffRemoved", "- 42 if (!value) return defaultValue;"),
theme.fg("toolDiffAdded", "+ 42 if (value === undefined) return defaultValue;"),
theme.fg("toolDiffContext", " 43 return value;"),
theme.fg("toolDiffContext", " ..."),
].join("\n"),
0,
0,
),
);
lines.push(...toolBox.render(width));
push();
push(" Fixed. Empty strings are preserved.");
return lines;
}
}
/**
* Component that renders a theme selector
* Component that renders a theme selector with a small live preview.
*/
export class ThemeSelectorComponent extends Container {
private selectList: SelectList;
private onPreview: (themeName: string) => void;
private selectedTheme: string;
private title: string;
private description: string | undefined;
private detectionDescription: string | undefined;
private hint: string;
private showBorder: boolean;
private border = new DynamicBorder();
private demo: ThemeDemoComponent;
constructor(
currentTheme: string,
onSelect: (themeName: string) => void,
onCancel: () => void,
onPreview: (themeName: string) => void,
options: ThemeSelectorOptions = {},
) {
super();
this.onPreview = onPreview;
this.selectedTheme = currentTheme;
this.title = options.title ?? "Theme";
this.description = options.description;
this.detectionDescription = options.detectionDescription;
this.hint = options.hint ?? "Enter to select · Esc to go back";
this.showBorder = options.border ?? true;
this.demo = new ThemeDemoComponent(() => this.selectedTheme);
// Get available themes and create select items
const themes = getAvailableThemes();
const themeItems: SelectItem[] = themes.map((name) => ({
value: name,
label: name,
description: name === currentTheme ? "(current)" : undefined,
}));
const themes = options.availableThemes ?? getAvailableThemes();
const detectedThemeLabel = options.detectedThemeLabel ?? "detected";
const themeItems: SelectItem[] = themes.map((name) => {
const annotations: string[] = [];
if (name === currentTheme) annotations.push(name === options.detectedTheme ? detectedThemeLabel : "current");
if (name === options.detectedTheme && name !== currentTheme) annotations.push(detectedThemeLabel);
return {
value: name,
label: name,
description: annotations.length > 0 ? `(${annotations.join(", ")})` : undefined,
};
});
// Add top border
this.addChild(new DynamicBorder());
this.selectList = new SelectList(
themeItems,
options.maxVisible ?? Math.min(Math.max(themeItems.length, 1), 10),
getSelectListTheme(),
THEME_SELECT_LIST_LAYOUT,
);
// Create selector
this.selectList = new SelectList(themeItems, 10, getSelectListTheme(), THEME_SELECT_LIST_LAYOUT);
// Preselect current theme
const currentIndex = themes.indexOf(currentTheme);
if (currentIndex !== -1) {
this.selectList.setSelectedIndex(currentIndex);
@@ -52,13 +143,50 @@ export class ThemeSelectorComponent extends Container {
};
this.selectList.onSelectionChange = (item) => {
this.onPreview(item.value);
this.selectedTheme = item.value;
onPreview(item.value);
};
}
this.addChild(this.selectList);
override invalidate(): void {
this.border.invalidate();
this.demo.invalidate();
this.selectList.invalidate();
}
// Add bottom border
this.addChild(new DynamicBorder());
handleInput(data: string): void {
this.selectList.handleInput(data);
}
override render(width: number): string[] {
const lines: string[] = [];
const push = (line = "") => lines.push(truncateToWidth(line, width, ""));
if (this.showBorder) {
lines.push(...this.border.render(width));
}
push(` ${theme.fg("accent", theme.bold(this.title))}`);
if (this.description) {
for (const line of wrapTextWithAnsi(theme.fg("muted", this.description), Math.max(1, width - 4))) {
push(` ${line}`);
}
}
if (this.detectionDescription) {
for (const line of wrapTextWithAnsi(theme.fg("dim", this.detectionDescription), Math.max(1, width - 4))) {
push(` ${line}`);
}
}
push();
lines.push(...this.demo.render(width));
push();
lines.push(...this.selectList.render(width));
push();
push(` ${theme.fg("dim", this.hint)}`);
if (this.showBorder) {
lines.push(...this.border.render(width));
}
return lines;
}
getSelectList(): SelectList {
@@ -116,18 +116,22 @@ import { ScopedModelsSelectorComponent } from "./components/scoped-models-select
import { SessionSelectorComponent } from "./components/session-selector.js";
import { SettingsSelectorComponent } from "./components/settings-selector.js";
import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.js";
import { ThemeSelectorComponent } from "./components/theme-selector.js";
import { ToolExecutionComponent } from "./components/tool-execution.js";
import { TreeSelectorComponent } from "./components/tree-selector.js";
import { UserMessageComponent } from "./components/user-message.js";
import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
import {
detectTerminalBackground,
getAvailableThemes,
getAvailableThemesWithPaths,
getEditorTheme,
getMarkdownTheme,
getThemeByName,
getThemeForRgbColor,
initTheme,
onThemeChange,
parseOsc11BackgroundColor,
setRegisteredThemes,
setTheme,
setThemeInstance,
@@ -147,6 +151,8 @@ function isExpandable(obj: unknown): obj is Expandable {
}
class ExpandableText extends Text implements Expandable {
private expanded: boolean;
constructor(
private readonly getCollapsedText: () => string,
private readonly getExpandedText: () => string,
@@ -154,11 +160,23 @@ class ExpandableText extends Text implements Expandable {
paddingX = 0,
paddingY = 0,
) {
super(expanded ? getExpandedText() : getCollapsedText(), paddingX, paddingY);
super("", paddingX, paddingY);
this.expanded = expanded;
this.updateText();
}
private updateText(): void {
this.setText(this.expanded ? this.getExpandedText() : this.getCollapsedText());
}
setExpanded(expanded: boolean): void {
this.setText(expanded ? this.getExpandedText() : this.getCollapsedText());
this.expanded = expanded;
this.updateText();
}
override invalidate(): void {
this.updateText();
super.invalidate();
}
}
@@ -664,23 +682,26 @@ export class InteractiveMode {
this.setupKeyHandlers();
this.setupEditorSubmitHandler();
// Start the UI before initializing extensions so session_start handlers can use interactive dialogs
// Start the UI before initializing extensions so first-run setup can use interactive dialogs.
this.ui.start();
this.isInitialized = true;
// Set up theme file watcher before first-run theme selection so previews invalidate consistently.
onThemeChange(() => {
this.ui.invalidate();
this.updateEditorBorderColor();
this.ui.requestRender();
});
// Complete first-run theme selection before extensions can replace the editor and steal focus.
await this.showInitialThemeSelectorIfNeeded();
// Initialize extensions first so resources are shown before messages
await this.rebindCurrentSession();
// Render initial messages AFTER showing loaded resources
this.renderInitialMessages();
// Set up theme file watcher
onThemeChange(() => {
this.ui.invalidate();
this.updateEditorBorderColor();
this.ui.requestRender();
});
// Set up git branch watcher (uses provider instead of footer)
this.footerDataProvider.onBranchChange(() => {
this.ui.requestRender();
@@ -3816,8 +3837,133 @@ export class InteractiveMode {
this.ui.requestRender();
}
private previewTheme(themeName: string): { success: boolean; error?: string } {
const result = setTheme(themeName, true);
this.ui.invalidate();
this.updateEditorBorderColor();
this.ui.requestRender();
return result;
}
private selectTheme(themeName: string): boolean {
const result = this.previewTheme(themeName);
if (!result.success) {
this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`);
return false;
}
if (this.settingsManager.getTheme() !== themeName) {
this.settingsManager.setTheme(themeName);
}
return true;
}
private queryTerminalBackground(timeoutMs = 200): Promise<ReturnType<typeof detectTerminalBackground> | undefined> {
return new Promise((resolve) => {
let resolved = false;
let unsubscribed = false;
const unsubscribe = this.ui.addInputListener((data) => {
const rgb = parseOsc11BackgroundColor(data);
if (!rgb) {
return undefined;
}
if (!unsubscribed) {
unsubscribed = true;
unsubscribe();
}
const themeName = getThemeForRgbColor(rgb);
const detail = `OSC 11 background rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
if (!resolved) {
resolved = true;
clearTimeout(resolveTimer);
clearTimeout(cleanupTimer);
resolve({ theme: themeName, source: "terminal background", detail, confidence: "high" });
}
return { consume: true };
});
const resolveTimer = setTimeout(() => {
if (resolved) {
return;
}
resolved = true;
resolve(undefined);
}, timeoutMs);
const cleanupTimer = setTimeout(() => {
if (unsubscribed) {
return;
}
unsubscribed = true;
unsubscribe();
}, 2000);
this.ui.terminal.write("\x1b]11;?\x07");
});
}
private async showInitialThemeSelectorIfNeeded(): Promise<void> {
if (this.settingsManager.getTheme()) {
return;
}
const detection = (await this.queryTerminalBackground()) ?? detectTerminalBackground();
const detectedTheme = detection.theme;
const isFallbackDetection = detection.source === "fallback";
const detectedThemeLabel = isFallbackDetection ? "default" : "detected";
this.previewTheme(detectedTheme);
await new Promise<void>((resolve) => {
let handle: OverlayHandle | undefined;
let closed = false;
const done = () => {
if (closed) {
return;
}
closed = true;
handle?.hide();
this.ui.setFocus(this.editor);
this.ui.requestRender();
resolve();
};
const selector = new ThemeSelectorComponent(
detectedTheme,
(themeName) => {
if (!this.selectTheme(themeName)) {
return;
}
done();
this.showStatus(`Theme: ${themeName}`);
},
() => {
this.previewTheme(detectedTheme);
done();
this.showStatus(`Using detected ${detectedTheme} theme for this session`);
},
(themeName) => {
this.previewTheme(themeName);
},
{
title: "Choose a theme",
description:
"No theme is configured yet. Pick the option that is readable in this terminal; pi will save it to settings.",
detectedTheme,
detectedThemeLabel,
availableThemes: getAvailableThemes(),
hint: isFallbackDetection
? "Enter to save · Esc to use default for this session"
: "Enter to save · Esc to use detected for this session",
},
);
handle = this.ui.showOverlay(selector, {
width: "100%",
maxHeight: "100%",
margin: 0,
});
});
}
private showSettingsSelector(): void {
this.showSelector((done) => {
const currentTheme = this.settingsManager.getTheme() || theme.name || detectTerminalBackground().theme;
const selector = new SettingsSelectorComponent(
{
autoCompact: this.session.autoCompactionEnabled,
@@ -3831,7 +3977,7 @@ export class InteractiveMode {
transport: this.settingsManager.getTransport(),
thinkingLevel: this.session.thinkingLevel,
availableThinkingLevels: this.session.getAvailableThinkingLevels(),
currentTheme: this.settingsManager.getTheme() || "dark",
currentTheme,
availableThemes: getAvailableThemes(),
hideThinkingBlock: this.hideThinkingBlock,
collapseChangelog: this.settingsManager.getCollapseChangelog(),
@@ -3892,20 +4038,9 @@ export class InteractiveMode {
this.footer.invalidate();
this.updateEditorBorderColor();
},
onThemeChange: (themeName) => {
const result = setTheme(themeName, true);
this.settingsManager.setTheme(themeName);
this.ui.invalidate();
if (!result.success) {
this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`);
}
},
onThemeChange: (themeName) => this.selectTheme(themeName),
onThemePreview: (themeName) => {
const result = setTheme(themeName, true);
if (result.success) {
this.ui.invalidate();
this.ui.requestRender();
}
this.previewTheme(themeName);
},
onHideThinkingBlockChange: (hidden) => {
this.hideThinkingBlock = hidden;