| |
| |
| |
| |
|
|
| import * as crypto from "node:crypto"; |
| import * as fs from "node:fs"; |
| import * as os from "node:os"; |
| import * as path from "node:path"; |
| import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; |
| import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai"; |
| import type { AssistantMessage, ImageContent, Message, Model, Usage } from "@earendil-works/pi-ai/compat"; |
| import type { |
| AutocompleteItem, |
| AutocompleteProvider, |
| EditorComponent, |
| Keybinding, |
| KeyId, |
| MarkdownTheme, |
| OverlayHandle, |
| OverlayOptions, |
| SlashCommand, |
| TuiMainScreenRenderState, |
| } from "@earendil-works/pi-tui"; |
| import * as TuiLayouts from "@earendil-works/pi-tui"; |
| import { |
| CombinedAutocompleteProvider, |
| type Component, |
| Container, |
| fuzzyFilter, |
| getCapabilities, |
| hyperlink, |
| Markdown, |
| matchesKey, |
| Spacer, |
| setCapabilityOverrides, |
| setKeybindings, |
| Text, |
| TruncatedText, |
| type TUI, |
| TuiAltScreen, |
| TuiMainScreen, |
| visibleWidth, |
| } from "@earendil-works/pi-tui"; |
| import chalk from "chalk"; |
| import { spawn } from "child_process"; |
| import { |
| APP_NAME, |
| APP_TITLE, |
| CONFIG_DIR_NAME, |
| getAgentDir, |
| getAuthPath, |
| getDebugLogPath, |
| getDocsPath, |
| VERSION, |
| } from "../../config.ts"; |
| import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts"; |
| import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.ts"; |
| import type { AgentSessionRuntimeDiagnostic } from "../../core/agent-session-services.ts"; |
| import { |
| CACHE_TTL_MS, |
| type CacheMiss, |
| collectCacheMisses, |
| computeCacheWaste, |
| detectCacheMiss, |
| } from "../../core/cache-stats.ts"; |
| import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "../../core/defaults.ts"; |
| import type { |
| AutocompleteProviderFactory, |
| EditorFactory, |
| ExtensionCommandContext, |
| ExtensionContext, |
| ExtensionRunner, |
| ExtensionUIContext, |
| ExtensionUIDialogOptions, |
| ExtensionWidgetOptions, |
| MarkdownTransformer, |
| ProjectTrustContext, |
| WorkingIndicatorOptions, |
| } from "../../core/extensions/index.ts"; |
| import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts"; |
| import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts"; |
| import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts"; |
| import { createCompactionSummaryMessage } from "../../core/messages.ts"; |
| import { |
| defaultModelPerProvider, |
| findExactModelReferenceMatch, |
| resolveModelScopeFromModels, |
| } from "../../core/model-resolver.ts"; |
| import { CredentialSynchronizationError } from "../../core/model-runtime.ts"; |
| import { DefaultPackageManager } from "../../core/package-manager.ts"; |
| import type { ResourceDiagnostic } from "../../core/resource-loader.ts"; |
| import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts"; |
| import { type SessionEntry, SessionManager, sessionEntryToContextMessages } from "../../core/session-manager.ts"; |
| import type { FullscreenExitOutput, TuiMode } from "../../core/settings-manager.ts"; |
| import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts"; |
| import type { SourceInfo } from "../../core/source-info.ts"; |
| import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; |
| import { withBuiltInRenderers } from "../../core/tools/renderers/index.ts"; |
| import type { TruncationResult } from "../../core/tools/truncate.ts"; |
| import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts"; |
| import { getUsageCostBreakdown } from "../../core/usage-totals.ts"; |
| import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts"; |
| import { copyToClipboard, readClipboardText } from "../../utils/clipboard.ts"; |
| import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; |
| import { parseGitUrl } from "../../utils/git.ts"; |
| import { getCwdRelativePath } from "../../utils/paths.ts"; |
| import { getPiUserAgent } from "../../utils/pi-user-agent.ts"; |
| import { killTrackedDetachedChildren } from "../../utils/shell.ts"; |
| import { loadAllHighlightLanguages } from "../../utils/syntax-highlight.ts"; |
| import { ensureTool, type ToolStatus } from "../../utils/tools-manager.ts"; |
| import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-check.ts"; |
| import { createChatViewport } from "./chat-viewport.ts"; |
| import { ArminComponent } from "./components/armin.ts"; |
| import { AssistantMessageComponent } from "./components/assistant-message.ts"; |
| import { BashExecutionComponent } from "./components/bash-execution.ts"; |
| import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts"; |
| import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.ts"; |
| import { CustomEditor } from "./components/custom-editor.ts"; |
| import { CustomEntryComponent } from "./components/custom-entry.ts"; |
| import { CustomMessageComponent } from "./components/custom-message.ts"; |
| import { DaxnutsComponent } from "./components/daxnuts.ts"; |
| import { DynamicBorder } from "./components/dynamic-border.ts"; |
| import { EarendilAnnouncementComponent } from "./components/earendil-announcement.ts"; |
| import { ExtensionEditorComponent } from "./components/extension-editor.ts"; |
| import { ExtensionInputComponent } from "./components/extension-input.ts"; |
| import { ExtensionSelectorComponent } from "./components/extension-selector.ts"; |
| import { FooterComponent, formatTokens } from "./components/footer.ts"; |
| import { formatKeyText, keyDisplayText, keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.ts"; |
| import { LoginDialogComponent } from "./components/login-dialog.ts"; |
| import { createMermaidMarkdownTransformer } from "./components/mermaid.ts"; |
| import { ModelSelectorComponent } from "./components/model-selector.ts"; |
| import { |
| type AuthSelectorProvider, |
| formatAuthSelectorProviderType, |
| OAuthSelectorComponent, |
| } from "./components/oauth-selector.ts"; |
| import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.ts"; |
| import { SessionSelectorComponent } from "./components/session-selector.ts"; |
| import { SettingsSelectorComponent } from "./components/settings-selector.ts"; |
| import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.ts"; |
| import { |
| BranchSummaryStatusIndicator, |
| CompactionStatusIndicator, |
| IdleStatus, |
| RetryStatusIndicator, |
| type StatusIndicator, |
| WorkingStatusIndicator, |
| } from "./components/status-indicator.ts"; |
| import { ThinkingSelectorComponent } from "./components/thinking-selector.ts"; |
| import { ToolExecutionComponent } from "./components/tool-execution.ts"; |
| import { TreeSelectorComponent } from "./components/tree-selector.ts"; |
| import { TrustSelectorComponent } from "./components/trust-selector.ts"; |
| import { UserMessageComponent } from "./components/user-message.ts"; |
| import { UserMessageSelectorComponent } from "./components/user-message-selector.ts"; |
| import { editInExternalEditor } from "./external-editor.ts"; |
| import { refreshModelCatalogs } from "./model-catalog-refresh.ts"; |
| import { getModelSearchText } from "./model-search.ts"; |
| import { shareSession } from "./session-share.ts"; |
| import { |
| getAvailableThemes, |
| getAvailableThemesWithPaths, |
| getEditorTheme, |
| getMarkdownTheme, |
| getThemeByName, |
| onThemeChange, |
| setRegisteredThemes, |
| stopThemeWatcher, |
| Theme, |
| type ThemeColor, |
| theme, |
| } from "./theme/theme.ts"; |
| import { InteractiveThemeController } from "./theme/theme-controller.ts"; |
| import { createInteractiveTui, createInteractiveTuiReference } from "./tui-renderer.ts"; |
|
|
| export { createInteractiveTui, createInteractiveTuiReference } from "./tui-renderer.ts"; |
|
|
| |
| interface Expandable { |
| setExpanded(expanded: boolean): void; |
| } |
|
|
| interface WorkingStatusEditor extends EditorComponent { |
| readonly embedWorkingStatus: boolean; |
| setWorkingStatusIndicator(indicator: StatusIndicator | undefined): void; |
| } |
|
|
| function isWorkingStatusEditor(editor: EditorComponent): editor is WorkingStatusEditor { |
| return ( |
| "embedWorkingStatus" in editor && |
| editor.embedWorkingStatus === true && |
| "setWorkingStatusIndicator" in editor && |
| typeof editor.setWorkingStatusIndicator === "function" |
| ); |
| } |
|
|
| function isExpandable(obj: unknown): obj is Expandable { |
| return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function"; |
| } |
|
|
| class ExpandableText extends Text implements Expandable { |
| private readonly getCollapsedText: () => string; |
| private readonly getExpandedText: () => string; |
|
|
| constructor( |
| getCollapsedText: () => string, |
| getExpandedText: () => string, |
| expanded = false, |
| paddingX = 0, |
| paddingY = 0, |
| ) { |
| super(expanded ? getExpandedText() : getCollapsedText(), paddingX, paddingY); |
| this.getCollapsedText = getCollapsedText; |
| this.getExpandedText = getExpandedText; |
| } |
|
|
| setExpanded(expanded: boolean): void { |
| this.setText(expanded ? this.getExpandedText() : this.getCollapsedText()); |
| } |
| } |
|
|
| type CompactionQueuedMessage = { |
| text: string; |
| mode: "steer" | "followUp"; |
| }; |
|
|
| type CompactionCostNotice = { |
| type: "compaction_cost"; |
| kind: "compaction" | "branch_summary"; |
| usage: Usage; |
| }; |
|
|
| type RenderSessionItem = AgentMessage | Extract<SessionEntry, { type: "custom" }> | CompactionCostNotice; |
|
|
| function isCustomSessionEntry(item: RenderSessionItem): item is Extract<SessionEntry, { type: "custom" }> { |
| return "type" in item && item.type === "custom"; |
| } |
|
|
| function isCompactionCostNotice(item: RenderSessionItem): item is CompactionCostNotice { |
| return "type" in item && item.type === "compaction_cost"; |
| } |
|
|
| const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN"]); |
|
|
| function isDeadTerminalError(error: unknown): boolean { |
| if (!error || typeof error !== "object" || !("code" in error)) { |
| return false; |
| } |
| const code = (error as NodeJS.ErrnoException).code; |
| return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code); |
| } |
|
|
| const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = |
| "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings."; |
|
|
| function isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean { |
| return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat"); |
| } |
|
|
| function isUnknownModel(model: Model<any> | undefined): boolean { |
| return !!model && model.provider === "unknown" && model.id === "unknown" && model.api === "unknown"; |
| } |
|
|
| function quoteIfNeeded(value: string): string { |
| if (value.length > 0 && !/[^a-zA-Z0-9_\-./~:@]/.test(value)) { |
| return value; |
| } |
| return `'${value.replace(/'/g, `'\\''`)}'`; |
| } |
| |
| export function formatResumeCommand(sessionManager: SessionManager): string | undefined { |
| if (!process.stdout.isTTY) return undefined; |
| if (!sessionManager.isPersisted()) return undefined; |
| |
| const sessionFile = sessionManager.getSessionFile(); |
| if (!sessionFile || !fs.existsSync(sessionFile)) return undefined; |
| |
| const args = [APP_NAME]; |
| if (!sessionManager.usesDefaultSessionDir()) { |
| args.push("--session-dir", quoteIfNeeded(sessionManager.getSessionDir())); |
| } |
| args.push("--session", sessionManager.getSessionId()); |
| return args.join(" "); |
| } |
| |
| function hasDefaultModelProvider(providerId: string): providerId is keyof typeof defaultModelPerProvider { |
| return providerId in defaultModelPerProvider; |
| } |
| |
| function llamaCppPostLoginGuidance(actionLabel: string, loadedModelCount: number): string { |
| return loadedModelCount === 0 |
| ? `${actionLabel}. No llama.cpp models are loaded. Use /llama to load a model, then /model to select it.` |
| : `${actionLabel}. Use /model to select a loaded llama.cpp model, or /llama to manage models.`; |
| } |
| |
| type LoginProviderCompletionOption = { |
| id: string; |
| name: string; |
| authTypes: AuthSelectorProvider["authType"][]; |
| }; |
| |
| const AUTH_TYPE_ORDER = { oauth: 0, api_key: 1 } satisfies Record<AuthSelectorProvider["authType"], number>; |
| |
| function createFuzzyAutocompleteItems<T>( |
| items: T[], |
| prefix: string, |
| getSearchText: (item: T) => string, |
| toAutocompleteItem: (item: T) => AutocompleteItem, |
| ): AutocompleteItem[] | null { |
| const filtered = fuzzyFilter(items, prefix, getSearchText); |
| if (filtered.length === 0) return null; |
| return filtered.map(toAutocompleteItem); |
| } |
| |
| function getLoginProviderCompletionOptions( |
| providerOptions: readonly AuthSelectorProvider[], |
| ): LoginProviderCompletionOption[] { |
| const byId = new Map<string, LoginProviderCompletionOption>(); |
| for (const provider of providerOptions) { |
| const existing = byId.get(provider.id); |
| if (existing) { |
| if (!existing.authTypes.includes(provider.authType)) { |
| existing.authTypes.push(provider.authType); |
| existing.authTypes.sort((a, b) => AUTH_TYPE_ORDER[a] - AUTH_TYPE_ORDER[b]); |
| } |
| continue; |
| } |
| byId.set(provider.id, { |
| id: provider.id, |
| name: provider.name, |
| authTypes: [provider.authType], |
| }); |
| } |
| return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name)); |
| } |
| |
| function getLoginProviderSearchText(provider: LoginProviderCompletionOption): string { |
| const authTypes = provider.authTypes |
| .map((authType) => `${authType} ${formatAuthSelectorProviderType(authType)}`) |
| .join(" "); |
| return `${provider.id} ${provider.name} ${authTypes}`; |
| } |
| |
| function formatLoginProviderCompletionDescription(provider: LoginProviderCompletionOption): string { |
| const authTypes = provider.authTypes.map(formatAuthSelectorProviderType).join("/"); |
| return provider.name === provider.id ? authTypes : `${provider.name} · ${authTypes}`; |
| } |
| |
| /** |
| * Options for InteractiveMode initialization. |
| */ |
| export interface InteractiveModeOptions { |
| /** Providers that were migrated to auth.json (shows warning) */ |
| migratedProviders?: string[]; |
| /** Diagnostics collected before the interactive TUI was initialized. */ |
| startupDiagnostics?: AgentSessionRuntimeDiagnostic[]; |
| /** Warning message if session model couldn't be restored */ |
| modelFallbackMessage?: string; |
| /** Cwd to trust after reload if it gained a .pi directory during this implicitly trusted session. */ |
| autoTrustOnReloadCwd?: string; |
| /** Initial message to send on startup (can include @file content) */ |
| initialMessage?: string; |
| /** Images to attach to the initial message */ |
| initialImages?: ImageContent[]; |
| /** Additional messages to send after the initial message */ |
| initialMessages?: string[]; |
| /** Force verbose startup (overrides quietStartup setting) */ |
| verbose?: boolean; |
| /** TUI layout mode. */ |
| tuiMode?: TuiMode; |
| /** Initial interactive theme setting for this invocation. */ |
| initialThemeSetting?: string; |
| } |
| |
| export class InteractiveMode { |
| private runtimeHost: AgentSessionRuntime; |
| private renderer: TuiMainScreen | TuiAltScreen; |
| private ui: TUI; |
| private mainScreenRenderState: TuiMainScreenRenderState | undefined; |
| private loadedResourcesContainer: Container; |
| private chatContainer: Container; |
| private documentContainer: Container; |
| private transcriptScrollView: TuiLayouts.ScrollView | undefined; |
| private fullscreenLayoutRoot: Component | undefined; |
| private pendingMessagesContainer: Container; |
| private statusContainer: Container; |
| private defaultEditor: CustomEditor; |
| private editor: EditorComponent; |
| private editorComponentFactory: EditorFactory | undefined; |
| private autocompleteProvider: AutocompleteProvider | undefined; |
| private autocompleteProviderWrappers: AutocompleteProviderFactory[] = []; |
| private fdPath: string | undefined; |
| private editorContainer: Container; |
| private activeSelectorToken?: object; |
| private activeSelectorDispose?: () => void; |
| private footer: FooterComponent; |
| private footerContainer: Container; |
| private footerDataProvider: FooterDataProvider; |
| // Stored so the same manager can be injected into custom editors, selectors, and extension UI. |
| private keybindings: KeybindingsManager; |
| private version: string; |
| private isInitialized = false; |
| private onInputCallback?: (text: string) => void; |
| private pendingUserInputs: string[] = []; |
| private activeStatusIndicator: StatusIndicator | undefined = undefined; |
| private activeWorkingIndicatorEmbedded = false; |
| private readonly idleStatus = new IdleStatus(); |
| private workingMessage: string | undefined = undefined; |
| private workingVisible = true; |
| private workingIndicatorOptions: WorkingIndicatorOptions | undefined = undefined; |
| private readonly defaultWorkingMessage = "Working"; |
| private readonly defaultHiddenThinkingLabel = "Thinking..."; |
| private hiddenThinkingLabel = this.defaultHiddenThinkingLabel; |
| |
| private lastSigintTime = 0; |
| private lastEscapeTime = 0; |
| private changelogMarkdown: string | undefined = undefined; |
| private startupNoticesShown = false; |
| private anthropicSubscriptionWarningShown = false; |
| |
| // Status line tracking (for mutating immediately-sequential status updates) |
| private lastStatusSpacer: Spacer | undefined = undefined; |
| private lastStatusText: Text | undefined = undefined; |
| private managedToolStatusStarted = false; |
| |
| // Streaming message tracking |
| private streamingComponent: AssistantMessageComponent | undefined = undefined; |
| private streamingMessage: AssistantMessage | undefined = undefined; |
| |
| // Tool execution tracking: toolCallId -> component |
| private pendingTools = new Map<string, ToolExecutionComponent>(); |
| |
| // Tool output expansion state |
| private toolOutputExpanded = false; |
| |
| // Thinking block visibility state |
| private hideThinkingBlock = false; |
| private outputPad = 1; |
| private readonly mermaidMarkdownTransformer: MarkdownTransformer = createMermaidMarkdownTransformer({ |
| getMode: () => this.settingsManager.getMermaidRenderingMode(), |
| theme, |
| }); |
| |
| // Skill commands: command name -> skill file path |
| private skillCommands = new Map<string, string>(); |
| |
| // Agent subscription unsubscribe function |
| private unsubscribe?: () => void; |
| private signalCleanupHandlers: Array<() => void> = []; |
| |
| // Track if editor is in bash mode (text starts with !) |
| private isBashMode = false; |
| |
| // Track current bash execution component |
| private bashComponent: BashExecutionComponent | undefined = undefined; |
| |
| // Track pending bash components (shown in pending area, moved to chat on submit) |
| private pendingBashComponents: BashExecutionComponent[] = []; |
| |
| // Auto-compaction state |
| private autoCompactionEscapeHandler?: () => void; |
| |
| // Auto-retry state |
| private retryEscapeHandler?: () => void; |
| |
| // Messages queued while compaction is running |
| private compactionQueuedMessages: CompactionQueuedMessage[] = []; |
| |
| // Shutdown state |
| private shutdownRequested = false; |
| |
| // Extension UI state |
| private extensionSelector: ExtensionSelectorComponent | undefined = undefined; |
| private extensionInput: ExtensionInputComponent | undefined = undefined; |
| private extensionEditor: ExtensionEditorComponent | undefined = undefined; |
| private extensionTerminalInputSubscriptions = new Set<{ |
| handler: (data: string) => { consume?: boolean; data?: string } | undefined; |
| unsubscribe: () => void; |
| }>(); |
| |
| // Extension widgets (components rendered above/below the editor) |
| private extensionWidgetsAbove = new Map<string, Component & { dispose?(): void }>(); |
| private extensionWidgetsBelow = new Map<string, Component & { dispose?(): void }>(); |
| private widgetContainerAbove!: Container; |
| private widgetContainerBelow!: Container; |
| |
| // Custom footer from extension (undefined = use built-in footer) |
| private customFooter: (Component & { dispose?(): void }) | undefined = undefined; |
| |
| // Header container that holds the built-in or custom header |
| private headerContainer: Container; |
| |
| // Built-in header (logo + keybinding hints + changelog) |
| private builtInHeader: Component | undefined = undefined; |
| |
| // Custom header from extension (undefined = use built-in header) |
| private customHeader: (Component & { dispose?(): void }) | undefined = undefined; |
| |
| private options: InteractiveModeOptions; |
| private readonly onRightClickPaste = (): void => { |
| void this.handleRightClickPaste(); |
| }; |
| private autoTrustOnReloadCwd: string | undefined; |
| private themeController: InteractiveThemeController; |
| |
| // Convenience accessors |
| private get session(): AgentSession { |
| return this.runtimeHost.session; |
| } |
| private get agent() { |
| return this.session.agent; |
| } |
| private get sessionManager() { |
| return this.session.sessionManager; |
| } |
| private get settingsManager() { |
| return this.session.settingsManager; |
| } |
| |
| constructor(runtimeHost: AgentSessionRuntime, options: InteractiveModeOptions = {}) { |
| this.runtimeHost = runtimeHost; |
| setCapabilityOverrides(this.settingsManager.getTerminalCapabilityOverrides()); |
| const tuiMode = options.tuiMode ?? this.settingsManager.getTuiMode(); |
| this.options = { ...options, tuiMode }; |
| this.autoTrustOnReloadCwd = options.autoTrustOnReloadCwd; |
| this.runtimeHost.setBeforeSessionInvalidate(() => { |
| this.resetExtensionUI(); |
| }); |
| this.runtimeHost.setRebindSession(async () => { |
| await this.rebindCurrentSession({ renderBeforeBind: true }); |
| await this.themeController.applyFromSettings(); |
| }); |
| this.version = VERSION; |
| this.renderer = createInteractiveTui({ |
| tuiMode, |
| showHardwareCursor: this.settingsManager.getShowHardwareCursor(), |
| logDirectory: getAgentDir(), |
| onRightClickPaste: this.onRightClickPaste, |
| fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(), |
| }); |
| this.ui = createInteractiveTuiReference(() => this.renderer); |
| this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); |
| this.headerContainer = new Container(); |
| this.loadedResourcesContainer = new Container(); |
| this.chatContainer = new Container(); |
| this.documentContainer = new Container(); |
| this.documentContainer.addChild(this.headerContainer); |
| this.documentContainer.addChild(this.loadedResourcesContainer); |
| this.documentContainer.addChild(this.chatContainer); |
| this.pendingMessagesContainer = new Container(); |
| this.statusContainer = new Container(); |
| this.widgetContainerAbove = new Container(); |
| this.widgetContainerBelow = new Container(); |
| this.keybindings = KeybindingsManager.create(); |
| setKeybindings(this.keybindings); |
| const editorPaddingX = this.settingsManager.getEditorPaddingX(); |
| const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible(); |
| this.defaultEditor = new CustomEditor(this.ui, getEditorTheme(), this.keybindings, { |
| paddingX: editorPaddingX, |
| autocompleteMaxVisible, |
| embedWorkingStatus: true, |
| }); |
| this.editor = this.defaultEditor; |
| this.editorContainer = new Container(); |
| this.editorContainer.addChild(this.editor as Component); |
| this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd()); |
| this.footer = new FooterComponent(this.session, this.footerDataProvider); |
| this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled); |
| this.footerContainer = new Container(); |
| this.footerContainer.addChild(this.footer); |
| |
| // Load hide thinking block setting |
| this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); |
| this.outputPad = this.settingsManager.getOutputPad(); |
| |
| // Register themes from resource loader and initialize |
| setRegisteredThemes(this.session.resourceLoader.getThemes().themes); |
| this.themeController = new InteractiveThemeController(this.ui, { |
| getSettingsManager: () => this.settingsManager, |
| showError: (message) => this.showError(message), |
| onChanged: () => this.updateEditorBorderColor(), |
| initialThemeSetting: options.initialThemeSetting, |
| }); |
| } |
| |
| private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined { |
| if (!sourceInfo) { |
| return undefined; |
| } |
| |
| const scopePrefix = sourceInfo.scope === "user" ? "u" : sourceInfo.scope === "project" ? "p" : "t"; |
| const source = sourceInfo.source.trim(); |
| |
| if (source === "auto" || source === "local" || source === "cli") { |
| return scopePrefix; |
| } |
| |
| if (source.startsWith("npm:")) { |
| return `${scopePrefix}:${source}`; |
| } |
| |
| const gitSource = parseGitUrl(source); |
| if (gitSource) { |
| const ref = gitSource.ref ? `@${gitSource.ref}` : ""; |
| return `${scopePrefix}:git:${gitSource.host}/${gitSource.path}${ref}`; |
| } |
| |
| return scopePrefix; |
| } |
| |
| private prefixAutocompleteDescription(description: string | undefined, sourceInfo?: SourceInfo): string | undefined { |
| const sourceTag = this.getAutocompleteSourceTag(sourceInfo); |
| if (!sourceTag) { |
| return description; |
| } |
| return description ? `[${sourceTag}] ${description}` : `[${sourceTag}]`; |
| } |
| |
| private getBuiltInCommandConflictDiagnostics(extensionRunner: ExtensionRunner): ResourceDiagnostic[] { |
| const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name)); |
| return extensionRunner |
| .getRegisteredCommands() |
| .filter((command) => builtinNames.has(command.name)) |
| .map((command) => ({ |
| type: "warning" as const, |
| message: |
| command.invocationName === command.name |
| ? `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.` |
| : `Extension command '/${command.name}' conflicts with built-in interactive command. Available as '/${command.invocationName}'.`, |
| path: command.sourceInfo.path, |
| })); |
| } |
| |
| private createBaseAutocompleteProvider(): AutocompleteProvider { |
| // Define commands for autocomplete |
| const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({ |
| name: command.name, |
| description: command.description, |
| ...(command.argumentHint && { argumentHint: command.argumentHint }), |
| })); |
| |
| const modelCommand = slashCommands.find((command) => command.name === "model"); |
| if (modelCommand) { |
| modelCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { |
| const models = |
| this.session.scopedModels.length > 0 |
| ? this.session.scopedModels.map((s) => s.model) |
| : this.session.modelRuntime.getAvailableSnapshot(); |
| |
| if (models.length === 0) return null; |
| |
| // Create items with provider/id format |
| const items = models.map((m) => ({ |
| id: m.id, |
| provider: m.provider, |
| name: m.name, |
| label: `${m.provider}/${m.id}`, |
| })); |
| |
| return createFuzzyAutocompleteItems(items, prefix, getModelSearchText, (item) => ({ |
| value: item.label, |
| label: item.id, |
| description: item.provider, |
| })); |
| }; |
| } |
| |
| const thinkingCommand = slashCommands.find((command) => command.name === "thinking"); |
| if (thinkingCommand) { |
| thinkingCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { |
| return createFuzzyAutocompleteItems( |
| this.session.getAvailableThinkingLevels(), |
| prefix, |
| (level) => level, |
| (level) => ({ |
| value: level, |
| label: level, |
| }), |
| ); |
| }; |
| } |
| |
| const loginCommand = slashCommands.find((command) => command.name === "login"); |
| if (loginCommand) { |
| loginCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { |
| const providers = getLoginProviderCompletionOptions(this.getLoginProviderOptions()); |
| return createFuzzyAutocompleteItems(providers, prefix, getLoginProviderSearchText, (provider) => ({ |
| value: provider.id, |
| label: provider.id, |
| description: formatLoginProviderCompletionDescription(provider), |
| })); |
| }; |
| } |
| |
| // Convert prompt templates to SlashCommand format for autocomplete |
| const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({ |
| name: cmd.name, |
| description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo), |
| ...(cmd.argumentHint && { argumentHint: cmd.argumentHint }), |
| })); |
| |
| // Convert extension commands to SlashCommand format |
| const builtinCommandNames = new Set(slashCommands.map((c) => c.name)); |
| const extensionCommands: SlashCommand[] = this.session.extensionRunner |
| .getRegisteredCommands() |
| .filter((cmd) => !builtinCommandNames.has(cmd.name)) |
| .map((cmd) => ({ |
| name: cmd.invocationName, |
| description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo), |
| getArgumentCompletions: cmd.getArgumentCompletions, |
| })); |
| |
| // Build skill commands from session.skills (if enabled) |
| this.skillCommands.clear(); |
| const skillCommandList: SlashCommand[] = []; |
| if (this.settingsManager.getEnableSkillCommands()) { |
| for (const skill of this.session.resourceLoader.getSkills().skills) { |
| const commandName = `skill:${skill.name}`; |
| this.skillCommands.set(commandName, skill.filePath); |
| skillCommandList.push({ |
| name: commandName, |
| description: this.prefixAutocompleteDescription(skill.description, skill.sourceInfo), |
| }); |
| } |
| } |
| |
| return new CombinedAutocompleteProvider( |
| [...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList], |
| this.sessionManager.getCwd(), |
| this.fdPath, |
| ); |
| } |
| |
| private setupAutocompleteProvider(): void { |
| let provider = this.createBaseAutocompleteProvider(); |
| const triggerCharacters: string[] = []; |
| for (const wrapProvider of this.autocompleteProviderWrappers) { |
| provider = wrapProvider(provider); |
| triggerCharacters.push(...(provider.triggerCharacters ?? [])); |
| } |
| if (triggerCharacters.length > 0) { |
| provider.triggerCharacters = [...new Set(triggerCharacters)]; |
| } |
| |
| this.autocompleteProvider = provider; |
| this.defaultEditor.setAutocompleteProvider(provider); |
| if (this.editor !== this.defaultEditor) { |
| this.editor.setAutocompleteProvider?.(provider); |
| } |
| } |
| |
| private showStartupNoticesIfNeeded(): void { |
| if (this.startupNoticesShown) { |
| return; |
| } |
| this.startupNoticesShown = true; |
| |
| if (!this.changelogMarkdown) { |
| return; |
| } |
| |
| if (this.chatContainer.children.length > 0) { |
| this.chatContainer.addChild(new Spacer(1)); |
| } |
| this.chatContainer.addChild(new DynamicBorder()); |
| if (this.settingsManager.getCollapseChangelog()) { |
| const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/); |
| const latestVersion = versionMatch ? versionMatch[1] : this.version; |
| const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`; |
| this.chatContainer.addChild(new Text(condensedText, 1, 0)); |
| } else { |
| this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0)); |
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild( |
| new Markdown(this.changelogMarkdown.trim(), 1, 0, this.getMarkdownThemeWithSettings()), |
| ); |
| this.chatContainer.addChild(new Spacer(1)); |
| } |
| this.chatContainer.addChild(new DynamicBorder()); |
| } |
| |
| private mountInteractiveTui(tui: TuiMainScreen | TuiAltScreen, components: readonly Component[]): void { |
| for (const component of components) tui.addChild(component); |
| if (TuiLayouts.isViewportTUI(tui)) { |
| if (!this.fullscreenLayoutRoot) throw new Error("Fullscreen layout is not initialized"); |
| tui.setLayoutRoot(this.fullscreenLayoutRoot); |
| } |
| } |
| |
| private stopInteractiveTui(fullscreenExitOutput: FullscreenExitOutput): void { |
| if (this.renderer.mode === "fullscreen" && fullscreenExitOutput === "transcript") { |
| while (this.renderer.hasOverlayEntries) this.renderer.hideOverlay(); |
| this.switchTuiMode("regular", false, false); |
| this.renderer.renderNow(); |
| } |
| this.ui.stop({ preserveScreen: this.renderer.mode === "fullscreen" }); |
| } |
| |
| private switchTuiMode(mode: TuiMode, restoreProgress = true, startRenderer = true): boolean { |
| const previousUi = this.renderer; |
| if (mode === previousUi.mode) return true; |
| if (previousUi.hasOverlayEntries) return false; |
| |
| const components = [...previousUi.children]; |
| const focus = previousUi.getFocusedComponent(); |
| const terminal = previousUi.terminal; |
| const showHardwareCursor = previousUi.getShowHardwareCursor(); |
| const clearOnShrink = previousUi.getClearOnShrink(); |
| const onDebug = previousUi.onDebug; |
| if (previousUi instanceof TuiMainScreen) { |
| this.mainScreenRenderState = previousUi.captureRenderState(); |
| } |
| |
| previousUi.stop({ preserveScreen: true }); |
| previousUi.setFocus(null); |
| previousUi.clear(); |
| if (TuiLayouts.isViewportTUI(previousUi)) previousUi.setLayoutRoot(undefined); |
| |
| const nextUi = createInteractiveTui({ |
| tuiMode: mode, |
| showHardwareCursor, |
| logDirectory: getAgentDir(), |
| terminal, |
| onRightClickPaste: this.onRightClickPaste, |
| fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(), |
| }); |
| nextUi.setClearOnShrink(clearOnShrink); |
| nextUi.onDebug = onDebug; |
| if (nextUi instanceof TuiMainScreen && this.mainScreenRenderState) { |
| nextUi.restoreRenderState(this.mainScreenRenderState); |
| } |
| this.renderer = nextUi; |
| this.options.tuiMode = mode; |
| this.mountInteractiveTui(nextUi, components); |
| nextUi.invalidate(); |
| nextUi.setFocus(focus); |
| if (!startRenderer) return true; |
| nextUi.start(); |
| this.themeController.rebindTui(); |
| this.rebindExtensionTerminalInputListeners(); |
| if ( |
| restoreProgress && |
| this.settingsManager.getShowTerminalProgress() && |
| (this.session.isStreaming || this.session.isCompacting) |
| ) { |
| terminal.setProgress(true); |
| } |
| return true; |
| } |
| |
| async init(): Promise<void> { |
| if (this.isInitialized) return; |
| |
| this.registerSignalHandlers(); |
| |
| // Load changelog (only show new entries, skip for resumed sessions) |
| this.changelogMarkdown = this.getChangelogForDisplay(); |
| |
| if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) { |
| const modelList = this.session.scopedModels |
| .map((sm) => { |
| const thinkingStr = sm.thinkingLevel ? `:${sm.thinkingLevel}` : ""; |
| return `${sm.model.id}${thinkingStr}`; |
| }) |
| .join(", "); |
| const cycleKeys = this.keybindings.getKeys("app.model.cycleForward"); |
| const cycleHint = |
| cycleKeys.length > 0 |
| ? theme.fg("muted", ` (${formatKeyText(cycleKeys.join("/"), { capitalize: true })} to cycle)`) |
| : ""; |
| console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`)); |
| } |
| |
| // Keep one component tree and remount it when changing renderers. |
| this.renderWidgets(); // Initialize with default spacer |
| const viewport = createChatViewport({ |
| document: this.documentContainer, |
| pendingMessages: this.pendingMessagesContainer, |
| status: this.statusContainer, |
| widgetsAbove: this.widgetContainerAbove, |
| editor: this.editorContainer, |
| widgetsBelow: this.widgetContainerBelow, |
| footer: this.footerContainer, |
| scrollbar: this.settingsManager.getFullscreenScrollbar(), |
| scrollbarTrackStyle: (text) => theme.fg("scrollbarTrack", text), |
| scrollbarThumbStyle: (text) => theme.fg("scrollbarThumb", text), |
| }); |
| this.transcriptScrollView = viewport.transcript; |
| this.fullscreenLayoutRoot = viewport.root; |
| this.mountInteractiveTui(this.renderer, [ |
| this.documentContainer, |
| this.pendingMessagesContainer, |
| this.statusContainer, |
| this.widgetContainerAbove, |
| this.editorContainer, |
| this.widgetContainerBelow, |
| this.footerContainer, |
| ]); |
| // Accept text while startup completes, but only enable interrupt, exit, and submission feedback. |
| this.defaultEditor.onAction("app.clear", () => this.handleCtrlC()); |
| this.defaultEditor.onCtrlD = () => this.handleCtrlD(); |
| this.defaultEditor.onSubmit = (text) => this.handleStartupSubmit(text); |
| this.ui.setFocus(this.editor); |
| |
| // Start the UI before initializing extensions so session_start handlers can use interactive dialogs |
| this.ui.start(); |
| this.isInitialized = true; |
| |
| await this.themeController.applyFromSettings(); |
| |
| // Add header with keybindings from config (unless silenced) |
| if (this.options.verbose || !this.settingsManager.getQuietStartup()) { |
| const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`); |
| |
| // Build startup instructions using keybinding hint helpers |
| const hint = (keybinding: AppKeybinding, description: string) => keyHint(keybinding, description); |
| |
| const expandedInstructions = [ |
| hint("app.interrupt", "to interrupt"), |
| hint("app.clear", "to clear"), |
| rawKeyHint(`${keyText("app.clear")} twice`, "to exit"), |
| hint("app.exit", "to exit (empty)"), |
| hint("app.suspend", "to suspend"), |
| keyHint("tui.editor.deleteToLineEnd", "to delete to end"), |
| hint("app.thinking.cycle", "to cycle thinking level"), |
| rawKeyHint(`${keyText("app.model.cycleForward")}/${keyText("app.model.cycleBackward")}`, "to cycle models"), |
| hint("app.model.select", "to select model"), |
| hint("app.tools.expand", "to expand tools"), |
| hint("app.thinking.toggle", "to expand thinking"), |
| hint("app.editor.external", "for external editor"), |
| rawKeyHint("/", "for commands"), |
| rawKeyHint("!", "to run bash"), |
| rawKeyHint("!!", "to run bash (no context)"), |
| hint("app.message.followUp", "to queue follow-up"), |
| hint("app.message.dequeue", "to edit all queued messages"), |
| hint("app.clipboard.pasteImage", "to paste image (with text fallback)"), |
| rawKeyHint("drop files", "to attach"), |
| ].join("\n"); |
| const compactInstructions = [ |
| hint("app.interrupt", "interrupt"), |
| rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "clear/exit"), |
| rawKeyHint("/", "commands"), |
| rawKeyHint("!", "bash"), |
| hint("app.tools.expand", "more"), |
| ].join(theme.fg("muted", " · ")); |
| const compactOnboarding = theme.fg( |
| "dim", |
| `Press ${keyText("app.tools.expand")} to show full startup help and loaded resources.`, |
| ); |
| const onboarding = theme.fg( |
| "dim", |
| `Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.`, |
| ); |
| this.builtInHeader = new ExpandableText( |
| () => `${logo}\n${compactInstructions}\n${compactOnboarding}\n\n${onboarding}`, |
| () => `${logo}\n${expandedInstructions}\n\n${onboarding}`, |
| this.getStartupExpansionState(), |
| 1, |
| 0, |
| ); |
| |
| // Setup UI layout |
| this.headerContainer.addChild(new Spacer(1)); |
| this.headerContainer.addChild(this.builtInHeader); |
| this.headerContainer.addChild(new Spacer(1)); |
| } else { |
| // Minimal header when silenced |
| this.builtInHeader = new Text("", 0, 0); |
| this.headerContainer.addChild(this.builtInHeader); |
| } |
| this.ui.requestRender(); |
| |
| // Ensure fd and rg are available after mounting the TUI (downloads if missing, adds to PATH via getBinDir) |
| // so slow downloads do not make startup appear frozen. |
| // Both are needed: fd for autocomplete, rg for grep tool and bash commands. |
| const [fdPath] = await Promise.all([ |
| ensureTool("fd", (status) => this.showManagedToolStatus(status)), |
| ensureTool("rg", (status) => this.showManagedToolStatus(status)), |
| ]); |
| this.fdPath = fdPath; |
| |
| // Enable the remaining input handlers only after managed-tool setup completes. |
| this.setupKeyHandlers(); |
| this.setupEditorSubmitHandler(); |
| this.ui.requestRender(); |
| |
| // Initialize extensions first so resources are shown before messages |
| await this.rebindCurrentSession(); |
| |
| // Render initial messages AFTER showing loaded resources |
| this.renderInitialMessages(); |
| |
| // Set up theme file watcher |
| onThemeChange(() => { |
| this.ui.invalidate(); |
| this.updateEditorBorderColor(); |
| this.ui.requestRender(); |
| }); |
| |
| // Set up git branch watcher (uses provider instead of footer) |
| this.footerDataProvider.onBranchChange(() => { |
| this.ui.requestRender(); |
| }); |
| |
| // Initialize available provider count for footer display |
| await this.updateAvailableProviderCount(); |
| |
| // Flush the completed startup state before loading the remaining syntax grammars. |
| this.ui.renderNow(); |
| void loadAllHighlightLanguages().then(() => { |
| if (!this.isInitialized) return; |
| this.ui.invalidate(); |
| this.ui.requestRender(); |
| }); |
| } |
| |
| /** |
| * Update terminal title with session name and cwd. |
| */ |
| private updateTerminalTitle(): void { |
| const cwdBasename = path.basename(this.sessionManager.getCwd()); |
| const sessionName = this.sessionManager.getSessionName(); |
| if (sessionName) { |
| this.ui.terminal.setTitle(`${APP_TITLE} - ${sessionName} - ${cwdBasename}`); |
| } else { |
| this.ui.terminal.setTitle(`${APP_TITLE} - ${cwdBasename}`); |
| } |
| } |
| |
| /** |
| * Run the interactive mode. This is the main entry point. |
| * Initializes the UI, shows warnings, processes initial messages, and starts the interactive loop. |
| */ |
| async run(): Promise<void> { |
| await this.init(); |
| |
| if (!process.env.PI_OFFLINE) { |
| const controller = new AbortController(); |
| const timeout = setTimeout(() => controller.abort(), 15_000); |
| void refreshModelCatalogs(this.session.modelRuntime, controller.signal) |
| .then(() => this.updateAvailableProviderCount()) |
| .catch(() => {}) |
| .finally(() => clearTimeout(timeout)); |
| } |
| |
| // Start version check asynchronously |
| checkForNewPiVersion(this.version).then((newRelease) => { |
| if (newRelease) { |
| this.showNewVersionNotification(newRelease); |
| } |
| }); |
| |
| // Start package update check asynchronously |
| this.checkForPackageUpdates() |
| .then((updates) => { |
| if (updates.length > 0) { |
| this.showPackageUpdateNotification(updates); |
| } |
| }) |
| .finally(() => { |
| // On Windows, npm can overwrite the shared console title while checking |
| // extension package versions. Restore Pi's title after the startup check. |
| if (process.platform === "win32" && this.isInitialized) { |
| this.updateTerminalTitle(); |
| } |
| }); |
| |
| // Check tmux keyboard setup asynchronously |
| this.checkTmuxKeyboardSetup().then((warning) => { |
| if (warning) { |
| this.showWarning(warning); |
| } |
| }); |
| |
| // Show startup warnings |
| const { |
| migratedProviders, |
| startupDiagnostics, |
| modelFallbackMessage, |
| initialMessage, |
| initialImages, |
| initialMessages, |
| } = this.options; |
| |
| for (const diagnostic of startupDiagnostics ?? []) { |
| if (diagnostic.type === "error") { |
| this.showError(diagnostic.message); |
| } else if (diagnostic.type === "warning") { |
| this.showWarning(diagnostic.message); |
| } else { |
| this.showStatus(diagnostic.message); |
| } |
| } |
| |
| if (migratedProviders && migratedProviders.length > 0) { |
| this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`); |
| } |
| |
| const modelsJsonError = this.session.modelRuntime.getError(); |
| if (modelsJsonError) { |
| this.showError(`models.json error: ${modelsJsonError}`); |
| } |
| |
| if (modelFallbackMessage) { |
| this.showWarning(modelFallbackMessage); |
| } |
| |
| void this.maybeWarnAboutAnthropicSubscriptionAuth(); |
| |
| // Process initial messages |
| if (initialMessage) { |
| try { |
| await this.session.prompt(initialMessage, { images: initialImages }); |
| } catch (error: unknown) { |
| const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; |
| this.showError(errorMessage); |
| } |
| } |
| |
| if (initialMessages) { |
| for (const message of initialMessages) { |
| try { |
| await this.session.prompt(message); |
| } catch (error: unknown) { |
| const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; |
| this.showError(errorMessage); |
| } |
| } |
| } |
| |
| // Main interactive loop |
| while (true) { |
| const userInput = await this.getUserInput(); |
| try { |
| await this.session.prompt(userInput); |
| } catch (error: unknown) { |
| const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; |
| this.showError(errorMessage); |
| } |
| } |
| } |
| |
| private async checkForPackageUpdates(): Promise<string[]> { |
| if (process.env.PI_OFFLINE) { |
| return []; |
| } |
| |
| try { |
| const packageManager = new DefaultPackageManager({ |
| cwd: this.sessionManager.getCwd(), |
| agentDir: getAgentDir(), |
| settingsManager: this.settingsManager, |
| }); |
| const updates = await packageManager.checkForAvailableUpdates(); |
| return updates.map((update) => update.displayName); |
| } catch { |
| return []; |
| } |
| } |
| |
| private async checkTmuxKeyboardSetup(): Promise<string | undefined> { |
| if (!process.env.TMUX) return undefined; |
| |
| const runTmuxShow = (option: string): Promise<string | undefined> => { |
| return new Promise((resolve) => { |
| const proc = spawn("tmux", ["show", "-gv", option], { |
| stdio: ["ignore", "pipe", "ignore"], |
| }); |
| let stdout = ""; |
| const timer = setTimeout(() => { |
| proc.kill(); |
| resolve(undefined); |
| }, 2000); |
| |
| proc.stdout?.on("data", (data) => { |
| stdout += data.toString(); |
| }); |
| proc.on("error", () => { |
| clearTimeout(timer); |
| resolve(undefined); |
| }); |
| proc.on("close", (code) => { |
| clearTimeout(timer); |
| resolve(code === 0 ? stdout.trim() : undefined); |
| }); |
| }); |
| }; |
| |
| const [extendedKeys, extendedKeysFormat] = await Promise.all([ |
| runTmuxShow("extended-keys"), |
| runTmuxShow("extended-keys-format"), |
| ]); |
| |
| // If we couldn't query tmux (timeout, sandbox, etc.), don't warn |
| if (extendedKeys === undefined) return undefined; |
| |
| if (extendedKeys !== "on" && extendedKeys !== "always") { |
| return "tmux extended-keys is off. Modified Enter keys may not work. Add `set -g extended-keys on` to ~/.tmux.conf and restart tmux."; |
| } |
| |
| if (extendedKeysFormat === "xterm") { |
| return "tmux extended-keys-format is xterm. Pi works best with csi-u. Add `set -g extended-keys-format csi-u` to ~/.tmux.conf and restart tmux."; |
| } |
| |
| return undefined; |
| } |
| |
| /** |
| * Get changelog entries to display on startup. |
| * Only shows new entries since last seen version, skips for resumed sessions. |
| */ |
| private getChangelogForDisplay(): string | undefined { |
| // Skip changelog for resumed/continued sessions (already have messages) |
| if (this.session.state.messages.length > 0) { |
| return undefined; |
| } |
| |
| const lastVersion = this.settingsManager.getLastChangelogVersion(); |
| const changelogPath = getChangelogPath(); |
| const entries = parseChangelog(changelogPath); |
| |
| if (!lastVersion) { |
| // Fresh install - record the version, send telemetry, don't show changelog |
| this.settingsManager.setLastChangelogVersion(VERSION); |
| this.reportInstallTelemetry(VERSION); |
| return undefined; |
| } |
| |
| const newEntries = getNewEntries(entries, lastVersion); |
| if (newEntries.length > 0) { |
| this.settingsManager.setLastChangelogVersion(VERSION); |
| this.reportInstallTelemetry(VERSION); |
| return newEntries.map((e) => normalizeChangelogLinks(e.content, e)).join("\n\n"); |
| } |
| |
| return undefined; |
| } |
| |
| private reportInstallTelemetry(version: string): void { |
| if (process.env.PI_OFFLINE) { |
| return; |
| } |
| |
| if (!isInstallTelemetryEnabled(this.settingsManager)) { |
| return; |
| } |
| |
| void fetch(`https: |
| headers: { |
| "User-Agent": getPiUserAgent(version), |
| }, |
| signal: AbortSignal.timeout(5000), |
| }) |
| .then(() => undefined) |
| .catch(() => undefined); |
| } |
|
|
| private getMarkdownThemeWithSettings(): MarkdownTheme { |
| return { |
| ...getMarkdownTheme(), |
| codeBlockIndent: this.settingsManager.getCodeBlockIndent(), |
| }; |
| } |
|
|
| |
| |
| |
|
|
| private formatDisplayPath(p: string): string { |
| const home = os.homedir(); |
| let result = p; |
|
|
| |
| if (result.startsWith(home)) { |
| result = `~${result.slice(home.length)}`; |
| } |
|
|
| return result; |
| } |
|
|
| private formatExtensionDisplayPath(path: string): string { |
| let result = this.formatDisplayPath(path); |
| result = result.replace(/\/index\.ts$/, "").replace(/\/index\.js$/, ""); |
| return result; |
| } |
|
|
| private formatContextPath(p: string): string { |
| const cwd = path.resolve(this.sessionManager.getCwd()); |
| const absolutePath = path.isAbsolute(p) ? path.resolve(p) : path.resolve(cwd, p); |
| const relativePath = getCwdRelativePath(absolutePath, cwd); |
| if (relativePath !== undefined) { |
| return relativePath; |
| } |
|
|
| return this.formatDisplayPath(absolutePath); |
| } |
|
|
| private getStartupExpansionState(): boolean { |
| return this.options.verbose || this.toolOutputExpanded; |
| } |
|
|
| |
| |
| |
| private getShortPath(fullPath: string, sourceInfo?: SourceInfo): string { |
| const normalizedFullPath = fullPath.replace(/\\/g, "/"); |
| const baseDir = sourceInfo?.baseDir; |
| if (baseDir && this.isPackageSource(sourceInfo)) { |
| const normalizedBaseDir = baseDir.replace(/\\/g, "/"); |
| const npmRootMatch = normalizedBaseDir.match(/^(.*\/node_modules)\/(@?[^/]+(?:\/[^/]+)?)$/); |
| |
| if (npmRootMatch?.[1] && normalizedFullPath.startsWith(`${npmRootMatch[1]}/`)) { |
| return path.posix.relative(normalizedBaseDir, normalizedFullPath); |
| } |
|
|
| const relativePath = path.relative(path.resolve(baseDir), path.resolve(fullPath)); |
| if ( |
| relativePath && |
| relativePath !== "." && |
| !relativePath.startsWith("..") && |
| !relativePath.startsWith(`..${path.sep}`) && |
| !path.isAbsolute(relativePath) |
| ) { |
| return relativePath.replace(/\\/g, "/"); |
| } |
| } |
|
|
| const source = sourceInfo?.source ?? ""; |
| const npmMatch = normalizedFullPath.match(/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/); |
| if (npmMatch && source.startsWith("npm:")) { |
| return npmMatch[2]; |
| } |
|
|
| const gitMatch = normalizedFullPath.match(/git\/[^/]+\/[^/]+\/(.*)/); |
| if (gitMatch && source.startsWith("git:")) { |
| return gitMatch[1]; |
| } |
|
|
| return this.formatDisplayPath(fullPath); |
| } |
|
|
| private getCompactPathLabel(resourcePath: string, sourceInfo?: SourceInfo): string { |
| const shortPath = this.getShortPath(resourcePath, sourceInfo); |
| const normalizedPath = shortPath.replace(/\\/g, "/"); |
| const segments = normalizedPath.split("/").filter((segment) => segment.length > 0 && segment !== "~"); |
| if (segments.length > 0) { |
| return segments[segments.length - 1]!; |
| } |
| return shortPath; |
| } |
|
|
| private getCompactPackageSourceLabel(sourceInfo?: SourceInfo): string { |
| const source = sourceInfo?.source ?? ""; |
| if (source.startsWith("npm:")) { |
| return source.slice("npm:".length) || source; |
| } |
|
|
| const gitSource = parseGitUrl(source); |
| if (gitSource) { |
| return gitSource.path || source; |
| } |
|
|
| return source; |
| } |
|
|
| private getCompactExtensionLabel(resourcePath: string, sourceInfo?: SourceInfo): string { |
| if (!this.isPackageSource(sourceInfo)) { |
| return this.getCompactPathLabel(resourcePath, sourceInfo); |
| } |
|
|
| const sourceLabel = this.getCompactPackageSourceLabel(sourceInfo); |
| if (!sourceLabel) { |
| return this.getCompactPathLabel(resourcePath, sourceInfo); |
| } |
|
|
| const shortPath = this.getShortPath(resourcePath, sourceInfo).replace(/\\/g, "/"); |
| const packagePath = shortPath.startsWith("extensions/") ? shortPath.slice("extensions/".length) : shortPath; |
| const parsedPath = path.posix.parse(packagePath); |
|
|
| if (parsedPath.name === "index") { |
| return !parsedPath.dir || parsedPath.dir === "." ? sourceLabel : `${sourceLabel}:${parsedPath.dir}`; |
| } |
|
|
| return `${sourceLabel}:${packagePath}`; |
| } |
|
|
| private getCompactDisplayPathSegments(resourcePath: string): string[] { |
| return this.formatDisplayPath(resourcePath) |
| .replace(/\\/g, "/") |
| .split("/") |
| .filter((segment) => segment.length > 0 && segment !== "~"); |
| } |
|
|
| private getCompactNonPackageExtensionLabel( |
| resourcePath: string, |
| index: number, |
| allPaths: Array<{ path: string; segments: string[] }>, |
| ): string { |
| const segments = allPaths[index]?.segments; |
| if (!segments || segments.length === 0) { |
| return this.getCompactPathLabel(resourcePath); |
| } |
|
|
| for (let segmentCount = 1; segmentCount <= segments.length; segmentCount += 1) { |
| const candidate = segments.slice(-segmentCount).join("/"); |
| const isUnique = allPaths.every((item, itemIndex) => { |
| if (itemIndex === index) { |
| return true; |
| } |
| return item.segments.slice(-segmentCount).join("/") !== candidate; |
| }); |
|
|
| if (isUnique) { |
| return candidate; |
| } |
| } |
|
|
| return segments.join("/"); |
| } |
|
|
| private getCompactExtensionLabels(extensions: Array<{ path: string; sourceInfo?: SourceInfo }>): string[] { |
| const nonPackageExtensions = extensions |
| .map((extension) => { |
| const segments = this.getCompactDisplayPathSegments(extension.path); |
| const lastSegment = segments[segments.length - 1]; |
| if (segments.length > 1 && (lastSegment === "index.ts" || lastSegment === "index.js")) { |
| segments.pop(); |
| } |
| return { |
| path: extension.path, |
| sourceInfo: extension.sourceInfo, |
| segments, |
| }; |
| }) |
| .filter((extension) => !this.isPackageSource(extension.sourceInfo)); |
|
|
| return extensions.map((extension) => { |
| if (this.isPackageSource(extension.sourceInfo)) { |
| return this.getCompactExtensionLabel(extension.path, extension.sourceInfo); |
| } |
|
|
| const nonPackageIndex = nonPackageExtensions.findIndex((item) => item.path === extension.path); |
| if (nonPackageIndex === -1) { |
| return this.getCompactPathLabel(extension.path, extension.sourceInfo); |
| } |
|
|
| return this.getCompactNonPackageExtensionLabel(extension.path, nonPackageIndex, nonPackageExtensions); |
| }); |
| } |
|
|
| private getDisplaySourceInfo(sourceInfo?: SourceInfo): { |
| label: string; |
| scopeLabel?: string; |
| color: "accent" | "muted"; |
| } { |
| const source = sourceInfo?.source ?? "local"; |
| const scope = sourceInfo?.scope ?? "project"; |
| if (source === "local") { |
| if (scope === "user") { |
| return { label: "user", color: "muted" }; |
| } |
| if (scope === "project") { |
| return { label: "project", color: "muted" }; |
| } |
| if (scope === "temporary") { |
| return { label: "path", scopeLabel: "temp", color: "muted" }; |
| } |
| return { label: "path", color: "muted" }; |
| } |
|
|
| if (source === "cli") { |
| return { label: "path", scopeLabel: scope === "temporary" ? "temp" : undefined, color: "muted" }; |
| } |
|
|
| const scopeLabel = |
| scope === "user" ? "user" : scope === "project" ? "project" : scope === "temporary" ? "temp" : undefined; |
| return { label: source, scopeLabel, color: "accent" }; |
| } |
|
|
| private getScopeGroup(sourceInfo?: SourceInfo): "user" | "project" | "path" { |
| const source = sourceInfo?.source ?? "local"; |
| const scope = sourceInfo?.scope ?? "project"; |
| if (source === "cli" || scope === "temporary") return "path"; |
| if (scope === "user") return "user"; |
| if (scope === "project") return "project"; |
| return "path"; |
| } |
|
|
| private isPackageSource(sourceInfo?: SourceInfo): boolean { |
| const source = sourceInfo?.source ?? ""; |
| return source.startsWith("npm:") || source.startsWith("git:"); |
| } |
|
|
| private buildScopeGroups(items: Array<{ path: string; sourceInfo?: SourceInfo }>): Array<{ |
| scope: "user" | "project" | "path"; |
| paths: Array<{ path: string; sourceInfo?: SourceInfo }>; |
| packages: Map<string, Array<{ path: string; sourceInfo?: SourceInfo }>>; |
| }> { |
| const groups: Record< |
| "user" | "project" | "path", |
| { |
| scope: "user" | "project" | "path"; |
| paths: Array<{ path: string; sourceInfo?: SourceInfo }>; |
| packages: Map<string, Array<{ path: string; sourceInfo?: SourceInfo }>>; |
| } |
| > = { |
| user: { scope: "user", paths: [], packages: new Map() }, |
| project: { scope: "project", paths: [], packages: new Map() }, |
| path: { scope: "path", paths: [], packages: new Map() }, |
| }; |
|
|
| for (const item of items) { |
| const groupKey = this.getScopeGroup(item.sourceInfo); |
| const group = groups[groupKey]; |
| const source = item.sourceInfo?.source ?? "local"; |
|
|
| if (this.isPackageSource(item.sourceInfo)) { |
| const list = group.packages.get(source) ?? []; |
| list.push(item); |
| group.packages.set(source, list); |
| } else { |
| group.paths.push(item); |
| } |
| } |
|
|
| return [groups.project, groups.user, groups.path].filter( |
| (group) => group.paths.length > 0 || group.packages.size > 0, |
| ); |
| } |
|
|
| private formatScopeGroups( |
| groups: Array<{ |
| scope: "user" | "project" | "path"; |
| paths: Array<{ path: string; sourceInfo?: SourceInfo }>; |
| packages: Map<string, Array<{ path: string; sourceInfo?: SourceInfo }>>; |
| }>, |
| options: { |
| formatPath: (item: { path: string; sourceInfo?: SourceInfo }) => string; |
| formatPackagePath: (item: { path: string; sourceInfo?: SourceInfo }, source: string) => string; |
| }, |
| ): string { |
| const lines: string[] = []; |
|
|
| for (const group of groups) { |
| lines.push(` ${theme.fg("accent", group.scope)}`); |
|
|
| const sortedPaths = [...group.paths].sort((a, b) => a.path.localeCompare(b.path)); |
| for (const item of sortedPaths) { |
| lines.push(theme.fg("dim", ` ${options.formatPath(item)}`)); |
| } |
|
|
| const sortedPackages = Array.from(group.packages.entries()).sort(([a], [b]) => a.localeCompare(b)); |
| for (const [source, items] of sortedPackages) { |
| lines.push(` ${theme.fg("mdLink", source)}`); |
| const sortedPackagePaths = [...items].sort((a, b) => a.path.localeCompare(b.path)); |
| for (const item of sortedPackagePaths) { |
| lines.push(theme.fg("dim", ` ${options.formatPackagePath(item, source)}`)); |
| } |
| } |
| } |
|
|
| return lines.join("\n"); |
| } |
|
|
| private findSourceInfoForPath(p: string, sourceInfos: Map<string, SourceInfo>): SourceInfo | undefined { |
| const exact = sourceInfos.get(p); |
| if (exact) return exact; |
|
|
| let current = p; |
| while (current.includes("/")) { |
| current = current.substring(0, current.lastIndexOf("/")); |
| const parent = sourceInfos.get(current); |
| if (parent) return parent; |
| } |
|
|
| return undefined; |
| } |
|
|
| private formatPathWithSource(p: string, sourceInfo?: SourceInfo): string { |
| if (sourceInfo) { |
| const shortPath = this.getShortPath(p, sourceInfo); |
| const { label, scopeLabel } = this.getDisplaySourceInfo(sourceInfo); |
| const labelText = scopeLabel ? `${label} (${scopeLabel})` : label; |
| return `${labelText} ${shortPath}`; |
| } |
| return this.formatDisplayPath(p); |
| } |
|
|
| private formatDiagnostics(diagnostics: readonly ResourceDiagnostic[], sourceInfos: Map<string, SourceInfo>): string { |
| const lines: string[] = []; |
|
|
| |
| const collisions = new Map<string, ResourceDiagnostic[]>(); |
| const otherDiagnostics: ResourceDiagnostic[] = []; |
|
|
| for (const d of diagnostics) { |
| if (d.type === "collision" && d.collision) { |
| const list = collisions.get(d.collision.name) ?? []; |
| list.push(d); |
| collisions.set(d.collision.name, list); |
| } else { |
| otherDiagnostics.push(d); |
| } |
| } |
|
|
| |
| for (const [name, collisionList] of collisions) { |
| const first = collisionList[0]?.collision; |
| if (!first) continue; |
| lines.push(theme.fg("warning", ` "${name}" collision:`)); |
| lines.push( |
| theme.fg( |
| "dim", |
| ` ${theme.fg("success", "✓")} ${this.formatPathWithSource(first.winnerPath, this.findSourceInfoForPath(first.winnerPath, sourceInfos))}`, |
| ), |
| ); |
| for (const d of collisionList) { |
| if (d.collision) { |
| lines.push( |
| theme.fg( |
| "dim", |
| ` ${theme.fg("warning", "✗")} ${this.formatPathWithSource(d.collision.loserPath, this.findSourceInfoForPath(d.collision.loserPath, sourceInfos))} (skipped)`, |
| ), |
| ); |
| } |
| } |
| } |
|
|
| for (const d of otherDiagnostics) { |
| if (d.path) { |
| const formattedPath = this.formatPathWithSource(d.path, this.findSourceInfoForPath(d.path, sourceInfos)); |
| lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${formattedPath}`)); |
| lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`)); |
| } else { |
| lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`)); |
| } |
| } |
|
|
| return lines.join("\n"); |
| } |
|
|
| private showLoadedResources(options?: { |
| extensions?: Array<{ path: string; sourceInfo?: SourceInfo }>; |
| force?: boolean; |
| showDiagnosticsWhenQuiet?: boolean; |
| }): void { |
| |
| this.loadedResourcesContainer.clear(); |
|
|
| const showListing = options?.force || this.options.verbose || !this.settingsManager.getQuietStartup(); |
| const showDiagnostics = showListing || options?.showDiagnosticsWhenQuiet === true; |
| if (!showListing && !showDiagnostics) { |
| return; |
| } |
|
|
| const sectionHeader = (name: string, color: ThemeColor = "mdHeading") => theme.fg(color, `[${name}]`); |
| const formatCompactList = (items: string[], options?: { sort?: boolean }): string => { |
| const labels = items.map((item) => item.trim()).filter((item) => item.length > 0); |
| if (options?.sort !== false) { |
| labels.sort((a, b) => a.localeCompare(b)); |
| } |
| return theme.fg("dim", ` ${labels.join(", ")}`); |
| }; |
| const addLoadedSection = ( |
| name: string, |
| collapsedBody: string, |
| expandedBody = collapsedBody, |
| color: ThemeColor = "mdHeading", |
| ): void => { |
| const section = new ExpandableText( |
| () => `${sectionHeader(name, color)}\n${collapsedBody}`, |
| () => `${sectionHeader(name, color)}\n${expandedBody}`, |
| this.getStartupExpansionState(), |
| 0, |
| 0, |
| ); |
| this.loadedResourcesContainer.addChild(section); |
| this.loadedResourcesContainer.addChild(new Spacer(1)); |
| }; |
|
|
| const skillsResult = this.session.resourceLoader.getSkills(); |
| const promptsResult = this.session.resourceLoader.getPrompts(); |
| const themesResult = this.session.resourceLoader.getThemes(); |
| const extensions = |
| options?.extensions ?? |
| this.session.resourceLoader |
| .getExtensions() |
| .extensions.filter((extension) => !extension.hidden) |
| .map((extension) => ({ |
| path: extension.path, |
| sourceInfo: extension.sourceInfo, |
| })); |
| const sourceInfos = new Map<string, SourceInfo>(); |
| for (const extension of extensions) { |
| if (extension.sourceInfo) { |
| sourceInfos.set(extension.path, extension.sourceInfo); |
| } |
| } |
| for (const skill of skillsResult.skills) { |
| if (skill.sourceInfo) { |
| sourceInfos.set(skill.filePath, skill.sourceInfo); |
| } |
| } |
| for (const prompt of promptsResult.prompts) { |
| if (prompt.sourceInfo) { |
| sourceInfos.set(prompt.filePath, prompt.sourceInfo); |
| } |
| } |
| for (const loadedTheme of themesResult.themes) { |
| if (loadedTheme.sourcePath && loadedTheme.sourceInfo) { |
| sourceInfos.set(loadedTheme.sourcePath, loadedTheme.sourceInfo); |
| } |
| } |
|
|
| if (showListing) { |
| const systemPromptSource = this.session.resourceLoader.getSystemPromptSource(); |
| const contextFiles = [ |
| ...(systemPromptSource ? [systemPromptSource] : []), |
| ...this.session.resourceLoader.getAppendSystemPromptSources(), |
| ...this.session.resourceLoader.getAgentsFiles().agentsFiles, |
| ]; |
| if (contextFiles.length > 0) { |
| this.loadedResourcesContainer.addChild(new Spacer(1)); |
| const contextList = contextFiles |
| .map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`)) |
| .join("\n"); |
| const contextCompactList = formatCompactList( |
| contextFiles.map((contextFile) => this.formatContextPath(contextFile.path)), |
| { sort: false }, |
| ); |
| addLoadedSection("Context", contextCompactList, contextList); |
| } |
|
|
| const skills = skillsResult.skills; |
| if (skills.length > 0) { |
| const groups = this.buildScopeGroups( |
| skills.map((skill) => ({ path: skill.filePath, sourceInfo: skill.sourceInfo })), |
| ); |
| const skillList = this.formatScopeGroups(groups, { |
| formatPath: (item) => this.formatDisplayPath(item.path), |
| formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo), |
| }); |
| const skillCompactList = formatCompactList(skills.map((skill) => skill.name)); |
| addLoadedSection("Skills", skillCompactList, skillList); |
| } |
|
|
| const templates = this.session.promptTemplates; |
| if (templates.length > 0) { |
| const groups = this.buildScopeGroups( |
| templates.map((template) => ({ path: template.filePath, sourceInfo: template.sourceInfo })), |
| ); |
| const templateByPath = new Map(templates.map((t) => [t.filePath, t])); |
| const templateList = this.formatScopeGroups(groups, { |
| formatPath: (item) => { |
| const template = templateByPath.get(item.path); |
| return template ? `/${template.name}` : this.formatDisplayPath(item.path); |
| }, |
| formatPackagePath: (item) => { |
| const template = templateByPath.get(item.path); |
| return template ? `/${template.name}` : this.formatDisplayPath(item.path); |
| }, |
| }); |
| const promptCompactList = formatCompactList(templates.map((template) => `/${template.name}`)); |
| addLoadedSection("Prompts", promptCompactList, templateList); |
| } |
|
|
| if (extensions.length > 0) { |
| const groups = this.buildScopeGroups(extensions); |
| const extList = this.formatScopeGroups(groups, { |
| formatPath: (item) => this.formatExtensionDisplayPath(item.path), |
| formatPackagePath: (item) => |
| this.formatExtensionDisplayPath(this.getShortPath(item.path, item.sourceInfo)), |
| }); |
| const extensionCompactList = formatCompactList(this.getCompactExtensionLabels(extensions)); |
| addLoadedSection("Extensions", extensionCompactList, extList, "mdHeading"); |
| } |
|
|
| |
| const loadedThemes = themesResult.themes; |
| const customThemes = loadedThemes.filter((t) => t.sourcePath); |
| if (customThemes.length > 0) { |
| const groups = this.buildScopeGroups( |
| customThemes.map((loadedTheme) => ({ |
| path: loadedTheme.sourcePath!, |
| sourceInfo: loadedTheme.sourceInfo, |
| })), |
| ); |
| const themeList = this.formatScopeGroups(groups, { |
| formatPath: (item) => this.formatDisplayPath(item.path), |
| formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo), |
| }); |
| const themeCompactList = formatCompactList( |
| customThemes.map( |
| (loadedTheme) => |
| loadedTheme.name ?? this.getCompactPathLabel(loadedTheme.sourcePath!, loadedTheme.sourceInfo), |
| ), |
| ); |
| addLoadedSection("Themes", themeCompactList, themeList); |
| } |
| } |
|
|
| if (showDiagnostics) { |
| const skillDiagnostics = skillsResult.diagnostics; |
| if (skillDiagnostics.length > 0) { |
| const warningLines = this.formatDiagnostics(skillDiagnostics, sourceInfos); |
| this.loadedResourcesContainer.addChild( |
| new Text(`${theme.fg("warning", "[Skill conflicts]")}\n${warningLines}`, 0, 0), |
| ); |
| this.loadedResourcesContainer.addChild(new Spacer(1)); |
| } |
|
|
| const promptDiagnostics = promptsResult.diagnostics; |
| if (promptDiagnostics.length > 0) { |
| const warningLines = this.formatDiagnostics(promptDiagnostics, sourceInfos); |
| this.loadedResourcesContainer.addChild( |
| new Text(`${theme.fg("warning", "[Prompt conflicts]")}\n${warningLines}`, 0, 0), |
| ); |
| this.loadedResourcesContainer.addChild(new Spacer(1)); |
| } |
|
|
| const extensionDiagnostics: ResourceDiagnostic[] = []; |
| const extensionErrors = this.session.resourceLoader.getExtensions().errors; |
| if (extensionErrors.length > 0) { |
| for (const error of extensionErrors) { |
| extensionDiagnostics.push({ type: "error", message: error.error, path: error.path }); |
| } |
| } |
|
|
| const commandDiagnostics = this.session.extensionRunner.getCommandDiagnostics(); |
| extensionDiagnostics.push(...commandDiagnostics); |
| extensionDiagnostics.push(...this.getBuiltInCommandConflictDiagnostics(this.session.extensionRunner)); |
|
|
| const shortcutDiagnostics = this.session.extensionRunner.getShortcutDiagnostics(); |
| extensionDiagnostics.push(...shortcutDiagnostics); |
|
|
| if (extensionDiagnostics.length > 0) { |
| const warningLines = this.formatDiagnostics(extensionDiagnostics, sourceInfos); |
| this.loadedResourcesContainer.addChild( |
| new Text(`${theme.fg("warning", "[Extension issues]")}\n${warningLines}`, 0, 0), |
| ); |
| this.loadedResourcesContainer.addChild(new Spacer(1)); |
| } |
|
|
| const themeDiagnostics = themesResult.diagnostics; |
| if (themeDiagnostics.length > 0) { |
| const warningLines = this.formatDiagnostics(themeDiagnostics, sourceInfos); |
| this.loadedResourcesContainer.addChild( |
| new Text(`${theme.fg("warning", "[Theme conflicts]")}\n${warningLines}`, 0, 0), |
| ); |
| this.loadedResourcesContainer.addChild(new Spacer(1)); |
| } |
| } |
| } |
|
|
| |
| |
| |
| private async bindCurrentSessionExtensions(): Promise<void> { |
| const uiContext = this.createExtensionUIContext(); |
| await this.session.bindExtensions({ |
| uiContext, |
| mode: "tui", |
| abortHandler: () => { |
| this.restoreQueuedMessagesToEditor({ abort: true }); |
| }, |
| commandContextActions: { |
| waitForIdle: () => this.session.waitForIdle(), |
| newSession: async (options) => { |
| this.clearStatusIndicator(); |
| try { |
| return await this.runtimeHost.newSession(options); |
| } catch (error: unknown) { |
| return this.handleFatalRuntimeError("Failed to create session", error); |
| } |
| }, |
| fork: async (entryId, options) => { |
| try { |
| const result = await this.runtimeHost.fork(entryId, options); |
| if (!result.cancelled) { |
| this.editor.setText(result.selectedText ?? ""); |
| this.showStatus("Forked to new session"); |
| } |
| return { cancelled: result.cancelled }; |
| } catch (error: unknown) { |
| return this.handleFatalRuntimeError("Failed to fork session", error); |
| } |
| }, |
| navigateTree: async (targetId, options) => { |
| const result = await this.session.navigateTree(targetId, { |
| summarize: options?.summarize, |
| customInstructions: options?.customInstructions, |
| replaceInstructions: options?.replaceInstructions, |
| label: options?.label, |
| }); |
| if (result.cancelled) { |
| return { cancelled: true }; |
| } |
|
|
| this.chatContainer.clear(); |
| this.renderInitialMessages(); |
| if (result.editorText && !this.editor.getText().trim()) { |
| this.editor.setText(result.editorText); |
| } |
| this.showStatus("Navigated to selected point"); |
| void this.flushCompactionQueue({ willRetry: false }); |
| return { cancelled: false }; |
| }, |
| switchSession: async (sessionPath, options) => { |
| return this.handleResumeSession(sessionPath, options); |
| }, |
| reload: async () => { |
| await this.handleReloadCommand(); |
| }, |
| }, |
| shutdownHandler: () => { |
| this.shutdownRequested = true; |
| if (this.session.isIdle) { |
| void this.shutdown(); |
| } |
| }, |
| onError: (error) => { |
| this.showExtensionError(error.extensionPath, error.error, error.stack); |
| }, |
| }); |
|
|
| setRegisteredThemes(this.session.resourceLoader.getThemes().themes); |
| this.setupAutocompleteProvider(); |
|
|
| const extensionRunner = this.session.extensionRunner; |
| this.setupExtensionShortcuts(extensionRunner); |
| this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true }); |
| this.showStartupNoticesIfNeeded(); |
| } |
|
|
| private applyFullscreenScrollbarSetting(): void { |
| this.transcriptScrollView?.setScrollbar(this.settingsManager.getFullscreenScrollbar()); |
| } |
|
|
| private applyRuntimeSettings(): void { |
| setCapabilityOverrides(this.settingsManager.getTerminalCapabilityOverrides()); |
| configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs()); |
| this.applyFullscreenScrollbarSetting(); |
| if (this.renderer instanceof TuiAltScreen) { |
| this.renderer.setCopyOnSelect(this.settingsManager.getFullscreenCopyOnSelect()); |
| } |
| this.footer.setSession(this.session); |
| this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled); |
| this.footerDataProvider.setCwd(this.sessionManager.getCwd()); |
| this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); |
| this.outputPad = this.settingsManager.getOutputPad(); |
| this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor()); |
| const clearOnShrink = this.settingsManager.getClearOnShrink(); |
| this.ui.setClearOnShrink(clearOnShrink); |
| if (!clearOnShrink && !this.activeStatusIndicator) { |
| this.statusContainer.clear(); |
| } |
| const editorPaddingX = this.settingsManager.getEditorPaddingX(); |
| const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible(); |
| this.defaultEditor.setPaddingX(editorPaddingX); |
| this.defaultEditor.setAutocompleteMaxVisible(autocompleteMaxVisible); |
| if (this.editor !== this.defaultEditor) { |
| this.editor.setPaddingX?.(editorPaddingX); |
| this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible); |
| } |
| } |
|
|
| private async rebindCurrentSession(options: { renderBeforeBind?: boolean } = {}): Promise<void> { |
| const session = this.session; |
|
|
| this.unsubscribe?.(); |
| this.unsubscribe = undefined; |
| this.applyRuntimeSettings(); |
|
|
| if (options.renderBeforeBind) { |
| this.renderCurrentSessionState(); |
| this.subscribeToAgent(); |
| } |
|
|
| await this.bindCurrentSessionExtensions(); |
|
|
| if (this.session !== session) { |
| return; |
| } |
|
|
| if (!options.renderBeforeBind) { |
| this.subscribeToAgent(); |
| } |
|
|
| await this.updateAvailableProviderCount(); |
| this.updateEditorBorderColor(); |
| this.updateTerminalTitle(); |
| } |
|
|
| private async handleFatalRuntimeError(prefix: string, error: unknown): Promise<never> { |
| const message = error instanceof Error ? error.message : String(error); |
| this.showError(`${prefix}: ${message}`); |
| stopThemeWatcher(); |
| this.stop("transcript"); |
| process.exit(1); |
| } |
|
|
| private renderCurrentSessionState(): void { |
| this.loadedResourcesContainer.clear(); |
| this.chatContainer.clear(); |
| this.pendingMessagesContainer.clear(); |
| this.compactionQueuedMessages = []; |
| this.streamingComponent = undefined; |
| this.streamingMessage = undefined; |
| this.pendingTools.clear(); |
| this.renderInitialMessages(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| private getRegisteredToolDefinition(toolName: string) { |
| return withBuiltInRenderers(toolName, this.session.getToolDefinition(toolName)); |
| } |
|
|
| private getMarkdownTransformers(): MarkdownTransformer[] { |
| return [this.mermaidMarkdownTransformer, ...this.session.extensionRunner.getMarkdownTransformers()]; |
| } |
|
|
| |
| |
| |
| private setupExtensionShortcuts(extensionRunner: ExtensionRunner): void { |
| const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig()); |
| if (shortcuts.size === 0) return; |
|
|
| |
| const createContext = (): ExtensionContext => ({ |
| ui: this.createExtensionUIContext(), |
| mode: "tui", |
| hasUI: true, |
| cwd: this.sessionManager.getCwd(), |
| sessionManager: this.sessionManager, |
| modelRegistry: extensionRunner.getModelRegistry(), |
| model: this.session.model, |
| scopedModels: this.session.scopedModels, |
| thinkingLevel: this.session.thinkingLevel, |
| isIdle: () => this.session.isIdle, |
| isProjectTrusted: () => this.settingsManager.isProjectTrusted(), |
| signal: this.session.agent.signal, |
| abort: () => { |
| this.restoreQueuedMessagesToEditor({ abort: true }); |
| }, |
| hasPendingMessages: () => this.session.pendingMessageCount > 0, |
| shutdown: () => { |
| this.shutdownRequested = true; |
| }, |
| getContextUsage: () => this.session.getContextUsage(), |
| compact: (options) => { |
| void (async () => { |
| try { |
| const result = await this.session.compact(options?.customInstructions); |
| options?.onComplete?.(result); |
| } catch (error) { |
| const err = error instanceof Error ? error : new Error(String(error)); |
| options?.onError?.(err); |
| } |
| })(); |
| }, |
| getSystemPrompt: () => this.session.systemPrompt, |
| }); |
|
|
| |
| this.defaultEditor.onExtensionShortcut = (data: string) => { |
| for (const [shortcutStr, shortcut] of shortcuts) { |
| |
| if (matchesKey(data, shortcutStr as KeyId)) { |
| |
| Promise.resolve(shortcut.handler(createContext())).catch((err) => { |
| this.showError(`Shortcut handler error: ${err instanceof Error ? err.message : String(err)}`); |
| }); |
| return true; |
| } |
| } |
| return false; |
| }; |
| } |
|
|
| |
| |
| |
| private setExtensionStatus(key: string, text: string | undefined): void { |
| this.footerDataProvider.setExtensionStatus(key, text); |
| this.ui.requestRender(); |
| } |
|
|
| private setEditorWorkingStatusIndicator(indicator: StatusIndicator | undefined): boolean { |
| this.defaultEditor.setWorkingStatusIndicator(undefined); |
| if (!isWorkingStatusEditor(this.editor)) return false; |
| this.editor.setWorkingStatusIndicator(indicator); |
| return true; |
| } |
|
|
| private showStatusIndicator(indicator: StatusIndicator): void { |
| this.activeStatusIndicator?.dispose(); |
| this.activeStatusIndicator = indicator; |
| this.activeWorkingIndicatorEmbedded = false; |
| this.statusContainer.clear(); |
| this.setEditorWorkingStatusIndicator(undefined); |
| if (this.setEditorWorkingStatusIndicator(indicator)) { |
| this.activeWorkingIndicatorEmbedded = true; |
| return; |
| } |
| this.statusContainer.addChild(indicator); |
| } |
|
|
| private clearStatusIndicator(kind?: StatusIndicator["kind"]): void { |
| if (kind && this.activeStatusIndicator?.kind !== kind) { |
| return; |
| } |
| const clearedIndicator = this.activeStatusIndicator; |
| const clearedIndicatorWasEmbedded = this.activeWorkingIndicatorEmbedded; |
| clearedIndicator?.dispose(); |
| this.activeStatusIndicator = undefined; |
| this.activeWorkingIndicatorEmbedded = false; |
| this.statusContainer.clear(); |
| this.setEditorWorkingStatusIndicator(undefined); |
| if ( |
| clearedIndicator && |
| !clearedIndicatorWasEmbedded && |
| this.options.tuiMode === "regular" && |
| this.ui.getClearOnShrink() |
| ) { |
| this.statusContainer.addChild(this.idleStatus); |
| } |
| } |
|
|
| private showWorkingStatusIndicator(): void { |
| const colorFn = isWorkingStatusEditor(this.editor) |
| ? (text: string) => |
| (this.editor.borderColor ?? theme.getThinkingBorderColor(this.session.thinkingLevel || "off"))(text) |
| : undefined; |
| this.showStatusIndicator( |
| new WorkingStatusIndicator( |
| this.ui, |
| this.workingMessage ?? this.defaultWorkingMessage, |
| this.workingIndicatorOptions, |
| colorFn, |
| ), |
| ); |
| } |
|
|
| private setWorkingVisible(visible: boolean): void { |
| this.workingVisible = visible; |
| if (!visible) { |
| this.clearStatusIndicator("working"); |
| this.ui.requestRender(); |
| return; |
| } |
| if (this.session.isStreaming && this.activeStatusIndicator?.kind !== "working") { |
| this.showWorkingStatusIndicator(); |
| } |
| this.ui.requestRender(); |
| } |
|
|
| private setWorkingIndicator(options?: WorkingIndicatorOptions): void { |
| this.workingIndicatorOptions = options; |
| if (this.activeStatusIndicator?.kind === "working") { |
| this.activeStatusIndicator.setIndicator(options); |
| } |
| this.ui.requestRender(); |
| } |
|
|
| private setHiddenThinkingLabel(label?: string): void { |
| this.hiddenThinkingLabel = label ?? this.defaultHiddenThinkingLabel; |
| for (const child of this.chatContainer.children) { |
| if (child instanceof AssistantMessageComponent) { |
| child.setHiddenThinkingLabel(this.hiddenThinkingLabel); |
| } |
| } |
| if (this.streamingComponent) { |
| this.streamingComponent.setHiddenThinkingLabel(this.hiddenThinkingLabel); |
| } |
| this.ui.requestRender(); |
| } |
|
|
| |
| |
| |
| private setExtensionWidget( |
| key: string, |
| content: string[] | ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined, |
| options?: ExtensionWidgetOptions, |
| ): void { |
| const placement = options?.placement ?? "aboveEditor"; |
| const removeExisting = (map: Map<string, Component & { dispose?(): void }>) => { |
| const existing = map.get(key); |
| if (existing?.dispose) existing.dispose(); |
| map.delete(key); |
| }; |
|
|
| removeExisting(this.extensionWidgetsAbove); |
| removeExisting(this.extensionWidgetsBelow); |
|
|
| if (content === undefined) { |
| this.renderWidgets(); |
| return; |
| } |
|
|
| let component: Component & { dispose?(): void }; |
|
|
| if (Array.isArray(content)) { |
| |
| const container = new Container(); |
| for (const line of content.slice(0, InteractiveMode.MAX_WIDGET_LINES)) { |
| container.addChild(new Text(line, 1, 0)); |
| } |
| if (content.length > InteractiveMode.MAX_WIDGET_LINES) { |
| container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0)); |
| } |
| component = container; |
| } else { |
| |
| component = content(this.ui, theme); |
| } |
|
|
| const targetMap = placement === "belowEditor" ? this.extensionWidgetsBelow : this.extensionWidgetsAbove; |
| targetMap.set(key, component); |
| this.renderWidgets(); |
| } |
|
|
| private clearExtensionWidgets(): void { |
| for (const widget of this.extensionWidgetsAbove.values()) { |
| widget.dispose?.(); |
| } |
| for (const widget of this.extensionWidgetsBelow.values()) { |
| widget.dispose?.(); |
| } |
| this.extensionWidgetsAbove.clear(); |
| this.extensionWidgetsBelow.clear(); |
| this.renderWidgets(); |
| } |
|
|
| private resetExtensionUI(): void { |
| if (this.extensionSelector) { |
| this.hideExtensionSelector(); |
| } |
| if (this.extensionInput) { |
| this.hideExtensionInput(); |
| } |
| if (this.extensionEditor) { |
| this.hideExtensionEditor(); |
| } |
| this.ui.hideOverlay(); |
| this.clearExtensionTerminalInputListeners(); |
| this.setExtensionFooter(undefined); |
| this.setExtensionHeader(undefined); |
| this.clearExtensionWidgets(); |
| this.footerDataProvider.clearExtensionStatuses(); |
| this.footer.invalidate(); |
| this.autocompleteProviderWrappers = []; |
| this.setCustomEditorComponent(undefined); |
| this.setupAutocompleteProvider(); |
| this.defaultEditor.onExtensionShortcut = undefined; |
| this.updateTerminalTitle(); |
| this.workingMessage = undefined; |
| this.workingVisible = true; |
| this.setWorkingIndicator(); |
| if (this.activeStatusIndicator?.kind === "working") { |
| this.activeStatusIndicator.setMessage( |
| `${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`, |
| ); |
| } |
| this.setHiddenThinkingLabel(); |
| } |
|
|
| |
| private static readonly MAX_WIDGET_LINES = 10; |
|
|
| |
| |
| |
| private renderWidgets(): void { |
| if (!this.widgetContainerAbove || !this.widgetContainerBelow) return; |
| this.renderWidgetContainer(this.widgetContainerAbove, this.extensionWidgetsAbove, true, true); |
| this.renderWidgetContainer(this.widgetContainerBelow, this.extensionWidgetsBelow, false, false); |
| this.ui.requestRender(); |
| } |
|
|
| private renderWidgetContainer( |
| container: Container, |
| widgets: Map<string, Component & { dispose?(): void }>, |
| spacerWhenEmpty: boolean, |
| leadingSpacer: boolean, |
| ): void { |
| container.clear(); |
|
|
| if (widgets.size === 0) { |
| if (spacerWhenEmpty) { |
| container.addChild(new Spacer(1)); |
| } |
| return; |
| } |
|
|
| if (leadingSpacer) { |
| container.addChild(new Spacer(1)); |
| } |
| for (const component of widgets.values()) { |
| container.addChild(component); |
| } |
| } |
|
|
| |
| |
| |
| private setExtensionFooter( |
| factory: |
| | ((tui: TUI, thm: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?(): void }) |
| | undefined, |
| ): void { |
| |
| if (this.customFooter?.dispose) { |
| this.customFooter.dispose(); |
| } |
|
|
| this.footerContainer.clear(); |
| if (factory) { |
| |
| this.customFooter = factory(this.ui, theme, this.footerDataProvider); |
| this.footerContainer.addChild(this.customFooter); |
| } else { |
| |
| this.customFooter = undefined; |
| this.footerContainer.addChild(this.footer); |
| } |
|
|
| this.ui.requestRender(); |
| } |
|
|
| |
| |
| |
| private setExtensionHeader(factory: ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined): void { |
| |
| if (!this.builtInHeader) { |
| return; |
| } |
|
|
| |
| if (this.customHeader?.dispose) { |
| this.customHeader.dispose(); |
| } |
|
|
| |
| const currentHeader = this.customHeader || this.builtInHeader; |
| const index = this.headerContainer.children.indexOf(currentHeader); |
|
|
| if (factory) { |
| |
| this.customHeader = factory(this.ui, theme); |
| if (isExpandable(this.customHeader)) { |
| this.customHeader.setExpanded(this.toolOutputExpanded); |
| } |
| if (index !== -1) { |
| this.headerContainer.children[index] = this.customHeader; |
| } else { |
| |
| this.headerContainer.children.unshift(this.customHeader); |
| } |
| } else { |
| |
| this.customHeader = undefined; |
| if (isExpandable(this.builtInHeader)) { |
| this.builtInHeader.setExpanded(this.toolOutputExpanded); |
| } |
| if (index !== -1) { |
| this.headerContainer.children[index] = this.builtInHeader; |
| } |
| } |
|
|
| this.ui.requestRender(); |
| } |
|
|
| private addExtensionTerminalInputListener( |
| handler: (data: string) => { consume?: boolean; data?: string } | undefined, |
| ): () => void { |
| const subscription = { handler, unsubscribe: this.ui.addInputListener(handler) }; |
| this.extensionTerminalInputSubscriptions.add(subscription); |
| return () => { |
| subscription.unsubscribe(); |
| this.extensionTerminalInputSubscriptions.delete(subscription); |
| }; |
| } |
|
|
| private rebindExtensionTerminalInputListeners(): void { |
| for (const subscription of this.extensionTerminalInputSubscriptions) { |
| subscription.unsubscribe(); |
| subscription.unsubscribe = this.ui.addInputListener(subscription.handler); |
| } |
| } |
|
|
| private clearExtensionTerminalInputListeners(): void { |
| for (const subscription of this.extensionTerminalInputSubscriptions) subscription.unsubscribe(); |
| this.extensionTerminalInputSubscriptions.clear(); |
| } |
|
|
| |
| |
| |
| private createProjectTrustContext(cwd: string): ProjectTrustContext { |
| const ui = this.createExtensionUIContext(); |
| return { |
| cwd, |
| mode: "tui", |
| hasUI: true, |
| ui: { |
| select: ui.select, |
| confirm: ui.confirm, |
| input: ui.input, |
| notify: ui.notify, |
| }, |
| }; |
| } |
|
|
| private createExtensionUIContext(): ExtensionUIContext { |
| return { |
| select: (title, options, opts) => this.showExtensionSelector(title, options, opts), |
| confirm: (title, message, opts) => this.showExtensionConfirm(title, message, opts), |
| input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts), |
| notify: (message, type) => this.showExtensionNotify(message, type), |
| onTerminalInput: (handler) => this.addExtensionTerminalInputListener(handler), |
| setStatus: (key, text) => this.setExtensionStatus(key, text), |
| setWorkingMessage: (message) => { |
| this.workingMessage = message; |
| if (this.activeStatusIndicator?.kind === "working") { |
| this.activeStatusIndicator.setMessage(message ?? this.defaultWorkingMessage); |
| } |
| }, |
| setWorkingVisible: (visible) => this.setWorkingVisible(visible), |
| setWorkingIndicator: (options) => this.setWorkingIndicator(options), |
| setHiddenThinkingLabel: (label) => this.setHiddenThinkingLabel(label), |
| setWidget: (key, content, options) => this.setExtensionWidget(key, content, options), |
| setFooter: (factory) => this.setExtensionFooter(factory), |
| setHeader: (factory) => this.setExtensionHeader(factory), |
| setTitle: (title) => this.ui.terminal.setTitle(title), |
| custom: (factory, options) => this.showExtensionCustom(factory, options), |
| pasteToEditor: (text) => this.editor.handleInput(`\x1b[200~${text}\x1b[201~`), |
| setEditorText: (text) => this.editor.setText(text), |
| getEditorText: () => this.editor.getExpandedText?.() ?? this.editor.getText(), |
| editor: (title, prefill) => this.showExtensionEditor(title, prefill), |
| addAutocompleteProvider: (factory) => { |
| this.autocompleteProviderWrappers.push(factory); |
| this.setupAutocompleteProvider(); |
| }, |
| setEditorComponent: (factory) => this.setCustomEditorComponent(factory), |
| getEditorComponent: () => this.editorComponentFactory, |
| get theme() { |
| return theme; |
| }, |
| getAllThemes: () => getAvailableThemesWithPaths(), |
| getTheme: (name) => getThemeByName(name), |
| setTheme: (themeOrName) => { |
| if (themeOrName instanceof Theme) { |
| return this.themeController.setThemeInstance(themeOrName); |
| } |
| const result = this.themeController.setThemeName(themeOrName); |
| if (result.success) { |
| if (this.settingsManager.getTheme() !== themeOrName) { |
| this.settingsManager.setTheme(themeOrName); |
| } |
| } |
| return result; |
| }, |
| getToolsExpanded: () => this.toolOutputExpanded, |
| setToolsExpanded: (expanded) => this.setToolsExpanded(expanded), |
| }; |
| } |
|
|
| |
| |
| |
| private showExtensionSelector( |
| title: string, |
| options: string[], |
| opts?: ExtensionUIDialogOptions, |
| ): Promise<string | undefined> { |
| return new Promise((resolve) => { |
| if (opts?.signal?.aborted) { |
| resolve(undefined); |
| return; |
| } |
|
|
| const onAbort = () => { |
| this.hideExtensionSelector(); |
| resolve(undefined); |
| }; |
| opts?.signal?.addEventListener("abort", onAbort, { once: true }); |
|
|
| this.extensionSelector = new ExtensionSelectorComponent( |
| title, |
| options, |
| (option) => { |
| opts?.signal?.removeEventListener("abort", onAbort); |
| this.hideExtensionSelector(); |
| resolve(option); |
| }, |
| () => { |
| opts?.signal?.removeEventListener("abort", onAbort); |
| this.hideExtensionSelector(); |
| resolve(undefined); |
| }, |
| { tui: this.ui, timeout: opts?.timeout, onToggleToolsExpanded: () => this.toggleToolOutputExpansion() }, |
| ); |
|
|
| this.disposeActiveSelector(); |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(this.extensionSelector); |
| this.ui.setFocus(this.extensionSelector); |
| this.ui.requestRender(); |
| }); |
| } |
|
|
| |
| |
| |
| private hideExtensionSelector(): void { |
| this.extensionSelector?.dispose(); |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(this.editor); |
| this.extensionSelector = undefined; |
| this.ui.setFocus(this.editor); |
| this.ui.requestRender(); |
| } |
|
|
| |
| |
| |
| private async showExtensionConfirm( |
| title: string, |
| message: string, |
| opts?: ExtensionUIDialogOptions, |
| ): Promise<boolean> { |
| const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"], opts); |
| return result === "Yes"; |
| } |
|
|
| private async promptForMissingSessionCwd(error: MissingSessionCwdError): Promise<string | undefined> { |
| const confirmed = await this.showExtensionConfirm( |
| "Session cwd not found", |
| formatMissingSessionCwdPrompt(error.issue), |
| ); |
| return confirmed ? error.issue.fallbackCwd : undefined; |
| } |
|
|
| |
| |
| |
| private showExtensionInput( |
| title: string, |
| placeholder?: string, |
| opts?: ExtensionUIDialogOptions, |
| ): Promise<string | undefined> { |
| return new Promise((resolve) => { |
| if (opts?.signal?.aborted) { |
| resolve(undefined); |
| return; |
| } |
|
|
| const onAbort = () => { |
| this.hideExtensionInput(); |
| resolve(undefined); |
| }; |
| opts?.signal?.addEventListener("abort", onAbort, { once: true }); |
|
|
| this.extensionInput = new ExtensionInputComponent( |
| title, |
| placeholder, |
| (value) => { |
| opts?.signal?.removeEventListener("abort", onAbort); |
| this.hideExtensionInput(); |
| resolve(value); |
| }, |
| () => { |
| opts?.signal?.removeEventListener("abort", onAbort); |
| this.hideExtensionInput(); |
| resolve(undefined); |
| }, |
| { tui: this.ui, timeout: opts?.timeout }, |
| ); |
|
|
| this.disposeActiveSelector(); |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(this.extensionInput); |
| this.ui.setFocus(this.extensionInput); |
| this.ui.requestRender(); |
| }); |
| } |
|
|
| |
| |
| |
| private hideExtensionInput(): void { |
| this.extensionInput?.dispose(); |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(this.editor); |
| this.extensionInput = undefined; |
| this.ui.setFocus(this.editor); |
| this.ui.requestRender(); |
| } |
|
|
| |
| |
| |
| private showExtensionEditor(title: string, prefill?: string): Promise<string | undefined> { |
| return new Promise((resolve) => { |
| this.extensionEditor = new ExtensionEditorComponent( |
| this.ui, |
| this.keybindings, |
| title, |
| prefill, |
| (value) => { |
| this.hideExtensionEditor(); |
| resolve(value); |
| }, |
| () => { |
| this.hideExtensionEditor(); |
| resolve(undefined); |
| }, |
| undefined, |
| this.settingsManager.getExternalEditorCommand(), |
| ); |
|
|
| this.disposeActiveSelector(); |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(this.extensionEditor); |
| this.ui.setFocus(this.extensionEditor); |
| this.ui.requestRender(); |
| }); |
| } |
|
|
| |
| |
| |
| private hideExtensionEditor(): void { |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(this.editor); |
| this.extensionEditor = undefined; |
| this.ui.setFocus(this.editor); |
| this.ui.requestRender(); |
| } |
|
|
| |
| |
| |
| |
| private setCustomEditorComponent(factory: EditorFactory | undefined): void { |
| this.editorComponentFactory = factory; |
|
|
| |
| const currentText = this.editor.getText(); |
|
|
| this.disposeActiveSelector(); |
| this.editorContainer.clear(); |
|
|
| if (factory) { |
| |
| const newEditor = factory(this.ui, getEditorTheme(), this.keybindings); |
|
|
| |
| newEditor.onSubmit = this.defaultEditor.onSubmit; |
| newEditor.onChange = this.defaultEditor.onChange; |
|
|
| |
| newEditor.setText(currentText); |
|
|
| |
| if (newEditor.borderColor !== undefined) { |
| newEditor.borderColor = this.defaultEditor.borderColor; |
| } |
| if (newEditor.setPaddingX !== undefined) { |
| newEditor.setPaddingX(this.defaultEditor.getPaddingX()); |
| } |
| if (newEditor.setAutocompleteMaxVisible !== undefined) { |
| newEditor.setAutocompleteMaxVisible(this.defaultEditor.getAutocompleteMaxVisible()); |
| } |
|
|
| |
| if (newEditor.setAutocompleteProvider && this.autocompleteProvider) { |
| newEditor.setAutocompleteProvider(this.autocompleteProvider); |
| } |
|
|
| |
| |
| const customEditor = newEditor as unknown as Record<string, unknown>; |
| if ("actionHandlers" in customEditor && customEditor.actionHandlers instanceof Map) { |
| if (!customEditor.onEscape) { |
| customEditor.onEscape = () => this.defaultEditor.onEscape?.(); |
| } |
| if (!customEditor.onCtrlD) { |
| customEditor.onCtrlD = () => this.defaultEditor.onCtrlD?.(); |
| } |
| if (!customEditor.onPasteImage) { |
| customEditor.onPasteImage = () => this.defaultEditor.onPasteImage?.(); |
| } |
| if (!customEditor.onExtensionShortcut) { |
| customEditor.onExtensionShortcut = (data: string) => this.defaultEditor.onExtensionShortcut?.(data); |
| } |
| |
| for (const [action, handler] of this.defaultEditor.actionHandlers) { |
| (customEditor.actionHandlers as Map<string, () => void>).set(action, handler); |
| } |
| } |
|
|
| this.editor = newEditor; |
| } else { |
| |
| this.defaultEditor.setText(currentText); |
| this.editor = this.defaultEditor; |
| } |
|
|
| this.editorContainer.addChild(this.editor as Component); |
| if (this.activeStatusIndicator) { |
| this.statusContainer.clear(); |
| this.activeWorkingIndicatorEmbedded = this.setEditorWorkingStatusIndicator(this.activeStatusIndicator); |
| if (!this.activeWorkingIndicatorEmbedded) { |
| this.statusContainer.addChild(this.activeStatusIndicator); |
| } |
| } |
| this.ui.setFocus(this.editor as Component); |
| this.ui.requestRender(); |
| } |
|
|
| |
| |
| |
| private showExtensionNotify(message: string, type?: "info" | "warning" | "error"): void { |
| if (type === "error") { |
| this.showError(message); |
| } else if (type === "warning") { |
| this.showWarning(message); |
| } else { |
| this.showStatus(message); |
| } |
| } |
|
|
| |
| private async showExtensionCustom<T>( |
| factory: ( |
| tui: TUI, |
| theme: Theme, |
| keybindings: KeybindingsManager, |
| done: (result: T) => void, |
| ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>, |
| options?: { |
| overlay?: boolean; |
| overlayOptions?: OverlayOptions | (() => OverlayOptions); |
| onHandle?: (handle: OverlayHandle) => void; |
| }, |
| ): Promise<T> { |
| const savedText = this.editor.getText(); |
| const isOverlay = options?.overlay ?? false; |
|
|
| const restoreEditor = () => { |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(this.editor); |
| this.editor.setText(savedText); |
| this.ui.setFocus(this.editor); |
| this.ui.requestRender(); |
| }; |
|
|
| return new Promise((resolve, reject) => { |
| let component: Component & { dispose?(): void }; |
| let closed = false; |
|
|
| const close = (result: T) => { |
| if (closed) return; |
| closed = true; |
| if (isOverlay) this.ui.hideOverlay(); |
| else restoreEditor(); |
| |
| resolve(result); |
| try { |
| component?.dispose?.(); |
| } catch { |
| |
| } |
| }; |
|
|
| Promise.resolve(factory(this.ui, theme, this.keybindings, close)) |
| .then((c) => { |
| if (closed) return; |
| component = c; |
| if (isOverlay) { |
| |
| const resolveOptions = (): OverlayOptions | undefined => { |
| if (options?.overlayOptions) { |
| const opts = |
| typeof options.overlayOptions === "function" |
| ? options.overlayOptions() |
| : options.overlayOptions; |
| return opts; |
| } |
| |
| const w = (component as { width?: number }).width; |
| return w ? { width: w } : undefined; |
| }; |
| const handle = this.ui.showOverlay(component, resolveOptions()); |
| |
| options?.onHandle?.(handle); |
| } else { |
| this.disposeActiveSelector(); |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(component); |
| this.ui.setFocus(component); |
| this.ui.requestRender(); |
| } |
| }) |
| .catch((err) => { |
| if (closed) return; |
| if (!isOverlay) restoreEditor(); |
| reject(err); |
| }); |
| }); |
| } |
|
|
| |
| |
| |
| private showExtensionError(extensionPath: string, error: string, stack?: string): void { |
| const errorMsg = `Extension "${extensionPath}" error: ${error}`; |
| const errorText = new Text(theme.fg("error", errorMsg), 1, 0); |
| this.chatContainer.addChild(errorText); |
| if (stack) { |
| |
| const stackLines = stack |
| .split("\n") |
| .slice(1) |
| .map((line) => theme.fg("dim", ` ${line.trim()}`)) |
| .join("\n"); |
| if (stackLines) { |
| this.chatContainer.addChild(new Text(stackLines, 1, 0)); |
| } |
| } |
| this.ui.requestRender(); |
| } |
|
|
| |
| |
| |
|
|
| private setupKeyHandlers(): void { |
| |
| |
| this.defaultEditor.onEscape = () => { |
| if (this.session.isStreaming) { |
| this.restoreQueuedMessagesToEditor({ abort: true }); |
| } else if (this.session.isBashRunning) { |
| this.session.abortBash(); |
| } else if (this.isBashMode) { |
| this.editor.setText(""); |
| this.isBashMode = false; |
| this.updateEditorBorderColor(); |
| } else if (!this.editor.getText().trim()) { |
| |
| const action = this.settingsManager.getDoubleEscapeAction(); |
| if (action !== "none") { |
| const now = Date.now(); |
| if (now - this.lastEscapeTime < 500) { |
| if (action === "tree") { |
| this.showTreeSelector(); |
| } else { |
| this.showUserMessageSelector(); |
| } |
| this.lastEscapeTime = 0; |
| } else { |
| this.lastEscapeTime = now; |
| } |
| } |
| } |
| }; |
|
|
| |
| this.defaultEditor.onAction("app.clear", () => this.handleCtrlC()); |
| this.defaultEditor.onCtrlD = () => this.handleCtrlD(); |
| this.defaultEditor.onAction("app.suspend", () => this.handleCtrlZ()); |
| this.defaultEditor.onAction("app.thinking.cycle", () => this.cycleThinkingLevel()); |
| this.defaultEditor.onAction("app.model.cycleForward", () => this.cycleModel("forward")); |
| this.defaultEditor.onAction("app.model.cycleBackward", () => this.cycleModel("backward")); |
|
|
| |
| this.ui.onDebug = () => this.handleDebugCommand(); |
| this.defaultEditor.onAction("app.model.select", () => this.showModelSelector()); |
| this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion()); |
| this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility()); |
| this.defaultEditor.onAction("app.editor.external", () => void this.handleOpenExternalEditor()); |
| this.defaultEditor.onAction( |
| "app.message.copy", |
| () => void this.handleCopyCommand({ flashConfirmation: true, preferSelection: true }), |
| ); |
| this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp()); |
| this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue()); |
| this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand()); |
| this.defaultEditor.onAction("app.session.tree", () => this.showTreeSelector()); |
| this.defaultEditor.onAction("app.session.fork", () => this.showUserMessageSelector()); |
| this.defaultEditor.onAction("app.session.resume", () => this.showSessionSelector()); |
|
|
| this.defaultEditor.onChange = (text: string) => { |
| const wasBashMode = this.isBashMode; |
| this.isBashMode = text.trimStart().startsWith("!"); |
| if (wasBashMode !== this.isBashMode) { |
| this.updateEditorBorderColor(); |
| } |
| }; |
|
|
| |
| |
| this.defaultEditor.onPasteImage = () => { |
| void this.handleClipboardPaste(); |
| }; |
| } |
|
|
| private async handleRightClickPaste(): Promise<void> { |
| const target = this.renderer.getFocusedComponent(); |
| const handleInput = target?.handleInput; |
| if (!target || !handleInput) return; |
| try { |
| const text = await readClipboardText(); |
| if (!text || this.renderer.getFocusedComponent() !== target) return; |
| handleInput.call(target, `\x1b[200~${text}\x1b[201~`); |
| this.ui.requestRender(); |
| } catch { |
| |
| } |
| } |
|
|
| private async handleClipboardPaste(): Promise<void> { |
| try { |
| const image = await readClipboardImage(); |
| if (image) { |
| const tmpDir = os.tmpdir(); |
| const ext = extensionForImageMimeType(image.mimeType) ?? "png"; |
| const fileName = `pi-clipboard-${crypto.randomUUID()}.${ext}`; |
| const filePath = path.join(tmpDir, fileName); |
| fs.writeFileSync(filePath, Buffer.from(image.bytes)); |
|
|
| this.editor.insertTextAtCursor?.(filePath); |
| this.ui.requestRender(); |
| return; |
| } |
|
|
| const text = await readClipboardText(); |
| if (text) { |
| this.editor.insertTextAtCursor?.(text); |
| this.ui.requestRender(); |
| } |
| } catch { |
| |
| } |
| } |
|
|
| private handleStartupSubmit(text: string): void { |
| this.editor.setText(text); |
| this.showStatus("Startup is still in progress"); |
| } |
|
|
| private setupEditorSubmitHandler(): void { |
| this.defaultEditor.onSubmit = async (text: string) => { |
| text = text.trim(); |
| if (!text) return; |
|
|
| |
| if (text === "/settings") { |
| this.showSettingsSelector(); |
| this.editor.setText(""); |
| return; |
| } |
| if (text === "/scoped-models") { |
| this.editor.setText(""); |
| await this.showModelsSelector(); |
| return; |
| } |
| if (text === "/model" || text.startsWith("/model ")) { |
| const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined; |
| this.editor.setText(""); |
| await this.handleModelCommand(searchTerm); |
| return; |
| } |
| if (text === "/thinking" || text.startsWith("/thinking ")) { |
| const searchTerm = text.startsWith("/thinking ") ? text.slice(10).trim() : undefined; |
| this.editor.setText(""); |
| this.handleThinkingCommand(searchTerm); |
| return; |
| } |
| if (text === "/export" || text.startsWith("/export ")) { |
| await this.handleExportCommand(text); |
| this.editor.setText(""); |
| return; |
| } |
| if (text === "/import" || text.startsWith("/import ")) { |
| await this.handleImportCommand(text); |
| this.editor.setText(""); |
| return; |
| } |
| if (text === "/share") { |
| await this.handleShareCommand(); |
| this.editor.setText(""); |
| return; |
| } |
| if (text === "/copy") { |
| await this.handleCopyCommand(); |
| this.editor.setText(""); |
| return; |
| } |
| if (text === "/name" || text.startsWith("/name ")) { |
| this.handleNameCommand(text); |
| this.editor.setText(""); |
| return; |
| } |
| if (text === "/session") { |
| this.handleSessionCommand(); |
| this.editor.setText(""); |
| return; |
| } |
| if (text === "/changelog") { |
| this.handleChangelogCommand(); |
| this.editor.setText(""); |
| return; |
| } |
| if (text === "/hotkeys") { |
| this.handleHotkeysCommand(); |
| this.editor.setText(""); |
| return; |
| } |
| if (text === "/fork") { |
| this.showUserMessageSelector(); |
| this.editor.setText(""); |
| return; |
| } |
| if (text === "/clone") { |
| this.editor.setText(""); |
| await this.handleCloneCommand(); |
| return; |
| } |
| if (text === "/tree") { |
| this.showTreeSelector(); |
| this.editor.setText(""); |
| return; |
| } |
| if (text === "/trust") { |
| this.showTrustSelector(); |
| this.editor.setText(""); |
| return; |
| } |
| if (text === "/login" || text.startsWith("/login ")) { |
| const providerRef = text.startsWith("/login ") ? text.slice(7).trim() : undefined; |
| this.editor.setText(""); |
| await this.handleLoginCommand(providerRef); |
| return; |
| } |
| if (text === "/logout") { |
| this.showOAuthSelector("logout"); |
| this.editor.setText(""); |
| return; |
| } |
| if (text === "/new") { |
| this.editor.setText(""); |
| await this.handleClearCommand(); |
| return; |
| } |
| if (text === "/compact" || text.startsWith("/compact ")) { |
| const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined; |
| this.editor.setText(""); |
| await this.handleCompactCommand(customInstructions); |
| return; |
| } |
| if (text === "/reload") { |
| this.editor.setText(""); |
| await this.handleReloadCommand(); |
| return; |
| } |
| if (text === "/debug") { |
| this.handleDebugCommand(); |
| this.editor.setText(""); |
| return; |
| } |
| if (text === "/arminsayshi") { |
| this.handleArminSaysHi(); |
| this.editor.setText(""); |
| return; |
| } |
| if (text === "/dementedelves") { |
| this.handleDementedDelves(); |
| this.editor.setText(""); |
| return; |
| } |
| if (text === "/resume") { |
| this.showSessionSelector(); |
| this.editor.setText(""); |
| return; |
| } |
| if (text === "/quit") { |
| this.editor.setText(""); |
| await this.shutdown(); |
| return; |
| } |
|
|
| |
| if (text.startsWith("!")) { |
| const isExcluded = text.startsWith("!!"); |
| const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim(); |
| if (command) { |
| if (this.session.isBashRunning) { |
| this.showWarning("A bash command is already running. Press Esc to cancel it first."); |
| this.editor.setText(text); |
| return; |
| } |
| this.editor.addToHistory?.(text); |
| await this.handleBashCommand(command, isExcluded); |
| this.isBashMode = false; |
| this.updateEditorBorderColor(); |
| return; |
| } |
| } |
|
|
| |
| if (this.session.isCompacting) { |
| if (this.isExtensionCommand(text)) { |
| this.editor.addToHistory?.(text); |
| this.editor.setText(""); |
| await this.session.prompt(text); |
| } else { |
| this.queueCompactionMessage(text, "steer"); |
| } |
| return; |
| } |
|
|
| |
| |
| if (this.session.isStreaming) { |
| this.editor.addToHistory?.(text); |
| this.editor.setText(""); |
| await this.session.prompt(text, { streamingBehavior: "steer" }); |
| this.updatePendingMessagesDisplay(); |
| this.ui.requestRender(); |
| return; |
| } |
|
|
| |
| |
| this.flushPendingBashComponents(); |
|
|
| if (this.onInputCallback) { |
| this.onInputCallback(text); |
| } else { |
| this.pendingUserInputs.push(text); |
| } |
| this.editor.addToHistory?.(text); |
| }; |
| } |
|
|
| private subscribeToAgent(): void { |
| this.unsubscribe = this.session.subscribe(async (event) => { |
| await this.handleEvent(event); |
| }); |
| } |
|
|
| private async handleEvent(event: AgentSessionEvent): Promise<void> { |
| if (!this.isInitialized) { |
| await this.init(); |
| } |
|
|
| this.footer.invalidate(); |
|
|
| switch (event.type) { |
| case "agent_start": |
| this.pendingTools.clear(); |
| |
| |
| if (this.retryEscapeHandler) { |
| this.defaultEditor.onEscape = this.retryEscapeHandler; |
| this.retryEscapeHandler = undefined; |
| } |
| break; |
|
|
| case "turn_start": |
| if (this.settingsManager.getShowTerminalProgress()) { |
| this.ui.terminal.setProgress(true); |
| } |
| if (this.workingVisible) { |
| if (this.activeStatusIndicator?.kind !== "working") { |
| this.showWorkingStatusIndicator(); |
| } |
| } else { |
| this.clearStatusIndicator(); |
| } |
| this.ui.requestRender(); |
| break; |
|
|
| case "queue_update": |
| this.updatePendingMessagesDisplay(); |
| this.ui.requestRender(); |
| break; |
|
|
| case "entry_appended": |
| if (event.entry.type === "custom") { |
| this.addCustomEntryToChat(event.entry); |
| this.ui.requestRender(); |
| } |
| break; |
|
|
| case "session_info_changed": |
| this.updateTerminalTitle(); |
| this.footer.invalidate(); |
| this.ui.requestRender(); |
| break; |
|
|
| case "thinking_level_changed": |
| this.footer.invalidate(); |
| this.updateEditorBorderColor(); |
| break; |
|
|
| case "message_start": |
| if (event.message.role === "custom") { |
| this.addMessageToChat(event.message); |
| this.ui.requestRender(); |
| } else if (event.message.role === "user") { |
| this.addMessageToChat(event.message); |
| this.updatePendingMessagesDisplay(); |
| this.ui.requestRender(); |
| } else if (event.message.role === "assistant") { |
| this.streamingComponent = new AssistantMessageComponent( |
| undefined, |
| this.hideThinkingBlock, |
| this.getMarkdownThemeWithSettings(), |
| this.hiddenThinkingLabel, |
| this.outputPad, |
| this.getMarkdownTransformers(), |
| ); |
| this.streamingMessage = event.message; |
| this.chatContainer.addChild(this.streamingComponent); |
| this.streamingComponent.updateContent(this.streamingMessage, true); |
| this.ui.requestRender(); |
| } |
| break; |
|
|
| case "message_update": |
| if (this.streamingComponent && event.message.role === "assistant") { |
| this.streamingMessage = event.message; |
| this.streamingComponent.updateContent(this.streamingMessage, true); |
|
|
| for (const content of this.streamingMessage.content) { |
| if (content.type === "toolCall") { |
| if (!this.pendingTools.has(content.id)) { |
| const component = new ToolExecutionComponent( |
| content.name, |
| content.id, |
| content.arguments, |
| { |
| showImages: this.settingsManager.getShowImages(), |
| imageWidthCells: this.settingsManager.getImageWidthCells(), |
| }, |
| this.getRegisteredToolDefinition(content.name), |
| this.ui, |
| this.sessionManager.getCwd(), |
| ); |
| component.setExpanded(this.toolOutputExpanded); |
| this.chatContainer.addChild(component); |
| this.pendingTools.set(content.id, component); |
| } else { |
| const component = this.pendingTools.get(content.id); |
| if (component) { |
| component.updateArgs(content.arguments); |
| } |
| } |
| } |
| } |
| this.ui.requestRender(); |
| } |
| break; |
|
|
| case "message_end": |
| if (event.message.role === "user") break; |
| if (this.streamingComponent && event.message.role === "assistant") { |
| this.streamingMessage = event.message; |
| let errorMessage: string | undefined; |
| if (this.streamingMessage.stopReason === "aborted") { |
| const retryAttempt = this.session.retryAttempt; |
| errorMessage = |
| retryAttempt > 0 |
| ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}` |
| : "Operation aborted"; |
| this.streamingMessage.errorMessage = errorMessage; |
| } |
| this.streamingComponent.updateContent(this.streamingMessage, false); |
|
|
| if (this.streamingMessage.stopReason === "aborted" || this.streamingMessage.stopReason === "error") { |
| if (!errorMessage) { |
| errorMessage = this.streamingMessage.errorMessage || "Error"; |
| } |
| for (const [, component] of this.pendingTools.entries()) { |
| component.updateResult({ |
| content: [{ type: "text", text: errorMessage }], |
| isError: true, |
| }); |
| } |
| this.pendingTools.clear(); |
| } else { |
| |
| for (const [, component] of this.pendingTools.entries()) { |
| component.setArgsComplete(); |
| } |
| this.maybeShowAssistantDiagnostics(this.streamingMessage); |
| this.maybeShowCacheMissNotice(this.streamingMessage); |
| } |
| this.streamingComponent = undefined; |
| this.streamingMessage = undefined; |
| this.footer.invalidate(); |
| } |
| this.ui.requestRender(); |
| break; |
|
|
| case "bash_execution_update": |
| |
| break; |
|
|
| case "tool_execution_start": { |
| let component = this.pendingTools.get(event.toolCallId); |
| if (!component) { |
| component = new ToolExecutionComponent( |
| event.toolName, |
| event.toolCallId, |
| event.args, |
| { |
| showImages: this.settingsManager.getShowImages(), |
| imageWidthCells: this.settingsManager.getImageWidthCells(), |
| }, |
| this.getRegisteredToolDefinition(event.toolName), |
| this.ui, |
| this.sessionManager.getCwd(), |
| ); |
| component.setExpanded(this.toolOutputExpanded); |
| this.chatContainer.addChild(component); |
| this.pendingTools.set(event.toolCallId, component); |
| } |
| component.markExecutionStarted(); |
| this.ui.requestRender(); |
| break; |
| } |
|
|
| case "tool_execution_update": { |
| const component = this.pendingTools.get(event.toolCallId); |
| if (component) { |
| component.updateResult({ ...event.partialResult, isError: false }, true); |
| this.ui.requestRender(); |
| } |
| break; |
| } |
|
|
| case "tool_execution_end": { |
| const component = this.pendingTools.get(event.toolCallId); |
| if (component) { |
| component.updateResult({ ...event.result, isError: event.isError }); |
| this.pendingTools.delete(event.toolCallId); |
| this.ui.requestRender(); |
| } |
| break; |
| } |
|
|
| case "agent_end": |
| if (this.settingsManager.getShowTerminalProgress()) { |
| this.ui.terminal.setProgress(false); |
| } |
| this.clearStatusIndicator("working"); |
| if (this.streamingComponent) { |
| this.chatContainer.removeChild(this.streamingComponent); |
| this.streamingComponent = undefined; |
| this.streamingMessage = undefined; |
| } |
| this.pendingTools.clear(); |
|
|
| this.ui.requestRender(); |
| break; |
|
|
| case "agent_settled": |
| await this.checkShutdownRequested(); |
| break; |
|
|
| case "compaction_start": { |
| if (this.settingsManager.getShowTerminalProgress()) { |
| this.ui.terminal.setProgress(true); |
| } |
| |
| this.autoCompactionEscapeHandler = this.defaultEditor.onEscape; |
| this.defaultEditor.onEscape = () => { |
| this.session.abortCompaction(); |
| }; |
| this.showStatusIndicator(new CompactionStatusIndicator(this.ui, event.reason)); |
| this.ui.requestRender(); |
| break; |
| } |
|
|
| case "compaction_end": { |
| if (this.settingsManager.getShowTerminalProgress()) { |
| this.ui.terminal.setProgress(false); |
| } |
| if (this.autoCompactionEscapeHandler) { |
| this.defaultEditor.onEscape = this.autoCompactionEscapeHandler; |
| this.autoCompactionEscapeHandler = undefined; |
| } |
| this.clearStatusIndicator("compaction"); |
| if (event.aborted) { |
| if (event.reason === "manual") { |
| this.showError("Compaction cancelled"); |
| } else { |
| this.showStatus("Auto-compaction cancelled"); |
| } |
| } else if (event.result) { |
| const entries = this.sessionManager.buildContextEntries(); |
| if (entries[0]?.type !== "compaction") { |
| throw new Error("Completed compaction is missing from the session context"); |
| } |
| this.chatContainer.clear(); |
| |
| this.renderSessionEntries(entries.slice(1)); |
| this.addMessageToChat( |
| createCompactionSummaryMessage( |
| event.result.summary, |
| event.result.tokensBefore, |
| new Date().toISOString(), |
| ), |
| ); |
| if (event.result.usage) { |
| this.addCompactionCostNotice({ |
| type: "compaction_cost", |
| kind: "compaction", |
| usage: event.result.usage, |
| }); |
| } |
| this.footer.invalidate(); |
| } else if (event.errorMessage) { |
| if (event.reason === "manual") { |
| this.showError(event.errorMessage); |
| } else { |
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild(new Text(theme.fg("error", event.errorMessage), 1, 0)); |
| } |
| } |
| void this.flushCompactionQueue({ willRetry: event.willRetry }); |
| this.ui.requestRender(); |
| break; |
| } |
|
|
| case "auto_retry_start": { |
| |
| this.retryEscapeHandler = this.defaultEditor.onEscape; |
| this.defaultEditor.onEscape = () => { |
| this.session.abortRetry(); |
| }; |
| this.showStatusIndicator( |
| new RetryStatusIndicator(this.ui, event.attempt, event.maxAttempts, event.delayMs), |
| ); |
| this.ui.requestRender(); |
| break; |
| } |
|
|
| case "auto_retry_end": { |
| |
| if (this.retryEscapeHandler) { |
| this.defaultEditor.onEscape = this.retryEscapeHandler; |
| this.retryEscapeHandler = undefined; |
| } |
| this.clearStatusIndicator("retry"); |
| |
| if (!event.success) { |
| this.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`); |
| } |
| this.ui.requestRender(); |
| break; |
| } |
|
|
| case "summarization_retry_scheduled": { |
| this.showError(event.errorMessage); |
| this.showStatusIndicator( |
| new RetryStatusIndicator(this.ui, event.attempt, event.maxAttempts, event.delayMs), |
| ); |
| this.ui.requestRender(); |
| break; |
| } |
|
|
| case "summarization_retry_attempt_start": { |
| this.clearStatusIndicator("retry"); |
| if (event.source === "branchSummary") { |
| this.showStatusIndicator(new BranchSummaryStatusIndicator(this.ui)); |
| } else { |
| this.showStatusIndicator(new CompactionStatusIndicator(this.ui, event.reason)); |
| } |
| this.ui.requestRender(); |
| break; |
| } |
|
|
| case "summarization_retry_finished": { |
| this.clearStatusIndicator("retry"); |
| this.ui.requestRender(); |
| break; |
| } |
| } |
| } |
|
|
| |
| private getUserMessageText(message: Message): string { |
| if (message.role !== "user") return ""; |
| const textBlocks = |
| typeof message.content === "string" |
| ? [{ type: "text", text: message.content }] |
| : message.content.filter((c: { type: string }) => c.type === "text"); |
| return textBlocks.map((c) => (c as { text: string }).text).join(""); |
| } |
|
|
| |
| private showManagedToolStatus(status: ToolStatus): void { |
| if (!this.managedToolStatusStarted) { |
| this.chatContainer.addChild(new Spacer(1)); |
| this.managedToolStatusStarted = true; |
| } |
| const message = status.type === "warning" ? `Warning: ${status.message}` : status.message; |
| const color = status.type === "warning" ? "warning" : "dim"; |
| this.chatContainer.addChild(new Text(theme.fg(color, message), 1, 0)); |
| this.lastStatusSpacer = undefined; |
| this.lastStatusText = undefined; |
| this.ui.requestRender(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| private showStatus(message: string): void { |
| const children = this.chatContainer.children; |
| const last = children.length > 0 ? children[children.length - 1] : undefined; |
| const secondLast = children.length > 1 ? children[children.length - 2] : undefined; |
|
|
| if (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) { |
| this.lastStatusText.setText(theme.fg("dim", message)); |
| this.ui.requestRender(); |
| return; |
| } |
|
|
| const spacer = new Spacer(1); |
| const text = new Text(theme.fg("dim", message), 1, 0); |
| this.chatContainer.addChild(spacer); |
| this.chatContainer.addChild(text); |
| this.lastStatusSpacer = spacer; |
| this.lastStatusText = text; |
| this.ui.requestRender(); |
| } |
|
|
| private addCustomEntryToChat(entry: Extract<SessionEntry, { type: "custom" }>): void { |
| const renderer = this.session.extensionRunner.getEntryRenderer(entry.customType); |
| if (!renderer) { |
| return; |
| } |
| const component = new CustomEntryComponent(entry, renderer); |
| component.setExpanded(this.toolOutputExpanded); |
| if (!component.hasContent()) { |
| return; |
| } |
|
|
| if (this.streamingComponent) { |
| const streamingIndex = this.chatContainer.children.indexOf(this.streamingComponent); |
| if (streamingIndex >= 0) { |
| this.chatContainer.children.splice(streamingIndex, 0, component); |
| return; |
| } |
| } |
|
|
| this.chatContainer.addChild(component); |
| } |
|
|
| private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void { |
| switch (message.role) { |
| case "bashExecution": { |
| const component = new BashExecutionComponent(message.command, this.ui, message.excludeFromContext); |
| if (message.output) { |
| component.appendOutput(message.output); |
| } |
| component.setComplete( |
| message.exitCode, |
| message.cancelled, |
| message.truncated ? ({ truncated: true } as TruncationResult) : undefined, |
| message.fullOutputPath, |
| ); |
| this.chatContainer.addChild(component); |
| break; |
| } |
| case "custom": { |
| if (message.display) { |
| const renderer = this.session.extensionRunner.getMessageRenderer(message.customType); |
| const component = new CustomMessageComponent( |
| message, |
| renderer, |
| this.getMarkdownThemeWithSettings(), |
| this.outputPad, |
| ); |
| component.setExpanded(this.toolOutputExpanded); |
| this.chatContainer.addChild(component); |
| } |
| break; |
| } |
| case "compactionSummary": { |
| this.chatContainer.addChild(new Spacer(1)); |
| const component = new CompactionSummaryMessageComponent(message, this.getMarkdownThemeWithSettings()); |
| component.setExpanded(this.toolOutputExpanded); |
| this.chatContainer.addChild(component); |
| break; |
| } |
| case "branchSummary": { |
| this.chatContainer.addChild(new Spacer(1)); |
| const component = new BranchSummaryMessageComponent(message, this.getMarkdownThemeWithSettings()); |
| component.setExpanded(this.toolOutputExpanded); |
| this.chatContainer.addChild(component); |
| break; |
| } |
| case "user": { |
| const textContent = this.getUserMessageText(message); |
| if (textContent) { |
| if (this.chatContainer.children.length > 0) { |
| this.chatContainer.addChild(new Spacer(1)); |
| } |
| const skillBlock = parseSkillBlock(textContent); |
| if (skillBlock) { |
| |
| const component = new SkillInvocationMessageComponent( |
| skillBlock, |
| this.getMarkdownThemeWithSettings(), |
| ); |
| component.setExpanded(this.toolOutputExpanded); |
| this.chatContainer.addChild(component); |
| |
| if (skillBlock.userMessage) { |
| this.chatContainer.addChild(new Spacer(1)); |
| const userComponent = new UserMessageComponent( |
| skillBlock.userMessage, |
| this.getMarkdownThemeWithSettings(), |
| this.outputPad, |
| this.getMarkdownTransformers(), |
| ); |
| this.chatContainer.addChild(userComponent); |
| } |
| } else { |
| const userComponent = new UserMessageComponent( |
| textContent, |
| this.getMarkdownThemeWithSettings(), |
| this.outputPad, |
| this.getMarkdownTransformers(), |
| ); |
| this.chatContainer.addChild(userComponent); |
| } |
| if (options?.populateHistory) { |
| this.editor.addToHistory?.(textContent); |
| } |
| } |
| break; |
| } |
| case "assistant": { |
| const assistantComponent = new AssistantMessageComponent( |
| message, |
| this.hideThinkingBlock, |
| this.getMarkdownThemeWithSettings(), |
| this.hiddenThinkingLabel, |
| this.outputPad, |
| this.getMarkdownTransformers(), |
| ); |
| this.chatContainer.addChild(assistantComponent); |
| break; |
| } |
| case "toolResult": { |
| |
| break; |
| } |
| default: { |
| const _exhaustive: never = message; |
| } |
| } |
| } |
|
|
| private renderSessionItems( |
| items: readonly RenderSessionItem[], |
| options: { updateFooter?: boolean; populateHistory?: boolean } = {}, |
| ): void { |
| this.pendingTools.clear(); |
| const renderedPendingTools = new Map<string, ToolExecutionComponent>(); |
| |
| |
| const cacheMisses = this.settingsManager.getShowCacheMissNotices() |
| ? collectCacheMisses(this.sessionManager.getEntries(), this.session.modelRuntime) |
| : new Map<AssistantMessage, CacheMiss>(); |
|
|
| if (options.updateFooter) { |
| this.footer.invalidate(); |
| this.updateEditorBorderColor(); |
| } |
|
|
| for (const item of items) { |
| if (isCustomSessionEntry(item)) { |
| this.addCustomEntryToChat(item); |
| continue; |
| } |
| if (isCompactionCostNotice(item)) { |
| this.addCompactionCostNotice(item); |
| continue; |
| } |
|
|
| const message = item; |
| |
| if (message.role === "assistant") { |
| this.addMessageToChat(message); |
| |
| for (const content of message.content) { |
| if (content.type === "toolCall") { |
| const component = new ToolExecutionComponent( |
| content.name, |
| content.id, |
| content.arguments, |
| { |
| showImages: this.settingsManager.getShowImages(), |
| imageWidthCells: this.settingsManager.getImageWidthCells(), |
| }, |
| this.getRegisteredToolDefinition(content.name), |
| this.ui, |
| this.sessionManager.getCwd(), |
| ); |
| component.setExpanded(this.toolOutputExpanded); |
| this.chatContainer.addChild(component); |
|
|
| if (message.stopReason === "aborted" || message.stopReason === "error") { |
| let errorMessage: string; |
| if (message.stopReason === "aborted") { |
| const retryAttempt = this.session.retryAttempt; |
| errorMessage = |
| retryAttempt > 0 |
| ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}` |
| : "Operation aborted"; |
| } else { |
| errorMessage = message.errorMessage || "Error"; |
| } |
| component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true }); |
| } else { |
| renderedPendingTools.set(content.id, component); |
| } |
| } |
| } |
| if (message.stopReason !== "aborted" && message.stopReason !== "error") { |
| this.maybeShowAssistantDiagnostics(message); |
| const miss = cacheMisses.get(message); |
| if (miss) this.addCacheMissNotice(miss); |
| } |
| } else if (message.role === "toolResult") { |
| |
| const component = renderedPendingTools.get(message.toolCallId); |
| if (component) { |
| component.updateResult(message); |
| renderedPendingTools.delete(message.toolCallId); |
| } |
| } else { |
| |
| this.addMessageToChat(message, options); |
| } |
| } |
|
|
| for (const [toolCallId, component] of renderedPendingTools) { |
| this.pendingTools.set(toolCallId, component); |
| } |
| this.ui.requestRender(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| private renderSessionEntries( |
| entries: SessionEntry[], |
| options: { updateFooter?: boolean; populateHistory?: boolean } = {}, |
| ): void { |
| const items = entries.flatMap((entry): RenderSessionItem[] => { |
| if (entry.type === "custom") { |
| return [entry]; |
| } |
| const messages = sessionEntryToContextMessages(entry); |
| if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage && messages.length > 0) { |
| return [...messages, { type: "compaction_cost", kind: entry.type, usage: entry.usage }]; |
| } |
| return messages; |
| }); |
| this.renderSessionItems(items, options); |
| } |
|
|
| |
| |
| |
| |
| private addCompactionCostNotice(notice: CompactionCostNotice): void { |
| if (!this.settingsManager.getShowCacheMissNotices()) return; |
|
|
| const { usage } = notice; |
| const tokens = usage.input + usage.output + usage.cacheRead + usage.cacheWrite; |
| const cost = usage.cost.total >= 0.01 ? ` (~$${usage.cost.total.toFixed(2)})` : ""; |
| const label = notice.kind === "compaction" ? "Compaction" : "Branch summary"; |
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild( |
| new Text(theme.fg("warning", `${label}: ${formatTokens(tokens)} tokens billed${cost}`), 1, 0), |
| ); |
| } |
|
|
| private maybeShowAssistantDiagnostics(message: AssistantMessage): void { |
| if (!this.settingsManager.getShowCacheMissNotices()) return; |
|
|
| for (const diagnostic of message.diagnostics ?? []) { |
| if (diagnostic.type !== "anthropic_input_transformations") continue; |
| const transformations = diagnostic.details?.transformations; |
| if (!Array.isArray(transformations)) continue; |
|
|
| const dropped = transformations.flatMap((transformation): string[] => { |
| if (typeof transformation !== "object" || transformation === null) return []; |
| const details = transformation as Record<string, unknown>; |
| if (details.type !== "thinking_dropped") return []; |
| const reason = typeof details.reason === "string" ? details.reason : "unknown reason"; |
| const location = typeof details.path === "string" ? ` at ${details.path}` : ""; |
| return [`${reason}${location}`]; |
| }); |
| if (dropped.length === 0) continue; |
|
|
| const noun = dropped.length === 1 ? "thinking block" : `${dropped.length} thinking blocks`; |
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild( |
| new Text(theme.fg("warning", `Anthropic dropped ${noun}: ${dropped.join("; ")}`), 1, 0), |
| ); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| private maybeShowCacheMissNotice(message: AssistantMessage): void { |
| if (!this.settingsManager.getShowCacheMissNotices()) return; |
|
|
| |
| const miss = detectCacheMiss(this.sessionManager.getEntries(), message, this.session.modelRuntime); |
| if (miss) this.addCacheMissNotice(miss); |
| } |
|
|
| private addCacheMissNotice(miss: CacheMiss): void { |
| if (miss.missedTokens < 20_000 && miss.missedCost < 0.1) return; |
|
|
| const cost = miss.missedCost >= 0.01 ? ` (~$${miss.missedCost.toFixed(2)})` : ""; |
| const reBilled = `${formatTokens(miss.missedTokens)} tokens re-billed${cost}`; |
| let label = "Cache miss"; |
| if (miss.modelChanged) { |
| label = "Cache miss after model switch"; |
| } else if (miss.idleMs >= CACHE_TTL_MS) { |
| label = `Cache miss after ${Math.round(miss.idleMs / 60_000)}m idle`; |
| } |
| const text = theme.fg("warning", `${label}: ${reBilled}`); |
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild(new Text(text, 1, 0)); |
| } |
|
|
| renderInitialMessages(): void { |
| const entries = this.sessionManager.buildContextEntries(); |
| this.renderSessionEntries(entries, { |
| updateFooter: true, |
| populateHistory: true, |
| }); |
| this.renderProjectTrustWarningIfNeeded(); |
|
|
| |
| const allEntries = this.sessionManager.getEntries(); |
| const compactionCount = allEntries.filter((e) => e.type === "compaction").length; |
| if (compactionCount > 0) { |
| const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`; |
| this.showStatus(`Session compacted ${times}`); |
| } |
| } |
|
|
| private renderProjectTrustWarningIfNeeded(): void { |
| if (this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(this.sessionManager.getCwd())) { |
| return; |
| } |
|
|
| if (this.chatContainer.children.length > 0) { |
| this.chatContainer.addChild(new Spacer(1)); |
| } |
| this.chatContainer.addChild( |
| new Text( |
| theme.fg( |
| "warning", |
| `This project is not trusted. Project ${CONFIG_DIR_NAME} resources and packages are ignored. Use /trust to save a trust decision, then restart pi.`, |
| ), |
| 1, |
| 0, |
| ), |
| ); |
| } |
|
|
| async getUserInput(): Promise<string> { |
| const queuedInput = this.pendingUserInputs.shift(); |
| if (queuedInput !== undefined) { |
| return queuedInput; |
| } |
|
|
| return new Promise((resolve) => { |
| this.onInputCallback = (text: string) => { |
| this.onInputCallback = undefined; |
| resolve(text); |
| }; |
| }); |
| } |
|
|
| private rebuildChatFromMessages(): void { |
| this.chatContainer.clear(); |
| this.renderSessionEntries(this.sessionManager.buildContextEntries()); |
| } |
|
|
| |
| |
| |
|
|
| private handleCtrlC(): void { |
| const now = Date.now(); |
| if (now - this.lastSigintTime < 500) { |
| void this.shutdown(); |
| } else { |
| this.clearEditor(); |
| this.lastSigintTime = now; |
| } |
| } |
|
|
| private handleCtrlD(): void { |
| |
| void this.shutdown(); |
| } |
|
|
| |
| |
| |
| |
| |
| private isShuttingDown = false; |
|
|
| private async shutdown(options?: { fromSignal?: boolean }): Promise<void> { |
| if (this.isShuttingDown) return; |
| this.isShuttingDown = true; |
| |
| |
| |
|
|
| if (options?.fromSignal) { |
| |
| |
| |
| |
| |
| |
| |
| await this.runtimeHost.dispose(); |
| this.themeController.disableAutoSync(); |
| await this.ui.terminal.drainInput(1000); |
| this.stop(); |
| process.exit(0); |
| } |
|
|
| |
| |
| |
| |
| |
| this.themeController.disableAutoSync(); |
| await this.ui.terminal.drainInput(1000); |
|
|
| this.stop(); |
| await this.runtimeHost.dispose(); |
|
|
| const resumeCommand = formatResumeCommand(this.sessionManager); |
| if (resumeCommand) { |
| process.stdout.write(`${chalk.dim("To resume this session:")} ${resumeCommand}\n`); |
| } |
|
|
| process.exit(0); |
| } |
|
|
| private emergencyTerminalExit(): never { |
| this.isShuttingDown = true; |
| this.unregisterSignalHandlers(); |
| killTrackedDetachedChildren(); |
| |
| |
| process.exit(129); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private uncaughtCrash(error: Error): never { |
| if (this.isShuttingDown) { |
| process.exit(1); |
| } |
| this.isShuttingDown = true; |
| try { |
| this.unregisterSignalHandlers(); |
| } catch {} |
| try { |
| killTrackedDetachedChildren(); |
| } catch {} |
| try { |
| this.ui.stop(); |
| } catch {} |
| console.error(`${APP_NAME} exiting due to uncaughtException:`); |
| console.error(error); |
| process.exit(1); |
| } |
|
|
| |
| |
| |
| private async checkShutdownRequested(): Promise<void> { |
| if (!this.shutdownRequested) return; |
| await this.shutdown(); |
| } |
|
|
| private registerSignalHandlers(): void { |
| this.unregisterSignalHandlers(); |
|
|
| const signals: NodeJS.Signals[] = ["SIGTERM"]; |
| if (process.platform !== "win32") { |
| signals.push("SIGHUP"); |
| } |
|
|
| for (const signal of signals) { |
| const handler = () => { |
| |
| |
| |
| |
| killTrackedDetachedChildren(); |
| void this.shutdown({ fromSignal: true }); |
| }; |
| process.prependListener(signal, handler); |
| this.signalCleanupHandlers.push(() => process.off(signal, handler)); |
| } |
|
|
| const terminalErrorHandler = (error: Error) => { |
| if (isDeadTerminalError(error)) { |
| this.emergencyTerminalExit(); |
| } |
| throw error; |
| }; |
| process.stdout.on("error", terminalErrorHandler); |
| process.stderr.on("error", terminalErrorHandler); |
| this.signalCleanupHandlers.push(() => process.stdout.off("error", terminalErrorHandler)); |
| this.signalCleanupHandlers.push(() => process.stderr.off("error", terminalErrorHandler)); |
|
|
| |
| |
| |
| const uncaughtExceptionHandler = (error: Error) => this.uncaughtCrash(error); |
| process.prependListener("uncaughtException", uncaughtExceptionHandler); |
| this.signalCleanupHandlers.push(() => process.off("uncaughtException", uncaughtExceptionHandler)); |
| } |
|
|
| private unregisterSignalHandlers(): void { |
| for (const cleanup of this.signalCleanupHandlers) { |
| cleanup(); |
| } |
| this.signalCleanupHandlers = []; |
| } |
|
|
| private handleCtrlZ(): void { |
| if (process.platform === "win32") { |
| this.showStatus("Suspend to background is not supported on Windows"); |
| return; |
| } |
|
|
| |
| |
| |
| const suspendKeepAlive = setInterval(() => {}, 2 ** 30); |
|
|
| |
| |
| const ignoreSigint = () => {}; |
| process.on("SIGINT", ignoreSigint); |
|
|
| |
| process.once("SIGCONT", () => { |
| clearInterval(suspendKeepAlive); |
| process.removeListener("SIGINT", ignoreSigint); |
| this.ui.start(); |
| this.ui.requestRender(true); |
| }); |
|
|
| try { |
| |
| this.ui.stop(); |
|
|
| |
| process.kill(0, "SIGTSTP"); |
| } catch (error) { |
| clearInterval(suspendKeepAlive); |
| process.removeListener("SIGINT", ignoreSigint); |
| throw error; |
| } |
| } |
|
|
| private async handleFollowUp(): Promise<void> { |
| const text = (this.editor.getExpandedText?.() ?? this.editor.getText()).trim(); |
| if (!text) return; |
|
|
| |
| if (this.session.isCompacting) { |
| if (this.isExtensionCommand(text)) { |
| this.editor.addToHistory?.(text); |
| this.editor.setText(""); |
| await this.session.prompt(text); |
| } else { |
| this.queueCompactionMessage(text, "followUp"); |
| } |
| return; |
| } |
|
|
| |
| |
| if (this.session.isStreaming) { |
| this.editor.addToHistory?.(text); |
| this.editor.setText(""); |
| await this.session.prompt(text, { streamingBehavior: "followUp" }); |
| this.updatePendingMessagesDisplay(); |
| this.ui.requestRender(); |
| } |
| |
| else if (this.editor.onSubmit) { |
| this.editor.setText(""); |
| this.editor.onSubmit(text); |
| } |
| } |
|
|
| private handleDequeue(): void { |
| const restored = this.restoreQueuedMessagesToEditor(); |
| if (restored === 0) { |
| this.showStatus("No queued messages to restore"); |
| } else { |
| this.showStatus(`Restored ${restored} queued message${restored > 1 ? "s" : ""} to editor`); |
| } |
| } |
|
|
| private updateEditorBorderColor(): void { |
| if (this.isBashMode) { |
| this.editor.borderColor = theme.getBashModeBorderColor(); |
| } else { |
| const level = this.session.thinkingLevel || "off"; |
| this.editor.borderColor = theme.getThinkingBorderColor(level); |
| } |
| this.activeStatusIndicator?.invalidate(); |
| this.ui.requestRender(); |
| } |
|
|
| private cycleThinkingLevel(): void { |
| const newLevel = this.session.cycleThinkingLevel(); |
| if (newLevel === undefined) { |
| this.showStatus("Current model does not support thinking"); |
| } else { |
| this.footer.invalidate(); |
| this.updateEditorBorderColor(); |
| this.showStatus(`Thinking level: ${newLevel}`); |
| } |
| } |
|
|
| private async cycleModel(direction: "forward" | "backward"): Promise<void> { |
| try { |
| const result = await this.session.cycleModel(direction); |
| if (result === undefined) { |
| const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available"; |
| this.showStatus(msg); |
| } else { |
| this.footer.invalidate(); |
| this.updateEditorBorderColor(); |
| const thinkingStr = |
| result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : ""; |
| this.showStatus(`Switched to ${result.model.name || result.model.id}${thinkingStr}`); |
| void this.maybeWarnAboutAnthropicSubscriptionAuth(result.model); |
| } |
| } catch (error) { |
| this.showError(error instanceof Error ? error.message : String(error)); |
| } |
| } |
|
|
| private toggleToolOutputExpansion(): void { |
| this.setToolsExpanded(!this.toolOutputExpanded); |
| } |
|
|
| private setToolsExpanded(expanded: boolean): void { |
| if (expanded === this.toolOutputExpanded) return; |
|
|
| this.toolOutputExpanded = expanded; |
| const activeHeader = this.customHeader ?? this.builtInHeader; |
| if (isExpandable(activeHeader)) { |
| activeHeader.setExpanded(expanded); |
| } |
| for (const container of [this.loadedResourcesContainer, this.chatContainer]) { |
| for (const child of container.children) { |
| if (isExpandable(child)) { |
| child.setExpanded(expanded); |
| } |
| } |
| } |
| this.showStatus(`Tool output: ${expanded ? "expanded" : "collapsed"}`); |
| } |
|
|
| |
| private updateThinkingBlockVisibility(): void { |
| for (const child of this.chatContainer.children) { |
| if (child instanceof AssistantMessageComponent) { |
| child.setHideThinkingBlock(this.hideThinkingBlock); |
| } |
| } |
| this.ui.requestRender(); |
| } |
|
|
| private toggleThinkingBlockVisibility(): void { |
| this.hideThinkingBlock = !this.hideThinkingBlock; |
| this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock); |
| this.updateThinkingBlockVisibility(); |
| this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`); |
| } |
|
|
| private async handleOpenExternalEditor(): Promise<void> { |
| const editorCmd = this.settingsManager.getExternalEditorCommand(); |
| const content = this.editor.getExpandedText?.() ?? this.editor.getText(); |
| this.ui.stop(); |
| try { |
| const result = await editInExternalEditor({ |
| command: editorCmd, |
| content, |
| }); |
| if (result.status === "complete") { |
| this.editor.setText(result.content); |
| } |
| } finally { |
| this.ui.start(); |
| this.ui.requestRender(true); |
| } |
| } |
|
|
| |
| |
| |
|
|
| clearEditor(): void { |
| this.editor.setText(""); |
| this.ui.requestRender(); |
| } |
|
|
| showError(errorMessage: string): void { |
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), this.outputPad, 0)); |
| this.ui.requestRender(); |
| } |
|
|
| showWarning(warningMessage: string): void { |
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0)); |
| this.ui.requestRender(); |
| } |
|
|
| showNewVersionNotification(release: LatestPiRelease): void { |
| const action = theme.fg("accent", `${APP_NAME} update`); |
| const updateInstruction = theme.fg("muted", `New version ${release.version} is available. Run `) + action; |
| const changelogUrl = "https://pi.dev/changelog"; |
| const changelogLink = getCapabilities().hyperlinks |
| ? hyperlink(theme.fg("accent", changelogUrl), changelogUrl) |
| : theme.fg("accent", changelogUrl); |
| const changelogLine = theme.fg("muted", "Changelog: ") + changelogLink; |
| const note = release.note?.trim(); |
|
|
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text))); |
| this.chatContainer.addChild( |
| new Text(`${theme.bold(theme.fg("warning", "Update Available"))}\n${updateInstruction}`, 1, 0), |
| ); |
| if (note) { |
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild( |
| new Markdown(note, 1, 0, this.getMarkdownThemeWithSettings(), { |
| color: (text) => theme.fg("muted", text), |
| }), |
| ); |
| this.chatContainer.addChild(new Spacer(1)); |
| } |
| this.chatContainer.addChild(new Text(changelogLine, 1, 0)); |
| this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text))); |
| this.ui.requestRender(); |
| } |
|
|
| showPackageUpdateNotification(packages: string[]): void { |
| const action = theme.fg("accent", `${APP_NAME} update --extensions`); |
| const updateInstruction = theme.fg("muted", "Package updates are available. Run ") + action; |
| const packageLines = packages.map((pkg) => `- ${pkg}`).join("\n"); |
|
|
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text))); |
| this.chatContainer.addChild( |
| new Text( |
| `${theme.bold(theme.fg("warning", "Package Updates Available"))}\n${updateInstruction}\n${theme.fg("muted", "Packages:")}\n${packageLines}`, |
| 1, |
| 0, |
| ), |
| ); |
| this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text))); |
| this.ui.requestRender(); |
| } |
|
|
| |
| |
| |
| |
| private getAllQueuedMessages(): { steering: string[]; followUp: string[] } { |
| return { |
| steering: [ |
| ...this.session.getSteeringMessages(), |
| ...this.compactionQueuedMessages.filter((msg) => msg.mode === "steer").map((msg) => msg.text), |
| ], |
| followUp: [ |
| ...this.session.getFollowUpMessages(), |
| ...this.compactionQueuedMessages.filter((msg) => msg.mode === "followUp").map((msg) => msg.text), |
| ], |
| }; |
| } |
|
|
| |
| |
| |
| |
| private clearAllQueues(): { steering: string[]; followUp: string[] } { |
| const { steering, followUp } = this.session.clearQueue(); |
| const compactionSteering = this.compactionQueuedMessages |
| .filter((msg) => msg.mode === "steer") |
| .map((msg) => msg.text); |
| const compactionFollowUp = this.compactionQueuedMessages |
| .filter((msg) => msg.mode === "followUp") |
| .map((msg) => msg.text); |
| this.compactionQueuedMessages = []; |
| return { |
| steering: [...steering, ...compactionSteering], |
| followUp: [...followUp, ...compactionFollowUp], |
| }; |
| } |
|
|
| private updatePendingMessagesDisplay(): void { |
| this.pendingMessagesContainer.clear(); |
| const { steering: steeringMessages, followUp: followUpMessages } = this.getAllQueuedMessages(); |
| if (steeringMessages.length > 0 || followUpMessages.length > 0) { |
| this.pendingMessagesContainer.addChild(new Spacer(1)); |
| for (const message of steeringMessages) { |
| const text = theme.fg("dim", `Steering: ${message}`); |
| this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0)); |
| } |
| for (const message of followUpMessages) { |
| const text = theme.fg("dim", `Follow-up: ${message}`); |
| this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0)); |
| } |
| const dequeueHint = this.getAppKeyDisplay("app.message.dequeue"); |
| const hintText = theme.fg("dim", `↳ ${dequeueHint} to edit all queued messages`); |
| this.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0)); |
| } |
| } |
|
|
| private restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number { |
| const { steering, followUp } = this.clearAllQueues(); |
| const allQueued = [...steering, ...followUp]; |
| if (allQueued.length === 0) { |
| this.updatePendingMessagesDisplay(); |
| if (options?.abort) { |
| this.agent.abort(); |
| } |
| return 0; |
| } |
| const queuedText = allQueued.join("\n\n"); |
| const currentText = options?.currentText ?? this.editor.getText(); |
| const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n"); |
| this.editor.setText(combinedText); |
| this.updatePendingMessagesDisplay(); |
| if (options?.abort) { |
| this.agent.abort(); |
| } |
| return allQueued.length; |
| } |
|
|
| private queueCompactionMessage(text: string, mode: "steer" | "followUp"): void { |
| this.compactionQueuedMessages.push({ text, mode }); |
| this.editor.addToHistory?.(text); |
| this.editor.setText(""); |
| this.updatePendingMessagesDisplay(); |
| this.showStatus("Queued message for after compaction"); |
| } |
|
|
| private isExtensionCommand(text: string): boolean { |
| if (!text.startsWith("/")) return false; |
|
|
| const extensionRunner = this.session.extensionRunner; |
|
|
| const spaceIndex = text.indexOf(" "); |
| const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); |
| return !!extensionRunner.getCommand(commandName); |
| } |
|
|
| private async flushCompactionQueue(options?: { willRetry?: boolean }): Promise<void> { |
| if (this.compactionQueuedMessages.length === 0) { |
| return; |
| } |
|
|
| const queuedMessages = [...this.compactionQueuedMessages]; |
| this.compactionQueuedMessages = []; |
| this.updatePendingMessagesDisplay(); |
|
|
| const restoreQueue = (error: unknown) => { |
| this.session.clearQueue(); |
| this.compactionQueuedMessages = queuedMessages; |
| this.updatePendingMessagesDisplay(); |
| this.showError( |
| `Failed to send queued message${queuedMessages.length > 1 ? "s" : ""}: ${ |
| error instanceof Error ? error.message : String(error) |
| }`, |
| ); |
| }; |
|
|
| try { |
| if (options?.willRetry) { |
| |
| for (const message of queuedMessages) { |
| if (this.isExtensionCommand(message.text)) { |
| await this.session.prompt(message.text); |
| } else if (message.mode === "followUp") { |
| await this.session.followUp(message.text); |
| } else { |
| await this.session.steer(message.text); |
| } |
| } |
| this.updatePendingMessagesDisplay(); |
| return; |
| } |
|
|
| |
| const firstPromptIndex = queuedMessages.findIndex((message) => !this.isExtensionCommand(message.text)); |
| if (firstPromptIndex === -1) { |
| |
| for (const message of queuedMessages) { |
| await this.session.prompt(message.text); |
| } |
| return; |
| } |
|
|
| |
| const preCommands = queuedMessages.slice(0, firstPromptIndex); |
| const firstPrompt = queuedMessages[firstPromptIndex]; |
| const rest = queuedMessages.slice(firstPromptIndex + 1); |
|
|
| for (const message of preCommands) { |
| await this.session.prompt(message.text); |
| } |
|
|
| |
| const promptPromise = this.session |
| .prompt(firstPrompt.text, { streamingBehavior: firstPrompt.mode }) |
| .catch((error) => { |
| restoreQueue(error); |
| }); |
|
|
| |
| for (const message of rest) { |
| if (this.isExtensionCommand(message.text)) { |
| await this.session.prompt(message.text); |
| } else if (message.mode === "followUp") { |
| await this.session.followUp(message.text); |
| } else { |
| await this.session.steer(message.text); |
| } |
| } |
| this.updatePendingMessagesDisplay(); |
| void promptPromise; |
| } catch (error) { |
| restoreQueue(error); |
| } |
| } |
|
|
| |
| private flushPendingBashComponents(): void { |
| for (const component of this.pendingBashComponents) { |
| this.pendingMessagesContainer.removeChild(component); |
| this.chatContainer.addChild(component); |
| } |
| this.pendingBashComponents = []; |
| } |
|
|
| |
| |
| |
|
|
| private disposeActiveSelector(): void { |
| const dispose = this.activeSelectorDispose; |
| this.activeSelectorToken = undefined; |
| this.activeSelectorDispose = undefined; |
| dispose?.(); |
| } |
|
|
| |
| |
| |
| |
| private showSelector( |
| create: (done: () => void) => { component: Component; focus: Component; dispose?: () => void }, |
| ): void { |
| const token = {}; |
| let dispose: (() => void) | undefined; |
| const done = () => { |
| dispose?.(); |
| if (this.activeSelectorToken !== token) return; |
| this.activeSelectorToken = undefined; |
| this.activeSelectorDispose = undefined; |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(this.editor); |
| this.ui.setFocus(this.editor); |
| }; |
| const created = create(done); |
| dispose = created.dispose; |
| this.disposeActiveSelector(); |
| this.activeSelectorToken = token; |
| this.activeSelectorDispose = dispose; |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(created.component); |
| this.ui.setFocus(created.focus); |
| this.ui.requestRender(); |
| } |
|
|
| private showSettingsSelector(): void { |
| this.showSelector((done) => { |
| let selector: SettingsSelectorComponent | undefined; |
| const defaultProvider = this.settingsManager.getDefaultProvider(); |
| const defaultModelId = this.settingsManager.getDefaultModel(); |
| const defaultModel = defaultProvider && defaultModelId ? `${defaultProvider}/${defaultModelId}` : "not set"; |
| selector = new SettingsSelectorComponent( |
| { |
| autoCompact: this.session.autoCompactionEnabled, |
| defaultModel, |
| currentModel: this.session.model, |
| availableDefaultModels: this.session.modelRuntime.getAvailableSnapshot(), |
| showImages: this.settingsManager.getShowImages(), |
| imageWidthCells: this.settingsManager.getImageWidthCells(), |
| autoResizeImages: this.settingsManager.getImageAutoResize(), |
| blockImages: this.settingsManager.getBlockImages(), |
| enableSkillCommands: this.settingsManager.getEnableSkillCommands(), |
| steeringMode: this.session.steeringMode, |
| followUpMode: this.session.followUpMode, |
| transport: this.settingsManager.getTransport(), |
| httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(), |
| thinkingLevel: this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL, |
| availableThinkingLevels: [...THINKING_LEVEL_OPTIONS], |
| modelThinkingLevels: this.settingsManager.getAllModelThinkingLevels(), |
| currentTheme: this.themeController.getThemeSelection() || "dark", |
| terminalTheme: this.themeController.getTerminalTheme(), |
| availableThemes: getAvailableThemes(), |
| hideThinkingBlock: this.hideThinkingBlock, |
| mermaidRenderingMode: this.settingsManager.getMermaidRenderingMode(), |
| collapseChangelog: this.settingsManager.getCollapseChangelog(), |
| enableInstallTelemetry: this.settingsManager.getEnableInstallTelemetry(), |
| doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(), |
| treeFilterMode: this.settingsManager.getTreeFilterMode(), |
| showHardwareCursor: this.settingsManager.getShowHardwareCursor(), |
| showCacheMissNotices: this.settingsManager.getShowCacheMissNotices(), |
| defaultProjectTrust: this.settingsManager.getDefaultProjectTrust(), |
| editorPaddingX: this.settingsManager.getEditorPaddingX(), |
| outputPad: this.settingsManager.getOutputPad(), |
| autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(), |
| quietStartup: this.settingsManager.getQuietStartup(), |
| clearOnShrink: this.settingsManager.getClearOnShrink(), |
| showTerminalProgress: this.settingsManager.getShowTerminalProgress(), |
| tuiMode: this.ui.mode, |
| fullscreenExitOutput: this.settingsManager.getFullscreenExitOutput(), |
| fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(), |
| fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(), |
| warnings: this.settingsManager.getWarnings(), |
| }, |
| { |
| onAutoCompactChange: (enabled) => { |
| this.session.setAutoCompactionEnabled(enabled); |
| this.footer.setAutoCompactEnabled(enabled); |
| }, |
| onShowImagesChange: (enabled) => { |
| this.settingsManager.setShowImages(enabled); |
| for (const child of this.chatContainer.children) { |
| if (child instanceof ToolExecutionComponent) { |
| child.setShowImages(enabled); |
| } |
| } |
| }, |
| onImageWidthCellsChange: (width) => { |
| this.settingsManager.setImageWidthCells(width); |
| for (const child of this.chatContainer.children) { |
| if (child instanceof ToolExecutionComponent) { |
| child.setImageWidthCells(width); |
| } |
| } |
| }, |
| onAutoResizeImagesChange: (enabled) => { |
| this.settingsManager.setImageAutoResize(enabled); |
| }, |
| onBlockImagesChange: (blocked) => { |
| this.settingsManager.setBlockImages(blocked); |
| }, |
| onEnableSkillCommandsChange: (enabled) => { |
| this.settingsManager.setEnableSkillCommands(enabled); |
| this.setupAutocompleteProvider(); |
| }, |
| onSteeringModeChange: (mode) => { |
| this.session.setSteeringMode(mode); |
| }, |
| onFollowUpModeChange: (mode) => { |
| this.session.setFollowUpMode(mode); |
| }, |
| onTransportChange: (transport) => { |
| this.settingsManager.setTransport(transport); |
| this.session.agent.transport = transport; |
| }, |
| onHttpIdleTimeoutMsChange: (timeoutMs) => { |
| this.settingsManager.setHttpIdleTimeoutMs(timeoutMs); |
| configureHttpDispatcher(timeoutMs); |
| this.showStatus(`HTTP idle timeout: ${formatHttpIdleTimeoutMs(timeoutMs)}`); |
| }, |
| onModelThinkingLevelChange: (provider, modelId, level) => { |
| this.settingsManager.setModelThinkingLevel(provider, modelId, level); |
| |
| const current = this.session.model; |
| if (current && current.provider === provider && current.id === modelId) { |
| this.session.setThinkingLevel(level); |
| this.footer.invalidate(); |
| this.updateEditorBorderColor(); |
| } |
| }, |
| onModelThinkingLevelRemove: (provider, modelId) => { |
| this.settingsManager.removeModelThinkingLevel(provider, modelId); |
| |
| const current = this.session.model; |
| if (current && current.provider === provider && current.id === modelId) { |
| const globalDefault = this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; |
| this.session.setThinkingLevel(globalDefault); |
| this.footer.invalidate(); |
| this.updateEditorBorderColor(); |
| } |
| }, |
| onThemeChange: (themeSetting) => { |
| this.settingsManager.setTheme(themeSetting); |
| void this.themeController.setThemeSetting(themeSetting); |
| }, |
| onThemePreview: (themeName) => this.themeController.preview(themeName), |
| onHideThinkingBlockChange: (hidden) => { |
| this.hideThinkingBlock = hidden; |
| this.settingsManager.setHideThinkingBlock(hidden); |
| this.updateThinkingBlockVisibility(); |
| }, |
| onMermaidRenderingModeChange: (mode) => { |
| this.settingsManager.setMermaidRenderingMode(mode); |
| this.chatContainer.invalidate(); |
| this.ui.requestRender(); |
| }, |
| onShowCacheMissNoticesChange: (shown) => { |
| this.settingsManager.setShowCacheMissNotices(shown); |
| this.rebuildChatFromMessages(); |
| }, |
| onCollapseChangelogChange: (collapsed) => { |
| this.settingsManager.setCollapseChangelog(collapsed); |
| }, |
| onEnableInstallTelemetryChange: (enabled) => { |
| this.settingsManager.setEnableInstallTelemetry(enabled); |
| }, |
| onQuietStartupChange: (enabled) => { |
| this.settingsManager.setQuietStartup(enabled); |
| }, |
| onDefaultProjectTrustChange: (defaultProjectTrust) => { |
| this.settingsManager.setDefaultProjectTrust(defaultProjectTrust); |
| }, |
| onDoubleEscapeActionChange: (action) => { |
| this.settingsManager.setDoubleEscapeAction(action); |
| }, |
| onTreeFilterModeChange: (mode) => { |
| this.settingsManager.setTreeFilterMode(mode); |
| }, |
| onShowHardwareCursorChange: (enabled) => { |
| this.settingsManager.setShowHardwareCursor(enabled); |
| this.ui.setShowHardwareCursor(enabled); |
| }, |
| onEditorPaddingXChange: (padding) => { |
| this.settingsManager.setEditorPaddingX(padding); |
| this.defaultEditor.setPaddingX(padding); |
| if (this.editor !== this.defaultEditor && this.editor.setPaddingX !== undefined) { |
| this.editor.setPaddingX(padding); |
| } |
| }, |
| onOutputPadChange: (padding) => { |
| this.settingsManager.setOutputPad(padding); |
| this.outputPad = padding; |
| if (this.streamingComponent || this.session.isStreaming) { |
| for (const child of this.chatContainer.children) { |
| if ( |
| child instanceof AssistantMessageComponent || |
| child instanceof CustomMessageComponent || |
| child instanceof UserMessageComponent |
| ) { |
| child.setOutputPad(padding); |
| } |
| } |
| if (this.streamingComponent) { |
| this.streamingComponent.setOutputPad(padding); |
| } |
| this.ui.requestRender(); |
| return; |
| } |
| this.rebuildChatFromMessages(); |
| }, |
| onAutocompleteMaxVisibleChange: (maxVisible) => { |
| this.settingsManager.setAutocompleteMaxVisible(maxVisible); |
| this.defaultEditor.setAutocompleteMaxVisible(maxVisible); |
| if (this.editor !== this.defaultEditor && this.editor.setAutocompleteMaxVisible !== undefined) { |
| this.editor.setAutocompleteMaxVisible(maxVisible); |
| } |
| }, |
| onClearOnShrinkChange: (enabled) => { |
| this.settingsManager.setClearOnShrink(enabled); |
| this.ui.setClearOnShrink(enabled); |
| if (!enabled && !this.activeStatusIndicator) { |
| this.statusContainer.clear(); |
| } |
| }, |
| onShowTerminalProgressChange: (enabled) => { |
| this.settingsManager.setShowTerminalProgress(enabled); |
| }, |
| onTuiModeChange: (mode) => { |
| if (!this.switchTuiMode(mode)) { |
| selector?.getSettingsList().updateValue("tui-mode", this.ui.mode); |
| this.showStatus("Close active overlays before changing TUI mode"); |
| return; |
| } |
| this.settingsManager.setTuiMode(mode); |
| if (!this.activeStatusIndicator) this.statusContainer.clear(); |
| this.showStatus(`TUI mode: ${mode}`); |
| }, |
| onFullscreenExitOutputChange: (output) => { |
| this.settingsManager.setFullscreenExitOutput(output); |
| }, |
| onFullscreenScrollbarChange: (mode) => { |
| this.settingsManager.setFullscreenScrollbar(mode); |
| this.applyFullscreenScrollbarSetting(); |
| }, |
| onFullscreenCopyOnSelectChange: (enabled) => { |
| this.settingsManager.setFullscreenCopyOnSelect(enabled); |
| if (this.renderer instanceof TuiAltScreen) this.renderer.setCopyOnSelect(enabled); |
| }, |
| onWarningsChange: (warnings) => { |
| this.settingsManager.setWarnings(warnings); |
| }, |
| onCancel: () => { |
| done(); |
| this.ui.requestRender(); |
| }, |
| }, |
| ); |
| return { component: selector, focus: selector.getSettingsList() }; |
| }); |
| } |
|
|
| private handleThinkingCommand(searchTerm?: string): void { |
| const availableLevels = this.session.getAvailableThinkingLevels(); |
| if (!searchTerm) { |
| this.showThinkingSelector(); |
| return; |
| } |
|
|
| const normalized = searchTerm.trim().toLowerCase(); |
| const level = availableLevels.find((candidate) => candidate.toLowerCase() === normalized); |
| if (!level) { |
| this.showError(`Unknown thinking level "${searchTerm}". Available levels: ${availableLevels.join(", ")}.`); |
| return; |
| } |
|
|
| this.selectThinkingLevel(level, false); |
| } |
|
|
| private selectThinkingLevel(level: ThinkingLevel, persist: boolean): void { |
| try { |
| this.session.setThinkingLevel(level, { persist }); |
| this.footer.invalidate(); |
| this.updateEditorBorderColor(); |
| this.showStatus(persist ? `Default thinking level: ${level}` : `Thinking level: ${level}`); |
| } catch (error) { |
| this.showError(error instanceof Error ? error.message : String(error)); |
| } |
| } |
|
|
| private showThinkingSelector(): void { |
| this.showSelector((done) => { |
| const selectLevel = (level: ThinkingLevel, persist: boolean) => { |
| this.selectThinkingLevel(level, persist); |
| done(); |
| }; |
| const selector = new ThinkingSelectorComponent( |
| this.session.thinkingLevel ?? DEFAULT_THINKING_LEVEL, |
| this.session.getAvailableThinkingLevels(), |
| (level) => selectLevel(level, false), |
| () => { |
| done(); |
| this.ui.requestRender(); |
| }, |
| (level) => selectLevel(level, true), |
| this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL, |
| ); |
| return { component: selector, focus: selector }; |
| }); |
| } |
|
|
| private async handleModelCommand(searchTerm?: string): Promise<void> { |
| if (!searchTerm) { |
| this.showModelSelector(); |
| return; |
| } |
|
|
| const model = await this.findExactModelMatch(searchTerm); |
| if (model) { |
| try { |
| await this.session.setModel(model, { persist: false }); |
| this.footer.invalidate(); |
| this.updateEditorBorderColor(); |
| this.showStatus(`Model: ${model.id}`); |
| void this.maybeWarnAboutAnthropicSubscriptionAuth(model); |
| this.checkDaxnutsEasterEgg(model); |
| } catch (error) { |
| this.showError(error instanceof Error ? error.message : String(error)); |
| } |
| return; |
| } |
|
|
| this.showModelSelector(searchTerm); |
| } |
|
|
| private async findExactModelMatch(searchTerm: string): Promise<Model<any> | undefined> { |
| const cachedModels = |
| this.session.scopedModels.length > 0 |
| ? this.session.scopedModels.map((scoped) => scoped.model) |
| : [...this.session.modelRuntime.getAvailableSnapshot()]; |
| const cachedMatch = findExactModelReferenceMatch(searchTerm, cachedModels); |
| if (cachedMatch || this.session.scopedModels.length > 0) return cachedMatch; |
|
|
| this.showStatus("Refreshing model catalogs…"); |
| const controller = new AbortController(); |
| let timedOut = false; |
| const timeout = setTimeout(() => { |
| timedOut = true; |
| controller.abort(); |
| }, 15_000); |
| try { |
| const result = await refreshModelCatalogs(this.session.modelRuntime, controller.signal); |
| if (result.aborted && timedOut) { |
| this.showWarning("Model refresh timed out; searching cached models."); |
| } else if (result.errors.size > 0) { |
| this.showWarning(`Could not refresh ${[...result.errors.keys()].join(", ")}; searching cached models.`); |
| } |
| } catch (error) { |
| this.showWarning( |
| timedOut |
| ? "Model refresh timed out; searching cached models." |
| : `Could not refresh model catalogs: ${error instanceof Error ? error.message : String(error)}`, |
| ); |
| } finally { |
| clearTimeout(timeout); |
| } |
| return findExactModelReferenceMatch(searchTerm, [...this.session.modelRuntime.getAvailableSnapshot()]); |
| } |
|
|
| |
| private updateAvailableProviderCount(): void { |
| const models = |
| this.session.scopedModels.length > 0 |
| ? this.session.scopedModels.map((scoped) => scoped.model) |
| : this.session.modelRuntime.getAvailableSnapshot(); |
| const uniqueProviders = new Set(models.map((model) => model.provider)); |
| this.footerDataProvider.setAvailableProviderCount(uniqueProviders.size); |
| } |
|
|
| private async maybeWarnAboutAnthropicSubscriptionAuth( |
| model: Model<any> | undefined = this.session.model, |
| ): Promise<void> { |
| if (this.settingsManager.getWarnings().anthropicExtraUsage === false) { |
| return; |
| } |
| if (this.anthropicSubscriptionWarningShown) { |
| return; |
| } |
| if (!model || model.provider !== "anthropic") { |
| return; |
| } |
|
|
| try { |
| if ((await this.session.modelRuntime.checkAuth("anthropic"))?.type === "oauth") { |
| this.anthropicSubscriptionWarningShown = true; |
| this.showWarning(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING); |
| return; |
| } |
| const apiKey = (await this.session.modelRuntime.getAuth(model.provider))?.auth.apiKey; |
| if (!isAnthropicSubscriptionAuthKey(apiKey)) { |
| return; |
| } |
| this.anthropicSubscriptionWarningShown = true; |
| this.showWarning(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING); |
| } catch { |
| |
| } |
| } |
|
|
| private maybeSaveImplicitProjectTrustAfterReload(): boolean { |
| const cwd = this.sessionManager.getCwd(); |
| if (this.autoTrustOnReloadCwd !== cwd) { |
| return false; |
| } |
| if (!this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(cwd)) { |
| return false; |
| } |
|
|
| const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir); |
| try { |
| if (trustStore.get(cwd) !== null) { |
| this.autoTrustOnReloadCwd = undefined; |
| return false; |
| } |
| trustStore.set(cwd, true); |
| this.autoTrustOnReloadCwd = undefined; |
| return true; |
| } catch (error) { |
| this.showWarning( |
| `Could not save project trust after reload: ${error instanceof Error ? error.message : String(error)}`, |
| ); |
| return false; |
| } |
| } |
|
|
| private showTrustSelector(): void { |
| const cwd = this.sessionManager.getCwd(); |
| const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir); |
| const savedDecision = trustStore.getEntry(cwd); |
| this.showSelector((done) => { |
| const selector = new TrustSelectorComponent({ |
| cwd, |
| savedDecision, |
| projectTrusted: this.settingsManager.isProjectTrusted(), |
| onSelect: (selection) => { |
| trustStore.setMany(selection.updates); |
| done(); |
| this.showStatus( |
| `Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart ${APP_NAME} for this to take effect.`, |
| ); |
| }, |
| onCancel: () => { |
| done(); |
| this.ui.requestRender(); |
| }, |
| }); |
| return { component: selector, focus: selector }; |
| }); |
| } |
|
|
| private showModelSelector(initialSearchInput?: string): void { |
| this.showSelector((done) => { |
| const selectModel = async (model: Model<any>, persist: boolean) => { |
| try { |
| await this.session.setModel(model, { persist }); |
| this.updateAvailableProviderCount(); |
| this.footer.invalidate(); |
| this.updateEditorBorderColor(); |
| done(); |
| this.showStatus(persist ? `Default model: ${model.provider}/${model.id}` : `Model: ${model.id}`); |
| void this.maybeWarnAboutAnthropicSubscriptionAuth(model); |
| this.checkDaxnutsEasterEgg(model); |
| } catch (error) { |
| done(); |
| this.showError(error instanceof Error ? error.message : String(error)); |
| } |
| }; |
| const defaultProvider = this.settingsManager.getDefaultProvider(); |
| const defaultModel = this.settingsManager.getDefaultModel(); |
| const selector = new ModelSelectorComponent( |
| this.ui, |
| this.session.model, |
| this.session.modelRuntime, |
| this.session.scopedModels, |
| (model) => selectModel(model, false), |
| () => { |
| done(); |
| this.ui.requestRender(); |
| }, |
| initialSearchInput, |
| (model) => selectModel(model, true), |
| defaultProvider && defaultModel ? { provider: defaultProvider, id: defaultModel } : undefined, |
| ); |
| return { component: selector, focus: selector, dispose: () => selector.dispose() }; |
| }); |
| } |
|
|
| private showModelsSelector(): void { |
| let availableModels = [...this.session.modelRuntime.getAvailableSnapshot()]; |
| let availableModelIds = new Set(availableModels.map((model) => `${model.provider}/${model.id}`)); |
| const configuredPatterns = this.settingsManager.getEnabledModels(); |
| const sessionScopedModels = this.session.scopedModels; |
| const configuredEnabledIds = (models: readonly Model<any>[]): string[] | null => { |
| if (!configuredPatterns?.length) return null; |
| const resolved = resolveModelScopeFromModels(configuredPatterns, models); |
| const ids = resolved.scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`); |
| for (const diagnostic of resolved.diagnostics) { |
| if (diagnostic.code === "no-match" && !ids.includes(diagnostic.pattern)) ids.push(diagnostic.pattern); |
| } |
| return ids; |
| }; |
|
|
| let currentEnabledIds = |
| sessionScopedModels.length > 0 |
| ? sessionScopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`) |
| : configuredEnabledIds(availableModels); |
| let selectionChanged = false; |
|
|
| const updateSessionModels = (enabledIds: string[] | null): void => { |
| currentEnabledIds = enabledIds === null ? null : [...enabledIds]; |
| const hasEnabledAvailableModel = enabledIds?.some((id) => availableModelIds.has(id)) ?? false; |
| const allAvailableModelsEnabled = |
| enabledIds !== null && [...availableModelIds].every((id) => enabledIds.includes(id)); |
| if (enabledIds && hasEnabledAvailableModel && !allAvailableModelsEnabled) { |
| const newScopedModels = resolveModelScopeFromModels(enabledIds, availableModels).scopedModels; |
| this.session.setScopedModels( |
| newScopedModels.map((scoped) => ({ |
| model: scoped.model, |
| thinkingLevel: scoped.thinkingLevel, |
| })), |
| ); |
| } else { |
| this.session.setScopedModels([]); |
| } |
| this.updateAvailableProviderCount(); |
| this.ui.requestRender(); |
| }; |
|
|
| this.showSelector((done) => { |
| let disposed = false; |
| let timedOut = false; |
| const controller = new AbortController(); |
| const timeout = setTimeout(() => { |
| timedOut = true; |
| controller.abort(); |
| }, 15_000); |
| const selector = new ScopedModelsSelectorComponent( |
| { |
| allModels: availableModels, |
| enabledModelIds: currentEnabledIds, |
| refreshStatus: "Refreshing model catalogs…", |
| }, |
| { |
| onChange: (enabledIds) => { |
| selectionChanged = true; |
| updateSessionModels(enabledIds); |
| }, |
| onPersist: (enabledIds) => { |
| const allEnabled = |
| enabledIds !== null && |
| enabledIds.length === availableModels.length && |
| enabledIds.every((id) => availableModelIds.has(id)); |
| const newPatterns = enabledIds === null || allEnabled ? undefined : enabledIds; |
| this.settingsManager.setEnabledModels(newPatterns ? [...newPatterns] : undefined); |
| this.showStatus("Model selection saved to settings"); |
| }, |
| onCancel: () => { |
| done(); |
| this.ui.requestRender(); |
| }, |
| }, |
| ); |
| void refreshModelCatalogs(this.session.modelRuntime, controller.signal) |
| .then((result) => { |
| if (disposed) return; |
| availableModels = [...this.session.modelRuntime.getAvailableSnapshot()]; |
| availableModelIds = new Set(availableModels.map((model) => `${model.provider}/${model.id}`)); |
| if (!selectionChanged && sessionScopedModels.length === 0) { |
| currentEnabledIds = configuredEnabledIds(availableModels); |
| selector.updateModels(availableModels, currentEnabledIds); |
| } else { |
| selector.updateModels(availableModels); |
| } |
| if (currentEnabledIds !== null) updateSessionModels(currentEnabledIds); |
| if (result.aborted && timedOut) { |
| selector.setRefreshStatus("Model refresh timed out; showing cached models.", "warning"); |
| } else if (result.errors.size > 0) { |
| selector.setRefreshStatus( |
| `Could not refresh ${[...result.errors.keys()].join(", ")}; showing cached models.`, |
| "warning", |
| ); |
| } else { |
| selector.setRefreshStatus("Model catalogs refreshed.", "success"); |
| } |
| this.ui.requestRender(); |
| }) |
| .catch((error: unknown) => { |
| if (disposed) return; |
| selector.setRefreshStatus( |
| timedOut |
| ? "Model refresh timed out; showing cached models." |
| : `Could not refresh model catalogs: ${error instanceof Error ? error.message : String(error)}`, |
| "warning", |
| ); |
| this.ui.requestRender(); |
| }) |
| .finally(() => clearTimeout(timeout)); |
| return { |
| component: selector, |
| focus: selector, |
| dispose: () => { |
| disposed = true; |
| clearTimeout(timeout); |
| controller.abort(); |
| }, |
| }; |
| }); |
| } |
|
|
| private showUserMessageSelector(): void { |
| const userMessages = this.session.getUserMessagesForForking(); |
|
|
| if (userMessages.length === 0) { |
| this.showStatus("No messages to fork from"); |
| return; |
| } |
|
|
| const initialSelectedId = userMessages[userMessages.length - 1]?.entryId; |
|
|
| this.showSelector((done) => { |
| const selector = new UserMessageSelectorComponent( |
| userMessages.map((m) => ({ id: m.entryId, text: m.text })), |
| async (entryId) => { |
| done(); |
| try { |
| const result = await this.runtimeHost.fork(entryId); |
| if (result.cancelled) { |
| this.ui.requestRender(); |
| return; |
| } |
|
|
| this.editor.setText(result.selectedText ?? ""); |
| this.showStatus("Forked to new session"); |
| } catch (error: unknown) { |
| this.showError(error instanceof Error ? error.message : String(error)); |
| } |
| }, |
| () => { |
| done(); |
| this.ui.requestRender(); |
| }, |
| initialSelectedId, |
| ); |
| return { component: selector, focus: selector.getMessageList() }; |
| }); |
| } |
|
|
| private async handleCloneCommand(): Promise<void> { |
| const leafId = this.sessionManager.getLeafId(); |
| if (!leafId) { |
| this.showStatus("Nothing to clone yet"); |
| return; |
| } |
|
|
| try { |
| const result = await this.runtimeHost.fork(leafId, { position: "at" }); |
| if (result.cancelled) { |
| this.ui.requestRender(); |
| return; |
| } |
|
|
| this.editor.setText(""); |
| this.showStatus("Cloned to new session"); |
| } catch (error: unknown) { |
| this.showError(error instanceof Error ? error.message : String(error)); |
| } |
| } |
|
|
| private showTreeSelector(initialSelectedId?: string): void { |
| const tree = this.sessionManager.getTree(); |
| const realLeafId = this.sessionManager.getLeafId(); |
| const initialFilterMode = this.settingsManager.getTreeFilterMode(); |
|
|
| if (tree.length === 0) { |
| this.showStatus("No entries in session"); |
| return; |
| } |
|
|
| this.showSelector((done) => { |
| const selector = new TreeSelectorComponent( |
| tree, |
| realLeafId, |
| this.ui.terminal.rows, |
| async (entryId) => { |
| |
| if (entryId === this.sessionManager.getLeafId()) { |
| done(); |
| this.showStatus("Already at this point"); |
| return; |
| } |
|
|
| |
| done(); |
|
|
| |
| let wantsSummary = false; |
| let customInstructions: string | undefined; |
|
|
| |
| if (!this.settingsManager.getBranchSummarySkipPrompt()) { |
| while (true) { |
| const summaryChoice = await this.showExtensionSelector("Summarize branch?", [ |
| "No summary", |
| "Summarize", |
| "Summarize with custom prompt", |
| ]); |
|
|
| if (summaryChoice === undefined) { |
| |
| this.showTreeSelector(entryId); |
| return; |
| } |
|
|
| wantsSummary = summaryChoice !== "No summary"; |
|
|
| if (summaryChoice === "Summarize with custom prompt") { |
| customInstructions = await this.showExtensionEditor("Custom summarization instructions"); |
| if (customInstructions === undefined) { |
| |
| continue; |
| } |
| } |
|
|
| |
| break; |
| } |
| } |
|
|
| |
| if (this.session.isStreaming) { |
| this.restoreQueuedMessagesToEditor(); |
| await this.session.abort(); |
| } |
|
|
| |
| if (this.session.isCompacting) { |
| this.showError( |
| "Wait for the current compaction or tree navigation to finish before navigating the session tree.", |
| ); |
| return; |
| } |
|
|
| |
| let showingSummaryIndicator = false; |
| const originalOnEscape = this.defaultEditor.onEscape; |
|
|
| if (wantsSummary) { |
| this.defaultEditor.onEscape = () => { |
| this.session.abortBranchSummary(); |
| }; |
| this.chatContainer.addChild(new Spacer(1)); |
| this.showStatusIndicator(new BranchSummaryStatusIndicator(this.ui)); |
| showingSummaryIndicator = true; |
| this.ui.requestRender(); |
| } |
|
|
| try { |
| const result = await this.session.navigateTree(entryId, { |
| summarize: wantsSummary, |
| customInstructions, |
| }); |
|
|
| if (result.aborted) { |
| |
| this.showStatus("Branch summarization cancelled"); |
| this.showTreeSelector(entryId); |
| return; |
| } |
| if (result.cancelled) { |
| this.showStatus("Navigation cancelled"); |
| return; |
| } |
|
|
| |
| this.chatContainer.clear(); |
| this.renderInitialMessages(); |
| if (result.editorText && !this.editor.getText().trim()) { |
| this.editor.setText(result.editorText); |
| } |
| this.showStatus("Navigated to selected point"); |
| void this.flushCompactionQueue({ willRetry: false }); |
| } catch (error) { |
| this.showError(error instanceof Error ? error.message : String(error)); |
| } finally { |
| if (showingSummaryIndicator) { |
| this.clearStatusIndicator("branchSummary"); |
| } |
| this.defaultEditor.onEscape = originalOnEscape; |
| } |
| }, |
| () => { |
| done(); |
| this.ui.requestRender(); |
| }, |
| (entryId, label) => { |
| this.sessionManager.appendLabelChange(entryId, label); |
| this.ui.requestRender(); |
| }, |
| initialSelectedId, |
| initialFilterMode, |
| ); |
| selector.onCopy = async (text) => { |
| if (!text) { |
| this.showError("Selected entry has no text to copy"); |
| return; |
| } |
| try { |
| await copyToClipboard(text); |
| this.showStatus("Copied selected message to clipboard"); |
| } catch (error) { |
| this.showError(error instanceof Error ? error.message : String(error)); |
| } |
| }; |
| return { component: selector, focus: selector }; |
| }); |
| } |
|
|
| private showSessionSelector(): void { |
| this.showSelector((done) => { |
| const selector = new SessionSelectorComponent( |
| (onProgress) => |
| SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir(), onProgress), |
| (onProgress) => |
| this.sessionManager.usesDefaultSessionDir() |
| ? SessionManager.listAll(onProgress) |
| : SessionManager.listAll(this.sessionManager.getSessionDir(), onProgress), |
| async (sessionPath) => { |
| done(); |
| await this.handleResumeSession(sessionPath); |
| }, |
| () => { |
| done(); |
| this.ui.requestRender(); |
| }, |
| () => { |
| void this.shutdown(); |
| }, |
| () => this.ui.requestRender(), |
| { |
| renameSession: async (sessionFilePath: string, nextName: string | undefined) => { |
| const next = (nextName ?? "").trim(); |
| if (!next) return; |
| const mgr = SessionManager.open(sessionFilePath); |
| mgr.appendSessionInfo(next); |
| }, |
| showRenameHint: true, |
| keybindings: this.keybindings, |
| }, |
|
|
| this.sessionManager.getSessionFile(), |
| ); |
| return { component: selector, focus: selector }; |
| }); |
| } |
|
|
| private async handleResumeSession( |
| sessionPath: string, |
| options?: Parameters<ExtensionCommandContext["switchSession"]>[1], |
| ): Promise<{ cancelled: boolean }> { |
| this.clearStatusIndicator(); |
| try { |
| const result = await this.runtimeHost.switchSession(sessionPath, { |
| withSession: options?.withSession, |
| projectTrustContextFactory: (cwd) => this.createProjectTrustContext(cwd), |
| }); |
| if (result.cancelled) { |
| return result; |
| } |
| this.showStatus("Resumed session"); |
| return result; |
| } catch (error: unknown) { |
| if (error instanceof MissingSessionCwdError) { |
| const selectedCwd = await this.promptForMissingSessionCwd(error); |
| if (!selectedCwd) { |
| this.showStatus("Resume cancelled"); |
| return { cancelled: true }; |
| } |
| const result = await this.runtimeHost.switchSession(sessionPath, { |
| cwdOverride: selectedCwd, |
| withSession: options?.withSession, |
| projectTrustContextFactory: (cwd) => this.createProjectTrustContext(cwd), |
| }); |
| if (result.cancelled) { |
| return result; |
| } |
| this.showStatus("Resumed session in current cwd"); |
| return result; |
| } |
| return this.handleFatalRuntimeError("Failed to resume session", error); |
| } |
| } |
|
|
| private getLoginProviderOptions(authType?: "oauth" | "api_key"): AuthSelectorProvider[] { |
| const options: AuthSelectorProvider[] = []; |
| for (const provider of this.session.modelRuntime.getProviders()) { |
| const authStatus = this.session.modelRuntime.getProviderAuthStatus(provider.id); |
| const status = authStatus.configured |
| ? { |
| type: this.session.modelRuntime.isUsingOAuth(provider.id) ? ("oauth" as const) : ("api_key" as const), |
| source: authStatus.label ?? authStatus.source, |
| } |
| : undefined; |
| if ((!authType || authType === "oauth") && provider.auth.oauth) { |
| options.push({ |
| id: provider.id, |
| name: provider.name, |
| authType: "oauth", |
| method: provider.auth.oauth, |
| status, |
| }); |
| } |
| if ((!authType || authType === "api_key") && provider.auth.apiKey) { |
| options.push({ |
| id: provider.id, |
| name: provider.name, |
| authType: "api_key", |
| method: provider.auth.apiKey, |
| status, |
| }); |
| } |
| } |
| return options.sort((a, b) => a.name.localeCompare(b.name)); |
| } |
|
|
| private async getLogoutProviderOptions(): Promise<AuthSelectorProvider[]> { |
| return (await this.session.modelRuntime.listCredentials({ signal: AbortSignal.timeout(15_000) })) |
| .map(({ providerId, type }) => ({ |
| id: providerId, |
| name: this.session.modelRuntime.getProvider(providerId)?.name ?? providerId, |
| authType: type, |
| status: { type, source: "stored credential" }, |
| })) |
| .sort((a, b) => a.name.localeCompare(b.name)); |
| } |
|
|
| private findLoginProviderOptions(providerRef: string): AuthSelectorProvider[] { |
| const normalizedProviderRef = providerRef.trim().toLowerCase(); |
| if (!normalizedProviderRef) { |
| return []; |
| } |
|
|
| return this.getLoginProviderOptions().filter( |
| (provider) => |
| provider.id.toLowerCase() === normalizedProviderRef || |
| provider.name.toLowerCase() === normalizedProviderRef, |
| ); |
| } |
|
|
| private async handleLoginCommand(providerRef?: string): Promise<void> { |
| if (!providerRef) { |
| this.showLoginAuthTypeSelector(); |
| return; |
| } |
|
|
| const providerOptions = this.findLoginProviderOptions(providerRef); |
| if (providerOptions.length === 1) { |
| await this.startProviderLogin(providerOptions[0]!); |
| return; |
| } |
|
|
| if (providerOptions.length > 1) { |
| const providerIds = new Set(providerOptions.map((provider) => provider.id)); |
| if (providerIds.size === 1) { |
| this.showLoginAuthTypeSelector(providerOptions); |
| return; |
| } |
| } |
|
|
| this.showLoginProviderSelector(undefined, providerRef); |
| } |
|
|
| private async startProviderLogin(providerOption: AuthSelectorProvider): Promise<void> { |
| if (providerOption.authType === "oauth") { |
| await this.showLoginDialog(providerOption.id, providerOption.name); |
| } else if (providerOption.method?.login) { |
| await this.showApiKeyLoginDialog(providerOption.id, providerOption.name); |
| } else { |
| this.showAmbientAuthDialog(providerOption); |
| } |
| } |
|
|
| private showLoginAuthTypeSelector(providerOptions?: AuthSelectorProvider[]): void { |
| const oauthProvider = providerOptions?.find((provider) => provider.authType === "oauth"); |
| const oauthLoginLabel = |
| oauthProvider?.method && "loginLabel" in oauthProvider.method ? oauthProvider.method.loginLabel : undefined; |
| const subscriptionLabel = oauthLoginLabel ?? "Sign in with an account"; |
| const apiKeyLabel = "Sign in with an API key"; |
| const availableAuthTypes = providerOptions |
| ? new Set(providerOptions.map((provider) => provider.authType)) |
| : new Set<AuthSelectorProvider["authType"]>(["oauth", "api_key"]); |
| const options: string[] = []; |
| if (availableAuthTypes.has("oauth")) { |
| options.push(subscriptionLabel); |
| } |
| if (availableAuthTypes.has("api_key")) { |
| options.push(apiKeyLabel); |
| } |
|
|
| if (options.length === 0) { |
| this.showStatus("No login methods available."); |
| return; |
| } |
|
|
| if (providerOptions && options.length === 1) { |
| const providerOption = providerOptions[0]; |
| if (providerOption) { |
| void this.startProviderLogin(providerOption); |
| } |
| return; |
| } |
|
|
| const title = providerOptions?.[0] |
| ? `Select authentication method for ${providerOptions[0].name}:` |
| : "Select authentication method:"; |
| this.showSelector((done) => { |
| const selector = new ExtensionSelectorComponent( |
| title, |
| options, |
| (option) => { |
| done(); |
| const authType = option === subscriptionLabel ? "oauth" : "api_key"; |
| if (providerOptions) { |
| const providerOption = providerOptions.find((provider) => provider.authType === authType); |
| if (providerOption) { |
| void this.startProviderLogin(providerOption); |
| } |
| return; |
| } |
| this.showLoginProviderSelector(authType); |
| }, |
| () => { |
| done(); |
| this.ui.requestRender(); |
| }, |
| ); |
| return { component: selector, focus: selector }; |
| }); |
| } |
|
|
| private showLoginProviderSelector(authType?: AuthSelectorProvider["authType"], initialSearchInput?: string): void { |
| const providerOptions = this.getLoginProviderOptions(authType); |
| if (providerOptions.length === 0) { |
| const message = |
| authType === "oauth" |
| ? "No subscription providers available." |
| : authType === "api_key" |
| ? "No API key providers available." |
| : "No login providers available."; |
| this.showStatus(message); |
| return; |
| } |
|
|
| this.showSelector((done) => { |
| const selector = new OAuthSelectorComponent( |
| "login", |
| providerOptions, |
| async (providerId, selectedAuthType) => { |
| done(); |
|
|
| const providerOption = providerOptions.find( |
| (provider) => provider.id === providerId && provider.authType === selectedAuthType, |
| ); |
| if (!providerOption) { |
| return; |
| } |
|
|
| await this.startProviderLogin(providerOption); |
| }, |
| () => { |
| done(); |
| if (authType) { |
| this.showLoginAuthTypeSelector(); |
| } else { |
| this.ui.requestRender(); |
| } |
| }, |
| initialSearchInput, |
| ); |
| return { component: selector, focus: selector }; |
| }); |
| } |
|
|
| private async showOAuthSelector(mode: "login" | "logout"): Promise<void> { |
| if (mode === "login") { |
| this.showLoginAuthTypeSelector(); |
| return; |
| } |
|
|
| let providerOptions: AuthSelectorProvider[]; |
| try { |
| providerOptions = await this.getLogoutProviderOptions(); |
| } catch (error) { |
| this.showError(`Could not read stored credentials: ${error instanceof Error ? error.message : String(error)}`); |
| return; |
| } |
| if (providerOptions.length === 0) { |
| this.showStatus( |
| "No stored credentials to remove. /logout only removes credentials saved by /login; environment variables and models.json config are unchanged.", |
| ); |
| return; |
| } |
|
|
| this.showSelector((done) => { |
| const selector = new OAuthSelectorComponent( |
| mode, |
| providerOptions, |
| async (providerId: string) => { |
| done(); |
|
|
| const providerOption = providerOptions.find((provider) => provider.id === providerId); |
| if (!providerOption) { |
| return; |
| } |
|
|
| try { |
| await this.session.modelRuntime.logout(providerOption.id, { |
| signal: AbortSignal.timeout(15_000), |
| }); |
| await this.updateAvailableProviderCount(); |
| const message = |
| providerOption.authType === "oauth" |
| ? `Logged out of ${providerOption.name}` |
| : `Removed stored API key for ${providerOption.name}. Environment variables and models.json config are unchanged.`; |
| this.showStatus(message); |
| } catch (error: unknown) { |
| const message = error instanceof Error ? error.message : String(error); |
| this.showError( |
| error instanceof CredentialSynchronizationError |
| ? `Credentials removed for ${providerOption.name}, but local model state could not be synchronized: ${message}` |
| : `Logout failed: ${message}`, |
| ); |
| } |
| }, |
| () => { |
| done(); |
| this.ui.requestRender(); |
| }, |
| ); |
| return { component: selector, focus: selector }; |
| }); |
| } |
|
|
| private async completeProviderAuthentication( |
| providerId: string, |
| providerName: string, |
| authType: "oauth" | "api_key", |
| previousModel: Model<any> | undefined, |
| ): Promise<void> { |
| const actionLabel = authType === "oauth" ? `Logged in to ${providerName}` : `Saved API key for ${providerName}`; |
|
|
| const session = this.session; |
| |
| const deferSelection = |
| isUnknownModel(previousModel) && |
| hasDefaultModelProvider(providerId) && |
| !session.modelRuntime |
| .getAvailableSnapshot() |
| .some((model) => model.provider === providerId && model.id === defaultModelPerProvider[providerId]); |
| const finishAuthentication = async () => { |
| let selectedModel: Model<any> | undefined; |
| let selectionError: string | undefined; |
| if (isUnknownModel(previousModel)) { |
| const availableModels = this.session.modelRuntime.getAvailableSnapshot(); |
| const providerModels = availableModels.filter((model) => model.provider === providerId); |
| |
| if (providerId === "llama.cpp") { |
| selectionError = llamaCppPostLoginGuidance(actionLabel, providerModels.length); |
| } else if (!hasDefaultModelProvider(providerId)) { |
| selectionError = `${actionLabel}, but no default model is configured for provider "${providerId}". Use /model to select a model.`; |
| } else if (providerModels.length === 0) { |
| selectionError = `${actionLabel}, but no models are available for that provider. Use /model to select a model.`; |
| } else { |
| const defaultModelId = defaultModelPerProvider[providerId]; |
| |
| selectedModel = |
| providerModels.find((model) => model.id === defaultModelId) ?? |
| (providerId === "radius" ? providerModels[0] : undefined); |
| if (!selectedModel) { |
| selectionError = `${actionLabel}, but its default model "${defaultModelId}" is not available. Use /model to select a model.`; |
| } else { |
| try { |
| await this.session.setModel(selectedModel, { persist: true }); |
| } catch (error: unknown) { |
| selectedModel = undefined; |
| const errorMessage = error instanceof Error ? error.message : String(error); |
| selectionError = `${actionLabel}, but selecting its default model failed: ${errorMessage}. Use /model to select a model.`; |
| } |
| } |
| } |
| } |
|
|
| await this.updateAvailableProviderCount(); |
| this.footer.invalidate(); |
| this.updateEditorBorderColor(); |
| if (selectedModel) { |
| this.showStatus(`${actionLabel}. Selected ${selectedModel.id}. Credentials saved to ${getAuthPath()}`); |
| void this.maybeWarnAboutAnthropicSubscriptionAuth(selectedModel); |
| this.checkDaxnutsEasterEgg(selectedModel); |
| } else { |
| this.showStatus(`${actionLabel}. Credentials saved to ${getAuthPath()}`); |
| if (selectionError) { |
| this.showError(selectionError); |
| } else { |
| void this.maybeWarnAboutAnthropicSubscriptionAuth(); |
| } |
| } |
| }; |
| if (deferSelection) { |
| this.showStatus(`${actionLabel}. Credentials saved to ${getAuthPath()}. Refreshing model catalog…`); |
| } else { |
| await finishAuthentication(); |
| } |
|
|
| const controller = new AbortController(); |
| const timeout = setTimeout(() => controller.abort(), 15_000); |
| void session.modelRuntime |
| .refresh({ providers: [providerId], signal: controller.signal }) |
| .then(async (result) => { |
| if (result.aborted) { |
| this.showWarning(`${actionLabel}, but its model catalog refresh timed out; using cached models.`); |
| } else if (result.errors.size > 0) { |
| this.showWarning(`${actionLabel}, but its model catalog could not be refreshed; using cached models.`); |
| } |
| |
| if (deferSelection && this.session === session && session.model === previousModel) { |
| await finishAuthentication(); |
| } |
| this.updateAvailableProviderCount(); |
| this.footer.invalidate(); |
| this.ui.requestRender(); |
| }) |
| .catch((error: unknown) => { |
| this.showWarning( |
| `${actionLabel}, but its model catalog could not be refreshed: ${error instanceof Error ? error.message : String(error)}`, |
| ); |
| }) |
| .finally(() => clearTimeout(timeout)); |
| } |
|
|
| private showAmbientAuthDialog(providerOption: AuthSelectorProvider): void { |
| const restoreEditor = () => { |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(this.editor); |
| this.ui.setFocus(this.editor); |
| this.ui.requestRender(); |
| }; |
|
|
| const dialog = new LoginDialogComponent( |
| this.ui, |
| providerOption.id, |
| () => restoreEditor(), |
| providerOption.name, |
| `${providerOption.name} setup`, |
| ); |
| dialog.showInfo( |
| `${providerOption.method?.name ?? "Authentication"} is configured outside ${APP_NAME}.`, |
| [], |
| true, |
| ); |
|
|
| this.editorContainer.clear(); |
| this.editorContainer.addChild(dialog); |
| this.ui.setFocus(dialog); |
| this.ui.requestRender(); |
| } |
|
|
| private async showApiKeyLoginDialog(providerId: string, providerName: string): Promise<void> { |
| const previousModel = this.session.model; |
|
|
| const dialog = new LoginDialogComponent( |
| this.ui, |
| providerId, |
| (_success, _message) => { |
| |
| }, |
| providerName, |
| ); |
|
|
| if (providerId === "amazon-bedrock") { |
| dialog.showDetails([ |
| theme.fg("text", "You can also use an AWS profile, IAM keys, or role-based credentials."), |
| theme.fg("muted", "See:"), |
| theme.fg("accent", ` ${path.join(getDocsPath(), "providers.md")}`), |
| ]); |
| } |
|
|
| this.editorContainer.clear(); |
| this.editorContainer.addChild(dialog); |
| this.ui.setFocus(dialog); |
| this.ui.requestRender(); |
|
|
| const restoreEditor = () => { |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(this.editor); |
| this.ui.setFocus(this.editor); |
| this.ui.requestRender(); |
| }; |
|
|
| try { |
| await this.loginProvider(dialog, providerId, "api_key"); |
| restoreEditor(); |
| await this.completeProviderAuthentication(providerId, providerName, "api_key", previousModel); |
| } catch (error: unknown) { |
| restoreEditor(); |
| const errorMsg = error instanceof Error ? error.message : String(error); |
| if (error instanceof CredentialSynchronizationError) { |
| this.showError( |
| `Saved API key for ${providerName}, but local model state could not be synchronized: ${errorMsg}`, |
| ); |
| } else if (errorMsg !== "Login cancelled") { |
| this.showError(`Failed to save API key for ${providerName}: ${errorMsg}`); |
| } |
| } |
| } |
|
|
| private showAuthSelect( |
| dialog: LoginDialogComponent, |
| prompt: Extract<AuthPrompt, { type: "select" }>, |
| ): Promise<string> { |
| return new Promise((resolve, reject) => { |
| const restoreDialog = () => { |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(dialog); |
| this.ui.setFocus(dialog); |
| this.ui.requestRender(); |
| }; |
| const labels = prompt.options.map((option) => option.label); |
| const selector = new ExtensionSelectorComponent( |
| prompt.message, |
| labels, |
| (optionLabel) => { |
| restoreDialog(); |
| const id = prompt.options.find((option) => option.label === optionLabel)?.id; |
| if (id) resolve(id); |
| else reject(new Error("Login cancelled")); |
| }, |
| () => { |
| restoreDialog(); |
| reject(new Error("Login cancelled")); |
| }, |
| ); |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(selector); |
| this.ui.setFocus(selector); |
| this.ui.requestRender(); |
| }); |
| } |
|
|
| private async showAuthPrompt(dialog: LoginDialogComponent, prompt: AuthPrompt): Promise<string> { |
| let response: Promise<string>; |
| if (prompt.type === "select") { |
| response = this.showAuthSelect(dialog, prompt); |
| } else if (prompt.type === "manual_code") { |
| response = dialog.showManualInput(prompt.message); |
| } else { |
| response = dialog.showPrompt(prompt.message, prompt.placeholder); |
| } |
| if (!prompt.signal) return response; |
| if (prompt.signal.aborted) throw new Error("Login cancelled"); |
| const signal = prompt.signal; |
| let onAbort: (() => void) | undefined; |
| const aborted = new Promise<string>((_resolve, reject) => { |
| onAbort = () => reject(new Error("Login cancelled")); |
| signal.addEventListener("abort", onAbort, { once: true }); |
| }); |
| try { |
| return await Promise.race([response, aborted]); |
| } finally { |
| if (onAbort) signal.removeEventListener("abort", onAbort); |
| } |
| } |
|
|
| private notifyAuthDialog(dialog: LoginDialogComponent, event: AuthEvent): void { |
| if (event.type === "auth_url") { |
| dialog.showAuth(event.url, event.instructions); |
| } else if (event.type === "device_code") { |
| dialog.showDeviceCode(event); |
| dialog.showWaiting("Waiting for authentication..."); |
| } else if (event.type === "info") { |
| dialog.showInfo(event.message, event.links); |
| } else { |
| dialog.showProgress(event.message); |
| } |
| } |
|
|
| private async loginProvider( |
| dialog: LoginDialogComponent, |
| providerId: string, |
| method: "api_key" | "oauth", |
| ): Promise<void> { |
| await this.session.modelRuntime.login(providerId, method, { |
| signal: dialog.signal, |
| prompt: (prompt) => this.showAuthPrompt(dialog, prompt), |
| notify: (event) => this.notifyAuthDialog(dialog, event), |
| }); |
| } |
|
|
| private async showLoginDialog(providerId: string, providerName: string): Promise<void> { |
| const previousModel = this.session.model; |
| const dialog = new LoginDialogComponent(this.ui, providerId, (_success, _message) => {}, providerName); |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(dialog); |
| this.ui.setFocus(dialog); |
| this.ui.requestRender(); |
|
|
| const restoreEditor = () => { |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(this.editor); |
| this.ui.setFocus(this.editor); |
| this.ui.requestRender(); |
| }; |
|
|
| try { |
| await this.loginProvider(dialog, providerId, "oauth"); |
| restoreEditor(); |
| await this.completeProviderAuthentication(providerId, providerName, "oauth", previousModel); |
| } catch (error: unknown) { |
| restoreEditor(); |
| const errorMsg = error instanceof Error ? error.message : String(error); |
| if (error instanceof CredentialSynchronizationError) { |
| this.showError( |
| `Logged in to ${providerName}, but local model state could not be synchronized: ${errorMsg}`, |
| ); |
| } else if (errorMsg !== "Login cancelled") { |
| this.showError(`Failed to login to ${providerName}: ${errorMsg}`); |
| } |
| } |
| } |
|
|
| |
| |
| |
|
|
| private async handleReloadCommand(): Promise<void> { |
| if (this.session.isStreaming) { |
| this.showWarning("Wait for the current response to finish before reloading."); |
| return; |
| } |
| if (this.session.isCompacting) { |
| this.showWarning("Wait for compaction to finish before reloading."); |
| return; |
| } |
|
|
| this.resetExtensionUI(); |
|
|
| const reloadBox = new Container(); |
| const borderColor = (s: string) => theme.fg("border", s); |
| reloadBox.addChild(new DynamicBorder(borderColor)); |
| reloadBox.addChild(new Spacer(1)); |
| reloadBox.addChild( |
| new Text( |
| theme.fg("muted", "Reloading keybindings, extensions, skills, prompts, themes, and context files..."), |
| 1, |
| 0, |
| ), |
| ); |
| reloadBox.addChild(new Spacer(1)); |
| reloadBox.addChild(new DynamicBorder(borderColor)); |
|
|
| const previousEditor = this.editor; |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(reloadBox); |
| this.ui.setFocus(reloadBox); |
| this.ui.requestRender(true); |
| await new Promise((resolve) => process.nextTick(resolve)); |
|
|
| const dismissReloadBox = (editor: Component) => { |
| this.editorContainer.clear(); |
| this.editorContainer.addChild(editor); |
| this.ui.setFocus(editor); |
| this.ui.requestRender(); |
| }; |
|
|
| let chatRestoredBeforeSessionStart = false; |
| let reloadBoxDismissed = false; |
| const restoreChatBeforeSessionStart = () => { |
| if (chatRestoredBeforeSessionStart) { |
| return; |
| } |
| this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); |
| this.outputPad = this.settingsManager.getOutputPad(); |
| this.rebuildChatFromMessages(); |
| chatRestoredBeforeSessionStart = true; |
| }; |
|
|
| try { |
| await this.session.reload({ beforeSessionStart: restoreChatBeforeSessionStart }); |
| restoreChatBeforeSessionStart(); |
| this.keybindings.reload(); |
| const activeHeader = this.customHeader ?? this.builtInHeader; |
| if (isExpandable(activeHeader)) { |
| activeHeader.setExpanded(this.toolOutputExpanded); |
| } |
| setRegisteredThemes(this.session.resourceLoader.getThemes().themes); |
| this.applyRuntimeSettings(); |
| await this.themeController.applyFromSettings(); |
| this.setupAutocompleteProvider(); |
| const runner = this.session.extensionRunner; |
| this.setupExtensionShortcuts(runner); |
| this.showLoadedResources({ |
| force: false, |
| showDiagnosticsWhenQuiet: true, |
| }); |
| const savedImplicitProjectTrust = this.maybeSaveImplicitProjectTrustAfterReload(); |
| const modelsJsonError = this.session.modelRuntime.getError(); |
| if (modelsJsonError) { |
| this.showError(`models.json error: ${modelsJsonError}`); |
| } |
| this.showStatus( |
| savedImplicitProjectTrust |
| ? "Reloaded keybindings, extensions, skills, prompts, themes, and context files; saved project trust" |
| : "Reloaded keybindings, extensions, skills, prompts, themes, and context files", |
| ); |
| dismissReloadBox(this.editor as Component); |
| reloadBoxDismissed = true; |
| } catch (error) { |
| if (!reloadBoxDismissed) { |
| dismissReloadBox(previousEditor as Component); |
| } |
| this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`); |
| } |
| } |
|
|
| private async handleExportCommand(text: string): Promise<void> { |
| const outputPath = this.getPathCommandArgument(text, "/export"); |
|
|
| try { |
| if (outputPath?.endsWith(".jsonl")) { |
| const filePath = this.session.exportToJsonl(outputPath); |
| this.showStatus(`Session exported to: ${filePath}`); |
| } else { |
| const filePath = await this.session.exportToHtml(outputPath, { |
| themeName: theme.name, |
| }); |
| this.showStatus(`Session exported to: ${filePath}`); |
| } |
| } catch (error: unknown) { |
| this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); |
| } |
| } |
|
|
| private getPathCommandArgument(text: string, command: "/export" | "/import"): string | undefined { |
| if (text === command) { |
| return undefined; |
| } |
| if (!text.startsWith(`${command} `)) { |
| return undefined; |
| } |
|
|
| const argsString = text.slice(command.length + 1).trimStart(); |
| if (!argsString) { |
| return undefined; |
| } |
|
|
| const firstChar = argsString[0]; |
| if (firstChar === '"' || firstChar === "'") { |
| const closingQuoteIndex = argsString.indexOf(firstChar, 1); |
| if (closingQuoteIndex < 0) { |
| return undefined; |
| } |
| return argsString.slice(1, closingQuoteIndex); |
| } |
|
|
| const firstWhitespaceIndex = argsString.search(/\s/); |
| if (firstWhitespaceIndex < 0) { |
| return argsString; |
| } |
| return argsString.slice(0, firstWhitespaceIndex); |
| } |
|
|
| private async handleImportCommand(text: string): Promise<void> { |
| const inputPath = this.getPathCommandArgument(text, "/import"); |
| if (!inputPath) { |
| this.showError("Usage: /import <path.jsonl>"); |
| return; |
| } |
|
|
| const confirmed = await this.showExtensionConfirm("Import session", `Replace current session with ${inputPath}?`); |
| if (!confirmed) { |
| this.showStatus("Import cancelled"); |
| return; |
| } |
|
|
| try { |
| this.clearStatusIndicator(); |
| const result = await this.runtimeHost.importFromJsonl(inputPath); |
| if (result.cancelled) { |
| this.showStatus("Import cancelled"); |
| return; |
| } |
| this.showStatus(`Session imported from: ${inputPath}`); |
| } catch (error: unknown) { |
| if (error instanceof MissingSessionCwdError) { |
| const selectedCwd = await this.promptForMissingSessionCwd(error); |
| if (!selectedCwd) { |
| this.showStatus("Import cancelled"); |
| return; |
| } |
| const result = await this.runtimeHost.importFromJsonl(inputPath, selectedCwd); |
| if (result.cancelled) { |
| this.showStatus("Import cancelled"); |
| return; |
| } |
| this.showStatus(`Session imported from: ${inputPath}`); |
| return; |
| } |
| if (error instanceof SessionImportFileNotFoundError) { |
| this.showError(`Failed to import session: ${error.message}`); |
| return; |
| } |
| await this.handleFatalRuntimeError("Failed to import session", error); |
| } |
| } |
|
|
| private async handleShareCommand(): Promise<void> { |
| await shareSession({ |
| session: this.session, |
| ui: this.ui, |
| editorContainer: this.editorContainer, |
| editor: this.editor, |
| showStatus: (message) => this.showStatus(message), |
| showError: (message) => this.showError(message), |
| }); |
| } |
|
|
| private async handleCopyCommand( |
| options: { flashConfirmation?: boolean; preferSelection?: boolean } = {}, |
| ): Promise<void> { |
| if ( |
| options.preferSelection && |
| this.ui instanceof TuiAltScreen && |
| !this.ui.getCopyOnSelect() && |
| this.ui.hasActiveSelection() |
| ) { |
| await this.ui.copyActiveSelectionToClipboard(); |
| return; |
| } |
|
|
| const text = this.session.getLastAssistantText(); |
| if (!text) { |
| this.showError("No agent messages to copy yet."); |
| return; |
| } |
|
|
| try { |
| await copyToClipboard(text); |
| if (options.flashConfirmation && this.ui instanceof TuiAltScreen) { |
| this.ui.flash("Copied!"); |
| } else { |
| this.showStatus("Copied last agent message to clipboard"); |
| } |
| } catch (error) { |
| this.showError(error instanceof Error ? error.message : String(error)); |
| } |
| } |
|
|
| private handleNameCommand(text: string): void { |
| const name = text.replace(/^\/name\s*/, "").trim(); |
| if (!name) { |
| const currentName = this.sessionManager.getSessionName(); |
| if (currentName) { |
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild(new Text(theme.fg("dim", `Session name: ${currentName}`), 1, 0)); |
| } else { |
| this.showWarning("Usage: /name <name>"); |
| } |
| this.ui.requestRender(); |
| return; |
| } |
|
|
| this.session.setSessionName(name); |
| const sessionName = this.sessionManager.getSessionName(); |
| if (sessionName !== name) { |
| this.showWarning(`Session name was normalized from ${JSON.stringify(name)} to ${JSON.stringify(sessionName)}`); |
| } |
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild(new Text(theme.fg("dim", `Session name set: ${sessionName ?? name}`), 1, 0)); |
| this.ui.requestRender(); |
| } |
|
|
| private handleSessionCommand(): void { |
| const stats = this.session.getSessionStats(); |
| const sessionName = this.sessionManager.getSessionName(); |
| const entries = this.sessionManager.getEntries(); |
| const cacheWaste = computeCacheWaste(entries, this.session.modelRuntime); |
|
|
| |
| |
| |
| const usageBreakdown = getUsageCostBreakdown(entries); |
|
|
| let info = `${theme.bold("Session Info")}\n\n`; |
| if (sessionName) { |
| info += `${theme.fg("dim", "Name:")} ${sessionName}\n`; |
| } |
| info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`; |
| info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`; |
| info += `${theme.bold("Messages")}\n`; |
| info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n`; |
| info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`; |
| info += `${theme.fg("dim", "Assistant:")} ${stats.assistantMessages}\n`; |
| info += `${theme.fg("dim", "Tools:")} ${stats.toolCalls} calls, ${stats.toolResults} results\n\n`; |
| info += `${theme.bold("Tokens")}\n`; |
| |
| |
| |
| |
| const { input, cacheRead, cacheWrite } = stats.tokens; |
| const promptTokens = input + cacheRead + cacheWrite; |
| info += `${theme.fg("dim", "Input:")} ${promptTokens.toLocaleString()}\n`; |
| if (promptTokens > 0 && (cacheRead > 0 || cacheWrite > 0)) { |
| const hitRate = theme.fg("dim", `(${((cacheRead / promptTokens) * 100).toFixed(1)}%)`); |
| info += ` ${theme.fg("dim", "Cached:")} ${cacheRead.toLocaleString()} ${hitRate}\n`; |
| const written = |
| cacheWrite > 0 ? ` ${theme.fg("dim", `(${cacheWrite.toLocaleString()} written to cache)`)}` : ""; |
| info += ` ${theme.fg("dim", "Uncached:")} ${(input + cacheWrite).toLocaleString()}${written}\n`; |
| } |
| info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`; |
| info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`; |
|
|
| if (stats.cost > 0 || cacheWaste.missedTokens > 0) { |
| info += `\n${theme.bold("Cost")}\n`; |
| info += `${theme.fg("dim", "Total:")} $${stats.cost.toFixed(3)}`; |
| if (usageBreakdown.length > 1) { |
| for (const entry of usageBreakdown) { |
| info += `\n ${theme.fg("dim", `${entry.key}:`)} $${entry.cost.toFixed(3)} ${theme.fg("dim", `(${formatTokens(entry.tokens)} tokens)`)}`; |
| } |
| } |
| if (cacheWaste.missedTokens > 0) { |
| const missLabel = cacheWaste.missCount === 1 ? "1 miss" : `${cacheWaste.missCount} misses`; |
| const detail = `${cacheWaste.missedTokens.toLocaleString()} tokens, ${missLabel}`; |
| info += |
| cacheWaste.missedCost >= 0.0001 |
| ? `\n${theme.fg("dim", "Cache Re-billed:")} $${cacheWaste.missedCost.toFixed(3)} ${theme.fg("dim", `(${detail})`)}` |
| : `\n${theme.fg("dim", "Cache Re-billed:")} ${detail}`; |
| } |
| } |
|
|
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild(new Text(info, 1, 0)); |
| this.ui.requestRender(); |
| } |
|
|
| private handleChangelogCommand(): void { |
| const changelogPath = getChangelogPath(); |
| const allEntries = parseChangelog(changelogPath); |
|
|
| const changelogMarkdown = |
| allEntries.length > 0 |
| ? allEntries |
| .reverse() |
| .map((e) => normalizeChangelogLinks(e.content, e)) |
| .join("\n\n") |
| : "No changelog entries found."; |
|
|
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild(new DynamicBorder()); |
| this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0)); |
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, this.getMarkdownThemeWithSettings())); |
| this.chatContainer.addChild(new DynamicBorder()); |
| this.ui.requestRender(); |
| } |
|
|
| |
| |
| |
| private getAppKeyDisplay(action: AppKeybinding): string { |
| return keyDisplayText(action); |
| } |
|
|
| |
| |
| |
| private getEditorKeyDisplay(action: Keybinding): string { |
| return keyDisplayText(action); |
| } |
|
|
| private handleHotkeysCommand(): void { |
| |
| const cursorUp = this.getEditorKeyDisplay("tui.editor.cursorUp"); |
| const cursorDown = this.getEditorKeyDisplay("tui.editor.cursorDown"); |
| const cursorLeft = this.getEditorKeyDisplay("tui.editor.cursorLeft"); |
| const cursorRight = this.getEditorKeyDisplay("tui.editor.cursorRight"); |
| const cursorWordLeft = this.getEditorKeyDisplay("tui.editor.cursorWordLeft"); |
| const cursorWordRight = this.getEditorKeyDisplay("tui.editor.cursorWordRight"); |
| const cursorLineStart = this.getEditorKeyDisplay("tui.editor.cursorLineStart"); |
| const cursorLineEnd = this.getEditorKeyDisplay("tui.editor.cursorLineEnd"); |
| const jumpForward = this.getEditorKeyDisplay("tui.editor.jumpForward"); |
| const jumpBackward = this.getEditorKeyDisplay("tui.editor.jumpBackward"); |
| const pageUp = this.getEditorKeyDisplay("tui.editor.pageUp"); |
| const pageDown = this.getEditorKeyDisplay("tui.editor.pageDown"); |
|
|
| |
| const submit = this.getEditorKeyDisplay("tui.input.submit"); |
| const newLine = this.getEditorKeyDisplay("tui.input.newLine"); |
| const deleteWordBackward = this.getEditorKeyDisplay("tui.editor.deleteWordBackward"); |
| const deleteWordForward = this.getEditorKeyDisplay("tui.editor.deleteWordForward"); |
| const deleteToLineStart = this.getEditorKeyDisplay("tui.editor.deleteToLineStart"); |
| const deleteToLineEnd = this.getEditorKeyDisplay("tui.editor.deleteToLineEnd"); |
| const yank = this.getEditorKeyDisplay("tui.editor.yank"); |
| const yankPop = this.getEditorKeyDisplay("tui.editor.yankPop"); |
| const undo = this.getEditorKeyDisplay("tui.editor.undo"); |
| const tab = this.getEditorKeyDisplay("tui.input.tab"); |
|
|
| |
| const interrupt = this.getAppKeyDisplay("app.interrupt"); |
| const clear = this.getAppKeyDisplay("app.clear"); |
| const exit = this.getAppKeyDisplay("app.exit"); |
| const suspend = this.getAppKeyDisplay("app.suspend"); |
| const cycleThinkingLevel = this.getAppKeyDisplay("app.thinking.cycle"); |
| const cycleModelForward = this.getAppKeyDisplay("app.model.cycleForward"); |
| const selectModel = this.getAppKeyDisplay("app.model.select"); |
| const expandTools = this.getAppKeyDisplay("app.tools.expand"); |
| const toggleThinking = this.getAppKeyDisplay("app.thinking.toggle"); |
| const externalEditor = this.getAppKeyDisplay("app.editor.external"); |
| const cycleModelBackward = this.getAppKeyDisplay("app.model.cycleBackward"); |
| const copyMessage = this.getAppKeyDisplay("app.message.copy"); |
| const followUp = this.getAppKeyDisplay("app.message.followUp"); |
| const dequeue = this.getAppKeyDisplay("app.message.dequeue"); |
| const pasteImage = this.getAppKeyDisplay("app.clipboard.pasteImage"); |
|
|
| let hotkeys = ` |
| **Navigation** |
| | Key | Action | |
| |-----|--------| |
| | \`${cursorUp}\` / \`${cursorDown}\` / \`${cursorLeft}\` / \`${cursorRight}\` | Move cursor / browse history | |
| | \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word | |
| | \`${cursorLineStart}\` | Start of line | |
| | \`${cursorLineEnd}\` | End of line | |
| | \`${jumpForward}\` | Jump forward to character | |
| | \`${jumpBackward}\` | Jump backward to character | |
| | \`${pageUp}\` / \`${pageDown}\` | Scroll by page | |
| |
| **Editing** |
| | Key | Action | |
| |-----|--------| |
| | \`${submit}\` | Send message | |
| | \`${newLine}\` | New line${process.platform === "win32" ? " (Ctrl+Enter on Windows Terminal)" : ""} | |
| | \`${deleteWordBackward}\` | Delete word backwards | |
| | \`${deleteWordForward}\` | Delete word forwards | |
| | \`${deleteToLineStart}\` | Delete to start of line | |
| | \`${deleteToLineEnd}\` | Delete to end of line | |
| | \`${yank}\` | Paste the most-recently-deleted text | |
| | \`${yankPop}\` | Cycle through the deleted text after pasting | |
| | \`${undo}\` | Undo | |
| |
| **Other** |
| | Key | Action | |
| |-----|--------| |
| | \`${tab}\` | Path completion / accept autocomplete | |
| | \`${interrupt}\` | Cancel autocomplete / abort streaming | |
| | \`${clear}\` | Clear editor (first) / exit (second) | |
| | \`${exit}\` | Exit (when editor is empty) | |
| | \`${suspend}\` | Suspend to background | |
| | \`${cycleThinkingLevel}\` | Cycle thinking level | |
| | \`${cycleModelForward}\` / \`${cycleModelBackward}\` | Cycle models | |
| | \`${selectModel}\` | Open model selector | |
| | \`${expandTools}\` | Toggle tool output expansion | |
| | \`${toggleThinking}\` | Toggle thinking block visibility | |
| | \`${externalEditor}\` | Edit message in external editor | |
| | \`${copyMessage}\` | Copy last assistant message | |
| | \`${followUp}\` | Queue follow-up message | |
| | \`${dequeue}\` | Restore queued messages | |
| | \`${pasteImage}\` | Paste image or text from clipboard | |
| | \`/\` | Slash commands | |
| | \`!\` | Run bash command | |
| | \`!!\` | Run bash command (excluded from context) | |
| `; |
|
|
| |
| const extensionRunner = this.session.extensionRunner; |
| const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig()); |
| if (shortcuts.size > 0) { |
| hotkeys += ` |
| **Extensions** |
| | Key | Action | |
| |-----|--------| |
| `; |
| for (const [key, shortcut] of shortcuts) { |
| const description = shortcut.description ?? shortcut.extensionPath; |
| const keyDisplay = formatKeyText(key, { capitalize: true }); |
| hotkeys += `| \`${keyDisplay}\` | ${description} |\n`; |
| } |
| } |
|
|
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild(new DynamicBorder()); |
| this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0)); |
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, this.getMarkdownThemeWithSettings())); |
| this.chatContainer.addChild(new DynamicBorder()); |
| this.ui.requestRender(); |
| } |
|
|
| private async handleClearCommand(): Promise<void> { |
| this.clearStatusIndicator(); |
| try { |
| const result = await this.runtimeHost.newSession(); |
| if (result.cancelled) { |
| return; |
| } |
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1)); |
| this.ui.requestRender(); |
| } catch (error: unknown) { |
| await this.handleFatalRuntimeError("Failed to create session", error); |
| } |
| } |
|
|
| private handleDebugCommand(): void { |
| const width = this.ui.terminal.columns; |
| const height = this.ui.terminal.rows; |
| const allLines = this.ui.render(width); |
|
|
| const debugLogPath = getDebugLogPath(); |
| const debugData = [ |
| `Debug output at ${new Date().toISOString()}`, |
| `Terminal: ${width}x${height}`, |
| `Total lines: ${allLines.length}`, |
| "", |
| "=== All rendered lines with visible widths ===", |
| ...allLines.map((line, idx) => { |
| const vw = visibleWidth(line); |
| const escaped = JSON.stringify(line); |
| return `[${idx}] (w=${vw}) ${escaped}`; |
| }), |
| "", |
| "=== Agent messages (JSONL) ===", |
| ...this.session.messages.map((msg) => JSON.stringify(msg)), |
| "", |
| ].join("\n"); |
|
|
| fs.mkdirSync(path.dirname(debugLogPath), { recursive: true }); |
| fs.writeFileSync(debugLogPath, debugData); |
|
|
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild( |
| new Text(`${theme.fg("accent", "✓ Debug log written")}\n${theme.fg("muted", debugLogPath)}`, 1, 1), |
| ); |
| this.ui.requestRender(); |
| } |
|
|
| private handleArminSaysHi(): void { |
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild(new ArminComponent(this.ui)); |
| this.ui.requestRender(); |
| } |
|
|
| private handleDementedDelves(): void { |
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild(new EarendilAnnouncementComponent()); |
| this.ui.requestRender(); |
| } |
|
|
| private handleDaxnuts(): void { |
| this.chatContainer.addChild(new Spacer(1)); |
| this.chatContainer.addChild(new DaxnutsComponent(this.ui)); |
| this.ui.requestRender(); |
| } |
|
|
| private checkDaxnutsEasterEgg(model: { provider: string; id: string }): void { |
| if (model.provider === "opencode" && model.id.toLowerCase().includes("kimi-k2.5")) { |
| this.handleDaxnuts(); |
| } |
| } |
|
|
| private async handleBashCommand(command: string, excludeFromContext = false): Promise<void> { |
| const extensionRunner = this.session.extensionRunner; |
|
|
| |
| const eventResult = await extensionRunner.emitUserBash({ |
| type: "user_bash", |
| command, |
| excludeFromContext, |
| cwd: this.sessionManager.getCwd(), |
| }); |
|
|
| |
| if (eventResult?.result) { |
| const result = eventResult.result; |
|
|
| |
| this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext); |
| if (this.session.isStreaming) { |
| this.pendingMessagesContainer.addChild(this.bashComponent); |
| this.pendingBashComponents.push(this.bashComponent); |
| } else { |
| this.chatContainer.addChild(this.bashComponent); |
| } |
|
|
| |
| if (result.output) { |
| this.bashComponent.appendOutput(result.output); |
| } |
| this.bashComponent.setComplete( |
| result.exitCode, |
| result.cancelled, |
| result.truncated ? ({ truncated: true, content: result.output } as TruncationResult) : undefined, |
| result.fullOutputPath, |
| ); |
|
|
| |
| this.session.recordBashResult(command, result, { excludeFromContext }); |
| this.bashComponent = undefined; |
| this.ui.requestRender(); |
| return; |
| } |
|
|
| |
| const isDeferred = this.session.isStreaming; |
| this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext); |
|
|
| if (isDeferred) { |
| |
| this.pendingMessagesContainer.addChild(this.bashComponent); |
| this.pendingBashComponents.push(this.bashComponent); |
| } else { |
| |
| this.chatContainer.addChild(this.bashComponent); |
| } |
| this.ui.requestRender(); |
|
|
| try { |
| const result = await this.session.executeBash( |
| command, |
| (chunk) => { |
| if (this.bashComponent) { |
| this.bashComponent.appendOutput(chunk); |
| this.ui.requestRender(); |
| } |
| }, |
| { excludeFromContext, operations: eventResult?.operations }, |
| ); |
|
|
| if (this.bashComponent) { |
| this.bashComponent.setComplete( |
| result.exitCode, |
| result.cancelled, |
| result.truncated ? ({ truncated: true, content: result.output } as TruncationResult) : undefined, |
| result.fullOutputPath, |
| ); |
| } |
| } catch (error) { |
| if (this.bashComponent) { |
| this.bashComponent.setComplete(undefined, false); |
| } |
| this.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`); |
| } |
|
|
| this.bashComponent = undefined; |
| this.ui.requestRender(); |
| } |
|
|
| private async handleCompactCommand(customInstructions?: string): Promise<void> { |
| this.clearStatusIndicator(); |
|
|
| try { |
| await this.session.compact(customInstructions); |
| } catch { |
| |
| } |
| } |
|
|
| stop(fullscreenExitOutput = this.settingsManager.getFullscreenExitOutput()): void { |
| this.disposeActiveSelector(); |
| if (this.settingsManager.getShowTerminalProgress()) { |
| this.ui.terminal.setProgress(false); |
| } |
| this.clearStatusIndicator(); |
| this.themeController.disableAutoSync(); |
| this.clearExtensionTerminalInputListeners(); |
| this.footer.dispose(); |
| this.footerDataProvider.dispose(); |
| if (this.unsubscribe) { |
| this.unsubscribe(); |
| } |
| if (this.isInitialized) { |
| this.stopInteractiveTui(fullscreenExitOutput); |
| this.isInitialized = false; |
| } |
| this.unregisterSignalHandlers(); |
| } |
| } |
|
|