From a5ceff055deca97fdd5d37285207ca52eeb7ff26 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 18 May 2026 18:37:12 +0200 Subject: [PATCH] feat(coding-agent): add first-run theme selector Fixes #4185 --- packages/coding-agent/src/index.ts | 1 + .../src/modes/interactive/components/index.ts | 2 +- .../components/settings-selector.ts | 15 +- .../interactive/components/theme-selector.ts | 172 +++++++++++++--- .../src/modes/interactive/interactive-mode.ts | 183 +++++++++++++++--- 5 files changed, 319 insertions(+), 54 deletions(-) diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 8001509fe..ba7122be0 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -326,6 +326,7 @@ export { ShowImagesSelectorComponent, SkillInvocationMessageComponent, ThemeSelectorComponent, + type ThemeSelectorOptions, ThinkingSelectorComponent, ToolExecutionComponent, type ToolExecutionOptions, diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index 242d96673..a9f70492d 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -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"; diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 7d9d9cdf2..11a8f37a4 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -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, + }, ), }, ]; diff --git a/packages/coding-agent/src/modes/interactive/components/theme-selector.ts b/packages/coding-agent/src/modes/interactive/components/theme-selector.ts index 29e6e7791..4dabb46d6 100644 --- a/packages/coding-agent/src/modes/interactive/components/theme-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/theme-selector.ts @@ -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 { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b3df9eb4d..d6342ae3c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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 | 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 { + 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((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;