@mariozechner/pi-coding-agent
Version:
Coding agent CLI with read, bash, edit, write tools and session management
1,047 lines • 197 kB
JavaScript
/**
* Interactive mode for the coding agent.
* Handles TUI rendering and user interaction, delegating business logic to AgentSession.
*/
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { CombinedAutocompleteProvider, Container, fuzzyFilter, Loader, Markdown, matchesKey, ProcessTerminal, Spacer, setKeybindings, Text, TruncatedText, TUI, visibleWidth, } from "@mariozechner/pi-tui";
import { spawn, spawnSync } from "child_process";
import { APP_NAME, getAgentDir, getAuthPath, getDebugLogPath, getShareViewerUrl, getUpdateInstruction, VERSION, } from "../../config.js";
import { parseSkillBlock } from "../../core/agent-session.js";
import { SessionImportFileNotFoundError } from "../../core/agent-session-runtime.js";
import { FooterDataProvider } from "../../core/footer-data-provider.js";
import { KeybindingsManager } from "../../core/keybindings.js";
import { createCompactionSummaryMessage } from "../../core/messages.js";
import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.js";
import { DefaultPackageManager } from "../../core/package-manager.js";
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.js";
import { SessionManager } from "../../core/session-manager.js";
import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js";
import { isInstallTelemetryEnabled } from "../../core/telemetry.js";
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
import { copyToClipboard } from "../../utils/clipboard.js";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
import { parseGitUrl } from "../../utils/git.js";
import { killTrackedDetachedChildren } from "../../utils/shell.js";
import { ensureTool } from "../../utils/tools-manager.js";
import { ArminComponent } from "./components/armin.js";
import { AssistantMessageComponent } from "./components/assistant-message.js";
import { BashExecutionComponent } from "./components/bash-execution.js";
import { BorderedLoader } from "./components/bordered-loader.js";
import { BranchSummaryMessageComponent } from "./components/branch-summary-message.js";
import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.js";
import { CountdownTimer } from "./components/countdown-timer.js";
import { CustomEditor } from "./components/custom-editor.js";
import { CustomMessageComponent } from "./components/custom-message.js";
import { DaxnutsComponent } from "./components/daxnuts.js";
import { DynamicBorder } from "./components/dynamic-border.js";
import { EarendilAnnouncementComponent } from "./components/earendil-announcement.js";
import { ExtensionEditorComponent } from "./components/extension-editor.js";
import { ExtensionInputComponent } from "./components/extension-input.js";
import { ExtensionSelectorComponent } from "./components/extension-selector.js";
import { FooterComponent } from "./components/footer.js";
import { keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.js";
import { LoginDialogComponent } from "./components/login-dialog.js";
import { ModelSelectorComponent } from "./components/model-selector.js";
import { OAuthSelectorComponent } from "./components/oauth-selector.js";
import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.js";
import { SessionSelectorComponent } from "./components/session-selector.js";
import { SettingsSelectorComponent } from "./components/settings-selector.js";
import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.js";
import { ToolExecutionComponent } from "./components/tool-execution.js";
import { TreeSelectorComponent } from "./components/tree-selector.js";
import { UserMessageComponent } from "./components/user-message.js";
import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
import { getAvailableThemes, getAvailableThemesWithPaths, getEditorTheme, getMarkdownTheme, getThemeByName, initTheme, onThemeChange, setRegisteredThemes, setTheme, setThemeInstance, stopThemeWatcher, Theme, theme, } from "./theme/theme.js";
function isExpandable(obj) {
return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
}
class ExpandableText extends Text {
getCollapsedText;
getExpandedText;
constructor(getCollapsedText, getExpandedText, expanded = false, paddingX = 0, paddingY = 0) {
super(expanded ? getExpandedText() : getCollapsedText(), paddingX, paddingY);
this.getCollapsedText = getCollapsedText;
this.getExpandedText = getExpandedText;
}
setExpanded(expanded) {
this.setText(expanded ? this.getExpandedText() : this.getCollapsedText());
}
}
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party usage now draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage.";
function isAnthropicSubscriptionAuthKey(apiKey) {
return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");
}
function isUnknownModel(model) {
return !!model && model.provider === "unknown" && model.id === "unknown" && model.api === "unknown";
}
function hasDefaultModelProvider(providerId) {
return providerId in defaultModelPerProvider;
}
export class InteractiveMode {
options;
runtimeHost;
ui;
chatContainer;
pendingMessagesContainer;
statusContainer;
defaultEditor;
editor;
autocompleteProvider;
fdPath;
editorContainer;
footer;
footerDataProvider;
// Stored so the same manager can be injected into custom editors, selectors, and extension UI.
keybindings;
version;
isInitialized = false;
onInputCallback;
loadingAnimation = undefined;
pendingWorkingMessage = undefined;
workingIndicatorOptions = undefined;
defaultWorkingMessage = "Working...";
defaultHiddenThinkingLabel = "Thinking...";
hiddenThinkingLabel = this.defaultHiddenThinkingLabel;
lastSigintTime = 0;
lastEscapeTime = 0;
changelogMarkdown = undefined;
startupNoticesShown = false;
anthropicSubscriptionWarningShown = false;
// Status line tracking (for mutating immediately-sequential status updates)
lastStatusSpacer = undefined;
lastStatusText = undefined;
// Streaming message tracking
streamingComponent = undefined;
streamingMessage = undefined;
// Tool execution tracking: toolCallId -> component
pendingTools = new Map();
// Track first user message to avoid leading spacer at top of chat
isFirstUserMessage = true;
// Tool output expansion state
toolOutputExpanded = false;
// Thinking block visibility state
hideThinkingBlock = false;
// Skill commands: command name -> skill file path
skillCommands = new Map();
// Agent subscription unsubscribe function
unsubscribe;
signalCleanupHandlers = [];
// Track if editor is in bash mode (text starts with !)
isBashMode = false;
// Track current bash execution component
bashComponent = undefined;
// Track pending bash components (shown in pending area, moved to chat on submit)
pendingBashComponents = [];
// Auto-compaction state
autoCompactionLoader = undefined;
autoCompactionEscapeHandler;
// Auto-retry state
retryLoader = undefined;
retryCountdown = undefined;
retryEscapeHandler;
// Messages queued while compaction is running
compactionQueuedMessages = [];
// Shutdown state
shutdownRequested = false;
// Extension UI state
extensionSelector = undefined;
extensionInput = undefined;
extensionEditor = undefined;
extensionTerminalInputUnsubscribers = new Set();
// Extension widgets (components rendered above/below the editor)
extensionWidgetsAbove = new Map();
extensionWidgetsBelow = new Map();
widgetContainerAbove;
widgetContainerBelow;
// Custom footer from extension (undefined = use built-in footer)
customFooter = undefined;
// Header container that holds the built-in or custom header
headerContainer;
// Built-in header (logo + keybinding hints + changelog)
builtInHeader = undefined;
// Custom header from extension (undefined = use built-in header)
customHeader = undefined;
// Convenience accessors
get session() {
return this.runtimeHost.session;
}
get agent() {
return this.session.agent;
}
get sessionManager() {
return this.session.sessionManager;
}
get settingsManager() {
return this.session.settingsManager;
}
constructor(runtimeHost, options = {}) {
this.options = options;
this.runtimeHost = runtimeHost;
this.version = VERSION;
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
this.headerContainer = new Container();
this.chatContainer = new Container();
this.pendingMessagesContainer = new Container();
this.statusContainer = new Container();
this.widgetContainerAbove = new Container();
this.widgetContainerBelow = new Container();
this.keybindings = KeybindingsManager.create();
setKeybindings(this.keybindings);
const editorPaddingX = this.settingsManager.getEditorPaddingX();
const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
this.defaultEditor = new CustomEditor(this.ui, getEditorTheme(), this.keybindings, {
paddingX: editorPaddingX,
autocompleteMaxVisible,
});
this.editor = this.defaultEditor;
this.editorContainer = new Container();
this.editorContainer.addChild(this.editor);
this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());
this.footer = new FooterComponent(this.session, this.footerDataProvider);
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
// Load hide thinking block setting
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
// Register themes from resource loader and initialize
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
initTheme(this.settingsManager.getTheme(), true);
}
getAutocompleteSourceTag(sourceInfo) {
if (!sourceInfo) {
return undefined;
}
const scopePrefix = sourceInfo.scope === "user" ? "u" : sourceInfo.scope === "project" ? "p" : "t";
const source = sourceInfo.source.trim();
if (source === "auto" || source === "local" || source === "cli") {
return scopePrefix;
}
if (source.startsWith("npm:")) {
return `${scopePrefix}:${source}`;
}
const gitSource = parseGitUrl(source);
if (gitSource) {
const ref = gitSource.ref ? `@${gitSource.ref}` : "";
return `${scopePrefix}:git:${gitSource.host}/${gitSource.path}${ref}`;
}
return scopePrefix;
}
prefixAutocompleteDescription(description, sourceInfo) {
const sourceTag = this.getAutocompleteSourceTag(sourceInfo);
if (!sourceTag) {
return description;
}
return description ? `[${sourceTag}] ${description}` : `[${sourceTag}]`;
}
getBuiltInCommandConflictDiagnostics(extensionRunner) {
const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
return extensionRunner
.getRegisteredCommands()
.filter((command) => builtinNames.has(command.name))
.map((command) => ({
type: "warning",
message: command.invocationName === command.name
? `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.`
: `Extension command '/${command.name}' conflicts with built-in interactive command. Available as '/${command.invocationName}'.`,
path: command.sourceInfo.path,
}));
}
setupAutocomplete(fdPath) {
// Define commands for autocomplete
const slashCommands = BUILTIN_SLASH_COMMANDS.map((command) => ({
name: command.name,
description: command.description,
}));
const modelCommand = slashCommands.find((command) => command.name === "model");
if (modelCommand) {
modelCommand.getArgumentCompletions = (prefix) => {
// Get available models (scoped or from registry)
const models = this.session.scopedModels.length > 0
? this.session.scopedModels.map((s) => s.model)
: this.session.modelRegistry.getAvailable();
if (models.length === 0)
return null;
// Create items with provider/id format
const items = models.map((m) => ({
id: m.id,
provider: m.provider,
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}`);
if (filtered.length === 0)
return null;
return filtered.map((item) => ({
value: item.label,
label: item.id,
description: item.provider,
}));
};
}
// Convert prompt templates to SlashCommand format for autocomplete
const templateCommands = 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
const builtinCommandNames = new Set(slashCommands.map((c) => c.name));
const extensionCommands = this.session.extensionRunner
.getRegisteredCommands()
.filter((cmd) => !builtinCommandNames.has(cmd.name))
.map((cmd) => ({
name: cmd.invocationName,
description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),
getArgumentCompletions: cmd.getArgumentCompletions,
}));
// Build skill commands from session.skills (if enabled)
this.skillCommands.clear();
const skillCommandList = [];
if (this.settingsManager.getEnableSkillCommands()) {
for (const skill of this.session.resourceLoader.getSkills().skills) {
const commandName = `skill:${skill.name}`;
this.skillCommands.set(commandName, skill.filePath);
skillCommandList.push({
name: commandName,
description: this.prefixAutocompleteDescription(skill.description, skill.sourceInfo),
});
}
}
// Setup autocomplete
this.autocompleteProvider = new CombinedAutocompleteProvider([...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList], this.sessionManager.getCwd(), fdPath);
this.defaultEditor.setAutocompleteProvider(this.autocompleteProvider);
if (this.editor !== this.defaultEditor) {
this.editor.setAutocompleteProvider?.(this.autocompleteProvider);
}
}
showStartupNoticesIfNeeded() {
if (this.startupNoticesShown) {
return;
}
this.startupNoticesShown = true;
if (!this.changelogMarkdown) {
return;
}
if (this.chatContainer.children.length > 0) {
this.chatContainer.addChild(new Spacer(1));
}
this.chatContainer.addChild(new DynamicBorder());
if (this.settingsManager.getCollapseChangelog()) {
const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
const latestVersion = versionMatch ? versionMatch[1] : this.version;
const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
this.chatContainer.addChild(new Text(condensedText, 1, 0));
}
else {
this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, this.getMarkdownThemeWithSettings()));
this.chatContainer.addChild(new Spacer(1));
}
this.chatContainer.addChild(new DynamicBorder());
}
async init() {
if (this.isInitialized)
return;
this.registerSignalHandlers();
// Load changelog (only show new entries, skip for resumed sessions)
this.changelogMarkdown = this.getChangelogForDisplay();
// Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir)
// Both are needed: fd for autocomplete, rg for grep tool and bash commands
const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]);
this.fdPath = fdPath;
// Add header container as first child
this.ui.addChild(this.headerContainer);
// 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}`);
// Build startup instructions using keybinding hint helpers
const hint = (keybinding, description) => keyHint(keybinding, description);
const expandedInstructions = [
hint("app.interrupt", "to interrupt"),
hint("app.clear", "to clear"),
rawKeyHint(`${keyText("app.clear")} twice`, "to exit"),
hint("app.exit", "to exit (empty)"),
hint("app.suspend", "to suspend"),
keyHint("tui.editor.deleteToLineEnd", "to delete to end"),
hint("app.thinking.cycle", "to cycle thinking level"),
rawKeyHint(`${keyText("app.model.cycleForward")}/${keyText("app.model.cycleBackward")}`, "to cycle models"),
hint("app.model.select", "to select model"),
hint("app.tools.expand", "to expand tools"),
hint("app.thinking.toggle", "to expand thinking"),
hint("app.editor.external", "for external editor"),
rawKeyHint("/", "for commands"),
rawKeyHint("!", "to run bash"),
rawKeyHint("!!", "to run bash (no context)"),
hint("app.message.followUp", "to queue follow-up"),
hint("app.message.dequeue", "to edit all queued messages"),
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 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));
this.headerContainer.addChild(this.builtInHeader);
this.headerContainer.addChild(new Spacer(1));
}
else {
// Minimal header when silenced
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;
// Initialize extensions first so resources are shown before messages
await this.bindCurrentSessionExtensions();
// Render initial messages AFTER showing loaded resources
this.renderInitialMessages();
// Set terminal title
this.updateTerminalTitle();
// Subscribe to agent events
this.subscribeToAgent();
// Set up theme file watcher
onThemeChange(() => {
this.ui.invalidate();
this.updateEditorBorderColor();
this.ui.requestRender();
});
// Set up git branch watcher (uses provider instead of footer)
this.footerDataProvider.onBranchChange(() => {
this.ui.requestRender();
});
// Initialize available provider count for footer display
await this.updateAvailableProviderCount();
}
/**
* Update terminal title with session name and cwd.
*/
updateTerminalTitle() {
const cwdBasename = path.basename(this.sessionManager.getCwd());
const sessionName = this.sessionManager.getSessionName();
if (sessionName) {
this.ui.terminal.setTitle(`π - ${sessionName} - ${cwdBasename}`);
}
else {
this.ui.terminal.setTitle(`π - ${cwdBasename}`);
}
}
/**
* Run the interactive mode. This is the main entry point.
* Initializes the UI, shows warnings, processes initial messages, and starts the interactive loop.
*/
async run() {
await this.init();
// Start version check asynchronously
this.checkForNewVersion().then((newVersion) => {
if (newVersion) {
this.showNewVersionNotification(newVersion);
}
});
// Start package update check asynchronously
this.checkForPackageUpdates().then((updates) => {
if (updates.length > 0) {
this.showPackageUpdateNotification(updates);
}
});
// Check tmux keyboard setup asynchronously
this.checkTmuxKeyboardSetup().then((warning) => {
if (warning) {
this.showWarning(warning);
}
});
// Show startup warnings
const { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages } = this.options;
if (migratedProviders && migratedProviders.length > 0) {
this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`);
}
const modelsJsonError = this.session.modelRegistry.getError();
if (modelsJsonError) {
this.showError(`models.json error: ${modelsJsonError}`);
}
if (modelFallbackMessage) {
this.showWarning(modelFallbackMessage);
}
void this.maybeWarnAboutAnthropicSubscriptionAuth();
// Process initial messages
if (initialMessage) {
try {
await this.session.prompt(initialMessage, { images: initialImages });
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
this.showError(errorMessage);
}
}
if (initialMessages) {
for (const message of initialMessages) {
try {
await this.session.prompt(message);
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
this.showError(errorMessage);
}
}
}
// Main interactive loop
while (true) {
const userInput = await this.getUserInput();
try {
await this.session.prompt(userInput);
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
this.showError(errorMessage);
}
}
}
/**
* Check npm registry for a newer version.
*/
async checkForNewVersion() {
if (process.env.PI_SKIP_VERSION_CHECK || process.env.PI_OFFLINE)
return undefined;
try {
const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest", {
signal: AbortSignal.timeout(10000),
});
if (!response.ok)
return undefined;
const data = (await response.json());
const latestVersion = data.version;
if (latestVersion && latestVersion !== this.version) {
return latestVersion;
}
return undefined;
}
catch {
return undefined;
}
}
async checkForPackageUpdates() {
if (process.env.PI_OFFLINE) {
return [];
}
try {
const packageManager = new DefaultPackageManager({
cwd: this.sessionManager.getCwd(),
agentDir: getAgentDir(),
settingsManager: this.settingsManager,
});
const updates = await packageManager.checkForAvailableUpdates();
return updates.map((update) => update.displayName);
}
catch {
return [];
}
}
async checkTmuxKeyboardSetup() {
if (!process.env.TMUX)
return undefined;
const runTmuxShow = (option) => {
return new Promise((resolve) => {
const proc = spawn("tmux", ["show", "-gv", option], {
stdio: ["ignore", "pipe", "ignore"],
});
let stdout = "";
const timer = setTimeout(() => {
proc.kill();
resolve(undefined);
}, 2000);
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});
proc.on("error", () => {
clearTimeout(timer);
resolve(undefined);
});
proc.on("close", (code) => {
clearTimeout(timer);
resolve(code === 0 ? stdout.trim() : undefined);
});
});
};
const [extendedKeys, extendedKeysFormat] = await Promise.all([
runTmuxShow("extended-keys"),
runTmuxShow("extended-keys-format"),
]);
// If we couldn't query tmux (timeout, sandbox, etc.), don't warn
if (extendedKeys === undefined)
return undefined;
if (extendedKeys !== "on" && extendedKeys !== "always") {
return "tmux extended-keys is off. Modified Enter keys may not work. Add `set -g extended-keys on` to ~/.tmux.conf and restart tmux.";
}
if (extendedKeysFormat === "xterm") {
return "tmux extended-keys-format is xterm. Pi works best with csi-u. Add `set -g extended-keys-format csi-u` to ~/.tmux.conf and restart tmux.";
}
return undefined;
}
/**
* Get changelog entries to display on startup.
* Only shows new entries since last seen version, skips for resumed sessions.
*/
getChangelogForDisplay() {
// Skip changelog for resumed/continued sessions (already have messages)
if (this.session.state.messages.length > 0) {
return undefined;
}
const lastVersion = this.settingsManager.getLastChangelogVersion();
const changelogPath = getChangelogPath();
const entries = parseChangelog(changelogPath);
if (!lastVersion) {
// Fresh install - record the version, send telemetry, don't show changelog
this.settingsManager.setLastChangelogVersion(VERSION);
this.reportInstallTelemetry(VERSION);
return undefined;
}
const newEntries = getNewEntries(entries, lastVersion);
if (newEntries.length > 0) {
this.settingsManager.setLastChangelogVersion(VERSION);
this.reportInstallTelemetry(VERSION);
return newEntries.map((e) => e.content).join("\n\n");
}
return undefined;
}
reportInstallTelemetry(version) {
if (process.env.PI_OFFLINE) {
return;
}
if (!isInstallTelemetryEnabled(this.settingsManager)) {
return;
}
void fetch(`https://pi.dev/install?version=${encodeURIComponent(version)}`, {
signal: AbortSignal.timeout(5000),
})
.then(() => undefined)
.catch(() => undefined);
}
getMarkdownThemeWithSettings() {
return {
...getMarkdownTheme(),
codeBlockIndent: this.settingsManager.getCodeBlockIndent(),
};
}
// =========================================================================
// Extension System
// =========================================================================
formatDisplayPath(p) {
const home = os.homedir();
let result = p;
// Replace home directory with ~
if (result.startsWith(home)) {
result = `~${result.slice(home.length)}`;
}
return result;
}
formatContextPath(p) {
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);
}
getStartupExpansionState() {
return this.options.verbose || this.toolOutputExpanded;
}
/**
* Get a short path relative to the package root for display.
*/
getShortPath(fullPath, sourceInfo) {
const baseDir = sourceInfo?.baseDir;
if (baseDir && this.isPackageSource(sourceInfo)) {
const relativePath = path.relative(path.resolve(baseDir), path.resolve(fullPath));
if (relativePath &&
relativePath !== "." &&
!relativePath.startsWith("..") &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath)) {
return relativePath.replace(/\\/g, "/");
}
}
const source = sourceInfo?.source ?? "";
const npmMatch = fullPath.match(/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/);
if (npmMatch && source.startsWith("npm:")) {
return npmMatch[2];
}
const gitMatch = fullPath.match(/git\/[^/]+\/[^/]+\/(.*)/);
if (gitMatch && source.startsWith("git:")) {
return gitMatch[1];
}
return this.formatDisplayPath(fullPath);
}
getCompactPathLabel(resourcePath, sourceInfo) {
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;
}
getCompactPackageSourceLabel(sourceInfo) {
const source = sourceInfo?.source ?? "";
if (source.startsWith("npm:")) {
return source.slice("npm:".length) || source;
}
const gitSource = parseGitUrl(source);
if (gitSource) {
return gitSource.path || source;
}
return source;
}
getCompactExtensionLabel(resourcePath, sourceInfo) {
if (!this.isPackageSource(sourceInfo)) {
return this.getCompactPathLabel(resourcePath, sourceInfo);
}
const sourceLabel = this.getCompactPackageSourceLabel(sourceInfo);
if (!sourceLabel) {
return this.getCompactPathLabel(resourcePath, sourceInfo);
}
const shortPath = this.getShortPath(resourcePath, sourceInfo).replace(/\\/g, "/");
const packagePath = shortPath.startsWith("extensions/") ? shortPath.slice("extensions/".length) : shortPath;
const parsedPath = path.posix.parse(packagePath);
if (parsedPath.name === "index") {
return !parsedPath.dir || parsedPath.dir === "." ? sourceLabel : `${sourceLabel}:${parsedPath.dir}`;
}
return `${sourceLabel}:${packagePath}`;
}
getCompactDisplayPathSegments(resourcePath) {
return this.formatDisplayPath(resourcePath)
.replace(/\\/g, "/")
.split("/")
.filter((segment) => segment.length > 0 && segment !== "~");
}
getCompactNonPackageExtensionLabel(resourcePath, index, allPaths) {
const segments = allPaths[index]?.segments;
if (!segments || segments.length === 0) {
return this.getCompactPathLabel(resourcePath);
}
for (let segmentCount = 1; segmentCount <= segments.length; segmentCount += 1) {
const candidate = segments.slice(-segmentCount).join("/");
const isUnique = allPaths.every((item, itemIndex) => {
if (itemIndex === index) {
return true;
}
return item.segments.slice(-segmentCount).join("/") !== candidate;
});
if (isUnique) {
return candidate;
}
}
return segments.join("/");
}
getCompactExtensionLabels(extensions) {
const nonPackageExtensions = extensions
.map((extension) => ({
path: extension.path,
sourceInfo: extension.sourceInfo,
segments: this.getCompactDisplayPathSegments(extension.path),
}))
.filter((extension) => !this.isPackageSource(extension.sourceInfo));
return extensions.map((extension) => {
if (this.isPackageSource(extension.sourceInfo)) {
return this.getCompactExtensionLabel(extension.path, extension.sourceInfo);
}
const nonPackageIndex = nonPackageExtensions.findIndex((item) => item.path === extension.path);
if (nonPackageIndex === -1) {
return this.getCompactPathLabel(extension.path, extension.sourceInfo);
}
return this.getCompactNonPackageExtensionLabel(extension.path, nonPackageIndex, nonPackageExtensions);
});
}
getDisplaySourceInfo(sourceInfo) {
const source = sourceInfo?.source ?? "local";
const scope = sourceInfo?.scope ?? "project";
if (source === "local") {
if (scope === "user") {
return { label: "user", color: "muted" };
}
if (scope === "project") {
return { label: "project", color: "muted" };
}
if (scope === "temporary") {
return { label: "path", scopeLabel: "temp", color: "muted" };
}
return { label: "path", color: "muted" };
}
if (source === "cli") {
return { label: "path", scopeLabel: scope === "temporary" ? "temp" : undefined, color: "muted" };
}
const scopeLabel = scope === "user" ? "user" : scope === "project" ? "project" : scope === "temporary" ? "temp" : undefined;
return { label: source, scopeLabel, color: "accent" };
}
getScopeGroup(sourceInfo) {
const source = sourceInfo?.source ?? "local";
const scope = sourceInfo?.scope ?? "project";
if (source === "cli" || scope === "temporary")
return "path";
if (scope === "user")
return "user";
if (scope === "project")
return "project";
return "path";
}
isPackageSource(sourceInfo) {
const source = sourceInfo?.source ?? "";
return source.startsWith("npm:") || source.startsWith("git:");
}
buildScopeGroups(items) {
const groups = {
user: { scope: "user", paths: [], packages: new Map() },
project: { scope: "project", paths: [], packages: new Map() },
path: { scope: "path", paths: [], packages: new Map() },
};
for (const item of items) {
const groupKey = this.getScopeGroup(item.sourceInfo);
const group = groups[groupKey];
const source = item.sourceInfo?.source ?? "local";
if (this.isPackageSource(item.sourceInfo)) {
const list = group.packages.get(source) ?? [];
list.push(item);
group.packages.set(source, list);
}
else {
group.paths.push(item);
}
}
return [groups.project, groups.user, groups.path].filter((group) => group.paths.length > 0 || group.packages.size > 0);
}
formatScopeGroups(groups, options) {
const lines = [];
for (const group of groups) {
lines.push(` ${theme.fg("accent", group.scope)}`);
const sortedPaths = [...group.paths].sort((a, b) => a.path.localeCompare(b.path));
for (const item of sortedPaths) {
lines.push(theme.fg("dim", ` ${options.formatPath(item)}`));
}
const sortedPackages = Array.from(group.packages.entries()).sort(([a], [b]) => a.localeCompare(b));
for (const [source, items] of sortedPackages) {
lines.push(` ${theme.fg("mdLink", source)}`);
const sortedPackagePaths = [...items].sort((a, b) => a.path.localeCompare(b.path));
for (const item of sortedPackagePaths) {
lines.push(theme.fg("dim", ` ${options.formatPackagePath(item, source)}`));
}
}
}
return lines.join("\n");
}
findSourceInfoForPath(p, sourceInfos) {
const exact = sourceInfos.get(p);
if (exact)
return exact;
let current = p;
while (current.includes("/")) {
current = current.substring(0, current.lastIndexOf("/"));
const parent = sourceInfos.get(current);
if (parent)
return parent;
}
return undefined;
}
formatPathWithSource(p, sourceInfo) {
if (sourceInfo) {
const shortPath = this.getShortPath(p, sourceInfo);
const { label, scopeLabel } = this.getDisplaySourceInfo(sourceInfo);
const labelText = scopeLabel ? `${label} (${scopeLabel})` : label;
return `${labelText} ${shortPath}`;
}
return this.formatDisplayPath(p);
}
formatDiagnostics(diagnostics, sourceInfos) {
const lines = [];
// Group collision diagnostics by name
const collisions = new Map();
const otherDiagnostics = [];
for (const d of diagnostics) {
if (d.type === "collision" && d.collision) {
const list = collisions.get(d.collision.name) ?? [];
list.push(d);
collisions.set(d.collision.name, list);
}
else {
otherDiagnostics.push(d);
}
}
// Format collision diagnostics grouped by name
for (const [name, collisionList] of collisions) {
const first = collisionList[0]?.collision;
if (!first)
continue;
lines.push(theme.fg("warning", ` "${name}" collision:`));
lines.push(theme.fg("dim", ` ${theme.fg("success", "✓")} ${this.formatPathWithSource(first.winnerPath, this.findSourceInfoForPath(first.winnerPath, sourceInfos))}`));
for (const d of collisionList) {
if (d.collision) {
lines.push(theme.fg("dim", ` ${theme.fg("warning", "✗")} ${this.formatPathWithSource(d.collision.loserPath, this.findSourceInfoForPath(d.collision.loserPath, sourceInfos))} (skipped)`));
}
}
}
for (const d of otherDiagnostics) {
if (d.path) {
const formattedPath = this.formatPathWithSource(d.path, this.findSourceInfoForPath(d.path, sourceInfos));
lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${formattedPath}`));
lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`));
}
else {
lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`));
}
}
return lines.join("\n");
}
showLoadedResources(options) {
const showListing = options?.force || this.options.verbose || !this.settingsManager.getQuietStartup();
const showDiagnostics = showListing || options?.showDiagnosticsWhenQuiet === true;
if (!showListing && !showDiagnostics) {
return;
}
const sectionHeader = (name, color = "mdHeading") => theme.fg(color, `[${name}]`);
const formatCompactList = (items, options) => {
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, collapsedBody, expandedBody = collapsedBody, color = "mdHeading") => {
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();
const themesResult = this.session.resourceLoader.getThemes();
const extensions = options?.extensions ??
this.session.resourceLoader.getExtensions().extensions.map((extension) => ({
path: extension.path,
sourceInfo: extension.sourceInfo,
}));
const sourceInfos = new Map();
for (const extension of extensions) {
if (extension.sourceInfo) {
sourceInfos.set(extension.path, extension.sourceInfo);
}
}
for (const skill of skillsResult.skills) {
if (skill.sourceInfo) {
sourceInfos.set(skill.filePath, skill.sourceInfo);
}
}
for (const prompt of promptsResult.prompts) {
if (prompt.sourceInfo) {
sourceInfos.set(prompt.filePath, prompt.sourceInfo);
}
}
for (const loadedTheme of themesResult.themes) {
if (loadedTheme.sourcePath && loadedTheme.sourceInfo) {
sourceInfos.set(loadedTheme.sourcePath, loadedTheme.sourceInfo);
}
}
if (showListing) {
const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles;
if (contextFiles.length > 0) {
this.chatContainer.addChild(new Spacer(1));
const contextList = contextFiles
.map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`))
.join("\n");
const contextCompactList = formatCompactList(contextFiles.map((contextFile) => this.formatContextPath(contextFile.path)), { sort: false });
addLoadedSection("Context", contextCompactList, contextList);
}
const skills = skillsResult.skills;
if (skills.length > 0) {
const groups = this.buildScopeGroups(skills.map((skill) => ({ path: skill.filePath, sourceInfo: skill.sourceInfo })));
const skillList = this.formatScopeGroups(groups, {
formatPath: (item) => this.formatDisplayPath(item.path),
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
});
const skillCompactList = formatCompactList(skills.map((skill) => skill.name));
addLoadedSection("Skills", skillCompactList, skillList);
}
const templates = this.session.promptTemplates;
if (templates.length > 0) {
const groups = this.buildScopeGroups(templates.map((template) => ({ path: template.filePath, sourceInfo: template.sourceInfo })));
const templateByPath = new Map(templates.map((t) => [t.filePath, t]));
const templateList = this.formatScopeGroups(groups, {
formatPath: (item) => {
const template = templateByPath.get(item.path);
return template ? `/${template.name}` : this.formatDisplayPath(item.path);
},
formatPackagePath: (item) => {
const template = templateByPath.get(item.path);
return template ? `/${template.name}` : this.formatDisplayPath(item.path);
},
});
const promptCompactList = formatCompactList(templates.map((template) => `/${template.name}`));
addLoadedSection("Prompts", promptCompactList, templateList);
}
if (extensions.length > 0) {
const groups = this.buildScopeGroups(extensions);
const extList = this.formatScopeGroups(groups, {
formatPath: (item) => this.formatDisplayPath(item.path),
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
});
const extensionCompactList = formatCompactList(this.getCompactExtensionLabels(extensions));
addLoadedSection("Extensions", extensionCompactList, extList, "mdHeading");
}
// Show loaded themes (excluding built-in)
const loadedThemes = themesResult.themes;
const customThemes = loadedThemes.filter((t) => t.sourcePath);
if (customThemes.length > 0) {
const groups = this.buildScopeGroups(customThemes.map((loadedTheme) => ({
path: loadedTheme.sourcePath,
sourceInfo: loadedTheme.sourceInfo,
})));
const themeList = this.formatScopeGroups(groups, {
formatPath: (item) => this.formatDisplayPath(item.path),
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
});
const themeCompactList = formatCompactList(customThemes.map((loadedTheme) => loadedTheme.name ?? this.getCompactPathLabel(loadedTheme.sourcePath, loadedTheme.sourceInfo)));
addLoadedSection("Themes", themeCompactList, themeList);
}
}
if (showDiagnostics) {
const skillDiagnostics = skillsResult.diagnostics;
if (skillDiagnostics.length > 0) {
const warningLines = this.formatDiagnostics(skillDiagnostics, sourceInfos);
this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Skill conflicts]")}\n${warningLines}`, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
const promptDiagnostics = promptsResult.diagnostics;
if (promptDiagnostics.length > 0) {
const warningLines = this.formatDiagnostics(promptDiagnostics, sourceInfos);
this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Prompt conflicts]")}\n${warningLines}`, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
const extensionDiagnostics = [];
const extensionErrors = this.session.resourceLoader.getExtensions().errors;
if (extensionErrors.length > 0) {
for (const error of extensionErrors) {
extensionDiagnostics.push({ type: "error", message: error.error, path: error.path });
}