mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user