Merge main into model-registry

This commit is contained in:
Mario Zechner
2026-06-22 14:00:18 +02:00
220 changed files with 10488 additions and 4354 deletions
@@ -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();
}
}