@earendil-works/pi-coding-agent
Version:
Coding agent CLI with read, bash, edit, write tools and session management
1,061 lines • 231 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, getCapabilities, hyperlink, Markdown, matchesKey, ProcessTerminal, Spacer, setKeybindings, Text, TruncatedText, TUI, visibleWidth, } from "@earendil-works/pi-tui";
import chalk from "chalk";
import { spawn, spawnSync } from "child_process";
import { APP_NAME, APP_TITLE, CONFIG_DIR_NAME, getAgentDir, getAuthPath, getDebugLogPath, getDocsPath, getShareViewerUrl, VERSION, } from "../../config.js";
import { parseSkillBlock } from "../../core/agent-session.js";
import { SessionImportFileNotFoundError } from "../../core/agent-session-runtime.js";
import { CACHE_TTL_MS, collectCacheMisses, computeCacheWaste, detectCacheMiss, } from "../../core/cache-stats.js";
import { FooterDataProvider } from "../../core/footer-data-provider.js";
import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.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, sessionEntryToContextMessages } from "../../core/session-manager.js";
import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js";
import { isInstallTelemetryEnabled } from "../../core/telemetry.js";
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.js";
import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.js";
import { copyToClipboard, readClipboardText } from "../../utils/clipboard.js";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
import { parseGitUrl } from "../../utils/git.js";
import { getCwdRelativePath } from "../../utils/paths.js";
import { getPiUserAgent } from "../../utils/pi-user-agent.js";
import { killTrackedDetachedChildren } from "../../utils/shell.js";
import { ensureTool } from "../../utils/tools-manager.js";
import { checkForNewPiVersion } from "../../utils/version-check.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 { CustomEditor } from "./components/custom-editor.js";
import { CustomEntryComponent } from "./components/custom-entry.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, formatTokens } from "./components/footer.js";
import { formatKeyText, keyDisplayText, keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.js";
import { LoginDialogComponent } from "./components/login-dialog.js";
import { ModelSelectorComponent } from "./components/model-selector.js";
import { formatAuthSelectorProviderType, 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 { BranchSummaryStatusIndicator, CompactionStatusIndicator, IdleStatus, RetryStatusIndicator, WorkingStatusIndicator, } from "./components/status-indicator.js";
import { ToolExecutionComponent } from "./components/tool-execution.js";
import { TreeSelectorComponent } from "./components/tree-selector.js";
import { TrustSelectorComponent } from "./components/trust-selector.js";
import { UserMessageComponent } from "./components/user-message.js";
import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
import { getModelSearchText } from "./model-search.js";
import { getAvailableThemes, getAvailableThemesWithPaths, getEditorTheme, getMarkdownTheme, getThemeByName, onThemeChange, setRegisteredThemes, stopThemeWatcher, Theme, theme, } from "./theme/theme.js";
import { InteractiveThemeController } from "./theme/theme-controller.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());
}
}
function isCustomSessionEntry(item) {
return "type" in item && item.type === "custom";
}
const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN"]);
function isDeadTerminalError(error) {
if (!error || typeof error !== "object" || !("code" in error)) {
return false;
}
const code = error.code;
return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);
}
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings.";
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 quoteIfNeeded(value) {
if (value.length > 0 && !/[^a-zA-Z0-9_\-./~:@]/.test(value)) {
return value;
}
return `'${value.replace(/'/g, `'\\''`)}'`;
}
export function formatResumeCommand(sessionManager) {
if (!process.stdout.isTTY)
return undefined;
if (!sessionManager.isPersisted())
return undefined;
const sessionFile = sessionManager.getSessionFile();
if (!sessionFile || !fs.existsSync(sessionFile))
return undefined;
const args = [APP_NAME];
if (!sessionManager.usesDefaultSessionDir()) {
args.push("--session-dir", quoteIfNeeded(sessionManager.getSessionDir()));
}
args.push("--session", sessionManager.getSessionId());
return args.join(" ");
}
function hasDefaultModelProvider(providerId) {
return providerId in defaultModelPerProvider;
}
const AUTH_TYPE_ORDER = { oauth: 0, api_key: 1 };
function createFuzzyAutocompleteItems(items, prefix, getSearchText, toAutocompleteItem) {
const filtered = fuzzyFilter(items, prefix, getSearchText);
if (filtered.length === 0)
return null;
return filtered.map(toAutocompleteItem);
}
function getLoginProviderCompletionOptions(providerOptions) {
const byId = new Map();
for (const provider of providerOptions) {
const existing = byId.get(provider.id);
if (existing) {
if (!existing.authTypes.includes(provider.authType)) {
existing.authTypes.push(provider.authType);
existing.authTypes.sort((a, b) => AUTH_TYPE_ORDER[a] - AUTH_TYPE_ORDER[b]);
}
continue;
}
byId.set(provider.id, {
id: provider.id,
name: provider.name,
authTypes: [provider.authType],
});
}
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name));
}
function getLoginProviderSearchText(provider) {
const authTypes = provider.authTypes
.map((authType) => `${authType} ${formatAuthSelectorProviderType(authType)}`)
.join(" ");
return `${provider.id} ${provider.name} ${authTypes}`;
}
function formatLoginProviderCompletionDescription(provider) {
const authTypes = provider.authTypes.map(formatAuthSelectorProviderType).join("/");
return provider.name === provider.id ? authTypes : `${provider.name} · ${authTypes}`;
}
export class InteractiveMode {
runtimeHost;
ui;
loadedResourcesContainer;
chatContainer;
pendingMessagesContainer;
statusContainer;
defaultEditor;
editor;
editorComponentFactory;
autocompleteProvider;
autocompleteProviderWrappers = [];
fdPath;
editorContainer;
footer;
footerDataProvider;
// Stored so the same manager can be injected into custom editors, selectors, and extension UI.
keybindings;
version;
isInitialized = false;
onInputCallback;
pendingUserInputs = [];
activeStatusIndicator = undefined;
idleStatus = new IdleStatus();
workingMessage = undefined;
workingVisible = true;
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();
// Tool output expansion state
toolOutputExpanded = false;
// Thinking block visibility state
hideThinkingBlock = false;
outputPad = 1;
// 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
autoCompactionEscapeHandler;
// Auto-retry state
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;
options;
autoTrustOnReloadCwd;
themeController;
// 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.runtimeHost = runtimeHost;
this.options = options;
this.autoTrustOnReloadCwd = options.autoTrustOnReloadCwd;
this.runtimeHost.setBeforeSessionInvalidate(() => {
this.resetExtensionUI();
});
this.runtimeHost.setRebindSession(async () => {
await this.rebindCurrentSession({ renderBeforeBind: true });
});
this.version = VERSION;
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
this.headerContainer = new Container();
this.loadedResourcesContainer = 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();
this.outputPad = this.settingsManager.getOutputPad();
// Register themes from resource loader and initialize
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
this.themeController = new InteractiveThemeController(this.ui, this.settingsManager, (message) => this.showError(message), () => this.updateEditorBorderColor());
}
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,
}));
}
createBaseAutocompleteProvider() {
// Define commands for autocomplete
const slashCommands = BUILTIN_SLASH_COMMANDS.map((command) => ({
name: command.name,
description: command.description,
...(command.argumentHint && { argumentHint: command.argumentHint }),
}));
const modelCommand = slashCommands.find((command) => command.name === "model");
if (modelCommand) {
modelCommand.getArgumentCompletions = async (prefix) => {
// Get available models (scoped or from registry)
const models = this.session.scopedModels.length > 0
? this.session.scopedModels.map((s) => s.model)
: await this.session.modelRuntime.getAvailable();
if (models.length === 0)
return null;
// Create items with provider/id format
const items = models.map((m) => ({
id: m.id,
provider: m.provider,
name: m.name,
label: `${m.provider}/${m.id}`,
}));
return createFuzzyAutocompleteItems(items, prefix, getModelSearchText, (item) => ({
value: item.label,
label: item.id,
description: item.provider,
}));
};
}
const loginCommand = slashCommands.find((command) => command.name === "login");
if (loginCommand) {
loginCommand.getArgumentCompletions = (prefix) => {
const providers = getLoginProviderCompletionOptions(this.getLoginProviderOptions());
return createFuzzyAutocompleteItems(providers, prefix, getLoginProviderSearchText, (provider) => ({
value: provider.id,
label: provider.id,
description: formatLoginProviderCompletionDescription(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),
});
}
}
return new CombinedAutocompleteProvider([...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList], this.sessionManager.getCwd(), this.fdPath);
}
setupAutocompleteProvider() {
let provider = this.createBaseAutocompleteProvider();
const triggerCharacters = [];
for (const wrapProvider of this.autocompleteProviderWrappers) {
provider = wrapProvider(provider);
triggerCharacters.push(...(provider.triggerCharacters ?? []));
}
if (triggerCharacters.length > 0) {
provider.triggerCharacters = [...new Set(triggerCharacters)];
}
this.autocompleteProvider = provider;
this.defaultEditor.setAutocompleteProvider(provider);
if (this.editor !== this.defaultEditor) {
this.editor.setAutocompleteProvider?.(provider);
}
}
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;
if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) {
const modelList = this.session.scopedModels
.map((sm) => {
const thinkingStr = sm.thinkingLevel ? `:${sm.thinkingLevel}` : "";
return `${sm.model.id}${thinkingStr}`;
})
.join(", ");
const cycleKeys = this.keybindings.getKeys("app.model.cycleForward");
const cycleHint = cycleKeys.length > 0
? theme.fg("muted", ` (${formatKeyText(cycleKeys.join("/"), { capitalize: true })} to cycle)`)
: "";
console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
}
// Add header container as first child. Populate it after applying theme settings.
// Keep loaded resources before chat so restored session messages never precede them.
this.ui.addChild(this.headerContainer);
this.ui.addChild(this.loadedResourcesContainer);
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}`);
// 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 (with text fallback)"),
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.requestRender();
// Initialize extensions first so resources are shown before messages
await this.rebindCurrentSession();
// Render initial messages AFTER showing loaded resources
this.renderInitialMessages();
// Set up theme file watcher
onThemeChange(() => {
this.ui.invalidate();
this.updateEditorBorderColor();
this.ui.requestRender();
});
// Set up git branch watcher (uses provider instead of footer)
this.footerDataProvider.onBranchChange(() => {
this.ui.requestRender();
});
// 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(`${APP_TITLE} - ${sessionName} - ${cwdBasename}`);
}
else {
this.ui.terminal.setTitle(`${APP_TITLE} - ${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
checkForNewPiVersion(this.version).then((newRelease) => {
if (newRelease) {
this.showNewVersionNotification(newRelease);
}
});
// Start package update check asynchronously
this.checkForPackageUpdates()
.then((updates) => {
if (updates.length > 0) {
this.showPackageUpdateNotification(updates);
}
})
.finally(() => {
// On Windows, npm can overwrite the shared console title while checking
// extension package versions. Restore Pi's title after the startup check.
if (process.platform === "win32" && this.isInitialized) {
this.updateTerminalTitle();
}
});
// 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.modelRuntime.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);
}
}
}
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) => normalizeChangelogLinks(e.content, e)).join("\n\n");
}
return undefined;
}
reportInstallTelemetry(version) {
if (process.env.PI_OFFLINE) {
return;
}
if (!isInstallTelemetryEnabled(this.settingsManager)) {
return;
}
void fetch(`https://pi.dev/api/report-install?version=${encodeURIComponent(version)}`, {
headers: {
"User-Agent": getPiUserAgent(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;
}
formatExtensionDisplayPath(path) {
let result = this.formatDisplayPath(path);
result = result.replace(/\/index\.ts$/, "").replace(/\/index\.js$/, "");
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 = getCwdRelativePath(absolutePath, cwd);
if (relativePath !== undefined) {
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) => {
const segments = this.getCompactDisplayPathSegments(extension.path);
const lastSegment = segments[segments.length - 1];
if (segments.length > 1 && (lastSegment === "index.ts" || lastSegment === "index.js")) {
segments.pop();
}
return {
path: extension.path,
sourceInfo: extension.sourceInfo,
segments,
};
})
.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) {
// Resource rendering is idempotent; chat clears no longer clear this separate container.
this.loadedResourcesContainer.clear();
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.loadedResourcesContainer.addChild(section);
this.loadedResourcesContainer.addChild(new Spacer(1));
};
const s