ππ Agent

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 */
5
6import * 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";
161
162/** Interface for components that can be expanded/collapsed */
163interface Expandable {
164 setExpanded(expanded: boolean): void;
165}
166
167function isExpandable(obj: unknown): obj is Expandable {
168 return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
169}
170
171class ExpandableText extends Text implements Expandable {
172 private readonly getCollapsedText: () => string;
173 private readonly getExpandedText: () => string;
174
175 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 }
186
187 setExpanded(expanded: boolean): void {
188 this.setText(expanded ? this.getExpandedText() : this.getCollapsedText());
189 }
190}
191
192type CompactionQueuedMessage = {
193 text: string;
194 mode: "steer" | "followUp";
195};
196
197type RenderSessionItem = AgentMessage | Extract<SessionEntry, { type: "custom" }>;
198
199function isCustomSessionEntry(item: RenderSessionItem): item is Extract<SessionEntry, { type: "custom" }> {
200 return "type" in item && item.type === "custom";
201}
202
203const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN"]);
204
205function 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}
212
213const 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.";
215
216function isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean {
217 return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");
218}
219
220function isUnknownModel(model: Model<any> | undefined): boolean {
221 return !!model && model.provider === "unknown" && model.id === "unknown" && model.api === "unknown";
222}
223
224function 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}
230
231export function formatResumeCommand(sessionManager: SessionManager): string | undefined {
232 if (!process.stdout.isTTY) return undefined;
233 if (!sessionManager.isPersisted()) return undefined;
234
235 const sessionFile = sessionManager.getSessionFile();
236 if (!sessionFile || !fs.existsSync(sessionFile)) return undefined;
237
238 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}
245
246function hasDefaultModelProvider(providerId: string): providerId is keyof typeof defaultModelPerProvider {
247 return providerId in defaultModelPerProvider;
248}
249
250type LoginProviderCompletionOption = {
251 id: string;
252 name: string;
253 authTypes: AuthSelectorProvider["authType"][];
254};
255
256const AUTH_TYPE_ORDER = { oauth: 0, api_key: 1 } satisfies Record<AuthSelectorProvider["authType"], number>;
257
258function 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}
268
269function 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}
290
291function getLoginProviderSearchText(provider: LoginProviderCompletionOption): string {
292 const authTypes = provider.authTypes
293 .map((authType) => `${authType} ${formatAuthSelectorProviderType(authType)}`)
294 .join(" ");
295 return `${provider.id} ${provider.name} ${authTypes}`;
296}
297
298function formatLoginProviderCompletionDescription(provider: LoginProviderCompletionOption): string {
299 const authTypes = provider.authTypes.map(formatAuthSelectorProviderType).join("/");
300 return provider.name === provider.id ? authTypes : `${provider.name} · ${authTypes}`;
301}
302
303/**
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}
322
323export 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;
353
354 private lastSigintTime = 0;
355 private lastEscapeTime = 0;
356 private changelogMarkdown: string | undefined = undefined;
357 private startupNoticesShown = false;
358 private anthropicSubscriptionWarningShown = false;
359
360 // Status line tracking (for mutating immediately-sequential status updates)
361 private lastStatusSpacer: Spacer | undefined = undefined;
362 private lastStatusText: Text | undefined = undefined;
363
364 // Streaming message tracking
365 private streamingComponent: AssistantMessageComponent | undefined = undefined;
366 private streamingMessage: AssistantMessage | undefined = undefined;
367
368 // Tool execution tracking: toolCallId -> component
369 private pendingTools = new Map<string, ToolExecutionComponent>();
370
371 // Tool output expansion state
372 private toolOutputExpanded = false;
373
374 // Thinking block visibility state
375 private hideThinkingBlock = false;
376 private outputPad = 1;
377
378 // Skill commands: command name -> skill file path
379 private skillCommands = new Map<string, string>();
380
381 // Agent subscription unsubscribe function
382 private unsubscribe?: () => void;
383 private signalCleanupHandlers: Array<() => void> = [];
384
385 // Track if editor is in bash mode (text starts with !)
386 private isBashMode = false;
387
388 // Track current bash execution component
389 private bashComponent: BashExecutionComponent | undefined = undefined;
390
391 // Track pending bash components (shown in pending area, moved to chat on submit)
392 private pendingBashComponents: BashExecutionComponent[] = [];
393
394 // Auto-compaction state
395 private autoCompactionEscapeHandler?: () => void;
396
397 // Auto-retry state
398 private retryEscapeHandler?: () => void;
399
400 // Messages queued while compaction is running
401 private compactionQueuedMessages: CompactionQueuedMessage[] = [];
402
403 // Shutdown state
404 private shutdownRequested = false;
405
406 // Extension UI state
407 private extensionSelector: ExtensionSelectorComponent | undefined = undefined;
408 private extensionInput: ExtensionInputComponent | undefined = undefined;
409 private extensionEditor: ExtensionEditorComponent | undefined = undefined;
410 private extensionTerminalInputUnsubscribers = new Set<() => void>();
411
412 // 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;
417
418 // Custom footer from extension (undefined = use built-in footer)
419 private customFooter: (Component & { dispose?(): void }) | undefined = undefined;
420
421 // Header container that holds the built-in or custom header
422 private headerContainer: Container;
423
424 // Built-in header (logo + keybinding hints + changelog)
425 private builtInHeader: Component | undefined = undefined;
426
427 // Custom header from extension (undefined = use built-in header)
428 private customHeader: (Component & { dispose?(): void }) | undefined = undefined;
429
430 private options: InteractiveModeOptions;
431 private autoTrustOnReloadCwd: string | undefined;
432 private themeController: InteractiveThemeController;
433
434 // Convenience accessors
435 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 }
447
448 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);
482
483 // Load hide thinking block setting
484 this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
485 this.outputPad = this.settingsManager.getOutputPad();
486
487 // Register themes from resource loader and initialize
488 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 }
496
497 private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined {
498 if (!sourceInfo) {
499 return undefined;
500 }
501
502 const scopePrefix = sourceInfo.scope === "user" ? "u" : sourceInfo.scope === "project" ? "p" : "t";
503 const source = sourceInfo.source.trim();
504
505 if (source === "auto" || source === "local" || source === "cli") {
506 return scopePrefix;
507 }
508
509 if (source.startsWith("npm:")) {
510 return `${scopePrefix}:${source}`;
511 }
512
513 const gitSource = parseGitUrl(source);
514 if (gitSource) {
515 const ref = gitSource.ref ? `@${gitSource.ref}` : "";
516 return `${scopePrefix}:git:${gitSource.host}/${gitSource.path}${ref}`;
517 }
518
519 return scopePrefix;
520 }
521
522 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 }
529
530 private getBuiltInCommandConflictDiagnostics(extensionRunner: ExtensionRunner): ResourceDiagnostic[] {
531 const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
532 return extensionRunner
533 .getRegisteredCommands()
534 .filter((command) => builtinNames.has(command.name))
535 .map((command) => ({
536 type: "warning" as const,
537 message:
538 command.invocationName === command.name
539 ? `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 }
544
545 private createBaseAutocompleteProvider(): AutocompleteProvider {
546 // Define commands for autocomplete
547 const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({
548 name: command.name,
549 description: command.description,
550 ...(command.argumentHint && { argumentHint: command.argumentHint }),
551 }));
552
553 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 > 0
559 ? this.session.scopedModels.map((s) => s.model)
560 : await this.session.modelRuntime.getAvailable();
561
562 if (models.length === 0) return null;
563
564 // Create items with provider/id format
565 const items = models.map((m) => ({
566 id: m.id,
567 provider: m.provider,
568 name: m.name,
569 label: `${m.provider}/${m.id}`,
570 }));
571
572 return createFuzzyAutocompleteItems(items, prefix, getModelSearchText, (item) => ({
573 value: item.label,
574 label: item.id,
575 description: item.provider,
576 }));
577 };
578 }
579
580 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 }
591
592 // Convert prompt templates to SlashCommand format for autocomplete
593 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 }));
598
599 // Convert extension commands to SlashCommand format
600 const builtinCommandNames = new Set(slashCommands.map((c) => c.name));
601 const extensionCommands: SlashCommand[] = this.session.extensionRunner
602 .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 }));
609
610 // 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 }
623
624 return new CombinedAutocompleteProvider(
625 [...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList],
626 this.sessionManager.getCwd(),
627 this.fdPath,
628 );
629 }
630
631 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 }
641
642 this.autocompleteProvider = provider;
643 this.defaultEditor.setAutocompleteProvider(provider);
644 if (this.editor !== this.defaultEditor) {
645 this.editor.setAutocompleteProvider?.(provider);
646 }
647 }
648
649 private showStartupNoticesIfNeeded(): void {
650 if (this.startupNoticesShown) {
651 return;
652 }
653 this.startupNoticesShown = true;
654
655 if (!this.changelogMarkdown) {
656 return;
657 }
658
659 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 }
678
679 async init(): Promise<void> {
680 if (this.isInitialized) return;
681
682 this.registerSignalHandlers();
683
684 // Load changelog (only show new entries, skip for resumed sessions)
685 this.changelogMarkdown = this.getChangelogForDisplay();
686
687 // 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 commands
689 const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]);
690 this.fdPath = fdPath;
691
692 if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) {
693 const modelList = this.session.scopedModels
694 .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 > 0
702 ? theme.fg("muted", ` (${formatKeyText(cycleKeys.join("/"), { capitalize: true })} to cycle)`)
703 : "";
704 console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
705 }
706
707 // 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);
711
712 this.ui.addChild(this.chatContainer);
713 this.ui.addChild(this.pendingMessagesContainer);
714 this.ui.addChild(this.statusContainer);
715 this.renderWidgets(); // Initialize with default spacer
716 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);
721
722 this.setupKeyHandlers();
723 this.setupEditorSubmitHandler();
724
725 // Start the UI before initializing extensions so session_start handlers can use interactive dialogs
726 this.ui.start();
727 this.isInitialized = true;
728
729 await this.themeController.applyFromSettings();
730
731 // 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}`);
734
735 // Build startup instructions using keybinding hint helpers
736 const hint = (keybinding: AppKeybinding, description: string) => keyHint(keybinding, description);
737
738 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 );
781
782 // Setup UI layout
783 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 silenced
788 this.builtInHeader = new Text("", 0, 0);
789 this.headerContainer.addChild(this.builtInHeader);
790 }
791 this.ui.requestRender();
792
793 // Initialize extensions first so resources are shown before messages
794 await this.rebindCurrentSession();
795
796 // Render initial messages AFTER showing loaded resources
797 this.renderInitialMessages();
798
799 // Set up theme file watcher
800 onThemeChange(() => {
801 this.ui.invalidate();
802 this.updateEditorBorderColor();
803 this.ui.requestRender();
804 });
805
806 // Set up git branch watcher (uses provider instead of footer)
807 this.footerDataProvider.onBranchChange(() => {
808 this.ui.requestRender();
809 });
810
811 // Initialize available provider count for footer display
812 await this.updateAvailableProviderCount();
813 }
814
815 /**
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 }
827
828 /**
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();
834
835 if (!process.env.PI_OFFLINE) {
836 void this.session.modelRuntime
837 .refresh()
838 .then(() => this.updateAvailableProviderCount())
839 .catch(() => {});
840 }
841
842 // Start version check asynchronously
843 checkForNewPiVersion(this.version).then((newRelease) => {
844 if (newRelease) {
845 this.showNewVersionNotification(newRelease);
846 }
847 });
848
849 // Start package update check asynchronously
850 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 checking
858 // extension package versions. Restore Pi's title after the startup check.
859 if (process.platform === "win32" && this.isInitialized) {
860 this.updateTerminalTitle();
861 }
862 });
863
864 // Check tmux keyboard setup asynchronously
865 this.checkTmuxKeyboardSetup().then((warning) => {
866 if (warning) {
867 this.showWarning(warning);
868 }
869 });
870
871 // Show startup warnings
872 const { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages } = this.options;
873
874 if (migratedProviders && migratedProviders.length > 0) {
875 this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`);
876 }
877
878 const modelsJsonError = this.session.modelRuntime.getError();
879 if (modelsJsonError) {
880 this.showError(`models.json error: ${modelsJsonError}`);
881 }
882
883 if (modelFallbackMessage) {
884 this.showWarning(modelFallbackMessage);
885 }
886
887 void this.maybeWarnAboutAnthropicSubscriptionAuth();
888
889 // Process initial messages
890 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 }
898
899 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 }
909
910 // Main interactive loop
911 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 }
921
922 private async checkForPackageUpdates(): Promise<string[]> {
923 if (process.env.PI_OFFLINE) {
924 return [];
925 }
926
927 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 }
939
940 private async checkTmuxKeyboardSetup(): Promise<string | undefined> {
941 if (!process.env.TMUX) return undefined;
942
943 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);
953
954 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 };
967
968 const [extendedKeys, extendedKeysFormat] = await Promise.all([
969 runTmuxShow("extended-keys"),
970 runTmuxShow("extended-keys-format"),
971 ]);
972
973 // If we couldn't query tmux (timeout, sandbox, etc.), don't warn
974 if (extendedKeys === undefined) return undefined;
975
976 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 }
979
980 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 }
983
984 return undefined;
985 }
986
987 /**
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 }
996
997 const lastVersion = this.settingsManager.getLastChangelogVersion();
998 const changelogPath = getChangelogPath();
999 const entries = parseChangelog(changelogPath);
1000
1001 if (!lastVersion) {
1002 // Fresh install - record the version, send telemetry, don't show changelog
1003 this.settingsManager.setLastChangelogVersion(VERSION);
1004 this.reportInstallTelemetry(VERSION);
1005 return undefined;
1006 }
1007
1008 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 }
1014
1015 return undefined;
1016 }
1017
1018 private reportInstallTelemetry(version: string): void {
1019 if (process.env.PI_OFFLINE) {
1020 return;
1021 }
1022
1023 if (!isInstallTelemetryEnabled(this.settingsManager)) {
1024 return;
1025 }
1026
1027 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 }
1036
1037 private getMarkdownThemeWithSettings(): MarkdownTheme {
1038 return {
1039 ...getMarkdownTheme(),
1040 codeBlockIndent: this.settingsManager.getCodeBlockIndent(),
1041 };
1042 }
1043
1044 // =========================================================================
1045 // Extension System
1046 // =========================================================================
1047
1048 private formatDisplayPath(p: string): string {
1049 const home = os.homedir();
1050 let result = p;
1051
1052 // Replace home directory with ~
1053 if (result.startsWith(home)) {
1054 result = `~${result.slice(home.length)}`;
1055 }
1056
1057 return result;
1058 }
1059
1060 private formatExtensionDisplayPath(path: string): string {
1061 let result = this.formatDisplayPath(path);
1062 result = result.replace(/\/index\.ts$/, "").replace(/\/index\.js$/, "");
1063 return result;
1064 }
1065
1066 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 }
1073
1074 return this.formatDisplayPath(absolutePath);
1075 }
1076
1077 private getStartupExpansionState(): boolean {
1078 return this.options.verbose || this.toolOutputExpanded;
1079 }
1080
1081 /**
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 }
1094
1095 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 }
1106
1107 const source = sourceInfo?.source ?? "";
1108 const npmMatch = normalizedFullPath.match(/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/);
1109 if (npmMatch && source.startsWith("npm:")) {
1110 return npmMatch[2];
1111 }
1112
1113 const gitMatch = normalizedFullPath.match(/git\/[^/]+\/[^/]+\/(.*)/);
1114 if (gitMatch && source.startsWith("git:")) {
1115 return gitMatch[1];
1116 }
1117
1118 return this.formatDisplayPath(fullPath);
1119 }
1120
1121 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 }
1130
1131 private getCompactPackageSourceLabel(sourceInfo?: SourceInfo): string {
1132 const source = sourceInfo?.source ?? "";
1133 if (source.startsWith("npm:")) {
1134 return source.slice("npm:".length) || source;
1135 }
1136
1137 const gitSource = parseGitUrl(source);
1138 if (gitSource) {
1139 return gitSource.path || source;
1140 }
1141
1142 return source;
1143 }
1144
1145 private getCompactExtensionLabel(resourcePath: string, sourceInfo?: SourceInfo): string {
1146 if (!this.isPackageSource(sourceInfo)) {
1147 return this.getCompactPathLabel(resourcePath, sourceInfo);
1148 }
1149
1150 const sourceLabel = this.getCompactPackageSourceLabel(sourceInfo);
1151 if (!sourceLabel) {
1152 return this.getCompactPathLabel(resourcePath, sourceInfo);
1153 }
1154
1155 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);
1158
1159 if (parsedPath.name === "index") {
1160 return !parsedPath.dir || parsedPath.dir === "." ? sourceLabel : `${sourceLabel}:${parsedPath.dir}`;
1161 }
1162
1163 return `${sourceLabel}:${packagePath}`;
1164 }
1165
1166 private getCompactDisplayPathSegments(resourcePath: string): string[] {
1167 return this.formatDisplayPath(resourcePath)
1168 .replace(/\\/g, "/")
1169 .split("/")
1170 .filter((segment) => segment.length > 0 && segment !== "~");
1171 }
1172
1173 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 }
1182
1183 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 });
1191
1192 if (isUnique) {
1193 return candidate;
1194 }
1195 }
1196
1197 return segments.join("/");
1198 }
1199
1200 private getCompactExtensionLabels(extensions: Array<{ path: string; sourceInfo?: SourceInfo }>): string[] {
1201 const nonPackageExtensions = extensions
1202 .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));
1215
1216 return extensions.map((extension) => {
1217 if (this.isPackageSource(extension.sourceInfo)) {
1218 return this.getCompactExtensionLabel(extension.path, extension.sourceInfo);
1219 }
1220
1221 const nonPackageIndex = nonPackageExtensions.findIndex((item) => item.path === extension.path);
1222 if (nonPackageIndex === -1) {
1223 return this.getCompactPathLabel(extension.path, extension.sourceInfo);
1224 }
1225
1226 return this.getCompactNonPackageExtensionLabel(extension.path, nonPackageIndex, nonPackageExtensions);
1227 });
1228 }
1229
1230 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 }
1249
1250 if (source === "cli") {
1251 return { label: "path", scopeLabel: scope === "temporary" ? "temp" : undefined, color: "muted" };
1252 }
1253
1254 const scopeLabel =
1255 scope === "user" ? "user" : scope === "project" ? "project" : scope === "temporary" ? "temp" : undefined;
1256 return { label: source, scopeLabel, color: "accent" };
1257 }
1258
1259 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 }
1267
1268 private isPackageSource(sourceInfo?: SourceInfo): boolean {
1269 const source = sourceInfo?.source ?? "";
1270 return source.startsWith("npm:") || source.startsWith("git:");
1271 }
1272
1273 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 };
1290
1291 for (const item of items) {
1292 const groupKey = this.getScopeGroup(item.sourceInfo);
1293 const group = groups[groupKey];
1294 const source = item.sourceInfo?.source ?? "local";
1295
1296 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 }
1304
1305 return [groups.project, groups.user, groups.path].filter(
1306 (group) => group.paths.length > 0 || group.packages.size > 0,
1307 );
1308 }
1309
1310 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[] = [];
1322
1323 for (const group of groups) {
1324 lines.push(` ${theme.fg("accent", group.scope)}`);
1325
1326 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 }
1330
1331 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 }
1340
1341 return lines.join("\n");
1342 }
1343
1344 private findSourceInfoForPath(p: string, sourceInfos: Map<string, SourceInfo>): SourceInfo | undefined {
1345 const exact = sourceInfos.get(p);
1346 if (exact) return exact;
1347
1348 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 }
1354
1355 return undefined;
1356 }
1357
1358 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 }
1367
1368 private formatDiagnostics(diagnostics: readonly ResourceDiagnostic[], sourceInfos: Map<string, SourceInfo>): string {
1369 const lines: string[] = [];
1370
1371 // Group collision diagnostics by name
1372 const collisions = new Map<string, ResourceDiagnostic[]>();
1373 const otherDiagnostics: ResourceDiagnostic[] = [];
1374
1375 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 }
1384
1385 // Format collision diagnostics grouped by name
1386 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 }
1407
1408 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 }
1417
1418 return lines.join("\n");
1419 }
1420
1421 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();
1428
1429 const showListing = options?.force || this.options.verbose || !this.settingsManager.getQuietStartup();
1430 const showDiagnostics = showListing || options?.showDiagnosticsWhenQuiet === true;
1431 if (!showListing && !showDiagnostics) {
1432 return;
1433 }
1434
1435 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 };
1459
1460 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.resourceLoader
1466 .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 }
1493
1494 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 = contextFiles
1499 .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 }
1507
1508 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 }
1520
1521 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 }
1540
1541 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 }
1551
1552 // 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 }
1575
1576 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 }
1585
1586 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 }
1594
1595 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 }
1602
1603 const commandDiagnostics = this.session.extensionRunner.getCommandDiagnostics();
1604 extensionDiagnostics.push(...commandDiagnostics);
1605 extensionDiagnostics.push(...this.getBuiltInCommandConflictDiagnostics(this.session.extensionRunner));
1606
1607 const shortcutDiagnostics = this.session.extensionRunner.getShortcutDiagnostics();
1608 extensionDiagnostics.push(...shortcutDiagnostics);
1609
1610 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 }
1617
1618 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 }
1628
1629 /**
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 }
1672
1673 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 });
1699
1700 setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
1701 this.setupAutocompleteProvider();
1702
1703 const extensionRunner = this.session.extensionRunner;
1704 this.setupExtensionShortcuts(extensionRunner);
1705 this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true });
1706 this.showStartupNoticesIfNeeded();
1707 }
1708
1709 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 }
1731
1732 private async rebindCurrentSession(options: { renderBeforeBind?: boolean } = {}): Promise<void> {
1733 const session = this.session;
1734
1735 this.unsubscribe?.();
1736 this.unsubscribe = undefined;
1737 this.applyRuntimeSettings();
1738
1739 if (options.renderBeforeBind) {
1740 this.renderCurrentSessionState();
1741 this.subscribeToAgent();
1742 }
1743
1744 await this.bindCurrentSessionExtensions();
1745
1746 if (this.session !== session) {
1747 return;
1748 }
1749
1750 if (!options.renderBeforeBind) {
1751 this.subscribeToAgent();
1752 }
1753
1754 await this.updateAvailableProviderCount();
1755 this.updateEditorBorderColor();
1756 this.updateTerminalTitle();
1757 }
1758
1759 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 }
1766
1767 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 }
1777
1778 /**
1779 * Get a registered tool definition by name (for custom rendering).
1780 */
1781 private getRegisteredToolDefinition(toolName: string) {
1782 return this.session.getToolDefinition(toolName);
1783 }
1784
1785 /**
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;
1791
1792 // Create a context for shortcut handlers
1793 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