# Conflicts:
#	packages/coding-agent/CHANGELOG.md
This commit is contained in:
Mario Zechner
2026-04-17 00:28:14 +02:00
80 changed files with 2747 additions and 883 deletions

View File

@@ -47,7 +47,7 @@ import {
VERSION,
} from "../../config.js";
import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js";
import type { AgentSessionRuntime } from "../../core/agent-session-runtime.js";
import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.js";
import type {
ExtensionContext,
ExtensionRunner,
@@ -58,7 +58,7 @@ import type {
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.js";
import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.js";
import { createCompactionSummaryMessage } from "../../core/messages.js";
import { findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.js";
import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.js";
import { DefaultPackageManager } from "../../core/package-manager.js";
import type { ResourceDiagnostic } from "../../core/resource-loader.js";
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.js";
@@ -126,6 +126,22 @@ function isExpandable(obj: unknown): obj is Expandable {
return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
}
class ExpandableText extends Text implements Expandable {
constructor(
private readonly getCollapsedText: () => string,
private readonly getExpandedText: () => string,
expanded = false,
paddingX = 0,
paddingY = 0,
) {
super(expanded ? getExpandedText() : getCollapsedText(), paddingX, paddingY);
}
setExpanded(expanded: boolean): void {
this.setText(expanded ? this.getExpandedText() : this.getCollapsedText());
}
}
type CompactionQueuedMessage = {
text: string;
mode: "steer" | "followUp";
@@ -143,6 +159,14 @@ function isTruthyEnvFlag(value: string | undefined): boolean {
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
}
function isUnknownModel(model: Model<any> | undefined): boolean {
return !!model && model.provider === "unknown" && model.id === "unknown" && model.api === "unknown";
}
function hasDefaultModelProvider(providerId: string): providerId is keyof typeof defaultModelPerProvider {
return providerId in defaultModelPerProvider;
}
/**
* Options for InteractiveMode initialization.
*/
@@ -408,6 +432,7 @@ export class InteractiveMode {
const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({
name: cmd.name,
description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),
...(cmd.argumentHint && { argumentHint: cmd.argumentHint }),
}));
// Convert extension commands to SlashCommand format
@@ -499,7 +524,7 @@ export class InteractiveMode {
// Build startup instructions using keybinding hint helpers
const hint = (keybinding: AppKeybinding, description: string) => keyHint(keybinding, description);
const instructions = [
const expandedInstructions = [
hint("app.interrupt", "to interrupt"),
hint("app.clear", "to clear"),
rawKeyHint(`${keyText("app.clear")} twice`, "to exit"),
@@ -520,11 +545,28 @@ export class InteractiveMode {
hint("app.clipboard.pasteImage", "to paste image"),
rawKeyHint("drop files", "to attach"),
].join("\n");
const compactInstructions = [
hint("app.interrupt", "interrupt"),
rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "clear/exit"),
rawKeyHint("/", "commands"),
rawKeyHint("!", "bash"),
hint("app.tools.expand", "more"),
].join(theme.fg("muted", " · "));
const compactOnboarding = theme.fg(
"dim",
`Press ${keyText("app.tools.expand")} to show full startup help and loaded resources.`,
);
const onboarding = theme.fg(
"dim",
`Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.`,
);
this.builtInHeader = new Text(`${logo}\n${instructions}\n\n${onboarding}`, 1, 0);
this.builtInHeader = new ExpandableText(
() => `${logo}\n${compactInstructions}\n${compactOnboarding}\n\n${onboarding}`,
() => `${logo}\n${expandedInstructions}\n\n${onboarding}`,
this.getStartupExpansionState(),
1,
0,
);
// Setup UI layout
this.headerContainer.addChild(new Spacer(1));
@@ -836,6 +878,27 @@ export class InteractiveMode {
return result;
}
private formatContextPath(p: string): string {
const cwd = path.resolve(this.sessionManager.getCwd());
const absolutePath = path.isAbsolute(p) ? path.resolve(p) : path.resolve(cwd, p);
const relativePath = path.relative(cwd, absolutePath);
const isInsideCwd =
relativePath === "" ||
(!relativePath.startsWith("..") &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath));
if (isInsideCwd) {
return relativePath || ".";
}
return this.formatDisplayPath(absolutePath);
}
private getStartupExpansionState(): boolean {
return this.options.verbose || this.toolOutputExpanded;
}
/**
* Get a short path relative to the package root for display.
*/
@@ -1057,6 +1120,38 @@ export class InteractiveMode {
}
const sectionHeader = (name: string, color: ThemeColor = "mdHeading") => theme.fg(color, `[${name}]`);
const compactPath = (resourcePath: string, sourceInfo?: SourceInfo): string => {
const shortPath = this.getShortPath(resourcePath, sourceInfo);
const normalizedPath = shortPath.replace(/\\/g, "/");
const segments = normalizedPath.split("/").filter((segment) => segment.length > 0 && segment !== "~");
if (segments.length > 0) {
return segments[segments.length - 1]!;
}
return shortPath;
};
const formatCompactList = (items: string[], options?: { sort?: boolean }): string => {
const labels = items.map((item) => item.trim()).filter((item) => item.length > 0);
if (options?.sort !== false) {
labels.sort((a, b) => a.localeCompare(b));
}
return theme.fg("dim", ` ${labels.join(", ")}`);
};
const addLoadedSection = (
name: string,
collapsedBody: string,
expandedBody = collapsedBody,
color: ThemeColor = "mdHeading",
): void => {
const section = new ExpandableText(
() => `${sectionHeader(name, color)}\n${collapsedBody}`,
() => `${sectionHeader(name, color)}\n${expandedBody}`,
this.getStartupExpansionState(),
0,
0,
);
this.chatContainer.addChild(section);
this.chatContainer.addChild(new Spacer(1));
};
const skillsResult = this.session.resourceLoader.getSkills();
const promptsResult = this.session.resourceLoader.getPrompts();
@@ -1096,8 +1191,11 @@ export class InteractiveMode {
const contextList = contextFiles
.map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`))
.join("\n");
this.chatContainer.addChild(new Text(`${sectionHeader("Context")}\n${contextList}`, 0, 0));
this.chatContainer.addChild(new Spacer(1));
const contextCompactList = formatCompactList(
contextFiles.map((contextFile) => this.formatContextPath(contextFile.path)),
{ sort: false },
);
addLoadedSection("Context", contextCompactList, contextList);
}
const skills = skillsResult.skills;
@@ -1109,8 +1207,8 @@ export class InteractiveMode {
formatPath: (item) => this.formatDisplayPath(item.path),
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
});
this.chatContainer.addChild(new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0));
this.chatContainer.addChild(new Spacer(1));
const skillCompactList = formatCompactList(skills.map((skill) => skill.name));
addLoadedSection("Skills", skillCompactList, skillList);
}
const templates = this.session.promptTemplates;
@@ -1129,8 +1227,8 @@ export class InteractiveMode {
return template ? `/${template.name}` : this.formatDisplayPath(item.path);
},
});
this.chatContainer.addChild(new Text(`${sectionHeader("Prompts")}\n${templateList}`, 0, 0));
this.chatContainer.addChild(new Spacer(1));
const promptCompactList = formatCompactList(templates.map((template) => `/${template.name}`));
addLoadedSection("Prompts", promptCompactList, templateList);
}
if (extensions.length > 0) {
@@ -1139,8 +1237,10 @@ export class InteractiveMode {
formatPath: (item) => this.formatDisplayPath(item.path),
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
});
this.chatContainer.addChild(new Text(`${sectionHeader("Extensions", "mdHeading")}\n${extList}`, 0, 0));
this.chatContainer.addChild(new Spacer(1));
const extensionCompactList = formatCompactList(
extensions.map((extension) => compactPath(extension.path, extension.sourceInfo)),
);
addLoadedSection("Extensions", extensionCompactList, extList, "mdHeading");
}
// Show loaded themes (excluding built-in)
@@ -1157,8 +1257,12 @@ export class InteractiveMode {
formatPath: (item) => this.formatDisplayPath(item.path),
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
});
this.chatContainer.addChild(new Text(`${sectionHeader("Themes")}\n${themeList}`, 0, 0));
this.chatContainer.addChild(new Spacer(1));
const themeCompactList = formatCompactList(
customThemes.map(
(loadedTheme) => loadedTheme.name ?? compactPath(loadedTheme.sourcePath!, loadedTheme.sourceInfo),
),
);
addLoadedSection("Themes", themeCompactList, themeList);
}
}
@@ -1607,6 +1711,9 @@ export class InteractiveMode {
if (factory) {
// Create and add custom header
this.customHeader = factory(this.ui, theme);
if (isExpandable(this.customHeader)) {
this.customHeader.setExpanded(this.toolOutputExpanded);
}
if (index !== -1) {
this.headerContainer.children[index] = this.customHeader;
} else {
@@ -1616,6 +1723,9 @@ export class InteractiveMode {
} else {
// Restore built-in header
this.customHeader = undefined;
if (isExpandable(this.builtInHeader)) {
this.builtInHeader.setExpanded(this.toolOutputExpanded);
}
if (index !== -1) {
this.headerContainer.children[index] = this.builtInHeader;
}
@@ -2168,12 +2278,12 @@ export class InteractiveMode {
await this.handleModelCommand(searchTerm);
return;
}
if (text.startsWith("/export")) {
if (text === "/export" || text.startsWith("/export ")) {
await this.handleExportCommand(text);
this.editor.setText("");
return;
}
if (text.startsWith("/import")) {
if (text === "/import" || text.startsWith("/import ")) {
await this.handleImportCommand(text);
this.editor.setText("");
return;
@@ -3080,6 +3190,10 @@ export class InteractiveMode {
private setToolsExpanded(expanded: boolean): void {
this.toolOutputExpanded = expanded;
const activeHeader = this.customHeader ?? this.builtInHeader;
if (isExpandable(activeHeader)) {
activeHeader.setExpanded(expanded);
}
for (const child of this.chatContainer.children) {
if (isExpandable(child)) {
child.setExpanded(expanded);
@@ -4023,6 +4137,7 @@ export class InteractiveMode {
private async showLoginDialog(providerId: string): Promise<void> {
const providerInfo = this.session.modelRegistry.authStorage.getOAuthProviders().find((p) => p.id === providerId);
const providerName = providerInfo?.name || providerId;
const previousModel = this.session.model;
// Providers that use callback servers (can paste redirect URL)
const usesCallbackServer = providerInfo?.usesCallbackServer ?? false;
@@ -4098,8 +4213,50 @@ export class InteractiveMode {
// Success
restoreEditor();
this.session.modelRegistry.refresh();
let selectedModel: Model<any> | undefined;
let selectionError: string | undefined;
if (isUnknownModel(previousModel)) {
const availableModels = this.session.modelRegistry.getAvailable();
const providerModels = availableModels.filter((model) => model.provider === providerId);
if (!hasDefaultModelProvider(providerId)) {
selectionError = `Logged in to ${providerName}, but no default model is configured for provider "${providerId}". Use /model to select a model.`;
} else if (providerModels.length === 0) {
selectionError = `Logged in to ${providerName}, but no models are available for that provider. Use /model to select a model.`;
} else {
const defaultModelId = defaultModelPerProvider[providerId];
selectedModel = providerModels.find((model) => model.id === defaultModelId);
if (!selectedModel) {
selectionError = `Logged in to ${providerName}, but its default model "${defaultModelId}" is not available. Use /model to select a model.`;
} else {
try {
await this.session.setModel(selectedModel);
} catch (error: unknown) {
selectedModel = undefined;
const errorMessage = error instanceof Error ? error.message : String(error);
selectionError = `Logged in to ${providerName}, but selecting its default model failed: ${errorMessage}. Use /model to select a model.`;
}
}
}
}
await this.updateAvailableProviderCount();
this.showStatus(`Logged in to ${providerName}. Credentials saved to ${getAuthPath()}`);
this.footer.invalidate();
this.updateEditorBorderColor();
if (selectedModel) {
this.showStatus(
`Logged in to ${providerName}. Selected ${selectedModel.id}. Credentials saved to ${getAuthPath()}`,
);
void this.maybeWarnAboutAnthropicSubscriptionAuth(selectedModel);
this.checkDaxnutsEasterEgg(selectedModel);
} else {
this.showStatus(`Logged in to ${providerName}. Credentials saved to ${getAuthPath()}`);
if (selectionError) {
this.showError(selectionError);
} else {
void this.maybeWarnAboutAnthropicSubscriptionAuth();
}
}
} catch (error: unknown) {
restoreEditor();
const errorMsg = error instanceof Error ? error.message : String(error);
@@ -4152,6 +4309,10 @@ export class InteractiveMode {
try {
await this.session.reload();
this.keybindings.reload();
const activeHeader = this.customHeader ?? this.builtInHeader;
if (isExpandable(activeHeader)) {
activeHeader.setExpanded(this.toolOutputExpanded);
}
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
const themeName = this.settingsManager.getTheme();
@@ -4192,8 +4353,7 @@ export class InteractiveMode {
}
private async handleExportCommand(text: string): Promise<void> {
const parts = text.split(/\s+/);
const outputPath = parts.length > 1 ? parts[1] : undefined;
const outputPath = this.getPathCommandArgument(text, "/export");
try {
if (outputPath?.endsWith(".jsonl")) {
@@ -4208,13 +4368,41 @@ export class InteractiveMode {
}
}
private getPathCommandArgument(text: string, command: "/export" | "/import"): string | undefined {
if (text === command) {
return undefined;
}
if (!text.startsWith(`${command} `)) {
return undefined;
}
const argsString = text.slice(command.length + 1).trimStart();
if (!argsString) {
return undefined;
}
const firstChar = argsString[0];
if (firstChar === '"' || firstChar === "'") {
const closingQuoteIndex = argsString.indexOf(firstChar, 1);
if (closingQuoteIndex < 0) {
return undefined;
}
return argsString.slice(1, closingQuoteIndex);
}
const firstWhitespaceIndex = argsString.search(/\s/);
if (firstWhitespaceIndex < 0) {
return argsString;
}
return argsString.slice(0, firstWhitespaceIndex);
}
private async handleImportCommand(text: string): Promise<void> {
const parts = text.split(/\s+/);
if (parts.length < 2 || !parts[1]) {
const inputPath = this.getPathCommandArgument(text, "/import");
if (!inputPath) {
this.showError("Usage: /import <path.jsonl>");
return;
}
const inputPath = parts[1];
const confirmed = await this.showExtensionConfirm("Import session", `Replace current session with ${inputPath}?`);
if (!confirmed) {
@@ -4253,6 +4441,10 @@ export class InteractiveMode {
this.showStatus(`Session imported from: ${inputPath}`);
return;
}
if (error instanceof SessionImportFileNotFoundError) {
this.showError(`Failed to import session: ${error.message}`);
return;
}
await this.handleFatalRuntimeError("Failed to import session", error);
}
}