Merge main into model-registry
This commit is contained in:
@@ -73,7 +73,7 @@ function formatBaseDir(baseDir: string): string {
|
||||
return displayPath.endsWith("/") ? displayPath : `${displayPath}/`;
|
||||
}
|
||||
|
||||
function getGroupLabel(metadata: PathMetadata): string {
|
||||
function getGroupLabel(metadata: PathMetadata, agentDir: string): string {
|
||||
if (metadata.origin === "package") {
|
||||
return `${metadata.source} (${metadata.scope})`;
|
||||
}
|
||||
@@ -84,12 +84,12 @@ function getGroupLabel(metadata: PathMetadata): string {
|
||||
? `User (${formatBaseDir(metadata.baseDir)})`
|
||||
: `Project (${formatBaseDir(metadata.baseDir)})`;
|
||||
}
|
||||
return metadata.scope === "user" ? "User (~/.pi/agent/)" : "Project (.pi/)";
|
||||
return metadata.scope === "user" ? `User (${formatBaseDir(agentDir)})` : `Project (${CONFIG_DIR_NAME}/)`;
|
||||
}
|
||||
return metadata.scope === "user" ? "User settings" : "Project settings";
|
||||
}
|
||||
|
||||
function buildGroups(resolved: ResolvedPaths): ResourceGroup[] {
|
||||
function buildGroups(resolved: ResolvedPaths, agentDir: string): ResourceGroup[] {
|
||||
const groupMap = new Map<string, ResourceGroup>();
|
||||
|
||||
const addToGroup = (resources: ResolvedResource[], resourceType: ResourceType) => {
|
||||
@@ -100,7 +100,7 @@ function buildGroups(resolved: ResolvedPaths): ResourceGroup[] {
|
||||
if (!groupMap.has(groupKey)) {
|
||||
groupMap.set(groupKey, {
|
||||
key: groupKey,
|
||||
label: getGroupLabel(metadata),
|
||||
label: getGroupLabel(metadata, agentDir),
|
||||
scope: metadata.scope,
|
||||
origin: metadata.origin,
|
||||
source: metadata.source,
|
||||
@@ -601,7 +601,7 @@ export class ConfigSelectorComponent extends Container implements Focusable {
|
||||
) {
|
||||
super();
|
||||
|
||||
const groups = buildGroups(resolvedPaths);
|
||||
const groups = buildGroups(resolvedPaths, agentDir);
|
||||
|
||||
// Add header
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import type { AgentSession } from "../../../core/agent-session.ts";
|
||||
import { areExperimentalFeaturesEnabled } from "../../../core/experimental.ts";
|
||||
import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts";
|
||||
import { theme } from "../theme/theme.ts";
|
||||
|
||||
@@ -159,6 +160,9 @@ export class FooterComponent implements Component {
|
||||
contextPercentStr = contextPercentDisplay;
|
||||
}
|
||||
statsParts.push(contextPercentStr);
|
||||
if (areExperimentalFeaturesEnabled()) {
|
||||
statsParts.push(`${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`);
|
||||
}
|
||||
|
||||
let statsLeft = statsParts.join(" ");
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ export class LoginDialogComponent extends Container implements Focusable {
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0));
|
||||
|
||||
openBrowser(info.verificationUri);
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@earendil-works/pi-tui";
|
||||
import type { ModelRegistry } from "../../../core/model-registry.ts";
|
||||
import type { SettingsManager } from "../../../core/settings-manager.ts";
|
||||
import { getModelSelectorSearchText } from "../model-search.ts";
|
||||
import { theme } from "../theme/theme.ts";
|
||||
import { DynamicBorder } from "./dynamic-border.ts";
|
||||
import { keyHint } from "./keybinding-hints.ts";
|
||||
@@ -217,10 +218,8 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
||||
|
||||
private filterModels(query: string): void {
|
||||
this.filteredModels = query
|
||||
? fuzzyFilter(
|
||||
this.activeModels,
|
||||
query,
|
||||
({ id, provider }) => `${id} ${provider} ${provider}/${id} ${provider} ${id}`,
|
||||
? fuzzyFilter(this.activeModels, query, ({ id, provider, model }) =>
|
||||
getModelSelectorSearchText({ id, provider, name: model.name }),
|
||||
)
|
||||
: this.activeModels;
|
||||
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Spacer,
|
||||
Text,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import { getModelSearchText } from "../model-search.ts";
|
||||
import { theme } from "../theme/theme.ts";
|
||||
import { DynamicBorder } from "./dynamic-border.ts";
|
||||
import { keyText } from "./keybinding-hints.ts";
|
||||
@@ -182,7 +183,11 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
|
||||
private refresh(): void {
|
||||
const query = this.searchInput.getValue();
|
||||
const items = this.buildItems();
|
||||
this.filteredItems = query ? fuzzyFilter(items, query, (i) => `${i.model.id} ${i.model.provider}`) : items;
|
||||
this.filteredItems = query
|
||||
? fuzzyFilter(items, query, (i) =>
|
||||
getModelSearchText({ id: i.model.id, provider: i.model.provider, name: i.model.name }),
|
||||
)
|
||||
: items;
|
||||
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
|
||||
this.updateList();
|
||||
this.footerText.setText(this.getFooterText());
|
||||
|
||||
@@ -694,7 +694,6 @@ export class SessionSelectorComponent extends Container implements Focusable {
|
||||
private allSessions: SessionInfo[] | null = null;
|
||||
private currentSessionsLoader: SessionsLoader;
|
||||
private allSessionsLoader: SessionsLoader;
|
||||
private onCancel: () => void;
|
||||
private requestRender: () => void;
|
||||
private renameSession?: (sessionPath: string, currentName: string | undefined) => Promise<void>;
|
||||
private currentLoading = false;
|
||||
@@ -751,7 +750,6 @@ export class SessionSelectorComponent extends Container implements Focusable {
|
||||
this.keybindings = options?.keybindings ?? KeybindingsManager.create();
|
||||
this.currentSessionsLoader = currentSessionsLoader;
|
||||
this.allSessionsLoader = allSessionsLoader;
|
||||
this.onCancel = onCancel;
|
||||
this.requestRender = requestRender;
|
||||
this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.nameFilter, this.requestRender);
|
||||
const renameSession = options?.renameSession;
|
||||
@@ -948,10 +946,6 @@ export class SessionSelectorComponent extends Container implements Focusable {
|
||||
this.header.setLoading(false);
|
||||
this.sessionList.setSessions(sessions, showCwd);
|
||||
this.requestRender();
|
||||
|
||||
if (scope === "all" && sessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) {
|
||||
this.onCancel();
|
||||
}
|
||||
} catch (err) {
|
||||
if (scope === "current") {
|
||||
this.currentLoading = false;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import type { Transport } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
type Component,
|
||||
Container,
|
||||
getCapabilities,
|
||||
type SelectItem,
|
||||
@@ -13,7 +14,13 @@ import {
|
||||
} from "@earendil-works/pi-tui";
|
||||
import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.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 { keyDisplayText } from "./keybinding-hints.ts";
|
||||
|
||||
@@ -55,6 +62,7 @@ export interface SettingsConfig {
|
||||
thinkingLevel: ThinkingLevel;
|
||||
availableThinkingLevels: ThinkingLevel[];
|
||||
currentTheme: string;
|
||||
terminalTheme: TerminalTheme;
|
||||
availableThemes: string[];
|
||||
hideThinkingBlock: 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.
|
||||
*/
|
||||
@@ -353,28 +604,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,
|
||||
})),
|
||||
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);
|
||||
},
|
||||
),
|
||||
new ThemeSubmenu(currentValue, config.terminalTheme, config.availableThemes, callbacks, done),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -561,6 +791,9 @@ export class SettingsSelectorComponent extends Container {
|
||||
case "terminal-progress":
|
||||
callbacks.onShowTerminalProgressChange(newValue === "true");
|
||||
break;
|
||||
case "theme":
|
||||
callbacks.onThemeChange(newValue);
|
||||
break;
|
||||
}
|
||||
},
|
||||
callbacks.onCancel,
|
||||
|
||||
@@ -4,15 +4,18 @@ import {
|
||||
type Focusable,
|
||||
getKeybindings,
|
||||
Input,
|
||||
type Keybinding,
|
||||
Spacer,
|
||||
sliceByColumn,
|
||||
Text,
|
||||
TruncatedText,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
wrapTextWithAnsi,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import type { SessionTreeNode } from "../../../core/session-manager.ts";
|
||||
import { theme } from "../theme/theme.ts";
|
||||
import { DynamicBorder } from "./dynamic-border.ts";
|
||||
import { keyHint, keyText } from "./keybinding-hints.ts";
|
||||
import { formatKeyText, keyHint } from "./keybinding-hints.ts";
|
||||
|
||||
/** Gutter info: position (displayIndent where connector was) and whether to show │ */
|
||||
interface GutterInfo {
|
||||
@@ -35,6 +38,59 @@ interface FlatNode {
|
||||
isVirtualRootChild: boolean;
|
||||
}
|
||||
|
||||
interface HorizontalViewportRow {
|
||||
gutter: string;
|
||||
body: string;
|
||||
anchorCol: number;
|
||||
bodyWidth: number;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
const TREE_GUTTER_WIDTH = 2;
|
||||
const MIN_VISIBLE_ANCHOR_CONTENT_WIDTH = 4;
|
||||
const MAX_VISIBLE_ANCHOR_CONTENT_WIDTH = 20;
|
||||
const MIN_ANCHOR_CONTEXT_WIDTH = 2;
|
||||
const MAX_ANCHOR_CONTEXT_WIDTH = 12;
|
||||
|
||||
/**
|
||||
* Render tree rows into a horizontally clipped viewport.
|
||||
*
|
||||
* The tree gutter is always kept visible. The row bodies are shifted left only
|
||||
* when the selected row's anchor (the start of its entry text after tree
|
||||
* indentation/markers) would otherwise be too far right to see useful content.
|
||||
*/
|
||||
function renderHorizontalViewport(rows: HorizontalViewportRow[], width: number): string[] {
|
||||
const viewportWidth = Math.max(0, width - TREE_GUTTER_WIDTH);
|
||||
const maxBodyWidth = rows.reduce((max, row) => Math.max(max, row.bodyWidth), 0);
|
||||
const maxHorizontalScroll = Math.max(0, maxBodyWidth - viewportWidth);
|
||||
const selectedRow = rows.find((row) => row.isSelected);
|
||||
|
||||
// Only pan horizontally when needed to keep enough selected-row content visible after its anchor.
|
||||
let horizontalScroll = 0;
|
||||
if (selectedRow && maxHorizontalScroll > 0) {
|
||||
const minVisibleAnchorContentWidth = Math.min(
|
||||
MAX_VISIBLE_ANCHOR_CONTENT_WIDTH,
|
||||
Math.max(MIN_VISIBLE_ANCHOR_CONTENT_WIDTH, Math.floor(viewportWidth / 3)),
|
||||
);
|
||||
if (selectedRow.anchorCol > viewportWidth - minVisibleAnchorContentWidth) {
|
||||
const anchorContextWidth = Math.min(
|
||||
MAX_ANCHOR_CONTEXT_WIDTH,
|
||||
Math.max(MIN_ANCHOR_CONTEXT_WIDTH, Math.floor(viewportWidth / 4)),
|
||||
);
|
||||
horizontalScroll = Math.min(maxHorizontalScroll, selectedRow.anchorCol - anchorContextWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// Clip only the body; the fixed-width gutter remains visible as navigation context.
|
||||
return rows.map((row) => {
|
||||
const line =
|
||||
horizontalScroll > 0
|
||||
? `${row.gutter}${sliceByColumn(row.body, horizontalScroll, viewportWidth, true)}\x1b[0m`
|
||||
: row.gutter + row.body;
|
||||
return truncateToWidth(line, width, "");
|
||||
});
|
||||
}
|
||||
|
||||
/** Filter mode for tree display */
|
||||
export type FilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all";
|
||||
|
||||
@@ -617,6 +673,7 @@ class TreeList implements Component {
|
||||
);
|
||||
const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length);
|
||||
|
||||
const renderedRows: HorizontalViewportRow[] = [];
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const flatNode = this.filteredNodes[i];
|
||||
const entry = flatNode.node.entry;
|
||||
@@ -680,14 +737,18 @@ class TreeList implements Component {
|
||||
? theme.fg("muted", `${this.formatLabelTimestamp(flatNode.node.labelTimestamp)} `)
|
||||
: "";
|
||||
const content = this.getEntryDisplayText(flatNode.node, isSelected);
|
||||
|
||||
let line = cursor + theme.fg("dim", prefix) + foldMarker + pathMarker + label + labelTimestamp + content;
|
||||
const prefixPart = theme.fg("dim", prefix) + foldMarker + pathMarker;
|
||||
const anchorCol = visibleWidth(prefixPart);
|
||||
let gutter = cursor;
|
||||
let body = prefixPart + label + labelTimestamp + content;
|
||||
if (isSelected) {
|
||||
line = theme.bg("selectedBg", line);
|
||||
gutter = theme.bg("selectedBg", gutter);
|
||||
body = theme.bg("selectedBg", body);
|
||||
}
|
||||
lines.push(truncateToWidth(line, width));
|
||||
renderedRows.push({ gutter, body, anchorCol, bodyWidth: visibleWidth(body), isSelected });
|
||||
}
|
||||
|
||||
lines.push(...renderHorizontalViewport(renderedRows, width));
|
||||
lines.push(
|
||||
truncateToWidth(
|
||||
theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getStatusLabels()}`),
|
||||
@@ -1075,6 +1136,98 @@ class SearchLine implements Component {
|
||||
handleInput(_keyData: string): void {}
|
||||
}
|
||||
|
||||
/** Component that renders tree help as semantic rows with chunk-aware wrapping */
|
||||
class TreeHelp implements Component {
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const items = TREE_HELP_ITEMS.map(({ keys, label, labelFirst }) => {
|
||||
const text = formatHelpKeys(keys);
|
||||
if (!text) return label;
|
||||
return labelFirst ? `${label} ${text}` : `${text} ${label}`;
|
||||
});
|
||||
|
||||
const availableWidth = Math.max(1, width);
|
||||
const indent = " ";
|
||||
const separator = " · ";
|
||||
const lines: string[] = [];
|
||||
let currentLine = "";
|
||||
|
||||
for (const item of items) {
|
||||
const candidate = currentLine
|
||||
? `${currentLine}${separator}${item}`
|
||||
: visibleWidth(`${indent}${item}`) <= availableWidth
|
||||
? `${indent}${item}`
|
||||
: item;
|
||||
if (!currentLine || visibleWidth(candidate) <= availableWidth) {
|
||||
currentLine = candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push(...wrapTextWithAnsi(currentLine.trimEnd(), availableWidth));
|
||||
currentLine = visibleWidth(`${indent}${item}`) <= availableWidth ? `${indent}${item}` : item;
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(...wrapTextWithAnsi(currentLine.trimEnd(), availableWidth));
|
||||
}
|
||||
|
||||
return lines.map((line) => theme.fg("muted", line));
|
||||
}
|
||||
}
|
||||
|
||||
const TREE_HELP_ITEMS: Array<{ keys: Keybinding[]; label: string; labelFirst?: boolean }> = [
|
||||
{ keys: ["tui.select.up", "tui.select.down"], label: "move" },
|
||||
{ keys: ["tui.editor.cursorLeft", "tui.editor.cursorRight"], label: "page" },
|
||||
{ keys: ["app.tree.foldOrUp", "app.tree.unfoldOrDown"], label: "branch" },
|
||||
{ keys: ["app.tree.editLabel"], label: "label" },
|
||||
{ keys: ["app.tree.toggleLabelTimestamp"], label: "label time" },
|
||||
{
|
||||
keys: [
|
||||
"app.tree.filter.default",
|
||||
"app.tree.filter.noTools",
|
||||
"app.tree.filter.userOnly",
|
||||
"app.tree.filter.labeledOnly",
|
||||
"app.tree.filter.all",
|
||||
],
|
||||
label: "filters",
|
||||
labelFirst: true,
|
||||
},
|
||||
{ keys: ["app.tree.filter.cycleForward", "app.tree.filter.cycleBackward"], label: "cycle", labelFirst: true },
|
||||
];
|
||||
|
||||
function formatHelpKeys(keybindings: Keybinding[]): string {
|
||||
const keys: string[] = [];
|
||||
for (const keybinding of keybindings) {
|
||||
const key = getKeybindings().getKeys(keybinding)[0];
|
||||
if (key !== undefined) keys.push(key);
|
||||
}
|
||||
if (keys.length === 0) return "";
|
||||
|
||||
return formatKeyText(compactRawKeys(keys))
|
||||
.replace(/\bpageUp\b/g, "pgup")
|
||||
.replace(/\bpageDown\b/g, "pgdn")
|
||||
.replace(/\bup\b/g, "↑")
|
||||
.replace(/\bdown\b/g, "↓")
|
||||
.replace(/\bleft\b/g, "←")
|
||||
.replace(/\bright\b/g, "→");
|
||||
}
|
||||
|
||||
function compactRawKeys(keys: string[]): string {
|
||||
if (keys.length === 1) return keys[0]!;
|
||||
|
||||
const parts = keys.map((key) => {
|
||||
const separatorIndex = key.lastIndexOf("+");
|
||||
return separatorIndex === -1
|
||||
? { prefix: "", suffix: key }
|
||||
: { prefix: key.slice(0, separatorIndex + 1), suffix: key.slice(separatorIndex + 1) };
|
||||
});
|
||||
const prefix = parts[0]!.prefix;
|
||||
return prefix && parts.every((part) => part.prefix === prefix)
|
||||
? `${prefix}${parts.map((part) => part.suffix).join("/")}`
|
||||
: keys.join("/");
|
||||
}
|
||||
|
||||
/** Label input component shown when editing a label */
|
||||
class LabelInput implements Component, Focusable {
|
||||
private input: Input;
|
||||
@@ -1181,25 +1334,7 @@ export class TreeSelectorComponent extends Container implements Focusable {
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Text(theme.bold(" Session Tree"), 1, 0));
|
||||
const filterKeys = [
|
||||
keyText("app.tree.filter.default"),
|
||||
keyText("app.tree.filter.noTools"),
|
||||
keyText("app.tree.filter.userOnly"),
|
||||
keyText("app.tree.filter.labeledOnly"),
|
||||
keyText("app.tree.filter.all"),
|
||||
].join("/");
|
||||
const cycleKeys = `${keyText("app.tree.filter.cycleForward")}/${keyText("app.tree.filter.cycleBackward")}`;
|
||||
const branchKeys = `${keyText("app.tree.foldOrUp")}/${keyText("app.tree.unfoldOrDown")}`;
|
||||
this.addChild(
|
||||
new TruncatedText(
|
||||
theme.fg(
|
||||
"muted",
|
||||
` ↑/↓: move. ←/→: page. ${branchKeys}: fold/branch. ${keyText("app.tree.editLabel")}: label. ${filterKeys}: filters (${cycleKeys} cycle). ${keyText("app.tree.toggleLabelTimestamp")}: label time`,
|
||||
),
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
this.addChild(new TreeHelp());
|
||||
this.addChild(new SearchLine(this.treeList));
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui";
|
||||
import {
|
||||
getProjectTrustOptions,
|
||||
getProjectTrustPath,
|
||||
type ProjectTrustOption,
|
||||
type ProjectTrustStoreEntry,
|
||||
} from "../../../core/trust-manager.ts";
|
||||
@@ -19,12 +18,12 @@ export interface TrustSelectorOptions {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function formatDecision(cwd: string, decision: ProjectTrustStoreEntry | null): string {
|
||||
function formatDecision(trustPath: string | undefined, decision: ProjectTrustStoreEntry | null): string {
|
||||
if (decision === null) {
|
||||
return "none";
|
||||
}
|
||||
const label = decision.decision ? "trusted" : "untrusted";
|
||||
if (decision.path !== getProjectTrustPath(cwd)) {
|
||||
if (trustPath !== undefined && decision.path !== trustPath) {
|
||||
return `${label} (inherited from ${decision.path})`;
|
||||
}
|
||||
return `${label} (${decision.path})`;
|
||||
@@ -56,7 +55,14 @@ export class TrustSelectorComponent extends Container {
|
||||
this.addChild(new Text(theme.fg("muted", options.cwd), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(
|
||||
new Text(theme.fg("muted", `Saved decision: ${formatDecision(options.cwd, options.savedDecision)}`), 1, 0),
|
||||
new Text(
|
||||
theme.fg(
|
||||
"muted",
|
||||
`Saved decision: ${formatDecision(this.trustOptions[0]?.savedPath, options.savedDecision)}`,
|
||||
),
|
||||
1,
|
||||
0,
|
||||
),
|
||||
);
|
||||
this.addChild(
|
||||
new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0),
|
||||
|
||||
@@ -52,6 +52,7 @@ import { spawn, spawnSync } from "child_process";
|
||||
import {
|
||||
APP_NAME,
|
||||
APP_TITLE,
|
||||
CONFIG_DIR_NAME,
|
||||
getAgentDir,
|
||||
getAuthPath,
|
||||
getDebugLogPath,
|
||||
@@ -86,7 +87,7 @@ import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
|
||||
import type { SourceInfo } from "../../core/source-info.ts";
|
||||
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
|
||||
import type { TruncationResult } from "../../core/tools/truncate.ts";
|
||||
import { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../../core/trust-manager.ts";
|
||||
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";
|
||||
import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";
|
||||
import { copyToClipboard } from "../../utils/clipboard.ts";
|
||||
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
|
||||
@@ -125,22 +126,21 @@ import { TreeSelectorComponent } from "./components/tree-selector.ts";
|
||||
import { TrustSelectorComponent } from "./components/trust-selector.ts";
|
||||
import { UserMessageComponent } from "./components/user-message.ts";
|
||||
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
|
||||
import { getModelSearchText } from "./model-search.ts";
|
||||
import {
|
||||
getAvailableThemes,
|
||||
getAvailableThemesWithPaths,
|
||||
getEditorTheme,
|
||||
getMarkdownTheme,
|
||||
getThemeByName,
|
||||
initTheme,
|
||||
onThemeChange,
|
||||
setRegisteredThemes,
|
||||
setTheme,
|
||||
setThemeInstance,
|
||||
stopThemeWatcher,
|
||||
Theme,
|
||||
type ThemeColor,
|
||||
theme,
|
||||
} from "./theme/theme.ts";
|
||||
import { InteractiveThemeController } from "./theme/theme-controller.ts";
|
||||
|
||||
/** Interface for components that can be expanded/collapsed */
|
||||
interface Expandable {
|
||||
@@ -371,6 +371,7 @@ export class InteractiveMode {
|
||||
|
||||
private options: InteractiveModeOptions;
|
||||
private autoTrustOnReloadCwd: string | undefined;
|
||||
private themeController: InteractiveThemeController;
|
||||
|
||||
// Convenience accessors
|
||||
private get session(): AgentSession {
|
||||
@@ -394,7 +395,7 @@ export class InteractiveMode {
|
||||
this.resetExtensionUI();
|
||||
});
|
||||
this.runtimeHost.setRebindSession(async () => {
|
||||
await this.rebindCurrentSession();
|
||||
await this.rebindCurrentSession({ renderBeforeBind: true });
|
||||
});
|
||||
this.version = VERSION;
|
||||
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
|
||||
@@ -425,7 +426,12 @@ export class InteractiveMode {
|
||||
|
||||
// Register themes from resource loader and initialize
|
||||
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
|
||||
initTheme(this.settingsManager.getTheme(), true);
|
||||
this.themeController = new InteractiveThemeController(
|
||||
this.ui,
|
||||
this.settingsManager,
|
||||
(message) => this.showError(message),
|
||||
() => this.updateEditorBorderColor(),
|
||||
);
|
||||
}
|
||||
|
||||
private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined {
|
||||
@@ -498,11 +504,12 @@ export class InteractiveMode {
|
||||
const items = models.map((m) => ({
|
||||
id: m.id,
|
||||
provider: m.provider,
|
||||
name: m.name,
|
||||
label: `${m.provider}/${m.id}`,
|
||||
}));
|
||||
|
||||
// Fuzzy filter by model ID + provider (allows "opus anthropic" to match)
|
||||
const filtered = fuzzyFilter(items, prefix, (item) => `${item.id} ${item.provider}`);
|
||||
// Fuzzy filter by model ID + provider in either order.
|
||||
const filtered = fuzzyFilter(items, prefix, getModelSearchText);
|
||||
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
@@ -629,9 +636,28 @@ export class InteractiveMode {
|
||||
console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
|
||||
}
|
||||
|
||||
// Add header container as first child
|
||||
// Add header container as first child. Populate it after detectThemeIfUnset.
|
||||
this.ui.addChild(this.headerContainer);
|
||||
|
||||
this.ui.addChild(this.chatContainer);
|
||||
this.ui.addChild(this.pendingMessagesContainer);
|
||||
this.ui.addChild(this.statusContainer);
|
||||
this.renderWidgets(); // Initialize with default spacer
|
||||
this.ui.addChild(this.widgetContainerAbove);
|
||||
this.ui.addChild(this.editorContainer);
|
||||
this.ui.addChild(this.widgetContainerBelow);
|
||||
this.ui.addChild(this.footer);
|
||||
this.ui.setFocus(this.editor);
|
||||
|
||||
this.setupKeyHandlers();
|
||||
this.setupEditorSubmitHandler();
|
||||
|
||||
// Start the UI before initializing extensions so session_start handlers can use interactive dialogs
|
||||
this.ui.start();
|
||||
this.isInitialized = true;
|
||||
|
||||
await this.themeController.applyFromSettings();
|
||||
|
||||
// Add header with keybindings from config (unless silenced)
|
||||
if (this.options.verbose || !this.settingsManager.getQuietStartup()) {
|
||||
const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
|
||||
@@ -692,23 +718,7 @@ export class InteractiveMode {
|
||||
this.builtInHeader = new Text("", 0, 0);
|
||||
this.headerContainer.addChild(this.builtInHeader);
|
||||
}
|
||||
|
||||
this.ui.addChild(this.chatContainer);
|
||||
this.ui.addChild(this.pendingMessagesContainer);
|
||||
this.ui.addChild(this.statusContainer);
|
||||
this.renderWidgets(); // Initialize with default spacer
|
||||
this.ui.addChild(this.widgetContainerAbove);
|
||||
this.ui.addChild(this.editorContainer);
|
||||
this.ui.addChild(this.widgetContainerBelow);
|
||||
this.ui.addChild(this.footer);
|
||||
this.ui.setFocus(this.editor);
|
||||
|
||||
this.setupKeyHandlers();
|
||||
this.setupEditorSubmitHandler();
|
||||
|
||||
// Start the UI before initializing extensions so session_start handlers can use interactive dialogs
|
||||
this.ui.start();
|
||||
this.isInitialized = true;
|
||||
this.ui.requestRender();
|
||||
|
||||
// Initialize extensions first so resources are shown before messages
|
||||
await this.rebindCurrentSession();
|
||||
@@ -1533,12 +1543,7 @@ export class InteractiveMode {
|
||||
}
|
||||
this.statusContainer.clear();
|
||||
try {
|
||||
const result = await this.runtimeHost.newSession(options);
|
||||
if (!result.cancelled) {
|
||||
this.renderCurrentSessionState();
|
||||
this.ui.requestRender();
|
||||
}
|
||||
return result;
|
||||
return await this.runtimeHost.newSession(options);
|
||||
} catch (error: unknown) {
|
||||
return this.handleFatalRuntimeError("Failed to create session", error);
|
||||
}
|
||||
@@ -1547,7 +1552,6 @@ export class InteractiveMode {
|
||||
try {
|
||||
const result = await this.runtimeHost.fork(entryId, options);
|
||||
if (!result.cancelled) {
|
||||
this.renderCurrentSessionState();
|
||||
this.editor.setText(result.selectedText ?? "");
|
||||
this.showStatus("Forked to new session");
|
||||
}
|
||||
@@ -1621,12 +1625,18 @@ export class InteractiveMode {
|
||||
}
|
||||
}
|
||||
|
||||
private async rebindCurrentSession(): Promise<void> {
|
||||
private async rebindCurrentSession(options: { renderBeforeBind?: boolean } = {}): Promise<void> {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = undefined;
|
||||
this.applyRuntimeSettings();
|
||||
await this.bindCurrentSessionExtensions();
|
||||
this.subscribeToAgent();
|
||||
if (options.renderBeforeBind) {
|
||||
this.renderCurrentSessionState();
|
||||
this.subscribeToAgent();
|
||||
await this.bindCurrentSessionExtensions();
|
||||
} else {
|
||||
await this.bindCurrentSessionExtensions();
|
||||
this.subscribeToAgent();
|
||||
}
|
||||
await this.updateAvailableProviderCount();
|
||||
this.updateEditorBorderColor();
|
||||
this.updateTerminalTitle();
|
||||
@@ -2054,16 +2064,13 @@ export class InteractiveMode {
|
||||
getTheme: (name) => getThemeByName(name),
|
||||
setTheme: (themeOrName) => {
|
||||
if (themeOrName instanceof Theme) {
|
||||
setThemeInstance(themeOrName);
|
||||
this.ui.requestRender();
|
||||
return { success: true };
|
||||
return this.themeController.setThemeInstance(themeOrName);
|
||||
}
|
||||
const result = setTheme(themeOrName, true);
|
||||
const result = this.themeController.setThemeName(themeOrName);
|
||||
if (result.success) {
|
||||
if (this.settingsManager.getTheme() !== themeOrName) {
|
||||
this.settingsManager.setTheme(themeOrName);
|
||||
}
|
||||
this.ui.requestRender();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
@@ -3271,7 +3278,7 @@ export class InteractiveMode {
|
||||
}
|
||||
|
||||
private renderProjectTrustWarningIfNeeded(): void {
|
||||
if (this.settingsManager.isProjectTrusted() || !hasProjectTrustInputs(this.sessionManager.getCwd())) {
|
||||
if (this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(this.sessionManager.getCwd())) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3282,7 +3289,7 @@ export class InteractiveMode {
|
||||
new Text(
|
||||
theme.fg(
|
||||
"warning",
|
||||
"This project is not trusted. Project .pi resources and packages are ignored. Use /trust to save a trust decision, then restart pi.",
|
||||
`This project is not trusted. Project ${CONFIG_DIR_NAME} resources and packages are ignored. Use /trust to save a trust decision, then restart pi.`,
|
||||
),
|
||||
1,
|
||||
0,
|
||||
@@ -3339,7 +3346,9 @@ export class InteractiveMode {
|
||||
private async shutdown(options?: { fromSignal?: boolean }): Promise<void> {
|
||||
if (this.isShuttingDown) return;
|
||||
this.isShuttingDown = true;
|
||||
this.unregisterSignalHandlers();
|
||||
// Keep signal handlers registered until terminal cleanup has completed.
|
||||
// `signal-exit` checks the listener list during the same SIGTERM/SIGHUP
|
||||
// dispatch and re-sends the signal if only its own listeners remain.
|
||||
|
||||
if (options?.fromSignal) {
|
||||
// Signal-triggered shutdown (SIGTERM/SIGHUP). Emit extension cleanup
|
||||
@@ -3350,6 +3359,7 @@ export class InteractiveMode {
|
||||
// which the stdout/stderr error handler turns into emergencyTerminalExit;
|
||||
// the render loop is already idle, so this cannot hot-spin (see #4144).
|
||||
await this.runtimeHost.dispose();
|
||||
this.themeController.disableAutoSync();
|
||||
await this.ui.terminal.drainInput(1000);
|
||||
this.stop();
|
||||
process.exit(0);
|
||||
@@ -3360,6 +3370,7 @@ export class InteractiveMode {
|
||||
// the final frame while the process is exiting.
|
||||
// Drain any in-flight Kitty key release events before stopping.
|
||||
// This prevents escape sequences from leaking to the parent shell over slow SSH.
|
||||
this.themeController.disableAutoSync();
|
||||
await this.ui.terminal.drainInput(1000);
|
||||
|
||||
this.stop();
|
||||
@@ -3689,7 +3700,6 @@ export class InteractiveMode {
|
||||
showError(errorMessage: string): void {
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
@@ -3704,7 +3714,7 @@ export class InteractiveMode {
|
||||
const updateInstruction = theme.fg("muted", `New version ${release.version} is available. Run `) + action;
|
||||
const changelogUrl = "https://pi.dev/changelog";
|
||||
const changelogLink = getCapabilities().hyperlinks
|
||||
? hyperlink(theme.fg("accent", "open changelog"), changelogUrl)
|
||||
? hyperlink(theme.fg("accent", changelogUrl), changelogUrl)
|
||||
: theme.fg("accent", changelogUrl);
|
||||
const changelogLine = theme.fg("muted", "Changelog: ") + changelogLink;
|
||||
const note = release.note?.trim();
|
||||
@@ -3729,7 +3739,7 @@ export class InteractiveMode {
|
||||
}
|
||||
|
||||
showPackageUpdateNotification(packages: string[]): void {
|
||||
const action = theme.fg("accent", `${APP_NAME} update`);
|
||||
const action = theme.fg("accent", `${APP_NAME} update --extensions`);
|
||||
const updateInstruction = theme.fg("muted", "Package updates are available. Run ") + action;
|
||||
const packageLines = packages.map((pkg) => `- ${pkg}`).join("\n");
|
||||
|
||||
@@ -3963,7 +3973,8 @@ export class InteractiveMode {
|
||||
httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(),
|
||||
thinkingLevel: this.session.thinkingLevel,
|
||||
availableThinkingLevels: this.session.getAvailableThinkingLevels(),
|
||||
currentTheme: this.settingsManager.getTheme() || "dark",
|
||||
currentTheme: this.settingsManager.getThemeSetting() || "dark",
|
||||
terminalTheme: this.themeController.getTerminalTheme(),
|
||||
availableThemes: getAvailableThemes(),
|
||||
hideThinkingBlock: this.hideThinkingBlock,
|
||||
collapseChangelog: this.settingsManager.getCollapseChangelog(),
|
||||
@@ -4030,21 +4041,11 @@ 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.`);
|
||||
}
|
||||
},
|
||||
onThemePreview: (themeName) => {
|
||||
const result = setTheme(themeName, true);
|
||||
if (result.success) {
|
||||
this.ui.invalidate();
|
||||
this.ui.requestRender();
|
||||
}
|
||||
onThemeChange: (themeSetting) => {
|
||||
this.settingsManager.setTheme(themeSetting);
|
||||
void this.themeController.applyFromSettings();
|
||||
},
|
||||
onThemePreview: (themeName) => this.themeController.preview(themeName),
|
||||
onHideThinkingBlockChange: (hidden) => {
|
||||
this.hideThinkingBlock = hidden;
|
||||
this.settingsManager.setHideThinkingBlock(hidden);
|
||||
@@ -4198,7 +4199,7 @@ export class InteractiveMode {
|
||||
if (this.autoTrustOnReloadCwd !== cwd) {
|
||||
return false;
|
||||
}
|
||||
if (!this.settingsManager.isProjectTrusted() || !hasProjectConfigDir(cwd)) {
|
||||
if (!this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(cwd)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4375,7 +4376,6 @@ export class InteractiveMode {
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderCurrentSessionState();
|
||||
this.editor.setText(result.selectedText ?? "");
|
||||
done();
|
||||
this.showStatus("Forked to new session");
|
||||
@@ -4408,7 +4408,6 @@ export class InteractiveMode {
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderCurrentSessionState();
|
||||
this.editor.setText("");
|
||||
this.showStatus("Cloned to new session");
|
||||
} catch (error: unknown) {
|
||||
@@ -4600,7 +4599,6 @@ export class InteractiveMode {
|
||||
if (result.cancelled) {
|
||||
return result;
|
||||
}
|
||||
this.renderCurrentSessionState();
|
||||
this.showStatus("Resumed session");
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
@@ -4618,7 +4616,6 @@ export class InteractiveMode {
|
||||
if (result.cancelled) {
|
||||
return result;
|
||||
}
|
||||
this.renderCurrentSessionState();
|
||||
this.showStatus("Resumed session in current cwd");
|
||||
return result;
|
||||
}
|
||||
@@ -5071,8 +5068,20 @@ export class InteractiveMode {
|
||||
this.ui.requestRender();
|
||||
};
|
||||
|
||||
let chatRestoredBeforeSessionStart = false;
|
||||
let reloadBoxDismissed = false;
|
||||
const restoreChatBeforeSessionStart = () => {
|
||||
if (chatRestoredBeforeSessionStart) {
|
||||
return;
|
||||
}
|
||||
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
|
||||
this.rebuildChatFromMessages();
|
||||
chatRestoredBeforeSessionStart = true;
|
||||
};
|
||||
|
||||
try {
|
||||
await this.session.reload();
|
||||
await this.session.reload({ beforeSessionStart: restoreChatBeforeSessionStart });
|
||||
restoreChatBeforeSessionStart();
|
||||
configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs());
|
||||
this.keybindings.reload();
|
||||
const activeHeader = this.customHeader ?? this.builtInHeader;
|
||||
@@ -5080,12 +5089,7 @@ export class InteractiveMode {
|
||||
activeHeader.setExpanded(this.toolOutputExpanded);
|
||||
}
|
||||
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
|
||||
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
|
||||
const themeName = this.settingsManager.getTheme();
|
||||
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.`);
|
||||
}
|
||||
await this.themeController.applyFromSettings();
|
||||
const editorPaddingX = this.settingsManager.getEditorPaddingX();
|
||||
const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
|
||||
this.defaultEditor.setPaddingX(editorPaddingX);
|
||||
@@ -5099,8 +5103,6 @@ export class InteractiveMode {
|
||||
this.setupAutocompleteProvider();
|
||||
const runner = this.session.extensionRunner;
|
||||
this.setupExtensionShortcuts(runner);
|
||||
this.rebuildChatFromMessages();
|
||||
dismissReloadBox(this.editor as Component);
|
||||
this.showLoadedResources({
|
||||
force: false,
|
||||
showDiagnosticsWhenQuiet: true,
|
||||
@@ -5115,8 +5117,12 @@ export class InteractiveMode {
|
||||
? "Reloaded keybindings, extensions, skills, prompts, themes; saved project trust"
|
||||
: "Reloaded keybindings, extensions, skills, prompts, themes",
|
||||
);
|
||||
dismissReloadBox(this.editor as Component);
|
||||
reloadBoxDismissed = true;
|
||||
} catch (error) {
|
||||
dismissReloadBox(previousEditor as Component);
|
||||
if (!reloadBoxDismissed) {
|
||||
dismissReloadBox(previousEditor as Component);
|
||||
}
|
||||
this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
@@ -5190,7 +5196,6 @@ export class InteractiveMode {
|
||||
this.showStatus("Import cancelled");
|
||||
return;
|
||||
}
|
||||
this.renderCurrentSessionState();
|
||||
this.showStatus(`Session imported from: ${inputPath}`);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof MissingSessionCwdError) {
|
||||
@@ -5204,7 +5209,6 @@ export class InteractiveMode {
|
||||
this.showStatus("Import cancelled");
|
||||
return;
|
||||
}
|
||||
this.renderCurrentSessionState();
|
||||
this.showStatus(`Session imported from: ${inputPath}`);
|
||||
return;
|
||||
}
|
||||
@@ -5543,7 +5547,6 @@ export class InteractiveMode {
|
||||
if (result.cancelled) {
|
||||
return;
|
||||
}
|
||||
this.renderCurrentSessionState();
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
|
||||
this.ui.requestRender();
|
||||
@@ -5697,14 +5700,6 @@ export class InteractiveMode {
|
||||
}
|
||||
|
||||
private async handleCompactCommand(customInstructions?: string): Promise<void> {
|
||||
const entries = this.sessionManager.getEntries();
|
||||
const messageCount = entries.filter((e) => e.type === "message").length;
|
||||
|
||||
if (messageCount < 2) {
|
||||
this.showWarning("Nothing to compact (no messages yet)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.loadingAnimation) {
|
||||
this.loadingAnimation.stop();
|
||||
this.loadingAnimation = undefined;
|
||||
@@ -5719,7 +5714,6 @@ export class InteractiveMode {
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.unregisterSignalHandlers();
|
||||
if (this.settingsManager.getShowTerminalProgress()) {
|
||||
this.ui.terminal.setProgress(false);
|
||||
}
|
||||
@@ -5727,6 +5721,7 @@ export class InteractiveMode {
|
||||
this.loadingAnimation.stop();
|
||||
this.loadingAnimation = undefined;
|
||||
}
|
||||
this.themeController.disableAutoSync();
|
||||
this.clearExtensionTerminalInputListeners();
|
||||
this.footer.dispose();
|
||||
this.footerDataProvider.dispose();
|
||||
@@ -5737,5 +5732,6 @@ export class InteractiveMode {
|
||||
this.ui.stop();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
this.unregisterSignalHandlers();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface ModelSearchItem {
|
||||
id: string;
|
||||
provider: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function getModelSearchText(item: ModelSearchItem): string {
|
||||
const { id, provider } = item;
|
||||
const name = item.name ? ` ${item.name}` : "";
|
||||
return `${id} ${provider} ${provider}/${id} ${provider} ${id}${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The /model selector search should rank exact provider-prefixed queries before proxy-provider IDs
|
||||
* like openrouter/openai/gpt-5, so keep the bare model ID out of the leading position.
|
||||
*/
|
||||
export function getModelSelectorSearchText(item: ModelSearchItem): string {
|
||||
const { id, provider } = item;
|
||||
const name = item.name ? ` ${item.name}` : "";
|
||||
return `${provider} ${provider}/${id} ${provider} ${id}${name}`;
|
||||
}
|
||||
@@ -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": {
|
||||
"type": "string",
|
||||
"description": "Theme name"
|
||||
"pattern": "^[^/]+$",
|
||||
"description": "Theme name. Must not contain '/' because it is reserved for automatic light/dark theme settings."
|
||||
},
|
||||
"vars": {
|
||||
"type": "object",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type EditorTheme,
|
||||
getCapabilities,
|
||||
type MarkdownTheme,
|
||||
type RgbColor,
|
||||
type SelectListTheme,
|
||||
type SettingsListTheme,
|
||||
} from "@earendil-works/pi-tui";
|
||||
@@ -502,6 +503,14 @@ function getCustomThemeInfos(): ThemeInfo[] {
|
||||
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 {
|
||||
if (!validateThemeJson.Check(json)) {
|
||||
const errors = Array.from(validateThemeJson.Errors(json));
|
||||
@@ -538,7 +547,9 @@ function parseThemeJson(label: string, json: unknown): ThemeJson {
|
||||
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 {
|
||||
@@ -624,10 +635,34 @@ export function getThemeByName(name: string): Theme | undefined {
|
||||
|
||||
export type TerminalTheme = "dark" | "light";
|
||||
|
||||
export interface RgbColor {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
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 {
|
||||
@@ -641,6 +676,15 @@ export interface TerminalThemeDetectionOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export interface TerminalBackgroundThemeDetector {
|
||||
queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise<RgbColor | undefined>;
|
||||
}
|
||||
|
||||
export interface TerminalBackgroundThemeDetectionOptions extends TerminalThemeDetectionOptions {
|
||||
ui: TerminalBackgroundThemeDetector;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
function getColorFgBgBackgroundIndex(colorfgbg: string): number | undefined {
|
||||
const parts = colorfgbg.split(";");
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
@@ -668,50 +712,7 @@ export function getThemeForRgbColor(rgb: RgbColor): TerminalTheme {
|
||||
return getRgbColorLuminance(rgb) >= 0.5 ? "light" : "dark";
|
||||
}
|
||||
|
||||
function parseOscHexChannel(channel: string): number | undefined {
|
||||
if (!/^[0-9a-f]+$/i.test(channel)) {
|
||||
return undefined;
|
||||
}
|
||||
const max = 16 ** channel.length - 1;
|
||||
if (max <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.round((parseInt(channel, 16) / max) * 255);
|
||||
}
|
||||
|
||||
export function parseOsc11BackgroundColor(data: string): RgbColor | undefined {
|
||||
const match = data.match(/^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = match[1].trim();
|
||||
if (value.startsWith("#")) {
|
||||
const hex = value.slice(1);
|
||||
if (/^[0-9a-f]{6}$/i.test(hex)) {
|
||||
return hexToRgb(value);
|
||||
}
|
||||
if (/^[0-9a-f]{12}$/i.test(hex)) {
|
||||
const r = parseOscHexChannel(hex.slice(0, 4));
|
||||
const g = parseOscHexChannel(hex.slice(4, 8));
|
||||
const b = parseOscHexChannel(hex.slice(8, 12));
|
||||
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rgbValue = value.replace(/^rgba?:/i, "");
|
||||
const [red, green, blue] = rgbValue.split("/");
|
||||
if (red === undefined || green === undefined || blue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const r = parseOscHexChannel(red);
|
||||
const g = parseOscHexChannel(green);
|
||||
const b = parseOscHexChannel(blue);
|
||||
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
|
||||
}
|
||||
|
||||
export function detectTerminalBackground(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection {
|
||||
export function detectTerminalBackgroundFromEnv(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection {
|
||||
const env = options.env ?? process.env;
|
||||
const colorfgbg = env.COLORFGBG || "";
|
||||
const bg = getColorFgBgBackgroundIndex(colorfgbg);
|
||||
@@ -732,8 +733,30 @@ export function detectTerminalBackground(options: TerminalThemeDetectionOptions
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectTerminalBackgroundTheme({
|
||||
ui,
|
||||
timeoutMs,
|
||||
env,
|
||||
}: TerminalBackgroundThemeDetectionOptions): Promise<TerminalThemeDetection> {
|
||||
try {
|
||||
const rgb = await ui.queryTerminalBackgroundColor({ timeoutMs });
|
||||
if (rgb) {
|
||||
return {
|
||||
theme: getThemeForRgbColor(rgb),
|
||||
source: "terminal background",
|
||||
detail: `OSC 11 background rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
|
||||
confidence: "high",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall back to environment-based detection when the terminal query fails.
|
||||
}
|
||||
|
||||
return detectTerminalBackgroundFromEnv({ env });
|
||||
}
|
||||
|
||||
export function getDefaultTheme(): string {
|
||||
return detectTerminalBackground().theme;
|
||||
return detectTerminalBackgroundFromEnv().theme;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -769,6 +792,7 @@ export function setRegisteredThemes(themes: Theme[]): void {
|
||||
registeredThemes.clear();
|
||||
for (const theme of themes) {
|
||||
if (theme.name) {
|
||||
assertThemeNameIsValid(theme.name);
|
||||
registeredThemes.set(theme.name, theme);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user