interactive-mode.ts
63 KB1801 lines
interactive-mode.ts
1/**2 * Interactive mode for the coding agent.3 * Handles TUI rendering and user interaction, delegating business logic to AgentSession.4 */56import * as crypto from "node:crypto";7import * as fs from "node:fs";8import * as os from "node:os";9import * as path from "node:path";10import type { AgentMessage } from "@earendil-works/pi-agent-core";11import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";12import type { AssistantMessage, ImageContent, Message, Model } from "@earendil-works/pi-ai/compat";13import type {14 AutocompleteItem,15 AutocompleteProvider,16 EditorComponent,17 Keybinding,18 KeyId,19 MarkdownTheme,20 OverlayHandle,21 OverlayOptions,22 SlashCommand,23} from "@earendil-works/pi-tui";24import {25 CombinedAutocompleteProvider,26 type Component,27 Container,28 fuzzyFilter,29 getCapabilities,30 hyperlink,31 Markdown,32 matchesKey,33 ProcessTerminal,34 Spacer,35 setKeybindings,36 Text,37 TruncatedText,38 TUI,39 visibleWidth,40} from "@earendil-works/pi-tui";41import chalk from "chalk";42import { spawn, spawnSync } from "child_process";43import {44 APP_NAME,45 APP_TITLE,46 CONFIG_DIR_NAME,47 getAgentDir,48 getAuthPath,49 getDebugLogPath,50 getDocsPath,51 getShareViewerUrl,52 VERSION,53} from "../../config.ts";54import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts";55import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.ts";56import {57 CACHE_TTL_MS,58 type CacheMiss,59 collectCacheMisses,60 computeCacheWaste,61 detectCacheMiss,62} from "../../core/cache-stats.ts";63import type {64 AutocompleteProviderFactory,65 EditorFactory,66 ExtensionCommandContext,67 ExtensionContext,68 ExtensionRunner,69 ExtensionUIContext,70 ExtensionUIDialogOptions,71 ExtensionWidgetOptions,72 ProjectTrustContext,73 WorkingIndicatorOptions,74} from "../../core/extensions/index.ts";75import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts";76import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts";77import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts";78import { createCompactionSummaryMessage } from "../../core/messages.ts";79import {80 defaultModelPerProvider,81 findExactModelReferenceMatch,82 resolveModelScope,83 resolveModelScopeWithDiagnostics,84} from "../../core/model-resolver.ts";85import { DefaultPackageManager } from "../../core/package-manager.ts";86import type { ResourceDiagnostic } from "../../core/resource-loader.ts";87import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";88import { type SessionEntry, SessionManager, sessionEntryToContextMessages } from "../../core/session-manager.ts";89import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";90import type { SourceInfo } from "../../core/source-info.ts";91import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";92import type { TruncationResult } from "../../core/tools/truncate.ts";93import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";94import { getUsageCostBreakdown } from "../../core/usage-totals.ts";95import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";96import { copyToClipboard, readClipboardText } from "../../utils/clipboard.ts";97import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";98import { parseGitUrl } from "../../utils/git.ts";99import { getCwdRelativePath } from "../../utils/paths.ts";100import { getPiUserAgent } from "../../utils/pi-user-agent.ts";101import { killTrackedDetachedChildren } from "../../utils/shell.ts";102import { ensureTool } from "../../utils/tools-manager.ts";103import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-check.ts";104import { ArminComponent } from "./components/armin.ts";105import { AssistantMessageComponent } from "./components/assistant-message.ts";106import { BashExecutionComponent } from "./components/bash-execution.ts";107import { BorderedLoader } from "./components/bordered-loader.ts";108import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts";109import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.ts";110import { CustomEditor } from "./components/custom-editor.ts";111import { CustomEntryComponent } from "./components/custom-entry.ts";112import { CustomMessageComponent } from "./components/custom-message.ts";113import { DaxnutsComponent } from "./components/daxnuts.ts";114import { DynamicBorder } from "./components/dynamic-border.ts";115import { EarendilAnnouncementComponent } from "./components/earendil-announcement.ts";116import { ExtensionEditorComponent } from "./components/extension-editor.ts";117import { ExtensionInputComponent } from "./components/extension-input.ts";118import { ExtensionSelectorComponent } from "./components/extension-selector.ts";119import { FooterComponent, formatTokens } from "./components/footer.ts";120import { formatKeyText, keyDisplayText, keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.ts";121import { LoginDialogComponent } from "./components/login-dialog.ts";122import { ModelSelectorComponent } from "./components/model-selector.ts";123import {124 type AuthSelectorProvider,125 formatAuthSelectorProviderType,126 OAuthSelectorComponent,127} from "./components/oauth-selector.ts";128import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.ts";129import { SessionSelectorComponent } from "./components/session-selector.ts";130import { SettingsSelectorComponent } from "./components/settings-selector.ts";131import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.ts";132import {133 BranchSummaryStatusIndicator,134 CompactionStatusIndicator,135 IdleStatus,136 RetryStatusIndicator,137 type StatusIndicator,138 WorkingStatusIndicator,139} from "./components/status-indicator.ts";140import { ToolExecutionComponent } from "./components/tool-execution.ts";141import { TreeSelectorComponent } from "./components/tree-selector.ts";142import { TrustSelectorComponent } from "./components/trust-selector.ts";143import { UserMessageComponent } from "./components/user-message.ts";144import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";145import { editInExternalEditor } from "./external-editor.ts";146import { getModelSearchText } from "./model-search.ts";147import {148 getAvailableThemes,149 getAvailableThemesWithPaths,150 getEditorTheme,151 getMarkdownTheme,152 getThemeByName,153 onThemeChange,154 setRegisteredThemes,155 stopThemeWatcher,156 Theme,157 type ThemeColor,158 theme,159} from "./theme/theme.ts";160import { InteractiveThemeController } from "./theme/theme-controller.ts";161162/** Interface for components that can be expanded/collapsed */163interface Expandable {164 setExpanded(expanded: boolean): void;165}166167function isExpandable(obj: unknown): obj is Expandable {168 return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";169}170171class ExpandableText extends Text implements Expandable {172 private readonly getCollapsedText: () => string;173 private readonly getExpandedText: () => string;174175 constructor(176 getCollapsedText: () => string,177 getExpandedText: () => string,178 expanded = false,179 paddingX = 0,180 paddingY = 0,181 ) {182 super(expanded ? getExpandedText() : getCollapsedText(), paddingX, paddingY);183 this.getCollapsedText = getCollapsedText;184 this.getExpandedText = getExpandedText;185 }186187 setExpanded(expanded: boolean): void {188 this.setText(expanded ? this.getExpandedText() : this.getCollapsedText());189 }190}191192type CompactionQueuedMessage = {193 text: string;194 mode: "steer" | "followUp";195};196197type RenderSessionItem = AgentMessage | Extract<SessionEntry, { type: "custom" }>;198199function isCustomSessionEntry(item: RenderSessionItem): item is Extract<SessionEntry, { type: "custom" }> {200 return "type" in item && item.type === "custom";201}202203const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN"]);204205function isDeadTerminalError(error: unknown): boolean {206 if (!error || typeof error !== "object" || !("code" in error)) {207 return false;208 }209 const code = (error as NodeJS.ErrnoException).code;210 return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);211}212213const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING =214 "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.";215216function isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean {217 return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");218}219220function isUnknownModel(model: Model<any> | undefined): boolean {221 return !!model && model.provider === "unknown" && model.id === "unknown" && model.api === "unknown";222}223224function quoteIfNeeded(value: string): string {225 if (value.length > 0 && !/[^a-zA-Z0-9_\-./~:@]/.test(value)) {226 return value;227 }228 return `'${value.replace(/'/g, `'\\''`)}'`;229}230231export function formatResumeCommand(sessionManager: SessionManager): string | undefined {232 if (!process.stdout.isTTY) return undefined;233 if (!sessionManager.isPersisted()) return undefined;234235 const sessionFile = sessionManager.getSessionFile();236 if (!sessionFile || !fs.existsSync(sessionFile)) return undefined;237238 const args = [APP_NAME];239 if (!sessionManager.usesDefaultSessionDir()) {240 args.push("--session-dir", quoteIfNeeded(sessionManager.getSessionDir()));241 }242 args.push("--session", sessionManager.getSessionId());243 return args.join(" ");244}245246function hasDefaultModelProvider(providerId: string): providerId is keyof typeof defaultModelPerProvider {247 return providerId in defaultModelPerProvider;248}249250type LoginProviderCompletionOption = {251 id: string;252 name: string;253 authTypes: AuthSelectorProvider["authType"][];254};255256const AUTH_TYPE_ORDER = { oauth: 0, api_key: 1 } satisfies Record<AuthSelectorProvider["authType"], number>;257258function createFuzzyAutocompleteItems<T>(259 items: T[],260 prefix: string,261 getSearchText: (item: T) => string,262 toAutocompleteItem: (item: T) => AutocompleteItem,263): AutocompleteItem[] | null {264 const filtered = fuzzyFilter(items, prefix, getSearchText);265 if (filtered.length === 0) return null;266 return filtered.map(toAutocompleteItem);267}268269function getLoginProviderCompletionOptions(270 providerOptions: readonly AuthSelectorProvider[],271): LoginProviderCompletionOption[] {272 const byId = new Map<string, LoginProviderCompletionOption>();273 for (const provider of providerOptions) {274 const existing = byId.get(provider.id);275 if (existing) {276 if (!existing.authTypes.includes(provider.authType)) {277 existing.authTypes.push(provider.authType);278 existing.authTypes.sort((a, b) => AUTH_TYPE_ORDER[a] - AUTH_TYPE_ORDER[b]);279 }280 continue;281 }282 byId.set(provider.id, {283 id: provider.id,284 name: provider.name,285 authTypes: [provider.authType],286 });287 }288 return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name));289}290291function getLoginProviderSearchText(provider: LoginProviderCompletionOption): string {292 const authTypes = provider.authTypes293 .map((authType) => `${authType} ${formatAuthSelectorProviderType(authType)}`)294 .join(" ");295 return `${provider.id} ${provider.name} ${authTypes}`;296}297298function formatLoginProviderCompletionDescription(provider: LoginProviderCompletionOption): string {299 const authTypes = provider.authTypes.map(formatAuthSelectorProviderType).join("/");300 return provider.name === provider.id ? authTypes : `${provider.name} · ${authTypes}`;301}302303/**304 * Options for InteractiveMode initialization.305 */306export interface InteractiveModeOptions {307 /** Providers that were migrated to auth.json (shows warning) */308 migratedProviders?: string[];309 /** Warning message if session model couldn't be restored */310 modelFallbackMessage?: string;311 /** Cwd to trust after reload if it gained a .pi directory during this implicitly trusted session. */312 autoTrustOnReloadCwd?: string;313 /** Initial message to send on startup (can include @file content) */314 initialMessage?: string;315 /** Images to attach to the initial message */316 initialImages?: ImageContent[];317 /** Additional messages to send after the initial message */318 initialMessages?: string[];319 /** Force verbose startup (overrides quietStartup setting) */320 verbose?: boolean;321}322323export class InteractiveMode {324 private runtimeHost: AgentSessionRuntime;325 private ui: TUI;326 private loadedResourcesContainer: Container;327 private chatContainer: Container;328 private pendingMessagesContainer: Container;329 private statusContainer: Container;330 private defaultEditor: CustomEditor;331 private editor: EditorComponent;332 private editorComponentFactory: EditorFactory | undefined;333 private autocompleteProvider: AutocompleteProvider | undefined;334 private autocompleteProviderWrappers: AutocompleteProviderFactory[] = [];335 private fdPath: string | undefined;336 private editorContainer: Container;337 private footer: FooterComponent;338 private footerDataProvider: FooterDataProvider;339 // Stored so the same manager can be injected into custom editors, selectors, and extension UI.340 private keybindings: KeybindingsManager;341 private version: string;342 private isInitialized = false;343 private onInputCallback?: (text: string) => void;344 private pendingUserInputs: string[] = [];345 private activeStatusIndicator: StatusIndicator | undefined = undefined;346 private readonly idleStatus = new IdleStatus();347 private workingMessage: string | undefined = undefined;348 private workingVisible = true;349 private workingIndicatorOptions: WorkingIndicatorOptions | undefined = undefined;350 private readonly defaultWorkingMessage = "Working...";351 private readonly defaultHiddenThinkingLabel = "Thinking...";352 private hiddenThinkingLabel = this.defaultHiddenThinkingLabel;353354 private lastSigintTime = 0;355 private lastEscapeTime = 0;356 private changelogMarkdown: string | undefined = undefined;357 private startupNoticesShown = false;358 private anthropicSubscriptionWarningShown = false;359360 // Status line tracking (for mutating immediately-sequential status updates)361 private lastStatusSpacer: Spacer | undefined = undefined;362 private lastStatusText: Text | undefined = undefined;363364 // Streaming message tracking365 private streamingComponent: AssistantMessageComponent | undefined = undefined;366 private streamingMessage: AssistantMessage | undefined = undefined;367368 // Tool execution tracking: toolCallId -> component369 private pendingTools = new Map<string, ToolExecutionComponent>();370371 // Tool output expansion state372 private toolOutputExpanded = false;373374 // Thinking block visibility state375 private hideThinkingBlock = false;376 private outputPad = 1;377378 // Skill commands: command name -> skill file path379 private skillCommands = new Map<string, string>();380381 // Agent subscription unsubscribe function382 private unsubscribe?: () => void;383 private signalCleanupHandlers: Array<() => void> = [];384385 // Track if editor is in bash mode (text starts with !)386 private isBashMode = false;387388 // Track current bash execution component389 private bashComponent: BashExecutionComponent | undefined = undefined;390391 // Track pending bash components (shown in pending area, moved to chat on submit)392 private pendingBashComponents: BashExecutionComponent[] = [];393394 // Auto-compaction state395 private autoCompactionEscapeHandler?: () => void;396397 // Auto-retry state398 private retryEscapeHandler?: () => void;399400 // Messages queued while compaction is running401 private compactionQueuedMessages: CompactionQueuedMessage[] = [];402403 // Shutdown state404 private shutdownRequested = false;405406 // Extension UI state407 private extensionSelector: ExtensionSelectorComponent | undefined = undefined;408 private extensionInput: ExtensionInputComponent | undefined = undefined;409 private extensionEditor: ExtensionEditorComponent | undefined = undefined;410 private extensionTerminalInputUnsubscribers = new Set<() => void>();411412 // Extension widgets (components rendered above/below the editor)413 private extensionWidgetsAbove = new Map<string, Component & { dispose?(): void }>();414 private extensionWidgetsBelow = new Map<string, Component & { dispose?(): void }>();415 private widgetContainerAbove!: Container;416 private widgetContainerBelow!: Container;417418 // Custom footer from extension (undefined = use built-in footer)419 private customFooter: (Component & { dispose?(): void }) | undefined = undefined;420421 // Header container that holds the built-in or custom header422 private headerContainer: Container;423424 // Built-in header (logo + keybinding hints + changelog)425 private builtInHeader: Component | undefined = undefined;426427 // Custom header from extension (undefined = use built-in header)428 private customHeader: (Component & { dispose?(): void }) | undefined = undefined;429430 private options: InteractiveModeOptions;431 private autoTrustOnReloadCwd: string | undefined;432 private themeController: InteractiveThemeController;433434 // Convenience accessors435 private get session(): AgentSession {436 return this.runtimeHost.session;437 }438 private get agent() {439 return this.session.agent;440 }441 private get sessionManager() {442 return this.session.sessionManager;443 }444 private get settingsManager() {445 return this.session.settingsManager;446 }447448 constructor(runtimeHost: AgentSessionRuntime, options: InteractiveModeOptions = {}) {449 this.runtimeHost = runtimeHost;450 this.options = options;451 this.autoTrustOnReloadCwd = options.autoTrustOnReloadCwd;452 this.runtimeHost.setBeforeSessionInvalidate(() => {453 this.resetExtensionUI();454 });455 this.runtimeHost.setRebindSession(async () => {456 await this.rebindCurrentSession({ renderBeforeBind: true });457 });458 this.version = VERSION;459 this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor(), getAgentDir());460 this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());461 this.headerContainer = new Container();462 this.loadedResourcesContainer = new Container();463 this.chatContainer = new Container();464 this.pendingMessagesContainer = new Container();465 this.statusContainer = new Container();466 this.widgetContainerAbove = new Container();467 this.widgetContainerBelow = new Container();468 this.keybindings = KeybindingsManager.create();469 setKeybindings(this.keybindings);470 const editorPaddingX = this.settingsManager.getEditorPaddingX();471 const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();472 this.defaultEditor = new CustomEditor(this.ui, getEditorTheme(), this.keybindings, {473 paddingX: editorPaddingX,474 autocompleteMaxVisible,475 });476 this.editor = this.defaultEditor;477 this.editorContainer = new Container();478 this.editorContainer.addChild(this.editor as Component);479 this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());480 this.footer = new FooterComponent(this.session, this.footerDataProvider);481 this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);482483 // Load hide thinking block setting484 this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();485 this.outputPad = this.settingsManager.getOutputPad();486487 // Register themes from resource loader and initialize488 setRegisteredThemes(this.session.resourceLoader.getThemes().themes);489 this.themeController = new InteractiveThemeController(490 this.ui,491 this.settingsManager,492 (message) => this.showError(message),493 () => this.updateEditorBorderColor(),494 );495 }496497 private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined {498 if (!sourceInfo) {499 return undefined;500 }501502 const scopePrefix = sourceInfo.scope === "user" ? "u" : sourceInfo.scope === "project" ? "p" : "t";503 const source = sourceInfo.source.trim();504505 if (source === "auto" || source === "local" || source === "cli") {506 return scopePrefix;507 }508509 if (source.startsWith("npm:")) {510 return `${scopePrefix}:${source}`;511 }512513 const gitSource = parseGitUrl(source);514 if (gitSource) {515 const ref = gitSource.ref ? `@${gitSource.ref}` : "";516 return `${scopePrefix}:git:${gitSource.host}/${gitSource.path}${ref}`;517 }518519 return scopePrefix;520 }521522 private prefixAutocompleteDescription(description: string | undefined, sourceInfo?: SourceInfo): string | undefined {523 const sourceTag = this.getAutocompleteSourceTag(sourceInfo);524 if (!sourceTag) {525 return description;526 }527 return description ? `[${sourceTag}] ${description}` : `[${sourceTag}]`;528 }529530 private getBuiltInCommandConflictDiagnostics(extensionRunner: ExtensionRunner): ResourceDiagnostic[] {531 const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));532 return extensionRunner533 .getRegisteredCommands()534 .filter((command) => builtinNames.has(command.name))535 .map((command) => ({536 type: "warning" as const,537 message:538 command.invocationName === command.name539 ? `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.`540 : `Extension command '/${command.name}' conflicts with built-in interactive command. Available as '/${command.invocationName}'.`,541 path: command.sourceInfo.path,542 }));543 }544545 private createBaseAutocompleteProvider(): AutocompleteProvider {546 // Define commands for autocomplete547 const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({548 name: command.name,549 description: command.description,550 ...(command.argumentHint && { argumentHint: command.argumentHint }),551 }));552553 const modelCommand = slashCommands.find((command) => command.name === "model");554 if (modelCommand) {555 modelCommand.getArgumentCompletions = async (prefix: string): Promise<AutocompleteItem[] | null> => {556 // Get available models (scoped or from registry)557 const models =558 this.session.scopedModels.length > 0559 ? this.session.scopedModels.map((s) => s.model)560 : await this.session.modelRuntime.getAvailable();561562 if (models.length === 0) return null;563564 // Create items with provider/id format565 const items = models.map((m) => ({566 id: m.id,567 provider: m.provider,568 name: m.name,569 label: `${m.provider}/${m.id}`,570 }));571572 return createFuzzyAutocompleteItems(items, prefix, getModelSearchText, (item) => ({573 value: item.label,574 label: item.id,575 description: item.provider,576 }));577 };578 }579580 const loginCommand = slashCommands.find((command) => command.name === "login");581 if (loginCommand) {582 loginCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => {583 const providers = getLoginProviderCompletionOptions(this.getLoginProviderOptions());584 return createFuzzyAutocompleteItems(providers, prefix, getLoginProviderSearchText, (provider) => ({585 value: provider.id,586 label: provider.id,587 description: formatLoginProviderCompletionDescription(provider),588 }));589 };590 }591592 // Convert prompt templates to SlashCommand format for autocomplete593 const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({594 name: cmd.name,595 description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),596 ...(cmd.argumentHint && { argumentHint: cmd.argumentHint }),597 }));598599 // Convert extension commands to SlashCommand format600 const builtinCommandNames = new Set(slashCommands.map((c) => c.name));601 const extensionCommands: SlashCommand[] = this.session.extensionRunner602 .getRegisteredCommands()603 .filter((cmd) => !builtinCommandNames.has(cmd.name))604 .map((cmd) => ({605 name: cmd.invocationName,606 description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),607 getArgumentCompletions: cmd.getArgumentCompletions,608 }));609610 // Build skill commands from session.skills (if enabled)611 this.skillCommands.clear();612 const skillCommandList: SlashCommand[] = [];613 if (this.settingsManager.getEnableSkillCommands()) {614 for (const skill of this.session.resourceLoader.getSkills().skills) {615 const commandName = `skill:${skill.name}`;616 this.skillCommands.set(commandName, skill.filePath);617 skillCommandList.push({618 name: commandName,619 description: this.prefixAutocompleteDescription(skill.description, skill.sourceInfo),620 });621 }622 }623624 return new CombinedAutocompleteProvider(625 [...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList],626 this.sessionManager.getCwd(),627 this.fdPath,628 );629 }630631 private setupAutocompleteProvider(): void {632 let provider = this.createBaseAutocompleteProvider();633 const triggerCharacters: string[] = [];634 for (const wrapProvider of this.autocompleteProviderWrappers) {635 provider = wrapProvider(provider);636 triggerCharacters.push(...(provider.triggerCharacters ?? []));637 }638 if (triggerCharacters.length > 0) {639 provider.triggerCharacters = [...new Set(triggerCharacters)];640 }641642 this.autocompleteProvider = provider;643 this.defaultEditor.setAutocompleteProvider(provider);644 if (this.editor !== this.defaultEditor) {645 this.editor.setAutocompleteProvider?.(provider);646 }647 }648649 private showStartupNoticesIfNeeded(): void {650 if (this.startupNoticesShown) {651 return;652 }653 this.startupNoticesShown = true;654655 if (!this.changelogMarkdown) {656 return;657 }658659 if (this.chatContainer.children.length > 0) {660 this.chatContainer.addChild(new Spacer(1));661 }662 this.chatContainer.addChild(new DynamicBorder());663 if (this.settingsManager.getCollapseChangelog()) {664 const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);665 const latestVersion = versionMatch ? versionMatch[1] : this.version;666 const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;667 this.chatContainer.addChild(new Text(condensedText, 1, 0));668 } else {669 this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));670 this.chatContainer.addChild(new Spacer(1));671 this.chatContainer.addChild(672 new Markdown(this.changelogMarkdown.trim(), 1, 0, this.getMarkdownThemeWithSettings()),673 );674 this.chatContainer.addChild(new Spacer(1));675 }676 this.chatContainer.addChild(new DynamicBorder());677 }678679 async init(): Promise<void> {680 if (this.isInitialized) return;681682 this.registerSignalHandlers();683684 // Load changelog (only show new entries, skip for resumed sessions)685 this.changelogMarkdown = this.getChangelogForDisplay();686687 // Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir)688 // Both are needed: fd for autocomplete, rg for grep tool and bash commands689 const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]);690 this.fdPath = fdPath;691692 if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) {693 const modelList = this.session.scopedModels694 .map((sm) => {695 const thinkingStr = sm.thinkingLevel ? `:${sm.thinkingLevel}` : "";696 return `${sm.model.id}${thinkingStr}`;697 })698 .join(", ");699 const cycleKeys = this.keybindings.getKeys("app.model.cycleForward");700 const cycleHint =701 cycleKeys.length > 0702 ? theme.fg("muted", ` (${formatKeyText(cycleKeys.join("/"), { capitalize: true })} to cycle)`)703 : "";704 console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));705 }706707 // Add header container as first child. Populate it after applying theme settings.708 // Keep loaded resources before chat so restored session messages never precede them.709 this.ui.addChild(this.headerContainer);710 this.ui.addChild(this.loadedResourcesContainer);711712 this.ui.addChild(this.chatContainer);713 this.ui.addChild(this.pendingMessagesContainer);714 this.ui.addChild(this.statusContainer);715 this.renderWidgets(); // Initialize with default spacer716 this.ui.addChild(this.widgetContainerAbove);717 this.ui.addChild(this.editorContainer);718 this.ui.addChild(this.widgetContainerBelow);719 this.ui.addChild(this.footer);720 this.ui.setFocus(this.editor);721722 this.setupKeyHandlers();723 this.setupEditorSubmitHandler();724725 // Start the UI before initializing extensions so session_start handlers can use interactive dialogs726 this.ui.start();727 this.isInitialized = true;728729 await this.themeController.applyFromSettings();730731 // Add header with keybindings from config (unless silenced)732 if (this.options.verbose || !this.settingsManager.getQuietStartup()) {733 const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);734735 // Build startup instructions using keybinding hint helpers736 const hint = (keybinding: AppKeybinding, description: string) => keyHint(keybinding, description);737738 const expandedInstructions = [739 hint("app.interrupt", "to interrupt"),740 hint("app.clear", "to clear"),741 rawKeyHint(`${keyText("app.clear")} twice`, "to exit"),742 hint("app.exit", "to exit (empty)"),743 hint("app.suspend", "to suspend"),744 keyHint("tui.editor.deleteToLineEnd", "to delete to end"),745 hint("app.thinking.cycle", "to cycle thinking level"),746 rawKeyHint(`${keyText("app.model.cycleForward")}/${keyText("app.model.cycleBackward")}`, "to cycle models"),747 hint("app.model.select", "to select model"),748 hint("app.tools.expand", "to expand tools"),749 hint("app.thinking.toggle", "to expand thinking"),750 hint("app.editor.external", "for external editor"),751 rawKeyHint("/", "for commands"),752 rawKeyHint("!", "to run bash"),753 rawKeyHint("!!", "to run bash (no context)"),754 hint("app.message.followUp", "to queue follow-up"),755 hint("app.message.dequeue", "to edit all queued messages"),756 hint("app.clipboard.pasteImage", "to paste image (with text fallback)"),757 rawKeyHint("drop files", "to attach"),758 ].join("\n");759 const compactInstructions = [760 hint("app.interrupt", "interrupt"),761 rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "clear/exit"),762 rawKeyHint("/", "commands"),763 rawKeyHint("!", "bash"),764 hint("app.tools.expand", "more"),765 ].join(theme.fg("muted", " · "));766 const compactOnboarding = theme.fg(767 "dim",768 `Press ${keyText("app.tools.expand")} to show full startup help and loaded resources.`,769 );770 const onboarding = theme.fg(771 "dim",772 `Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.`,773 );774 this.builtInHeader = new ExpandableText(775 () => `${logo}\n${compactInstructions}\n${compactOnboarding}\n\n${onboarding}`,776 () => `${logo}\n${expandedInstructions}\n\n${onboarding}`,777 this.getStartupExpansionState(),778 1,779 0,780 );781782 // Setup UI layout783 this.headerContainer.addChild(new Spacer(1));784 this.headerContainer.addChild(this.builtInHeader);785 this.headerContainer.addChild(new Spacer(1));786 } else {787 // Minimal header when silenced788 this.builtInHeader = new Text("", 0, 0);789 this.headerContainer.addChild(this.builtInHeader);790 }791 this.ui.requestRender();792793 // Initialize extensions first so resources are shown before messages794 await this.rebindCurrentSession();795796 // Render initial messages AFTER showing loaded resources797 this.renderInitialMessages();798799 // Set up theme file watcher800 onThemeChange(() => {801 this.ui.invalidate();802 this.updateEditorBorderColor();803 this.ui.requestRender();804 });805806 // Set up git branch watcher (uses provider instead of footer)807 this.footerDataProvider.onBranchChange(() => {808 this.ui.requestRender();809 });810811 // Initialize available provider count for footer display812 await this.updateAvailableProviderCount();813 }814815 /**816 * Update terminal title with session name and cwd.817 */818 private updateTerminalTitle(): void {819 const cwdBasename = path.basename(this.sessionManager.getCwd());820 const sessionName = this.sessionManager.getSessionName();821 if (sessionName) {822 this.ui.terminal.setTitle(`${APP_TITLE} - ${sessionName} - ${cwdBasename}`);823 } else {824 this.ui.terminal.setTitle(`${APP_TITLE} - ${cwdBasename}`);825 }826 }827828 /**829 * Run the interactive mode. This is the main entry point.830 * Initializes the UI, shows warnings, processes initial messages, and starts the interactive loop.831 */832 async run(): Promise<void> {833 await this.init();834835 if (!process.env.PI_OFFLINE) {836 void this.session.modelRuntime837 .refresh()838 .then(() => this.updateAvailableProviderCount())839 .catch(() => {});840 }841842 // Start version check asynchronously843 checkForNewPiVersion(this.version).then((newRelease) => {844 if (newRelease) {845 this.showNewVersionNotification(newRelease);846 }847 });848849 // Start package update check asynchronously850 this.checkForPackageUpdates()851 .then((updates) => {852 if (updates.length > 0) {853 this.showPackageUpdateNotification(updates);854 }855 })856 .finally(() => {857 // On Windows, npm can overwrite the shared console title while checking858 // extension package versions. Restore Pi's title after the startup check.859 if (process.platform === "win32" && this.isInitialized) {860 this.updateTerminalTitle();861 }862 });863864 // Check tmux keyboard setup asynchronously865 this.checkTmuxKeyboardSetup().then((warning) => {866 if (warning) {867 this.showWarning(warning);868 }869 });870871 // Show startup warnings872 const { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages } = this.options;873874 if (migratedProviders && migratedProviders.length > 0) {875 this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`);876 }877878 const modelsJsonError = this.session.modelRuntime.getError();879 if (modelsJsonError) {880 this.showError(`models.json error: ${modelsJsonError}`);881 }882883 if (modelFallbackMessage) {884 this.showWarning(modelFallbackMessage);885 }886887 void this.maybeWarnAboutAnthropicSubscriptionAuth();888889 // Process initial messages890 if (initialMessage) {891 try {892 await this.session.prompt(initialMessage, { images: initialImages });893 } catch (error: unknown) {894 const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";895 this.showError(errorMessage);896 }897 }898899 if (initialMessages) {900 for (const message of initialMessages) {901 try {902 await this.session.prompt(message);903 } catch (error: unknown) {904 const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";905 this.showError(errorMessage);906 }907 }908 }909910 // Main interactive loop911 while (true) {912 const userInput = await this.getUserInput();913 try {914 await this.session.prompt(userInput);915 } catch (error: unknown) {916 const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";917 this.showError(errorMessage);918 }919 }920 }921922 private async checkForPackageUpdates(): Promise<string[]> {923 if (process.env.PI_OFFLINE) {924 return [];925 }926927 try {928 const packageManager = new DefaultPackageManager({929 cwd: this.sessionManager.getCwd(),930 agentDir: getAgentDir(),931 settingsManager: this.settingsManager,932 });933 const updates = await packageManager.checkForAvailableUpdates();934 return updates.map((update) => update.displayName);935 } catch {936 return [];937 }938 }939940 private async checkTmuxKeyboardSetup(): Promise<string | undefined> {941 if (!process.env.TMUX) return undefined;942943 const runTmuxShow = (option: string): Promise<string | undefined> => {944 return new Promise((resolve) => {945 const proc = spawn("tmux", ["show", "-gv", option], {946 stdio: ["ignore", "pipe", "ignore"],947 });948 let stdout = "";949 const timer = setTimeout(() => {950 proc.kill();951 resolve(undefined);952 }, 2000);953954 proc.stdout?.on("data", (data) => {955 stdout += data.toString();956 });957 proc.on("error", () => {958 clearTimeout(timer);959 resolve(undefined);960 });961 proc.on("close", (code) => {962 clearTimeout(timer);963 resolve(code === 0 ? stdout.trim() : undefined);964 });965 });966 };967968 const [extendedKeys, extendedKeysFormat] = await Promise.all([969 runTmuxShow("extended-keys"),970 runTmuxShow("extended-keys-format"),971 ]);972973 // If we couldn't query tmux (timeout, sandbox, etc.), don't warn974 if (extendedKeys === undefined) return undefined;975976 if (extendedKeys !== "on" && extendedKeys !== "always") {977 return "tmux extended-keys is off. Modified Enter keys may not work. Add `set -g extended-keys on` to ~/.tmux.conf and restart tmux.";978 }979980 if (extendedKeysFormat === "xterm") {981 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.";982 }983984 return undefined;985 }986987 /**988 * Get changelog entries to display on startup.989 * Only shows new entries since last seen version, skips for resumed sessions.990 */991 private getChangelogForDisplay(): string | undefined {992 // Skip changelog for resumed/continued sessions (already have messages)993 if (this.session.state.messages.length > 0) {994 return undefined;995 }996997 const lastVersion = this.settingsManager.getLastChangelogVersion();998 const changelogPath = getChangelogPath();999 const entries = parseChangelog(changelogPath);10001001 if (!lastVersion) {1002 // Fresh install - record the version, send telemetry, don't show changelog1003 this.settingsManager.setLastChangelogVersion(VERSION);1004 this.reportInstallTelemetry(VERSION);1005 return undefined;1006 }10071008 const newEntries = getNewEntries(entries, lastVersion);1009 if (newEntries.length > 0) {1010 this.settingsManager.setLastChangelogVersion(VERSION);1011 this.reportInstallTelemetry(VERSION);1012 return newEntries.map((e) => normalizeChangelogLinks(e.content, e)).join("\n\n");1013 }10141015 return undefined;1016 }10171018 private reportInstallTelemetry(version: string): void {1019 if (process.env.PI_OFFLINE) {1020 return;1021 }10221023 if (!isInstallTelemetryEnabled(this.settingsManager)) {1024 return;1025 }10261027 void fetch(`https://pi.dev/api/report-install?version=${encodeURIComponent(version)}`, {1028 headers: {1029 "User-Agent": getPiUserAgent(version),1030 },1031 signal: AbortSignal.timeout(5000),1032 })1033 .then(() => undefined)1034 .catch(() => undefined);1035 }10361037 private getMarkdownThemeWithSettings(): MarkdownTheme {1038 return {1039 ...getMarkdownTheme(),1040 codeBlockIndent: this.settingsManager.getCodeBlockIndent(),1041 };1042 }10431044 // =========================================================================1045 // Extension System1046 // =========================================================================10471048 private formatDisplayPath(p: string): string {1049 const home = os.homedir();1050 let result = p;10511052 // Replace home directory with ~1053 if (result.startsWith(home)) {1054 result = `~${result.slice(home.length)}`;1055 }10561057 return result;1058 }10591060 private formatExtensionDisplayPath(path: string): string {1061 let result = this.formatDisplayPath(path);1062 result = result.replace(/\/index\.ts$/, "").replace(/\/index\.js$/, "");1063 return result;1064 }10651066 private formatContextPath(p: string): string {1067 const cwd = path.resolve(this.sessionManager.getCwd());1068 const absolutePath = path.isAbsolute(p) ? path.resolve(p) : path.resolve(cwd, p);1069 const relativePath = getCwdRelativePath(absolutePath, cwd);1070 if (relativePath !== undefined) {1071 return relativePath;1072 }10731074 return this.formatDisplayPath(absolutePath);1075 }10761077 private getStartupExpansionState(): boolean {1078 return this.options.verbose || this.toolOutputExpanded;1079 }10801081 /**1082 * Get a short path relative to the package root for display.1083 */1084 private getShortPath(fullPath: string, sourceInfo?: SourceInfo): string {1085 const normalizedFullPath = fullPath.replace(/\\/g, "/");1086 const baseDir = sourceInfo?.baseDir;1087 if (baseDir && this.isPackageSource(sourceInfo)) {1088 const normalizedBaseDir = baseDir.replace(/\\/g, "/");1089 const npmRootMatch = normalizedBaseDir.match(/^(.*\/node_modules)\/(@?[^/]+(?:\/[^/]+)?)$/);1090 // If fullPath is under the same node_modules root as baseDir, preserve that relative topology.1091 if (npmRootMatch?.[1] && normalizedFullPath.startsWith(`${npmRootMatch[1]}/`)) {1092 return path.posix.relative(normalizedBaseDir, normalizedFullPath);1093 }10941095 const relativePath = path.relative(path.resolve(baseDir), path.resolve(fullPath));1096 if (1097 relativePath &&1098 relativePath !== "." &&1099 !relativePath.startsWith("..") &&1100 !relativePath.startsWith(`..${path.sep}`) &&1101 !path.isAbsolute(relativePath)1102 ) {1103 return relativePath.replace(/\\/g, "/");1104 }1105 }11061107 const source = sourceInfo?.source ?? "";1108 const npmMatch = normalizedFullPath.match(/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/);1109 if (npmMatch && source.startsWith("npm:")) {1110 return npmMatch[2];1111 }11121113 const gitMatch = normalizedFullPath.match(/git\/[^/]+\/[^/]+\/(.*)/);1114 if (gitMatch && source.startsWith("git:")) {1115 return gitMatch[1];1116 }11171118 return this.formatDisplayPath(fullPath);1119 }11201121 private getCompactPathLabel(resourcePath: string, sourceInfo?: SourceInfo): string {1122 const shortPath = this.getShortPath(resourcePath, sourceInfo);1123 const normalizedPath = shortPath.replace(/\\/g, "/");1124 const segments = normalizedPath.split("/").filter((segment) => segment.length > 0 && segment !== "~");1125 if (segments.length > 0) {1126 return segments[segments.length - 1]!;1127 }1128 return shortPath;1129 }11301131 private getCompactPackageSourceLabel(sourceInfo?: SourceInfo): string {1132 const source = sourceInfo?.source ?? "";1133 if (source.startsWith("npm:")) {1134 return source.slice("npm:".length) || source;1135 }11361137 const gitSource = parseGitUrl(source);1138 if (gitSource) {1139 return gitSource.path || source;1140 }11411142 return source;1143 }11441145 private getCompactExtensionLabel(resourcePath: string, sourceInfo?: SourceInfo): string {1146 if (!this.isPackageSource(sourceInfo)) {1147 return this.getCompactPathLabel(resourcePath, sourceInfo);1148 }11491150 const sourceLabel = this.getCompactPackageSourceLabel(sourceInfo);1151 if (!sourceLabel) {1152 return this.getCompactPathLabel(resourcePath, sourceInfo);1153 }11541155 const shortPath = this.getShortPath(resourcePath, sourceInfo).replace(/\\/g, "/");1156 const packagePath = shortPath.startsWith("extensions/") ? shortPath.slice("extensions/".length) : shortPath;1157 const parsedPath = path.posix.parse(packagePath);11581159 if (parsedPath.name === "index") {1160 return !parsedPath.dir || parsedPath.dir === "." ? sourceLabel : `${sourceLabel}:${parsedPath.dir}`;1161 }11621163 return `${sourceLabel}:${packagePath}`;1164 }11651166 private getCompactDisplayPathSegments(resourcePath: string): string[] {1167 return this.formatDisplayPath(resourcePath)1168 .replace(/\\/g, "/")1169 .split("/")1170 .filter((segment) => segment.length > 0 && segment !== "~");1171 }11721173 private getCompactNonPackageExtensionLabel(1174 resourcePath: string,1175 index: number,1176 allPaths: Array<{ path: string; segments: string[] }>,1177 ): string {1178 const segments = allPaths[index]?.segments;1179 if (!segments || segments.length === 0) {1180 return this.getCompactPathLabel(resourcePath);1181 }11821183 for (let segmentCount = 1; segmentCount <= segments.length; segmentCount += 1) {1184 const candidate = segments.slice(-segmentCount).join("/");1185 const isUnique = allPaths.every((item, itemIndex) => {1186 if (itemIndex === index) {1187 return true;1188 }1189 return item.segments.slice(-segmentCount).join("/") !== candidate;1190 });11911192 if (isUnique) {1193 return candidate;1194 }1195 }11961197 return segments.join("/");1198 }11991200 private getCompactExtensionLabels(extensions: Array<{ path: string; sourceInfo?: SourceInfo }>): string[] {1201 const nonPackageExtensions = extensions1202 .map((extension) => {1203 const segments = this.getCompactDisplayPathSegments(extension.path);1204 const lastSegment = segments[segments.length - 1];1205 if (segments.length > 1 && (lastSegment === "index.ts" || lastSegment === "index.js")) {1206 segments.pop();1207 }1208 return {1209 path: extension.path,1210 sourceInfo: extension.sourceInfo,1211 segments,1212 };1213 })1214 .filter((extension) => !this.isPackageSource(extension.sourceInfo));12151216 return extensions.map((extension) => {1217 if (this.isPackageSource(extension.sourceInfo)) {1218 return this.getCompactExtensionLabel(extension.path, extension.sourceInfo);1219 }12201221 const nonPackageIndex = nonPackageExtensions.findIndex((item) => item.path === extension.path);1222 if (nonPackageIndex === -1) {1223 return this.getCompactPathLabel(extension.path, extension.sourceInfo);1224 }12251226 return this.getCompactNonPackageExtensionLabel(extension.path, nonPackageIndex, nonPackageExtensions);1227 });1228 }12291230 private getDisplaySourceInfo(sourceInfo?: SourceInfo): {1231 label: string;1232 scopeLabel?: string;1233 color: "accent" | "muted";1234 } {1235 const source = sourceInfo?.source ?? "local";1236 const scope = sourceInfo?.scope ?? "project";1237 if (source === "local") {1238 if (scope === "user") {1239 return { label: "user", color: "muted" };1240 }1241 if (scope === "project") {1242 return { label: "project", color: "muted" };1243 }1244 if (scope === "temporary") {1245 return { label: "path", scopeLabel: "temp", color: "muted" };1246 }1247 return { label: "path", color: "muted" };1248 }12491250 if (source === "cli") {1251 return { label: "path", scopeLabel: scope === "temporary" ? "temp" : undefined, color: "muted" };1252 }12531254 const scopeLabel =1255 scope === "user" ? "user" : scope === "project" ? "project" : scope === "temporary" ? "temp" : undefined;1256 return { label: source, scopeLabel, color: "accent" };1257 }12581259 private getScopeGroup(sourceInfo?: SourceInfo): "user" | "project" | "path" {1260 const source = sourceInfo?.source ?? "local";1261 const scope = sourceInfo?.scope ?? "project";1262 if (source === "cli" || scope === "temporary") return "path";1263 if (scope === "user") return "user";1264 if (scope === "project") return "project";1265 return "path";1266 }12671268 private isPackageSource(sourceInfo?: SourceInfo): boolean {1269 const source = sourceInfo?.source ?? "";1270 return source.startsWith("npm:") || source.startsWith("git:");1271 }12721273 private buildScopeGroups(items: Array<{ path: string; sourceInfo?: SourceInfo }>): Array<{1274 scope: "user" | "project" | "path";1275 paths: Array<{ path: string; sourceInfo?: SourceInfo }>;1276 packages: Map<string, Array<{ path: string; sourceInfo?: SourceInfo }>>;1277 }> {1278 const groups: Record<1279 "user" | "project" | "path",1280 {1281 scope: "user" | "project" | "path";1282 paths: Array<{ path: string; sourceInfo?: SourceInfo }>;1283 packages: Map<string, Array<{ path: string; sourceInfo?: SourceInfo }>>;1284 }1285 > = {1286 user: { scope: "user", paths: [], packages: new Map() },1287 project: { scope: "project", paths: [], packages: new Map() },1288 path: { scope: "path", paths: [], packages: new Map() },1289 };12901291 for (const item of items) {1292 const groupKey = this.getScopeGroup(item.sourceInfo);1293 const group = groups[groupKey];1294 const source = item.sourceInfo?.source ?? "local";12951296 if (this.isPackageSource(item.sourceInfo)) {1297 const list = group.packages.get(source) ?? [];1298 list.push(item);1299 group.packages.set(source, list);1300 } else {1301 group.paths.push(item);1302 }1303 }13041305 return [groups.project, groups.user, groups.path].filter(1306 (group) => group.paths.length > 0 || group.packages.size > 0,1307 );1308 }13091310 private formatScopeGroups(1311 groups: Array<{1312 scope: "user" | "project" | "path";1313 paths: Array<{ path: string; sourceInfo?: SourceInfo }>;1314 packages: Map<string, Array<{ path: string; sourceInfo?: SourceInfo }>>;1315 }>,1316 options: {1317 formatPath: (item: { path: string; sourceInfo?: SourceInfo }) => string;1318 formatPackagePath: (item: { path: string; sourceInfo?: SourceInfo }, source: string) => string;1319 },1320 ): string {1321 const lines: string[] = [];13221323 for (const group of groups) {1324 lines.push(` ${theme.fg("accent", group.scope)}`);13251326 const sortedPaths = [...group.paths].sort((a, b) => a.path.localeCompare(b.path));1327 for (const item of sortedPaths) {1328 lines.push(theme.fg("dim", ` ${options.formatPath(item)}`));1329 }13301331 const sortedPackages = Array.from(group.packages.entries()).sort(([a], [b]) => a.localeCompare(b));1332 for (const [source, items] of sortedPackages) {1333 lines.push(` ${theme.fg("mdLink", source)}`);1334 const sortedPackagePaths = [...items].sort((a, b) => a.path.localeCompare(b.path));1335 for (const item of sortedPackagePaths) {1336 lines.push(theme.fg("dim", ` ${options.formatPackagePath(item, source)}`));1337 }1338 }1339 }13401341 return lines.join("\n");1342 }13431344 private findSourceInfoForPath(p: string, sourceInfos: Map<string, SourceInfo>): SourceInfo | undefined {1345 const exact = sourceInfos.get(p);1346 if (exact) return exact;13471348 let current = p;1349 while (current.includes("/")) {1350 current = current.substring(0, current.lastIndexOf("/"));1351 const parent = sourceInfos.get(current);1352 if (parent) return parent;1353 }13541355 return undefined;1356 }13571358 private formatPathWithSource(p: string, sourceInfo?: SourceInfo): string {1359 if (sourceInfo) {1360 const shortPath = this.getShortPath(p, sourceInfo);1361 const { label, scopeLabel } = this.getDisplaySourceInfo(sourceInfo);1362 const labelText = scopeLabel ? `${label} (${scopeLabel})` : label;1363 return `${labelText} ${shortPath}`;1364 }1365 return this.formatDisplayPath(p);1366 }13671368 private formatDiagnostics(diagnostics: readonly ResourceDiagnostic[], sourceInfos: Map<string, SourceInfo>): string {1369 const lines: string[] = [];13701371 // Group collision diagnostics by name1372 const collisions = new Map<string, ResourceDiagnostic[]>();1373 const otherDiagnostics: ResourceDiagnostic[] = [];13741375 for (const d of diagnostics) {1376 if (d.type === "collision" && d.collision) {1377 const list = collisions.get(d.collision.name) ?? [];1378 list.push(d);1379 collisions.set(d.collision.name, list);1380 } else {1381 otherDiagnostics.push(d);1382 }1383 }13841385 // Format collision diagnostics grouped by name1386 for (const [name, collisionList] of collisions) {1387 const first = collisionList[0]?.collision;1388 if (!first) continue;1389 lines.push(theme.fg("warning", ` "${name}" collision:`));1390 lines.push(1391 theme.fg(1392 "dim",1393 ` ${theme.fg("success", "✓")} ${this.formatPathWithSource(first.winnerPath, this.findSourceInfoForPath(first.winnerPath, sourceInfos))}`,1394 ),1395 );1396 for (const d of collisionList) {1397 if (d.collision) {1398 lines.push(1399 theme.fg(1400 "dim",1401 ` ${theme.fg("warning", "✗")} ${this.formatPathWithSource(d.collision.loserPath, this.findSourceInfoForPath(d.collision.loserPath, sourceInfos))} (skipped)`,1402 ),1403 );1404 }1405 }1406 }14071408 for (const d of otherDiagnostics) {1409 if (d.path) {1410 const formattedPath = this.formatPathWithSource(d.path, this.findSourceInfoForPath(d.path, sourceInfos));1411 lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${formattedPath}`));1412 lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`));1413 } else {1414 lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`));1415 }1416 }14171418 return lines.join("\n");1419 }14201421 private showLoadedResources(options?: {1422 extensions?: Array<{ path: string; sourceInfo?: SourceInfo }>;1423 force?: boolean;1424 showDiagnosticsWhenQuiet?: boolean;1425 }): void {1426 // Resource rendering is idempotent; chat clears no longer clear this separate container.1427 this.loadedResourcesContainer.clear();14281429 const showListing = options?.force || this.options.verbose || !this.settingsManager.getQuietStartup();1430 const showDiagnostics = showListing || options?.showDiagnosticsWhenQuiet === true;1431 if (!showListing && !showDiagnostics) {1432 return;1433 }14341435 const sectionHeader = (name: string, color: ThemeColor = "mdHeading") => theme.fg(color, `[${name}]`);1436 const formatCompactList = (items: string[], options?: { sort?: boolean }): string => {1437 const labels = items.map((item) => item.trim()).filter((item) => item.length > 0);1438 if (options?.sort !== false) {1439 labels.sort((a, b) => a.localeCompare(b));1440 }1441 return theme.fg("dim", ` ${labels.join(", ")}`);1442 };1443 const addLoadedSection = (1444 name: string,1445 collapsedBody: string,1446 expandedBody = collapsedBody,1447 color: ThemeColor = "mdHeading",1448 ): void => {1449 const section = new ExpandableText(1450 () => `${sectionHeader(name, color)}\n${collapsedBody}`,1451 () => `${sectionHeader(name, color)}\n${expandedBody}`,1452 this.getStartupExpansionState(),1453 0,1454 0,1455 );1456 this.loadedResourcesContainer.addChild(section);1457 this.loadedResourcesContainer.addChild(new Spacer(1));1458 };14591460 const skillsResult = this.session.resourceLoader.getSkills();1461 const promptsResult = this.session.resourceLoader.getPrompts();1462 const themesResult = this.session.resourceLoader.getThemes();1463 const extensions =1464 options?.extensions ??1465 this.session.resourceLoader1466 .getExtensions()1467 .extensions.filter((extension) => !extension.hidden)1468 .map((extension) => ({1469 path: extension.path,1470 sourceInfo: extension.sourceInfo,1471 }));1472 const sourceInfos = new Map<string, SourceInfo>();1473 for (const extension of extensions) {1474 if (extension.sourceInfo) {1475 sourceInfos.set(extension.path, extension.sourceInfo);1476 }1477 }1478 for (const skill of skillsResult.skills) {1479 if (skill.sourceInfo) {1480 sourceInfos.set(skill.filePath, skill.sourceInfo);1481 }1482 }1483 for (const prompt of promptsResult.prompts) {1484 if (prompt.sourceInfo) {1485 sourceInfos.set(prompt.filePath, prompt.sourceInfo);1486 }1487 }1488 for (const loadedTheme of themesResult.themes) {1489 if (loadedTheme.sourcePath && loadedTheme.sourceInfo) {1490 sourceInfos.set(loadedTheme.sourcePath, loadedTheme.sourceInfo);1491 }1492 }14931494 if (showListing) {1495 const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles;1496 if (contextFiles.length > 0) {1497 this.loadedResourcesContainer.addChild(new Spacer(1));1498 const contextList = contextFiles1499 .map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`))1500 .join("\n");1501 const contextCompactList = formatCompactList(1502 contextFiles.map((contextFile) => this.formatContextPath(contextFile.path)),1503 { sort: false },1504 );1505 addLoadedSection("Context", contextCompactList, contextList);1506 }15071508 const skills = skillsResult.skills;1509 if (skills.length > 0) {1510 const groups = this.buildScopeGroups(1511 skills.map((skill) => ({ path: skill.filePath, sourceInfo: skill.sourceInfo })),1512 );1513 const skillList = this.formatScopeGroups(groups, {1514 formatPath: (item) => this.formatDisplayPath(item.path),1515 formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),1516 });1517 const skillCompactList = formatCompactList(skills.map((skill) => skill.name));1518 addLoadedSection("Skills", skillCompactList, skillList);1519 }15201521 const templates = this.session.promptTemplates;1522 if (templates.length > 0) {1523 const groups = this.buildScopeGroups(1524 templates.map((template) => ({ path: template.filePath, sourceInfo: template.sourceInfo })),1525 );1526 const templateByPath = new Map(templates.map((t) => [t.filePath, t]));1527 const templateList = this.formatScopeGroups(groups, {1528 formatPath: (item) => {1529 const template = templateByPath.get(item.path);1530 return template ? `/${template.name}` : this.formatDisplayPath(item.path);1531 },1532 formatPackagePath: (item) => {1533 const template = templateByPath.get(item.path);1534 return template ? `/${template.name}` : this.formatDisplayPath(item.path);1535 },1536 });1537 const promptCompactList = formatCompactList(templates.map((template) => `/${template.name}`));1538 addLoadedSection("Prompts", promptCompactList, templateList);1539 }15401541 if (extensions.length > 0) {1542 const groups = this.buildScopeGroups(extensions);1543 const extList = this.formatScopeGroups(groups, {1544 formatPath: (item) => this.formatExtensionDisplayPath(item.path),1545 formatPackagePath: (item) =>1546 this.formatExtensionDisplayPath(this.getShortPath(item.path, item.sourceInfo)),1547 });1548 const extensionCompactList = formatCompactList(this.getCompactExtensionLabels(extensions));1549 addLoadedSection("Extensions", extensionCompactList, extList, "mdHeading");1550 }15511552 // Show loaded themes (excluding built-in)1553 const loadedThemes = themesResult.themes;1554 const customThemes = loadedThemes.filter((t) => t.sourcePath);1555 if (customThemes.length > 0) {1556 const groups = this.buildScopeGroups(1557 customThemes.map((loadedTheme) => ({1558 path: loadedTheme.sourcePath!,1559 sourceInfo: loadedTheme.sourceInfo,1560 })),1561 );1562 const themeList = this.formatScopeGroups(groups, {1563 formatPath: (item) => this.formatDisplayPath(item.path),1564 formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),1565 });1566 const themeCompactList = formatCompactList(1567 customThemes.map(1568 (loadedTheme) =>1569 loadedTheme.name ?? this.getCompactPathLabel(loadedTheme.sourcePath!, loadedTheme.sourceInfo),1570 ),1571 );1572 addLoadedSection("Themes", themeCompactList, themeList);1573 }1574 }15751576 if (showDiagnostics) {1577 const skillDiagnostics = skillsResult.diagnostics;1578 if (skillDiagnostics.length > 0) {1579 const warningLines = this.formatDiagnostics(skillDiagnostics, sourceInfos);1580 this.loadedResourcesContainer.addChild(1581 new Text(`${theme.fg("warning", "[Skill conflicts]")}\n${warningLines}`, 0, 0),1582 );1583 this.loadedResourcesContainer.addChild(new Spacer(1));1584 }15851586 const promptDiagnostics = promptsResult.diagnostics;1587 if (promptDiagnostics.length > 0) {1588 const warningLines = this.formatDiagnostics(promptDiagnostics, sourceInfos);1589 this.loadedResourcesContainer.addChild(1590 new Text(`${theme.fg("warning", "[Prompt conflicts]")}\n${warningLines}`, 0, 0),1591 );1592 this.loadedResourcesContainer.addChild(new Spacer(1));1593 }15941595 const extensionDiagnostics: ResourceDiagnostic[] = [];1596 const extensionErrors = this.session.resourceLoader.getExtensions().errors;1597 if (extensionErrors.length > 0) {1598 for (const error of extensionErrors) {1599 extensionDiagnostics.push({ type: "error", message: error.error, path: error.path });1600 }1601 }16021603 const commandDiagnostics = this.session.extensionRunner.getCommandDiagnostics();1604 extensionDiagnostics.push(...commandDiagnostics);1605 extensionDiagnostics.push(...this.getBuiltInCommandConflictDiagnostics(this.session.extensionRunner));16061607 const shortcutDiagnostics = this.session.extensionRunner.getShortcutDiagnostics();1608 extensionDiagnostics.push(...shortcutDiagnostics);16091610 if (extensionDiagnostics.length > 0) {1611 const warningLines = this.formatDiagnostics(extensionDiagnostics, sourceInfos);1612 this.loadedResourcesContainer.addChild(1613 new Text(`${theme.fg("warning", "[Extension issues]")}\n${warningLines}`, 0, 0),1614 );1615 this.loadedResourcesContainer.addChild(new Spacer(1));1616 }16171618 const themeDiagnostics = themesResult.diagnostics;1619 if (themeDiagnostics.length > 0) {1620 const warningLines = this.formatDiagnostics(themeDiagnostics, sourceInfos);1621 this.loadedResourcesContainer.addChild(1622 new Text(`${theme.fg("warning", "[Theme conflicts]")}\n${warningLines}`, 0, 0),1623 );1624 this.loadedResourcesContainer.addChild(new Spacer(1));1625 }1626 }1627 }16281629 /**1630 * Initialize the extension system with TUI-based UI context.1631 */1632 private async bindCurrentSessionExtensions(): Promise<void> {1633 const uiContext = this.createExtensionUIContext();1634 await this.session.bindExtensions({1635 uiContext,1636 mode: "tui",1637 abortHandler: () => {1638 this.restoreQueuedMessagesToEditor({ abort: true });1639 },1640 commandContextActions: {1641 waitForIdle: () => this.session.waitForIdle(),1642 newSession: async (options) => {1643 this.clearStatusIndicator();1644 try {1645 return await this.runtimeHost.newSession(options);1646 } catch (error: unknown) {1647 return this.handleFatalRuntimeError("Failed to create session", error);1648 }1649 },1650 fork: async (entryId, options) => {1651 try {1652 const result = await this.runtimeHost.fork(entryId, options);1653 if (!result.cancelled) {1654 this.editor.setText(result.selectedText ?? "");1655 this.showStatus("Forked to new session");1656 }1657 return { cancelled: result.cancelled };1658 } catch (error: unknown) {1659 return this.handleFatalRuntimeError("Failed to fork session", error);1660 }1661 },1662 navigateTree: async (targetId, options) => {1663 const result = await this.session.navigateTree(targetId, {1664 summarize: options?.summarize,1665 customInstructions: options?.customInstructions,1666 replaceInstructions: options?.replaceInstructions,1667 label: options?.label,1668 });1669 if (result.cancelled) {1670 return { cancelled: true };1671 }16721673 this.chatContainer.clear();1674 this.renderInitialMessages();1675 if (result.editorText && !this.editor.getText().trim()) {1676 this.editor.setText(result.editorText);1677 }1678 this.showStatus("Navigated to selected point");1679 void this.flushCompactionQueue({ willRetry: false });1680 return { cancelled: false };1681 },1682 switchSession: async (sessionPath, options) => {1683 return this.handleResumeSession(sessionPath, options);1684 },1685 reload: async () => {1686 await this.handleReloadCommand();1687 },1688 },1689 shutdownHandler: () => {1690 this.shutdownRequested = true;1691 if (this.session.isIdle) {1692 void this.shutdown();1693 }1694 },1695 onError: (error) => {1696 this.showExtensionError(error.extensionPath, error.error, error.stack);1697 },1698 });16991700 setRegisteredThemes(this.session.resourceLoader.getThemes().themes);1701 this.setupAutocompleteProvider();17021703 const extensionRunner = this.session.extensionRunner;1704 this.setupExtensionShortcuts(extensionRunner);1705 this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true });1706 this.showStartupNoticesIfNeeded();1707 }17081709 private applyRuntimeSettings(): void {1710 configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs());1711 this.footer.setSession(this.session);1712 this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);1713 this.footerDataProvider.setCwd(this.sessionManager.getCwd());1714 this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();1715 this.outputPad = this.settingsManager.getOutputPad();1716 this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());1717 const clearOnShrink = this.settingsManager.getClearOnShrink();1718 this.ui.setClearOnShrink(clearOnShrink);1719 if (!clearOnShrink && !this.activeStatusIndicator) {1720 this.statusContainer.clear();1721 }1722 const editorPaddingX = this.settingsManager.getEditorPaddingX();1723 const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();1724 this.defaultEditor.setPaddingX(editorPaddingX);1725 this.defaultEditor.setAutocompleteMaxVisible(autocompleteMaxVisible);1726 if (this.editor !== this.defaultEditor) {1727 this.editor.setPaddingX?.(editorPaddingX);1728 this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible);1729 }1730 }17311732 private async rebindCurrentSession(options: { renderBeforeBind?: boolean } = {}): Promise<void> {1733 const session = this.session;17341735 this.unsubscribe?.();1736 this.unsubscribe = undefined;1737 this.applyRuntimeSettings();17381739 if (options.renderBeforeBind) {1740 this.renderCurrentSessionState();1741 this.subscribeToAgent();1742 }17431744 await this.bindCurrentSessionExtensions();17451746 if (this.session !== session) {1747 return;1748 }17491750 if (!options.renderBeforeBind) {1751 this.subscribeToAgent();1752 }17531754 await this.updateAvailableProviderCount();1755 this.updateEditorBorderColor();1756 this.updateTerminalTitle();1757 }17581759 private async handleFatalRuntimeError(prefix: string, error: unknown): Promise<never> {1760 const message = error instanceof Error ? error.message : String(error);1761 this.showError(`${prefix}: ${message}`);1762 stopThemeWatcher();1763 this.stop();1764 process.exit(1);1765 }17661767 private renderCurrentSessionState(): void {1768 this.loadedResourcesContainer.clear();1769 this.chatContainer.clear();1770 this.pendingMessagesContainer.clear();1771 this.compactionQueuedMessages = [];1772 this.streamingComponent = undefined;1773 this.streamingMessage = undefined;1774 this.pendingTools.clear();1775 this.renderInitialMessages();1776 }17771778 /**1779 * Get a registered tool definition by name (for custom rendering).1780 */1781 private getRegisteredToolDefinition(toolName: string) {1782 return this.session.getToolDefinition(toolName);1783 }17841785 /**1786 * Set up keyboard shortcuts registered by extensions.1787 */1788 private setupExtensionShortcuts(extensionRunner: ExtensionRunner): void {1789 const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig());1790 if (shortcuts.size === 0) return;17911792 // Create a context for shortcut handlers1793 const createContext = (): ExtensionContext => ({1794 ui: this.createExtensionUIContext(),1795 mode: "tui",1796 hasUI: true,1797 cwd: this.sessionManager.getCwd(),1798 sessionManager: this.sessionManager,1799 modelRegistry: extensionRunner.getModelRegistry(),1800 model: this.session.model,1801