UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

1,061 lines 231 kB
/** * 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