edge-pi-cli 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"interactive-mode.d.ts","sourceRoot":"","sources":["../../../src/modes/interactive/interactive-mode.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAqBH,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAAgB,MAAM,SAAS,CAAC;AAW5E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAC9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAEvD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AA+B7C,MAAM,WAAW,sBAAsB;IACtC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC;IACjB,YAAY,CAAC,EAAE,WAAW,EAAE,CAAC;IAC7B,OAAO,CAAC,EAAE,cAAc,EAAE,CAAC;IAC3B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,sFAAsF;IACtF,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,oFAAoF;IACpF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6FAA6F;IAC7F,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IAC5E,2DAA2D;IAC3D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,sEAAsE;IACtE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,mEAAmE;IACnE,WAAW,CAAC,EAAE,iBAAiB,CAAC;IAChC,iEAAiE;IACjE,aAAa,CAAC,EAAE,OAAO,CAAC;CACxB;AAED;;GAEG;AACH,wBAAsB,kBAAkB,CAAC,KAAK,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAG3G","sourcesContent":["/**\n * Interactive mode using @mariozechner/pi-tui.\n *\n * Replaces the old readline-based REPL with a proper TUI that matches\n * the UX patterns from @mariozechner/pi-coding-agent:\n * - Editor component for input with submit/escape handling\n * - Markdown rendering for assistant responses\n * - Tool execution components with collapsible output\n * - Footer with model/provider info and token stats\n * - Container-based layout (header → chat → pending → editor → footer)\n * - Context compaction (manual /compact + auto mode)\n */\n\nimport { existsSync, readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport {\n\tCombinedAutocompleteProvider,\n\tContainer,\n\tEditor,\n\tKey,\n\tLoader,\n\tmatchesKey,\n\tProcessTerminal,\n\ttype SelectItem,\n\tSelectList,\n\ttype SlashCommand,\n\tSpacer,\n\tText,\n\tTUI,\n} from \"@mariozechner/pi-tui\";\nimport type { ImagePart } from \"ai\";\nimport chalk from \"chalk\";\nimport type { CodingAgent, CodingAgentConfig, ModelMessage } from \"edge-pi\";\nimport {\n\ttype CompactionResult,\n\ttype CompactionSettings,\n\tcompact,\n\tDEFAULT_COMPACTION_SETTINGS,\n\testimateContextTokens,\n\tprepareCompaction,\n\tSessionManager as SessionManagerClass,\n\tshouldCompact,\n} from \"edge-pi\";\nimport type { AuthStorage } from \"../../auth/auth-storage.js\";\nimport type { ContextFile } from \"../../context.js\";\nimport { getLatestModels } from \"../../model-factory.js\";\nimport type { PromptTemplate } from \"../../prompts.js\";\nimport { expandPromptTemplate } from \"../../prompts.js\";\nimport type { SettingsManager } from \"../../settings.js\";\nimport type { Skill } from \"../../skills.js\";\nimport { executeBashCommand } from \"../../utils/bash-executor.js\";\nimport { type ClipboardImage, extensionForImageMimeType, readClipboardImage } from \"../../utils/clipboard-image.js\";\nimport { formatAIError } from \"../../utils/format-ai-error.js\";\nimport { formatPendingMessages, parseBashInput } from \"./bash-helpers.js\";\nimport { AssistantMessageComponent } from \"./components/assistant-message.js\";\nimport { BashExecutionComponent } from \"./components/bash-execution.js\";\nimport { CompactionSummaryComponent } from \"./components/compaction-summary.js\";\nimport { FooterComponent } from \"./components/footer.js\";\nimport { ToolExecutionComponent, type ToolOutput } from \"./components/tool-execution.js\";\nimport { UserMessageComponent } from \"./components/user-message.js\";\nimport { getEditorTheme, getMarkdownTheme, getSelectListTheme } from \"./theme.js\";\n\n/** Default context window size (used when model doesn't report one). */\nconst DEFAULT_CONTEXT_WINDOW = 200_000;\n\n/** Extract display-friendly output from a tool result. Handles both plain strings and structured objects with text/image fields. */\nfunction extractToolOutput(output: unknown): ToolOutput {\n\tif (typeof output === \"string\") {\n\t\treturn { text: output };\n\t}\n\tif (typeof output === \"object\" && output !== null && \"text\" in output && typeof (output as any).text === \"string\") {\n\t\tconst obj = output as any;\n\t\treturn {\n\t\t\ttext: obj.text,\n\t\t\t...(obj.image && { image: obj.image }),\n\t\t};\n\t}\n\treturn { text: JSON.stringify(output) };\n}\n\nexport interface InteractiveModeOptions {\n\tinitialMessage?: string;\n\tinitialMessages?: string[];\n\tskills?: Skill[];\n\tcontextFiles?: ContextFile[];\n\tprompts?: PromptTemplate[];\n\tverbose?: boolean;\n\tprovider: string;\n\tmodelId: string;\n\tauthStorage?: AuthStorage;\n\t/** Settings manager for persisting user preferences (provider, model, compaction). */\n\tsettingsManager?: SettingsManager;\n\t/** Path to the `fd` binary for @ file autocomplete, or undefined if unavailable. */\n\tfdPath?: string;\n\t/** Called when the user switches model via Ctrl+L. Returns a new agent for the new model. */\n\tonModelChange?: (provider: string, modelId: string) => Promise<CodingAgent>;\n\t/** Context window size for the model. Defaults to 200k. */\n\tcontextWindow?: number;\n\t/** Directory where session files are stored. Required for /resume. */\n\tsessionDir?: string;\n\t/** Agent config used to recreate agents when resuming sessions. */\n\tagentConfig?: CodingAgentConfig;\n\t/** When true, show the session picker immediately on startup. */\n\tresumeOnStart?: boolean;\n}\n\n/**\n * Run the interactive TUI mode with streaming output.\n */\nexport async function runInteractiveMode(agent: CodingAgent, options: InteractiveModeOptions): Promise<void> {\n\tconst mode = new InteractiveMode(agent, options);\n\tawait mode.run();\n}\n\n// ============================================================================\n// InteractiveMode class\n// ============================================================================\n\nclass InteractiveMode {\n\tprivate agent: CodingAgent;\n\tprivate options: InteractiveModeOptions;\n\tprivate currentProvider: string;\n\tprivate currentModelId: string;\n\n\tprivate ui!: TUI;\n\tprivate headerContainer!: Container;\n\tprivate chatContainer!: Container;\n\tprivate pendingContainer!: Container;\n\tprivate pendingMessagesContainer!: Container;\n\tprivate editorContainer!: Container;\n\tprivate editor!: Editor;\n\tprivate editorTheme!: import(\"@mariozechner/pi-tui\").EditorTheme;\n\n\t// Message queues\n\tprivate steeringMessages: string[] = [];\n\tprivate followUpMessages: string[] = [];\n\tprivate isStreaming = false;\n\n\t// Inline bash state\n\tprivate isBashMode = false;\n\tprivate isBashRunning = false;\n\tprivate bashAbortController: AbortController | null = null;\n\tprivate bashComponent: import(\"./components/bash-execution.js\").BashExecutionComponent | undefined = undefined;\n\tprivate footer!: FooterComponent;\n\n\t// Loading animation during agent processing\n\tprivate loadingAnimation: Loader | undefined = undefined;\n\n\t// Streaming state\n\tprivate streamingComponent: AssistantMessageComponent | undefined = undefined;\n\tprivate streamingText = \"\";\n\tprivate hadToolResults = false;\n\n\t// Tool execution tracking: toolCallId → component\n\tprivate pendingTools = new Map<string, ToolExecutionComponent>();\n\n\t// Tool output expansion state\n\tprivate toolOutputExpanded = false;\n\n\t// Callback for resolving user input promise\n\tprivate onInputCallback?: (text: string) => void;\n\n\t// Pending clipboard images to attach to the next message\n\tprivate pendingImages: ClipboardImage[] = [];\n\n\t// Compaction state\n\tprivate contextWindow: number;\n\tprivate compactionSettings: CompactionSettings;\n\tprivate autoCompaction = true;\n\tprivate isCompacting = false;\n\tprivate compactionAbortController: AbortController | null = null;\n\n\tconstructor(agent: CodingAgent, options: InteractiveModeOptions) {\n\t\tthis.agent = agent;\n\t\tthis.options = options;\n\t\tthis.currentProvider = options.provider;\n\t\tthis.currentModelId = options.modelId;\n\t\tthis.contextWindow = options.contextWindow ?? DEFAULT_CONTEXT_WINDOW;\n\n\t\t// Initialize compaction settings from persisted settings if available\n\t\tconst savedCompaction = options.settingsManager?.getCompaction();\n\t\tthis.compactionSettings = {\n\t\t\t...DEFAULT_COMPACTION_SETTINGS,\n\t\t\t...(savedCompaction?.reserveTokens !== undefined && { reserveTokens: savedCompaction.reserveTokens }),\n\t\t\t...(savedCompaction?.keepRecentTokens !== undefined && { keepRecentTokens: savedCompaction.keepRecentTokens }),\n\t\t};\n\t\tthis.autoCompaction = options.settingsManager?.getCompactionEnabled() ?? true;\n\t}\n\n\tasync run(): Promise<void> {\n\t\tthis.initUI();\n\t\tthis.updateFooterTokens();\n\n\t\t// Show session picker immediately if --resume was passed\n\t\tif (this.options.resumeOnStart) {\n\t\t\tawait this.handleResume();\n\t\t}\n\n\t\t// Process initial messages\n\t\tconst { initialMessage, initialMessages = [] } = this.options;\n\n\t\tconst allInitial: string[] = [];\n\t\tif (initialMessage) allInitial.push(initialMessage);\n\t\tallInitial.push(...initialMessages);\n\n\t\tfor (const msg of allInitial) {\n\t\t\tthis.chatContainer.addChild(new UserMessageComponent(msg, getMarkdownTheme()));\n\t\t\tthis.ui.requestRender();\n\t\t\tawait this.streamPrompt(msg);\n\t\t}\n\n\t\t// Main interactive loop\n\t\twhile (true) {\n\t\t\tconst userInput = await this.getUserInput();\n\t\t\tawait this.handleUserInput(userInput);\n\t\t}\n\t}\n\n\t// ========================================================================\n\t// UI Setup\n\t// ========================================================================\n\n\tprivate initUI(): void {\n\t\tconst { provider, modelId, skills = [], contextFiles = [], prompts = [], verbose } = this.options;\n\n\t\tthis.ui = new TUI(new ProcessTerminal());\n\n\t\t// Header\n\t\tthis.headerContainer = new Container();\n\t\tconst logo = chalk.bold(\"epi\") + chalk.dim(` - ${provider}/${modelId}`);\n\n\t\tconst hints = [\n\t\t\t`${chalk.dim(\"Escape\")} to abort`,\n\t\t\t`${chalk.dim(\"!\")} inline bash`,\n\t\t\t`${chalk.dim(\"Alt+Enter\")} follow-up while streaming`,\n\t\t\t`${chalk.dim(\"Ctrl+C\")} to exit`,\n\t\t\t`${chalk.dim(\"Ctrl+E\")} to expand tools`,\n\t\t\t`${chalk.dim(\"Ctrl+L\")} to switch model`,\n\t\t\t`${chalk.dim(\"Ctrl+V\")} to paste image`,\n\t\t\t`${chalk.dim(\"↑/↓\")} to browse history`,\n\t\t\t`${chalk.dim(\"@\")} for file references`,\n\t\t\t`${chalk.dim(\"/\")} for commands`,\n\t\t].join(\"\\n\");\n\n\t\tthis.headerContainer.addChild(new Spacer(1));\n\t\tthis.headerContainer.addChild(new Text(`${logo}\\n${hints}`, 1, 0));\n\t\tthis.headerContainer.addChild(new Spacer(1));\n\n\t\tif (verbose && this.agent.sessionManager?.getSessionFile()) {\n\t\t\tthis.headerContainer.addChild(\n\t\t\t\tnew Text(chalk.dim(`Session: ${this.agent.sessionManager.getSessionFile()}`), 1, 0),\n\t\t\t);\n\t\t}\n\n\t\t// Show loaded context, skills, and prompts at startup\n\t\tthis.showLoadedResources(contextFiles, skills, prompts);\n\n\t\t// Chat area\n\t\tthis.chatContainer = new Container();\n\n\t\t// Pending messages (loading animations, status)\n\t\tthis.pendingContainer = new Container();\n\n\t\t// Pending steering/follow-up messages\n\t\tthis.pendingMessagesContainer = new Container();\n\n\t\t// Editor with slash command autocomplete\n\t\tthis.editorTheme = getEditorTheme();\n\t\tthis.editor = new Editor(this.ui, this.editorTheme);\n\t\tthis.editor.setAutocompleteProvider(this.buildAutocompleteProvider());\n\t\tthis.editorContainer = new Container();\n\t\tthis.editorContainer.addChild(this.editor);\n\n\t\t// Footer\n\t\tthis.footer = new FooterComponent(this.currentProvider, this.currentModelId);\n\t\tthis.footer.setAutoCompaction(this.autoCompaction);\n\t\tthis.footer.setSubscription(this.isSubscriptionProvider());\n\n\t\t// Assemble layout\n\t\tthis.ui.addChild(this.headerContainer);\n\t\tthis.ui.addChild(this.chatContainer);\n\t\tthis.ui.addChild(this.pendingMessagesContainer);\n\t\tthis.ui.addChild(this.pendingContainer);\n\t\tthis.ui.addChild(this.editorContainer);\n\t\tthis.ui.addChild(this.footer);\n\n\t\tthis.ui.setFocus(this.editor);\n\t\tthis.setupKeyHandlers();\n\n\t\tthis.ui.start();\n\t}\n\n\t// ========================================================================\n\t// Key Handlers\n\t// ========================================================================\n\n\tprivate setupKeyHandlers(): void {\n\t\tthis.editor.onChange = (text: string) => {\n\t\t\tconst wasBashMode = this.isBashMode;\n\t\t\tthis.isBashMode = text.trimStart().startsWith(\"!\");\n\t\t\tif (wasBashMode !== this.isBashMode) {\n\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t}\n\t\t};\n\t\tthis.editor.onSubmit = (text: string) => {\n\t\t\ttext = text.trim();\n\t\t\tif (!text) return;\n\n\t\t\t// If agent is streaming, Enter becomes a steering message\n\t\t\tif (this.isStreaming) {\n\t\t\t\tthis.agent.steer({ role: \"user\", content: [{ type: \"text\", text }] });\n\t\t\t\tthis.steeringMessages.push(text);\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\tthis.updatePendingMessagesDisplay();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tthis.editor.addToHistory(text);\n\t\t\tthis.editor.setText(\"\");\n\n\t\t\tif (this.onInputCallback) {\n\t\t\t\tthis.onInputCallback(text);\n\t\t\t}\n\t\t};\n\n\t\tconst origHandleInput = this.editor.handleInput.bind(this.editor);\n\t\tthis.editor.handleInput = (data: string) => {\n\t\t\t// Escape: abort if agent is running or compacting\n\t\t\tif (matchesKey(data, Key.escape)) {\n\t\t\t\tif (this.isBashRunning && this.bashAbortController) {\n\t\t\t\t\tthis.bashAbortController.abort();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (this.isCompacting && this.compactionAbortController) {\n\t\t\t\t\tthis.compactionAbortController.abort();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (this.loadingAnimation) {\n\t\t\t\t\tthis.agent.abort();\n\t\t\t\t\tthis.stopLoading();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Ctrl+C: exit\n\t\t\tif (matchesKey(data, Key.ctrl(\"c\"))) {\n\t\t\t\tthis.shutdown();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Ctrl+D: exit if editor is empty\n\t\t\tif (matchesKey(data, Key.ctrl(\"d\"))) {\n\t\t\t\tif (this.editor.getText().length === 0) {\n\t\t\t\t\tthis.shutdown();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Ctrl+E: toggle tool output expansion\n\t\t\tif (matchesKey(data, Key.ctrl(\"e\"))) {\n\t\t\t\tthis.toggleToolExpansion();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Ctrl+L: select model\n\t\t\tif (matchesKey(data, Key.ctrl(\"l\"))) {\n\t\t\t\tthis.handleModelSelect();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Ctrl+V: paste image from clipboard\n\t\t\tif (matchesKey(data, Key.ctrl(\"v\"))) {\n\t\t\t\tthis.handleClipboardImagePaste();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Alt+Enter (Option+Enter on Mac): follow-up while streaming (or submit normally when idle)\n\t\t\tif (matchesKey(data, Key.alt(\"enter\"))) {\n\t\t\t\tconst text = this.editor.getText().trim();\n\t\t\t\tif (!text) return;\n\n\t\t\t\tif (this.isStreaming) {\n\t\t\t\t\tthis.followUpMessages.push(text);\n\t\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\t\tthis.updatePendingMessagesDisplay();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// Not streaming: treat like regular submit\n\t\t\t\tthis.editor.onSubmit?.(text);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Alt+Up (Option+Up on Mac): dequeue all queued messages back into the editor\n\t\t\tif (matchesKey(data, Key.alt(\"up\"))) {\n\t\t\t\tconst restored = this.clearAllQueues();\n\t\t\t\tif (restored.length > 0) {\n\t\t\t\t\tthis.editor.setText(restored.join(\"\\n\\n\"));\n\t\t\t\t\tthis.updatePendingMessagesDisplay();\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\torigHandleInput(data);\n\t\t};\n\t}\n\n\t// ========================================================================\n\t// User Input\n\t// ========================================================================\n\n\tprivate getUserInput(): Promise<string> {\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.onInputCallback = (text: string) => {\n\t\t\t\tthis.onInputCallback = undefined;\n\t\t\t\tresolve(text);\n\t\t\t};\n\t\t});\n\t}\n\n\tprivate async handleUserInput(input: string): Promise<void> {\n\t\t// Handle commands\n\t\tif (input === \"/help\") {\n\t\t\tthis.showHelp();\n\t\t\treturn;\n\t\t}\n\n\t\tif (input === \"/skills\") {\n\t\t\tthis.showSkills();\n\t\t\treturn;\n\t\t}\n\n\t\tif (input === \"/quit\" || input === \"/exit\") {\n\t\t\tthis.shutdown();\n\t\t\treturn;\n\t\t}\n\n\t\tif (input === \"/model\") {\n\t\t\tawait this.handleModelSelect();\n\t\t\treturn;\n\t\t}\n\n\t\tif (input === \"/login\") {\n\t\t\tawait this.handleLogin();\n\t\t\treturn;\n\t\t}\n\n\t\tif (input === \"/logout\") {\n\t\t\tawait this.handleLogout();\n\t\t\treturn;\n\t\t}\n\n\t\tif (input === \"/compact\" || input.startsWith(\"/compact \")) {\n\t\t\tconst customInstructions = input.startsWith(\"/compact \") ? input.slice(9).trim() : undefined;\n\t\t\tawait this.handleCompactCommand(customInstructions);\n\t\t\treturn;\n\t\t}\n\n\t\tif (input === \"/auto-compact\") {\n\t\t\tthis.toggleAutoCompaction();\n\t\t\treturn;\n\t\t}\n\n\t\tif (input === \"/resume\") {\n\t\t\tawait this.handleResume();\n\t\t\treturn;\n\t\t}\n\n\t\tif (input.startsWith(\"/skill:\")) {\n\t\t\tconst skillName = input.slice(\"/skill:\".length).trim();\n\t\t\tawait this.handleSkillInvocation(skillName);\n\t\t\treturn;\n\t\t}\n\n\t\t// Handle bash commands (! for normal, !! for excluded from context)\n\t\tconst bashParsed = parseBashInput(input);\n\t\tif (bashParsed) {\n\t\t\tif (this.isBashRunning) {\n\t\t\t\tthis.showStatus(chalk.yellow(\"A bash command is already running. Press Escape to cancel it first.\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait this.handleBashCommand(bashParsed.command, bashParsed.excludeFromContext);\n\t\t\tthis.isBashMode = false;\n\t\t\tthis.updateEditorBorderColor();\n\t\t\treturn;\n\t\t}\n\n\t\t// Try expanding prompt templates\n\t\tconst { prompts = [] } = this.options;\n\t\tconst expanded = expandPromptTemplate(input, prompts);\n\n\t\t// Capture and clear pending images\n\t\tconst images = this.pendingImages.length > 0 ? [...this.pendingImages] : undefined;\n\t\tthis.pendingImages = [];\n\n\t\t// Regular message (use expanded text if a prompt template was matched)\n\t\tconst imageLabel = images ? chalk.dim(` (${images.length} image${images.length > 1 ? \"s\" : \"\"})`) : \"\";\n\t\tthis.chatContainer.addChild(new UserMessageComponent(`${expanded}${imageLabel}`, getMarkdownTheme()));\n\t\tthis.ui.requestRender();\n\t\tawait this.streamPrompt(expanded, images);\n\t}\n\n\tprivate async handleBashCommand(command: string, excludeFromContext: boolean): Promise<void> {\n\t\tthis.bashAbortController = new AbortController();\n\t\tthis.isBashRunning = true;\n\n\t\tthis.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext);\n\t\tif (this.toolOutputExpanded) {\n\t\t\tthis.bashComponent.setExpanded(true);\n\t\t}\n\t\tthis.chatContainer.addChild(this.bashComponent);\n\t\tthis.ui.requestRender();\n\n\t\ttry {\n\t\t\tconst result = await executeBashCommand(command, {\n\t\t\t\tsignal: this.bashAbortController.signal,\n\t\t\t\tonChunk: (chunk) => {\n\t\t\t\t\tthis.bashComponent?.appendOutput(chunk);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tthis.bashComponent.setComplete(result.exitCode, result.cancelled, result.truncated, result.fullOutputPath);\n\t\t\tthis.ui.requestRender();\n\n\t\t\tif (!excludeFromContext) {\n\t\t\t\tconst msgText = `Ran \\`${command}\\`\\n\\n\\`\\`\\`\\n${result.output.trimEnd()}\\n\\`\\`\\``;\n\t\t\t\tconst userMsg: ModelMessage = { role: \"user\", content: [{ type: \"text\", text: msgText }] };\n\t\t\t\tthis.agent.setMessages([...this.agent.messages, userMsg]);\n\t\t\t\tthis.agent.sessionManager?.appendMessage(userMsg);\n\t\t\t}\n\t\t} finally {\n\t\t\tthis.isBashRunning = false;\n\t\t\tthis.bashAbortController = null;\n\t\t\tthis.bashComponent = undefined;\n\t\t}\n\t}\n\n\t// ========================================================================\n\t// Autocomplete\n\t// ========================================================================\n\n\tprivate buildAutocompleteProvider(): CombinedAutocompleteProvider {\n\t\tconst { skills = [], prompts = [], fdPath } = this.options;\n\n\t\tconst commands: SlashCommand[] = [\n\t\t\t{ name: \"help\", description: \"Show available commands\" },\n\t\t\t{ name: \"resume\", description: \"Resume a previous session\" },\n\t\t\t{ name: \"compact\", description: \"Manually compact the session context\" },\n\t\t\t{ name: \"auto-compact\", description: \"Toggle automatic context compaction\" },\n\t\t\t{ name: \"login\", description: \"Login to an OAuth provider\" },\n\t\t\t{ name: \"logout\", description: \"Logout from an OAuth provider\" },\n\t\t\t{ name: \"skills\", description: \"List loaded skills\" },\n\t\t\t{ name: \"model\", description: \"Switch model (Ctrl+L)\" },\n\t\t\t{ name: \"quit\", description: \"Exit the CLI\" },\n\t\t\t{ name: \"exit\", description: \"Exit the CLI\" },\n\t\t];\n\n\t\tfor (const skill of skills) {\n\t\t\tcommands.push({\n\t\t\t\tname: `skill:${skill.name}`,\n\t\t\t\tdescription: skill.description,\n\t\t\t});\n\t\t}\n\n\t\t// Add prompt templates as slash commands\n\t\tfor (const prompt of prompts) {\n\t\t\tcommands.push({\n\t\t\t\tname: prompt.name,\n\t\t\t\tdescription: prompt.description,\n\t\t\t});\n\t\t}\n\n\t\treturn new CombinedAutocompleteProvider(commands, process.cwd(), fdPath ?? null);\n\t}\n\n\t// ========================================================================\n\t// Model Selection\n\t// ========================================================================\n\n\tprivate async handleModelSelect(): Promise<void> {\n\t\tconst latestModels = getLatestModels();\n\t\tconst modelOptions: { provider: string; modelId: string; label: string }[] = [];\n\t\tfor (const [provider, models] of Object.entries(latestModels)) {\n\t\t\tfor (const modelId of models) {\n\t\t\t\tmodelOptions.push({ provider, modelId, label: `${provider}/${modelId}` });\n\t\t\t}\n\t\t}\n\n\t\tconst items: SelectItem[] = modelOptions.map((m) => {\n\t\t\tconst current = m.provider === this.currentProvider && m.modelId === this.currentModelId;\n\t\t\treturn {\n\t\t\t\tvalue: `${m.provider}/${m.modelId}`,\n\t\t\t\tlabel: current ? `${m.label} (current)` : m.label,\n\t\t\t};\n\t\t});\n\n\t\tconst selected = await this.showSelectList(\"Switch model\", items);\n\t\tif (!selected) return;\n\n\t\tconst [newProvider, ...modelParts] = selected.split(\"/\");\n\t\tconst newModelId = modelParts.join(\"/\");\n\n\t\tif (newProvider === this.currentProvider && newModelId === this.currentModelId) {\n\t\t\treturn;\n\t\t}\n\n\t\tthis.showStatus(chalk.dim(`Switching to ${newProvider}/${newModelId}...`));\n\n\t\tif (!this.options.onModelChange) {\n\t\t\tthis.showStatus(chalk.yellow(\"Model switching is not available.\"));\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tconst newAgent = await this.options.onModelChange(newProvider, newModelId);\n\t\t\t// Preserve conversation history\n\t\t\tnewAgent.setMessages([...this.agent.messages]);\n\t\t\tthis.agent = newAgent;\n\n\t\t\tthis.currentProvider = newProvider;\n\t\t\tthis.currentModelId = newModelId;\n\t\t\tthis.updateFooter();\n\n\t\t\t// Persist the choice for next startup\n\t\t\tthis.options.settingsManager?.setDefaults(newProvider, newModelId);\n\n\t\t\tthis.showStatus(chalk.green(`Switched to ${newProvider}/${newModelId}`));\n\t\t} catch (error) {\n\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\tthis.showStatus(chalk.red(`Failed to switch model: ${msg}`));\n\t\t}\n\t}\n\n\t// ========================================================================\n\t// Streaming\n\t// ========================================================================\n\n\tprivate async streamPrompt(prompt: string, images?: ClipboardImage[]): Promise<void> {\n\t\tthis.isStreaming = true;\n\t\tthis.updatePendingMessagesDisplay();\n\n\t\t// Build image parts from clipboard images\n\t\tconst imageParts: ImagePart[] = (images ?? []).map((img) => ({\n\t\t\ttype: \"image\" as const,\n\t\t\timage: Buffer.from(img.bytes).toString(\"base64\"),\n\t\t\tmediaType: img.mimeType,\n\t\t}));\n\n\t\t// Start loading animation\n\t\tthis.startLoading();\n\n\t\tthis.streamingComponent = undefined;\n\t\tthis.streamingText = \"\";\n\t\tthis.hadToolResults = false;\n\n\t\tlet errorDisplayed = false;\n\t\tlet streamFailed = false;\n\t\ttry {\n\t\t\tconst result =\n\t\t\t\timageParts.length > 0\n\t\t\t\t\t? await this.agent.stream({\n\t\t\t\t\t\t\tmessages: [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\trole: \"user\" as const,\n\t\t\t\t\t\t\t\t\tcontent: [{ type: \"text\" as const, text: prompt }, ...imageParts],\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t})\n\t\t\t\t\t: await this.agent.stream({ prompt });\n\n\t\t\tfor await (const part of result.fullStream) {\n\t\t\t\tswitch (part.type) {\n\t\t\t\t\tcase \"text-delta\":\n\t\t\t\t\t\t// After tool results, or for the very first text part, start a new assistant message component\n\t\t\t\t\t\t// so each agent step gets its own message bubble\n\t\t\t\t\t\tif (this.hadToolResults || !this.streamingComponent) {\n\t\t\t\t\t\t\tthis.streamingComponent = new AssistantMessageComponent(getMarkdownTheme());\n\t\t\t\t\t\t\tthis.streamingText = \"\";\n\t\t\t\t\t\t\tthis.hadToolResults = false;\n\t\t\t\t\t\t\tthis.chatContainer.addChild(this.streamingComponent);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.streamingText += part.text;\n\t\t\t\t\t\tthis.streamingComponent!.updateText(this.streamingText);\n\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t\tbreak;\n\n\t\t\t\t\tcase \"tool-call\": {\n\t\t\t\t\t\tconst args =\n\t\t\t\t\t\t\ttypeof part.input === \"object\" && part.input !== null\n\t\t\t\t\t\t\t\t? (part.input as Record<string, unknown>)\n\t\t\t\t\t\t\t\t: {};\n\t\t\t\t\t\tconst toolComponent = new ToolExecutionComponent(part.toolName, args);\n\n\t\t\t\t\t\tif (this.toolOutputExpanded) {\n\t\t\t\t\t\t\ttoolComponent.setExpanded(true);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tthis.pendingTools.set(part.toolCallId, toolComponent);\n\t\t\t\t\t\tthis.chatContainer.addChild(toolComponent);\n\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\n\t\t\t\t\tcase \"tool-result\": {\n\t\t\t\t\t\tconst toolComponent = this.pendingTools.get(part.toolCallId);\n\t\t\t\t\t\tif (toolComponent) {\n\t\t\t\t\t\t\tconst toolOutput = extractToolOutput(part.output);\n\t\t\t\t\t\t\ttoolComponent.updateResult(toolOutput, /* isError */ false, /* isPartial */ false);\n\t\t\t\t\t\t\tthis.pendingTools.delete(part.toolCallId);\n\t\t\t\t\t\t\tthis.hadToolResults = true;\n\t\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\n\t\t\t\t\tcase \"error\": {\n\t\t\t\t\t\tconst errorMessage = formatAIError(part.error);\n\t\t\t\t\t\tif (this.streamingComponent) {\n\t\t\t\t\t\t\tthis.streamingComponent.setError(errorMessage);\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tthis.showStatus(chalk.red(`Error: ${errorMessage}`));\n\t\t\t\t\t\t}\n\t\t\t\t\t\terrorDisplayed = true;\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (errorDisplayed) return;\n\n\t\t\t// Wait for stream to complete — the agent auto-updates messages and persists to session\n\t\t\tawait result.response;\n\n\t\t\t// Update footer token stats\n\t\t\tthis.updateFooterTokens();\n\n\t\t\t// Check for auto-compaction after successful response\n\t\t\tawait this.checkAutoCompaction();\n\t\t} catch (error) {\n\t\t\tif (errorDisplayed) {\n\t\t\t\tstreamFailed = true;\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tstreamFailed = true;\n\t\t\tif ((error as Error).name === \"AbortError\") {\n\t\t\t\tif (this.streamingComponent) {\n\t\t\t\t\tthis.streamingComponent.setAborted();\n\t\t\t\t} else {\n\t\t\t\t\tthis.showStatus(chalk.dim(\"[aborted]\"));\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tconst msg =\n\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t: typeof error === \"object\" && error !== null\n\t\t\t\t\t\t\t? JSON.stringify(error)\n\t\t\t\t\t\t\t: String(error);\n\t\t\t\tif (this.streamingComponent) {\n\t\t\t\t\tthis.streamingComponent.setError(msg);\n\t\t\t\t} else {\n\t\t\t\t\tthis.showStatus(chalk.red(`Error: ${msg}`));\n\t\t\t\t}\n\t\t\t}\n\t\t} finally {\n\t\t\tthis.stopLoading();\n\t\t\tthis.streamingComponent = undefined;\n\t\t\tthis.streamingText = \"\";\n\t\t\tthis.hadToolResults = false;\n\t\t\tthis.pendingTools.clear();\n\t\t\tthis.isStreaming = false;\n\t\t\tthis.steeringMessages = [];\n\t\t\tif (streamFailed) {\n\t\t\t\tthis.followUpMessages = [];\n\t\t\t}\n\t\t\tthis.updatePendingMessagesDisplay();\n\t\t\tthis.ui.requestRender();\n\t\t}\n\n\t\t// Process queued follow-ups (skipped if stream failed/aborted)\n\t\twhile (this.followUpMessages.length > 0) {\n\t\t\tconst next = this.followUpMessages.shift();\n\t\t\tif (!next) break;\n\n\t\t\tthis.updatePendingMessagesDisplay();\n\n\t\t\tthis.chatContainer.addChild(new UserMessageComponent(next, getMarkdownTheme()));\n\t\t\tthis.ui.requestRender();\n\t\t\tawait this.streamPrompt(next);\n\t\t}\n\t}\n\n\tprivate updatePendingMessagesDisplay(): void {\n\t\tthis.pendingMessagesContainer.clear();\n\n\t\t// If no agent is running, clear pending messages (they've been consumed)\n\t\tif (!this.isStreaming && this.followUpMessages.length === 0 && this.steeringMessages.length === 0) {\n\t\t\tthis.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\tconst lines = formatPendingMessages(this.steeringMessages, this.followUpMessages);\n\n\t\tif (lines.length === 0) {\n\t\t\tthis.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\tthis.pendingMessagesContainer.addChild(new Spacer(1));\n\t\tthis.pendingMessagesContainer.addChild(new Text(lines.map((l) => chalk.dim(l)).join(\"\\n\"), 1, 0));\n\t\tthis.pendingMessagesContainer.addChild(new Text(chalk.dim(\"↳ Alt+Up to edit queued messages\"), 1, 0));\n\t\tthis.pendingMessagesContainer.addChild(new Spacer(1));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate clearAllQueues(): string[] {\n\t\tconst restored = [...this.steeringMessages, ...this.followUpMessages];\n\t\tthis.steeringMessages = [];\n\t\tthis.followUpMessages = [];\n\t\treturn restored;\n\t}\n\n\t// ========================================================================\n\t// Loading Animation\n\t// ========================================================================\n\n\tprivate startLoading(): void {\n\t\tthis.stopLoading();\n\t\tthis.loadingAnimation = new Loader(\n\t\t\tthis.ui,\n\t\t\t(s: string) => chalk.cyan(s),\n\t\t\t(s: string) => chalk.dim(s),\n\t\t\t\"Working...\",\n\t\t);\n\t\tthis.loadingAnimation.start();\n\t\tthis.pendingContainer.addChild(new Spacer(1));\n\t\tthis.pendingContainer.addChild(this.loadingAnimation);\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate stopLoading(): void {\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.stop();\n\t\t\tthis.pendingContainer.clear();\n\t\t\tthis.loadingAnimation = undefined;\n\t\t\tthis.ui.requestRender();\n\t\t}\n\t}\n\n\t// ========================================================================\n\t// Tool Expansion\n\t// ========================================================================\n\n\tprivate toggleToolExpansion(): void {\n\t\tthis.toolOutputExpanded = !this.toolOutputExpanded;\n\n\t\t// Update all tool components and compaction components in the chat\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (child instanceof ToolExecutionComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t} else if (child instanceof CompactionSummaryComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t} else if (child instanceof BashExecutionComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t}\n\t\t}\n\n\t\tthis.ui.requestRender();\n\t}\n\n\t// ========================================================================\n\t// Clipboard Image Paste\n\t// ========================================================================\n\n\tprivate handleClipboardImagePaste(): void {\n\t\ttry {\n\t\t\tconst image = readClipboardImage();\n\t\t\tif (!image) return;\n\t\t\tthis.pendingImages.push(image);\n\t\t\tconst ext = extensionForImageMimeType(image.mimeType) ?? \"image\";\n\t\t\tconst label = `[image ${this.pendingImages.length}: ${ext}]`;\n\t\t\tthis.editor.insertTextAtCursor(label);\n\t\t\tthis.ui.requestRender();\n\t\t} catch {\n\t\t\t// Silently ignore clipboard errors (may not have permission, etc.)\n\t\t}\n\t}\n\n\t// ========================================================================\n\t// Compaction\n\t// ========================================================================\n\n\t/**\n\t * Handle the /compact command.\n\t */\n\tprivate async handleCompactCommand(_customInstructions?: string): Promise<void> {\n\t\tconst messages = this.agent.messages;\n\t\tif (messages.length < 2) {\n\t\t\tthis.showStatus(chalk.yellow(\"Nothing to compact (not enough messages).\"));\n\t\t\treturn;\n\t\t}\n\n\t\tawait this.executeCompaction(false);\n\t}\n\n\t/**\n\t * Toggle auto-compaction on/off.\n\t */\n\tprivate toggleAutoCompaction(): void {\n\t\tthis.autoCompaction = !this.autoCompaction;\n\t\tthis.footer.setAutoCompaction(this.autoCompaction);\n\t\tthis.options.settingsManager?.setCompactionEnabled(this.autoCompaction);\n\t\tthis.showStatus(\n\t\t\tthis.autoCompaction ? chalk.green(\"Auto-compaction enabled\") : chalk.dim(\"Auto-compaction disabled\"),\n\t\t);\n\t\tthis.ui.requestRender();\n\t}\n\n\t/**\n\t * Check if auto-compaction should trigger after an agent response.\n\t */\n\tprivate async checkAutoCompaction(): Promise<void> {\n\t\tif (!this.autoCompaction) return;\n\n\t\tconst contextTokens = estimateContextTokens([...this.agent.messages]);\n\t\tif (!shouldCompact(contextTokens, this.contextWindow, this.compactionSettings)) return;\n\n\t\tawait this.executeCompaction(true);\n\t}\n\n\t/**\n\t * Execute compaction (used by both manual /compact and auto mode).\n\t */\n\tprivate async executeCompaction(isAuto: boolean): Promise<CompactionResult | undefined> {\n\t\tif (this.isCompacting) return undefined;\n\n\t\tconst sessionManager = this.agent.sessionManager;\n\n\t\t// Build path entries from session if available, otherwise from agent messages\n\t\tconst pathEntries = sessionManager ? sessionManager.getBranch() : this.buildSessionEntriesFromMessages();\n\n\t\tif (pathEntries.length < 2) {\n\t\t\tif (!isAuto) {\n\t\t\t\tthis.showStatus(chalk.yellow(\"Nothing to compact (not enough messages).\"));\n\t\t\t}\n\t\t\treturn undefined;\n\t\t}\n\n\t\t// Prepare compaction\n\t\tconst preparation = prepareCompaction(pathEntries, this.compactionSettings);\n\t\tif (!preparation) {\n\t\t\tif (!isAuto) {\n\t\t\t\tthis.showStatus(chalk.yellow(\"Nothing to compact (already compacted or insufficient history).\"));\n\t\t\t}\n\t\t\treturn undefined;\n\t\t}\n\n\t\tif (preparation.messagesToSummarize.length === 0) {\n\t\t\tif (!isAuto) {\n\t\t\t\tthis.showStatus(chalk.yellow(\"Nothing to compact (no messages to summarize).\"));\n\t\t\t}\n\t\t\treturn undefined;\n\t\t}\n\n\t\tthis.isCompacting = true;\n\t\tthis.compactionAbortController = new AbortController();\n\n\t\t// Show compaction indicator\n\t\tconst label = isAuto\n\t\t\t? \"Auto-compacting context... (Escape to cancel)\"\n\t\t\t: \"Compacting context... (Escape to cancel)\";\n\t\tconst compactingLoader = new Loader(\n\t\t\tthis.ui,\n\t\t\t(s: string) => chalk.cyan(s),\n\t\t\t(s: string) => chalk.dim(s),\n\t\t\tlabel,\n\t\t);\n\t\tcompactingLoader.start();\n\t\tthis.pendingContainer.clear();\n\t\tthis.pendingContainer.addChild(new Spacer(1));\n\t\tthis.pendingContainer.addChild(compactingLoader);\n\t\tthis.ui.requestRender();\n\n\t\tlet result: CompactionResult | undefined;\n\n\t\ttry {\n\t\t\t// We need a LanguageModel for summarization. Use the agent's model\n\t\t\t// by extracting it from the config. The model is accessible through\n\t\t\t// the onModelChange callback pattern, but for simplicity we create\n\t\t\t// a model via the same factory used at startup.\n\t\t\tconst { model } = await this.getCompactionModel();\n\n\t\t\tresult = await compact(\n\t\t\t\tpreparation,\n\t\t\t\tmodel,\n\t\t\t\tthis.options.agentConfig?.providerOptions,\n\t\t\t\tthis.compactionAbortController.signal,\n\t\t\t);\n\n\t\t\t// Record compaction in session\n\t\t\tif (sessionManager) {\n\t\t\t\tsessionManager.appendCompaction(\n\t\t\t\t\tresult.summary,\n\t\t\t\t\tresult.firstKeptEntryId,\n\t\t\t\t\tresult.tokensBefore,\n\t\t\t\t\tresult.details,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\t// Rebuild agent messages from the session context\n\t\t\tif (sessionManager) {\n\t\t\t\tconst context = sessionManager.buildSessionContext();\n\t\t\t\tthis.agent.setMessages(context.messages);\n\t\t\t}\n\n\t\t\t// Rebuild the chat UI\n\t\t\tthis.rebuildChatFromSession();\n\n\t\t\t// Add compaction summary component so user sees it\n\t\t\tconst summaryComponent = new CompactionSummaryComponent(result.tokensBefore, result.summary);\n\t\t\tif (this.toolOutputExpanded) {\n\t\t\t\tsummaryComponent.setExpanded(true);\n\t\t\t}\n\t\t\tthis.chatContainer.addChild(summaryComponent);\n\n\t\t\t// Update footer tokens\n\t\t\tthis.updateFooterTokens();\n\n\t\t\tif (this.options.verbose) {\n\t\t\t\tconst tokensAfter = estimateContextTokens([...this.agent.messages]);\n\t\t\t\tthis.showStatus(\n\t\t\t\t\tchalk.dim(\n\t\t\t\t\t\t`Compacted: ${result.tokensBefore.toLocaleString()} -> ${tokensAfter.toLocaleString()} tokens`,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\tif (\n\t\t\t\tthis.compactionAbortController.signal.aborted ||\n\t\t\t\tmessage === \"Compaction cancelled\" ||\n\t\t\t\t(error instanceof Error && error.name === \"AbortError\")\n\t\t\t) {\n\t\t\t\tthis.showStatus(chalk.dim(\"Compaction cancelled.\"));\n\t\t\t} else {\n\t\t\t\tthis.showStatus(chalk.red(`Compaction failed: ${message}`));\n\t\t\t}\n\t\t} finally {\n\t\t\tcompactingLoader.stop();\n\t\t\tthis.pendingContainer.clear();\n\t\t\tthis.isCompacting = false;\n\t\t\tthis.compactionAbortController = null;\n\t\t\tthis.ui.requestRender();\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * Get the language model for compaction summarization.\n\t * Uses the same model creation path as the main agent.\n\t */\n\tprivate async getCompactionModel(): Promise<{ model: import(\"ai\").LanguageModel }> {\n\t\tconst { createModel } = await import(\"../../model-factory.js\");\n\t\treturn createModel({\n\t\t\tprovider: this.currentProvider,\n\t\t\tmodel: this.currentModelId,\n\t\t\tauthStorage: this.options.authStorage,\n\t\t});\n\t}\n\n\t/**\n\t * Build session entries from agent messages (when no session manager).\n\t * Creates synthetic SessionEntry objects for the compaction algorithm.\n\t */\n\tprivate buildSessionEntriesFromMessages(): import(\"edge-pi\").SessionEntry[] {\n\t\tconst messages = this.agent.messages;\n\t\tconst entries: import(\"edge-pi\").SessionEntry[] = [];\n\t\tlet parentId: string | null = null;\n\n\t\tfor (let i = 0; i < messages.length; i++) {\n\t\t\tconst id = `msg-${i}`;\n\t\t\tentries.push({\n\t\t\t\ttype: \"message\",\n\t\t\t\tid,\n\t\t\t\tparentId,\n\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t\tmessage: messages[i],\n\t\t\t});\n\t\t\tparentId = id;\n\t\t}\n\n\t\treturn entries;\n\t}\n\n\t/**\n\t * Rebuild the chat UI from session context after compaction.\n\t */\n\tprivate rebuildChatFromSession(): void {\n\t\tthis.chatContainer.clear();\n\n\t\tconst messages = this.agent.messages;\n\t\tfor (const msg of messages) {\n\t\t\tif (msg.role === \"user\") {\n\t\t\t\t// Check if this is a compaction summary\n\t\t\t\tconst content = msg.content;\n\t\t\t\tif (Array.isArray(content) && content.length > 0) {\n\t\t\t\t\tconst textBlock = content[0] as { type: string; text?: string };\n\t\t\t\t\tif (textBlock.type === \"text\" && textBlock.text?.startsWith('<summary type=\"compaction\"')) {\n\t\t\t\t\t\t// Skip compaction summaries in rebuild (they are injected by buildSessionContext)\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tif (textBlock.type === \"text\" && textBlock.text?.startsWith('<summary type=\"branch\"')) {\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst text = extractTextFromMessage(msg);\n\t\t\t\tif (text) {\n\t\t\t\t\tthis.chatContainer.addChild(new UserMessageComponent(text, getMarkdownTheme()));\n\t\t\t\t}\n\t\t\t} else if (msg.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = msg as import(\"edge-pi\").AssistantModelMessage;\n\t\t\t\tconst textParts: string[] = [];\n\t\t\t\tfor (const block of assistantMsg.content) {\n\t\t\t\t\tconst b = block as {\n\t\t\t\t\t\ttype: string;\n\t\t\t\t\t\ttext?: string;\n\t\t\t\t\t\ttoolName?: string;\n\t\t\t\t\t\tinput?: unknown;\n\t\t\t\t\t\ttoolCallId?: string;\n\t\t\t\t\t};\n\t\t\t\t\tif (b.type === \"text\" && b.text) {\n\t\t\t\t\t\ttextParts.push(b.text);\n\t\t\t\t\t} else if (b.type === \"tool-call\" && b.toolName) {\n\t\t\t\t\t\tconst args =\n\t\t\t\t\t\t\ttypeof b.input === \"object\" && b.input !== null ? (b.input as Record<string, unknown>) : {};\n\t\t\t\t\t\tconst toolComp = new ToolExecutionComponent(b.toolName, args);\n\t\t\t\t\t\tif (this.toolOutputExpanded) {\n\t\t\t\t\t\t\ttoolComp.setExpanded(true);\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Mark as completed (we don't have the result here, just show collapsed)\n\t\t\t\t\t\ttoolComp.updateResult({ text: \"(from history)\" }, false, false);\n\t\t\t\t\t\tthis.chatContainer.addChild(toolComp);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (textParts.length > 0) {\n\t\t\t\t\tconst comp = new AssistantMessageComponent(getMarkdownTheme());\n\t\t\t\t\tcomp.updateText(textParts.join(\"\"));\n\t\t\t\t\tthis.chatContainer.addChild(comp);\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Skip tool messages in UI rebuild - they are consumed by tool-call components\n\t\t}\n\n\t\tthis.ui.requestRender();\n\t}\n\n\t// ========================================================================\n\t// Footer Token Tracking\n\t// ========================================================================\n\n\t/**\n\t * Update the footer with current token count information.\n\t */\n\tprivate updateFooterTokens(): void {\n\t\tconst contextTokens = estimateContextTokens([...this.agent.messages]);\n\t\tthis.footer.setTokenInfo(contextTokens, this.contextWindow);\n\t\tthis.footer.setAutoCompaction(this.autoCompaction);\n\t\tthis.ui?.requestRender();\n\t}\n\n\t/**\n\t * Check if the current provider is using an OAuth subscription credential.\n\t */\n\tprivate isSubscriptionProvider(): boolean {\n\t\tconst { authStorage } = this.options;\n\t\tif (!authStorage) return false;\n\t\tconst cred = authStorage.get(this.currentProvider);\n\t\treturn cred?.type === \"oauth\";\n\t}\n\n\t/**\n\t * Replace the footer component and update token info.\n\t */\n\tprivate updateFooter(): void {\n\t\tthis.footer = new FooterComponent(this.currentProvider, this.currentModelId);\n\t\tthis.footer.setSubscription(this.isSubscriptionProvider());\n\t\tthis.updateFooterTokens();\n\n\t\t// Replace footer in UI\n\t\tconst children = this.ui.children;\n\t\tchildren[children.length - 1] = this.footer;\n\t\tthis.ui.requestRender();\n\t}\n\n\t// ========================================================================\n\t// Startup Resource Display\n\t// ========================================================================\n\n\tprivate formatDisplayPath(p: string): string {\n\t\tconst home = process.env.HOME || process.env.USERPROFILE || \"\";\n\t\tif (home && p.startsWith(home)) {\n\t\t\treturn `~${p.slice(home.length)}`;\n\t\t}\n\t\treturn p;\n\t}\n\n\tprivate showLoadedResources(contextFiles: ContextFile[], skills: Skill[], prompts: PromptTemplate[]): void {\n\t\tconst sectionHeader = (name: string) => chalk.cyan(`[${name}]`);\n\n\t\tif (contextFiles.length > 0) {\n\t\t\tconst contextList = contextFiles.map((f) => chalk.dim(` ${this.formatDisplayPath(f.path)}`)).join(\"\\n\");\n\t\t\tthis.headerContainer.addChild(new Text(`${sectionHeader(\"Context\")}\\n${contextList}`, 0, 0));\n\t\t\tthis.headerContainer.addChild(new Spacer(1));\n\t\t}\n\n\t\tif (skills.length > 0) {\n\t\t\tconst skillList = skills.map((s) => chalk.dim(` ${this.formatDisplayPath(s.filePath)}`)).join(\"\\n\");\n\t\t\tthis.headerContainer.addChild(new Text(`${sectionHeader(\"Skills\")}\\n${skillList}`, 0, 0));\n\t\t\tthis.headerContainer.addChild(new Spacer(1));\n\t\t}\n\n\t\tif (prompts.length > 0) {\n\t\t\tconst promptList = prompts\n\t\t\t\t.map((p) => {\n\t\t\t\t\tconst sourceLabel = chalk.cyan(p.source);\n\t\t\t\t\treturn chalk.dim(` ${sourceLabel} /${p.name}`);\n\t\t\t\t})\n\t\t\t\t.join(\"\\n\");\n\t\t\tthis.headerContainer.addChild(new Text(`${sectionHeader(\"Prompts\")}\\n${promptList}`, 0, 0));\n\t\t\tthis.headerContainer.addChild(new Spacer(1));\n\t\t}\n\t}\n\n\t// ========================================================================\n\t// Commands\n\t// ========================================================================\n\n\tprivate showHelp(): void {\n\t\tconst helpText = [\n\t\t\tchalk.bold(\"Commands:\"),\n\t\t\t\" !<command> Run inline bash and include output in context\",\n\t\t\t\" !!<command> Run inline bash but exclude output from context\",\n\t\t\t\" /resume Resume a previous session\",\n\t\t\t\" /compact [text] Compact the session context (optional instructions)\",\n\t\t\t\" /auto-compact Toggle automatic context compaction\",\n\t\t\t\" /model Switch model (Ctrl+L)\",\n\t\t\t\" /login Login to an OAuth provider\",\n\t\t\t\" /logout Logout from an OAuth provider\",\n\t\t\t\" /skills List loaded skills\",\n\t\t\t\" /skill:<name> Invoke a skill by name\",\n\t\t\t\" /quit, /exit Exit the CLI\",\n\t\t].join(\"\\n\");\n\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(helpText, 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate showSkills(): void {\n\t\tconst { skills = [] } = this.options;\n\n\t\tif (skills.length === 0) {\n\t\t\tthis.showStatus(chalk.dim(\"No skills loaded.\"));\n\t\t\treturn;\n\t\t}\n\n\t\tconst lines: string[] = [];\n\t\tfor (const skill of skills) {\n\t\t\tconst hidden = skill.disableModelInvocation ? chalk.dim(\" (hidden from model)\") : \"\";\n\t\t\tlines.push(` ${chalk.bold(skill.name)}${hidden}`);\n\t\t\tlines.push(chalk.dim(` ${skill.description}`));\n\t\t\tlines.push(chalk.dim(` ${skill.filePath}`));\n\t\t}\n\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(lines.join(\"\\n\"), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate async handleSkillInvocation(skillName: string): Promise<void> {\n\t\tconst { skills = [] } = this.options;\n\t\tconst skill = skills.find((s) => s.name === skillName);\n\n\t\tif (!skill) {\n\t\t\tthis.showStatus(chalk.red(`Skill \"${skillName}\" not found.`));\n\t\t\treturn;\n\t\t}\n\n\t\tconst skillPrompt = `Please read and follow the instructions in the skill file: ${skill.filePath}`;\n\t\tthis.chatContainer.addChild(new UserMessageComponent(skillPrompt, getMarkdownTheme()));\n\t\tthis.ui.requestRender();\n\t\tawait this.streamPrompt(skillPrompt);\n\t}\n\n\t// ========================================================================\n\t// OAuth Login/Logout\n\t// ========================================================================\n\n\tprivate async handleLogin(): Promise<void> {\n\t\tconst { authStorage } = this.options;\n\t\tif (!authStorage) {\n\t\t\tthis.showStatus(chalk.red(\"Auth storage not available.\"));\n\t\t\treturn;\n\t\t}\n\n\t\tconst providers = authStorage.getProviders();\n\t\tif (providers.length === 0) {\n\t\t\tthis.showStatus(chalk.dim(\"No OAuth providers registered.\"));\n\t\t\treturn;\n\t\t}\n\n\t\t// Use SelectList overlay for provider selection\n\t\tconst items: SelectItem[] = providers.map((p) => {\n\t\t\tconst loggedIn = authStorage.get(p.id)?.type === \"oauth\" ? \" (logged in)\" : \"\";\n\t\t\treturn { value: p.id, label: `${p.name}${loggedIn}` };\n\t\t});\n\n\t\tconst selected = await this.showSelectList(\"Login to OAuth provider\", items);\n\t\tif (!selected) return;\n\n\t\tconst provider = providers.find((p) => p.id === selected);\n\t\tif (!provider) return;\n\n\t\tthis.showStatus(chalk.dim(`Logging in to ${provider.name}...`));\n\n\t\ttry {\n\t\t\tawait authStorage.login(provider.id, {\n\t\t\t\tonAuth: (info) => {\n\t\t\t\t\tconst lines = [chalk.bold(\"Open this URL in your browser:\"), chalk.cyan(info.url)];\n\t\t\t\t\tif (info.instructions) {\n\t\t\t\t\t\tlines.push(chalk.dim(info.instructions));\n\t\t\t\t\t}\n\t\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\t\tthis.chatContainer.addChild(new Text(lines.join(\"\\n\"), 1, 0));\n\t\t\t\t\tthis.ui.requestRender();\n\n\t\t\t\t\t// Try to open browser\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst { execSync } = require(\"node:child_process\") as typeof import(\"node:child_process\");\n\t\t\t\t\t\tconst platform = process.platform;\n\t\t\t\t\t\tif (platform === \"darwin\") {\n\t\t\t\t\t\t\texecSync(`open \"${info.url}\"`, { stdio: \"ignore\" });\n\t\t\t\t\t\t} else if (platform === \"linux\") {\n\t\t\t\t\t\t\texecSync(`xdg-open \"${info.url}\" 2>/dev/null || sensible-browser \"${info.url}\" 2>/dev/null`, {\n\t\t\t\t\t\t\t\tstdio: \"ignore\",\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t} else if (platform === \"win32\") {\n\t\t\t\t\t\t\texecSync(`start \"\" \"${info.url}\"`, { stdio: \"ignore\" });\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// Silently fail - user can open manually\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\tonPrompt: async (promptInfo) => {\n\t\t\t\t\t// Show prompt message and wait for user input\n\t\t\t\t\tthis.showStatus(chalk.dim(promptInfo.message));\n\t\t\t\t\tconst answer = await this.getUserInput();\n\t\t\t\t\treturn answer.trim();\n\t\t\t\t},\n\t\t\t\tonProgress: (message) => {\n\t\t\t\t\tthis.showStatus(chalk.dim(message));\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tthis.footer.setSubscription(this.isSubscriptionProvider());\n\t\t\tthis.ui.requestRender();\n\t\t\tthis.showStatus(chalk.green(`Logged in to ${provider.name}. Credentials saved.`));\n\t\t} catch (error) {\n\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\tif (msg !== \"Login cancelled\") {\n\t\t\t\tthis.showStatus(chalk.red(`Login failed: ${msg}`));\n\t\t\t} else {\n\t\t\t\tthis.showStatus(chalk.dim(\"Login cancelled.\"));\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate async handleLogout(): Promise<void> {\n\t\tconst { authStorage } = this.options;\n\t\tif (!authStorage) {\n\t\t\tthis.showStatus(chalk.red(\"Auth storage not available.\"));\n\t\t\treturn;\n\t\t}\n\n\t\tconst loggedIn = authStorage\n\t\t\t.list()\n\t\t\t.filter((id) => authStorage.get(id)?.type === \"oauth\")\n\t\t\t.map((id) => {\n\t\t\t\tconst provider = authStorage.getProvider(id);\n\t\t\t\treturn { id, name: provider?.name ?? id };\n\t\t\t});\n\n\t\tif (loggedIn.length === 0) {\n\t\t\tthis.showStatus(chalk.dim(\"No OAuth providers logged in. Use /login first.\"));\n\t\t\treturn;\n\t\t}\n\n\t\tconst items: SelectItem[] = loggedIn.map((p) => ({\n\t\t\tvalue: p.id,\n\t\t\tlabel: p.name,\n\t\t}));\n\n\t\tconst selected = await this.showSelectList(\"Logout from OAuth provider\", items);\n\t\tif (!selected) return;\n\n\t\tconst entry = loggedIn.find((p) => p.id === selected);\n\t\tif (!entry) return;\n\n\t\tauthStorage.logout(entry.id);\n\t\tthis.showStatus(chalk.green(`Logged out of ${entry.name}.`));\n\t}\n\n\t// ========================================================================\n\t// Resume Session\n\t// ========================================================================\n\n\t/**\n\t * List session files from the session directory, sorted by modification time (newest first).\n\t * Returns metadata for each session including the first user message as a preview.\n\t */\n\tprivate listAvailableSessions(): { path: string; mtime: number; preview: string; timestamp: string }[] {\n\t\tconst { sessionDir } = this.options;\n\t\tif (!sessionDir || !existsSync(sessionDir)) return [];\n\n\t\ttry {\n\t\t\tconst files = readdirSync(sessionDir)\n\t\t\t\t.filter((f: string) => f.endsWith(\".jsonl\"))\n\t\t\t\t.map((f: string) => {\n\t\t\t\t\tconst filePath = join(sessionDir, f);\n\t\t\t\t\tconst mtime = statSync(filePath).mtime.getTime();\n\t\t\t\t\treturn { name: f, path: filePath, mtime };\n\t\t\t\t})\n\t\t\t\t.sort((a, b) => b.mtime - a.mtime);\n\n\t\t\tconst sessions: { path: string; mtime: number; preview: string; timestamp: string }[] = [];\n\t\t\tfor (const file of files) {\n\t\t\t\t// Skip the current session file\n\t\t\t\tif (this.agent.sessionManager?.getSessionFile() === file.path) continue;\n\n\t\t\t\tconst preview = this.getSessionPreview(file.path);\n\t\t\t\tconst timestamp = new Date(file.mtime).toLocaleString();\n\t\t\t\tsessions.push({ path: file.path, mtime: file.mtime, preview, timestamp });\n\t\t\t}\n\t\t\treturn sessions;\n\t\t} catch {\n\t\t\treturn [];\n\t\t}\n\t}\n\n\t/**\n\t * Extract the first user message from a session file for preview.\n\t */\n\tprivate getSessionPreview(filePath: string): string {\n\t\ttry {\n\t\t\tconst content = readFileSync(filePath, \"utf-8\");\n\t\t\tconst lines = content.trim().split(\"\\n\");\n\t\t\tfor (const line of lines) {\n\t\t\t\tif (!line.trim()) continue;\n\t\t\t\ttry {\n\t\t\t\t\tconst entry = JSON.parse(line);\n\t\t\t\t\tif (entry.type === \"message\" && entry.message?.role === \"user\") {\n\t\t\t\t\t\tconst msg = entry.message;\n\t\t\t\t\t\tlet text = \"\";\n\t\t\t\t\t\tif (typeof msg.content === \"string\") {\n\t\t\t\t\t\t\ttext = msg.content;\n\t\t\t\t\t\t} else if (Array.isArray(msg.content)) {\n\t\t\t\t\t\t\tfor (const block of msg.content) {\n\t\t\t\t\t\t\t\tif (block.type === \"text\" && block.text) {\n\t\t\t\t\t\t\t\t\ttext = block.text;\n\t\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Truncate and clean up for display\n\t\t\t\t\t\ttext = text.replace(/\\n/g, \" \").trim();\n\t\t\t\t\t\tif (text.length > 80) {\n\t\t\t\t\t\t\ttext = `${text.slice(0, 77)}...`;\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn text || \"(empty message)\";\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// Skip malformed lines\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn \"(no messages)\";\n\t\t} catch {\n\t\t\treturn \"(unreadable)\";\n\t\t}\n\t}\n\n\t/**\n\t * Format a relative time string (e.g. \"2 hours ago\", \"3 days ago\").\n\t */\n\tprivate formatRelativeTime(mtime: number): string {\n\t\tconst now = Date.now();\n\t\tconst diffMs = now - mtime;\n\t\tconst diffSec = Math.floor(diffMs / 1000);\n\t\tconst diffMin = Math.floor(diffSec / 60);\n\t\tconst diffHour = Math.floor(diffMin / 60);\n\t\tconst diffDay = Math.floor(diffHour / 24);\n\n\t\tif (diffMin < 1) return \"just now\";\n\t\tif (diffMin < 60) return `${diffMin}m ago`;\n\t\tif (diffHour < 24) return `${diffHour}h ago`;\n\t\tif (diffDay < 30) return `${diffDay}d ago`;\n\t\treturn new Date(mtime).toLocaleDateString();\n\t}\n\n\t/**\n\t * Handle the /resume command: show a list of previous sessions and load the selected one.\n\t */\n\tprivate async handleResume(): Promise<void> {\n\t\tconst sessions = this.listAvailableSessions();\n\t\tif (sessions.length === 0) {\n\t\t\tthis.showStatus(chalk.yellow(\"No previous sessions found.\"));\n\t\t\treturn;\n\t\t}\n\n\t\tconst items: SelectItem[] = sessions.map((s) => ({\n\t\t\tvalue: s.path,\n\t\t\tlabel: `${chalk.dim(this.formatRelativeTime(s.mtime))} ${s.preview}`,\n\t\t}));\n\n\t\tconst selected = await this.showSelectList(\"Resume session\", items);\n\t\tif (!selected) return;\n\n\t\tconst session = sessions.find((s) => s.path === selected);\n\t\tif (!session) return;\n\n\t\ttry {\n\t\t\t// Open the selected session and set on agent (auto-restores messages)\n\t\t\tconst sessionDir = this.options.sessionDir!;\n\t\t\tconst newSessionManager = SessionManagerClass.open(selected, sessionDir);\n\t\t\tthis.agent.sessionManager = newSessionManager;\n\n\t\t\t// Rebuild the chat UI\n\t\t\tthis.chatContainer.clear();\n\t\t\tthis.rebuildChatFromSession();\n\n\t\t\t// Update footer tokens\n\t\t\tthis.updateFooterTokens();\n\n\t\t\tconst msgCount = this.agent.messages.length;\n\t\t\tthis.showStatus(chalk.green(`Resumed session (${msgCount} messages)`));\n\t\t} catch (error) {\n\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\tthis.showStatus(chalk.red(`Failed to resume session: ${msg}`));\n\t\t}\n\t}\n\n\t// ========================================================================\n\t// Select List (overlay pattern from pi-coding-agent)\n\t// ========================================================================\n\n\tprivate showSelectList(title: string, items: SelectItem[]): Promise<string | null> {\n\t\treturn new Promise((resolve) => {\n\t\t\tconst container = new Container();\n\n\t\t\tcontainer.addChild(new Spacer(1));\n\t\t\tcontainer.addChild(new Text(chalk.bold.cyan(title), 1, 0));\n\n\t\t\tconst selectList = new SelectList(items, Math.min(items.length, 10), getSelectListTheme());\n\t\t\tselectList.onSelect = (item) => {\n\t\t\t\t// Restore normal UI\n\t\t\t\tthis.pendingContainer.clear();\n\t\t\t\tthis.editorContainer.addChild(this.editor);\n\t\t\t\tthis.ui.setFocus(this.editor);\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tresolve(item.value);\n\t\t\t};\n\t\t\tselectList.onCancel = () => {\n\t\t\t\tthis.pendingContainer.clear();\n\t\t\t\tthis.editorContainer.addChild(this.editor);\n\t\t\t\tthis.ui.setFocus(this.editor);\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tresolve(null);\n\t\t\t};\n\t\t\tcontainer.addChild(selectList);\n\t\t\tcontainer.addChild(new Text(chalk.dim(\"↑↓ navigate • enter select • esc cancel\"), 1, 0));\n\t\t\tcontainer.addChild(new Spacer(1));\n\n\t\t\t// Replace editor area with select list\n\t\t\tthis.editorContainer.clear();\n\t\t\tthis.pendingContainer.clear();\n\t\t\tthis.pendingContainer.addChild(container);\n\t\t\tthis.ui.setFocus(selectList);\n\t\t\tthis.ui.requestRender();\n\t\t});\n\t}\n\n\t// ========================================================================\n\t// Status & Utilities\n\t// ========================================================================\n\n\tprivate showStatus(text: string): void {\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(text, 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate updateEditorBorderColor(): void {\n\t\tthis.editorTheme.borderColor = this.isBashMode ? (s: string) => chalk.yellow(s) : (s: string) => chalk.gray(s);\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate shutdown(): void {\n\t\tthis.ui.stop();\n\t\tconsole.log(chalk.dim(\"\\nGoodbye.\"));\n\t\tprocess.exit(0);\n\t}\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction extractTextFromMessage(msg: ModelMessage): string {\n\tif (msg.role === \"user\") {\n\t\tconst content = (msg as import(\"edge-pi\").UserModelMessage).content;\n\t\tif (typeof content === \"string\") return content;\n\t\tif (Array.isArray(content)) {\n\t\t\treturn content\n\t\t\t\t.filter((c): c is { type: \"text\"; text: string } => (c as { type: string }).type === \"text\")\n\t\t\t\t.map((c) => c.text)\n\t\t\t\t.join(\"\");\n\t\t}\n\t}\n\treturn \"\";\n}\n"]}
1
+ {"version":3,"file":"interactive-mode.d.ts","sourceRoot":"","sources":["../../../src/modes/interactive/interactive-mode.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAqBH,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAE9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAC9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAEvD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAwC7C,MAAM,WAAW,sBAAsB;IACtC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC;IACjB,YAAY,CAAC,EAAE,WAAW,EAAE,CAAC;IAC7B,OAAO,CAAC,EAAE,cAAc,EAAE,CAAC;IAC3B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,sFAAsF;IACtF,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,oFAAoF;IACpF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6FAA6F;IAC7F,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IAC5E,2DAA2D;IAC3D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,sEAAsE;IACtE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,mEAAmE;IACnE,WAAW,CAAC,EAAE,iBAAiB,CAAC;IAChC,iEAAiE;IACjE,aAAa,CAAC,EAAE,OAAO,CAAC;CACxB;AAED;;GAEG;AACH,wBAAsB,kBAAkB,CAAC,KAAK,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAG3G","sourcesContent":["/**\n * Interactive mode using @mariozechner/pi-tui.\n *\n * Replaces the old readline-based REPL with a proper TUI that matches\n * the UX patterns from @mariozechner/pi-coding-agent:\n * - Editor component for input with submit/escape handling\n * - Markdown rendering for assistant responses\n * - Tool execution components with collapsible output\n * - Footer with model/provider info and token stats\n * - Container-based layout (header → chat → pending → editor → footer)\n * - Context compaction (manual /compact + auto mode)\n */\n\nimport { existsSync, readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport {\n\tCombinedAutocompleteProvider,\n\tContainer,\n\tEditor,\n\tKey,\n\tLoader,\n\tmatchesKey,\n\tProcessTerminal,\n\ttype SelectItem,\n\tSelectList,\n\ttype SlashCommand,\n\tSpacer,\n\tText,\n\tTUI,\n} from \"@mariozechner/pi-tui\";\nimport type { ImagePart, ModelMessage } from \"ai\";\nimport chalk from \"chalk\";\nimport type { CodingAgent, CodingAgentConfig } from \"edge-pi\";\nimport { type CompactionResult, estimateContextTokens, SessionManager as SessionManagerClass } from \"edge-pi\";\nimport type { AuthStorage } from \"../../auth/auth-storage.js\";\nimport type { ContextFile } from \"../../context.js\";\nimport { getLatestModels } from \"../../model-factory.js\";\nimport type { PromptTemplate } from \"../../prompts.js\";\nimport { expandPromptTemplate } from \"../../prompts.js\";\nimport type { SettingsManager } from \"../../settings.js\";\nimport type { Skill } from \"../../skills.js\";\nimport { executeBashCommand } from \"../../utils/bash-executor.js\";\nimport { type ClipboardImage, extensionForImageMimeType, readClipboardImage } from \"../../utils/clipboard-image.js\";\nimport { formatAIError } from \"../../utils/format-ai-error.js\";\nimport { formatPendingMessages, parseBashInput } from \"./bash-helpers.js\";\nimport { AssistantMessageComponent } from \"./components/assistant-message.js\";\nimport { BashExecutionComponent } from \"./components/bash-execution.js\";\nimport { CompactionSummaryComponent } from \"./components/compaction-summary.js\";\nimport { FooterComponent } from \"./components/footer.js\";\nimport { ToolExecutionComponent, type ToolOutput } from \"./components/tool-execution.js\";\nimport { UserMessageComponent } from \"./components/user-message.js\";\nimport { getEditorTheme, getMarkdownTheme, getSelectListTheme } from \"./theme.js\";\n\n/** Default context window size (used when model doesn't report one). */\nconst DEFAULT_CONTEXT_WINDOW = 200_000;\nconst DEFAULT_COMPACTION_SETTINGS = {\n\treserveTokens: 16384,\n\tkeepRecentTokens: 20000,\n} as const;\n\ntype CompactionSettings = {\n\treserveTokens: number;\n\tkeepRecentTokens: number;\n};\n\n/** Extract display-friendly output from a tool result. Handles both plain strings and structured objects with text/image fields. */\nfunction extractToolOutput(output: unknown): ToolOutput {\n\tif (typeof output === \"string\") {\n\t\treturn { text: output };\n\t}\n\tif (typeof output === \"object\" && output !== null && \"text\" in output && typeof (output as any).text === \"string\") {\n\t\tconst obj = output as any;\n\t\treturn {\n\t\t\ttext: obj.text,\n\t\t\t...(obj.image && { image: obj.image }),\n\t\t};\n\t}\n\treturn { text: JSON.stringify(output) };\n}\n\nexport interface InteractiveModeOptions {\n\tinitialMessage?: string;\n\tinitialMessages?: string[];\n\tskills?: Skill[];\n\tcontextFiles?: ContextFile[];\n\tprompts?: PromptTemplate[];\n\tverbose?: boolean;\n\tprovider: string;\n\tmodelId: string;\n\tauthStorage?: AuthStorage;\n\t/** Settings manager for persisting user preferences (provider, model, compaction). */\n\tsettingsManager?: SettingsManager;\n\t/** Path to the `fd` binary for @ file autocomplete, or undefined if unavailable. */\n\tfdPath?: string;\n\t/** Called when the user switches model via Ctrl+L. Returns a new agent for the new model. */\n\tonModelChange?: (provider: string, modelId: string) => Promise<CodingAgent>;\n\t/** Context window size for the model. Defaults to 200k. */\n\tcontextWindow?: number;\n\t/** Directory where session files are stored. Required for /resume. */\n\tsessionDir?: string;\n\t/** Agent config used to recreate agents when resuming sessions. */\n\tagentConfig?: CodingAgentConfig;\n\t/** When true, show the session picker immediately on startup. */\n\tresumeOnStart?: boolean;\n}\n\n/**\n * Run the interactive TUI mode with streaming output.\n */\nexport async function runInteractiveMode(agent: CodingAgent, options: InteractiveModeOptions): Promise<void> {\n\tconst mode = new InteractiveMode(agent, options);\n\tawait mode.run();\n}\n\n// ============================================================================\n// InteractiveMode class\n// ============================================================================\n\nclass InteractiveMode {\n\tprivate agent: CodingAgent;\n\tprivate options: InteractiveModeOptions;\n\tprivate currentProvider: string;\n\tprivate currentModelId: string;\n\n\tprivate ui!: TUI;\n\tprivate headerContainer!: Container;\n\tprivate chatContainer!: Container;\n\tprivate pendingContainer!: Container;\n\tprivate pendingMessagesContainer!: Container;\n\tprivate editorContainer!: Container;\n\tprivate editor!: Editor;\n\tprivate editorTheme!: import(\"@mariozechner/pi-tui\").EditorTheme;\n\n\t// Message queues\n\tprivate steeringMessages: string[] = [];\n\tprivate followUpMessages: string[] = [];\n\tprivate isStreaming = false;\n\n\t// Inline bash state\n\tprivate isBashMode = false;\n\tprivate isBashRunning = false;\n\tprivate bashAbortController: AbortController | null = null;\n\tprivate bashComponent: import(\"./components/bash-execution.js\").BashExecutionComponent | undefined = undefined;\n\tprivate footer!: FooterComponent;\n\n\t// Loading animation during agent processing\n\tprivate loadingAnimation: Loader | undefined = undefined;\n\n\t// Streaming state\n\tprivate streamingComponent: AssistantMessageComponent | undefined = undefined;\n\tprivate streamingText = \"\";\n\tprivate hadToolResults = false;\n\n\t// Tool execution tracking: toolCallId → component\n\tprivate pendingTools = new Map<string, ToolExecutionComponent>();\n\n\t// Tool output expansion state\n\tprivate toolOutputExpanded = false;\n\n\t// Callback for resolving user input promise\n\tprivate onInputCallback?: (text: string) => void;\n\n\t// Pending clipboard images to attach to the next message\n\tprivate pendingImages: ClipboardImage[] = [];\n\n\t// Compaction state\n\tprivate contextWindow: number;\n\tprivate compactionSettings: CompactionSettings;\n\tprivate autoCompaction = true;\n\tprivate isCompacting = false;\n\tprivate compactionLoader: Loader | null = null;\n\n\tconstructor(agent: CodingAgent, options: InteractiveModeOptions) {\n\t\tthis.agent = agent;\n\t\tthis.options = options;\n\t\tthis.currentProvider = options.provider;\n\t\tthis.currentModelId = options.modelId;\n\t\tthis.contextWindow = options.contextWindow ?? DEFAULT_CONTEXT_WINDOW;\n\n\t\t// Initialize compaction settings from persisted settings if available\n\t\tconst savedCompaction = options.settingsManager?.getCompaction();\n\t\tthis.compactionSettings = {\n\t\t\t...DEFAULT_COMPACTION_SETTINGS,\n\t\t\t...(savedCompaction?.reserveTokens !== undefined && { reserveTokens: savedCompaction.reserveTokens }),\n\t\t\t...(savedCompaction?.keepRecentTokens !== undefined && { keepRecentTokens: savedCompaction.keepRecentTokens }),\n\t\t};\n\t\tthis.autoCompaction = options.settingsManager?.getCompactionEnabled() ?? true;\n\n\t\tthis.configureAgentCompaction();\n\t}\n\n\tasync run(): Promise<void> {\n\t\tthis.initUI();\n\t\tthis.updateFooterTokens();\n\n\t\t// Show session picker immediately if --resume was passed\n\t\tif (this.options.resumeOnStart) {\n\t\t\tawait this.handleResume();\n\t\t}\n\n\t\t// Process initial messages\n\t\tconst { initialMessage, initialMessages = [] } = this.options;\n\n\t\tconst allInitial: string[] = [];\n\t\tif (initialMessage) allInitial.push(initialMessage);\n\t\tallInitial.push(...initialMessages);\n\n\t\tfor (const msg of allInitial) {\n\t\t\tthis.chatContainer.addChild(new UserMessageComponent(msg, getMarkdownTheme()));\n\t\t\tthis.ui.requestRender();\n\t\t\tawait this.streamPrompt(msg);\n\t\t}\n\n\t\t// Main interactive loop\n\t\twhile (true) {\n\t\t\tconst userInput = await this.getUserInput();\n\t\t\tawait this.handleUserInput(userInput);\n\t\t}\n\t}\n\n\t// ========================================================================\n\t// UI Setup\n\t// ========================================================================\n\n\tprivate initUI(): void {\n\t\tconst { provider, modelId, skills = [], contextFiles = [], prompts = [], verbose } = this.options;\n\n\t\tthis.ui = new TUI(new ProcessTerminal());\n\n\t\t// Header\n\t\tthis.headerContainer = new Container();\n\t\tconst logo = chalk.bold(\"epi\") + chalk.dim(` - ${provider}/${modelId}`);\n\n\t\tconst hints = [\n\t\t\t`${chalk.dim(\"Escape\")} to abort`,\n\t\t\t`${chalk.dim(\"!\")} inline bash`,\n\t\t\t`${chalk.dim(\"Alt+Enter\")} follow-up while streaming`,\n\t\t\t`${chalk.dim(\"Ctrl+C\")} to exit`,\n\t\t\t`${chalk.dim(\"Ctrl+E\")} to expand tools`,\n\t\t\t`${chalk.dim(\"Ctrl+L\")} to switch model`,\n\t\t\t`${chalk.dim(\"Ctrl+V\")} to paste image`,\n\t\t\t`${chalk.dim(\"↑/↓\")} to browse history`,\n\t\t\t`${chalk.dim(\"@\")} for file references`,\n\t\t\t`${chalk.dim(\"/\")} for commands`,\n\t\t].join(\"\\n\");\n\n\t\tthis.headerContainer.addChild(new Spacer(1));\n\t\tthis.headerContainer.addChild(new Text(`${logo}\\n${hints}`, 1, 0));\n\t\tthis.headerContainer.addChild(new Spacer(1));\n\n\t\tif (verbose && this.agent.sessionManager?.getSessionFile()) {\n\t\t\tthis.headerContainer.addChild(\n\t\t\t\tnew Text(chalk.dim(`Session: ${this.agent.sessionManager.getSessionFile()}`), 1, 0),\n\t\t\t);\n\t\t}\n\n\t\t// Show loaded context, skills, and prompts at startup\n\t\tthis.showLoadedResources(contextFiles, skills, prompts);\n\n\t\t// Chat area\n\t\tthis.chatContainer = new Container();\n\n\t\t// Pending messages (loading animations, status)\n\t\tthis.pendingContainer = new Container();\n\n\t\t// Pending steering/follow-up messages\n\t\tthis.pendingMessagesContainer = new Container();\n\n\t\t// Editor with slash command autocomplete\n\t\tthis.editorTheme = getEditorTheme();\n\t\tthis.editor = new Editor(this.ui, this.editorTheme);\n\t\tthis.editor.setAutocompleteProvider(this.buildAutocompleteProvider());\n\t\tthis.editorContainer = new Container();\n\t\tthis.editorContainer.addChild(this.editor);\n\n\t\t// Footer\n\t\tthis.footer = new FooterComponent(this.currentProvider, this.currentModelId);\n\t\tthis.footer.setAutoCompaction(this.autoCompaction);\n\t\tthis.footer.setSubscription(this.isSubscriptionProvider());\n\n\t\t// Assemble layout\n\t\tthis.ui.addChild(this.headerContainer);\n\t\tthis.ui.addChild(this.chatContainer);\n\t\tthis.ui.addChild(this.pendingMessagesContainer);\n\t\tthis.ui.addChild(this.pendingContainer);\n\t\tthis.ui.addChild(this.editorContainer);\n\t\tthis.ui.addChild(this.footer);\n\n\t\tthis.ui.setFocus(this.editor);\n\t\tthis.setupKeyHandlers();\n\n\t\tthis.ui.start();\n\t}\n\n\t// ========================================================================\n\t// Key Handlers\n\t// ========================================================================\n\n\tprivate setupKeyHandlers(): void {\n\t\tthis.editor.onChange = (text: string) => {\n\t\t\tconst wasBashMode = this.isBashMode;\n\t\t\tthis.isBashMode = text.trimStart().startsWith(\"!\");\n\t\t\tif (wasBashMode !== this.isBashMode) {\n\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t}\n\t\t};\n\t\tthis.editor.onSubmit = (text: string) => {\n\t\t\ttext = text.trim();\n\t\t\tif (!text) return;\n\n\t\t\t// If agent is streaming, Enter becomes a steering message\n\t\t\tif (this.isStreaming) {\n\t\t\t\tthis.agent.steer({ role: \"user\", content: [{ type: \"text\", text }] });\n\t\t\t\tthis.steeringMessages.push(text);\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\tthis.updatePendingMessagesDisplay();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tthis.editor.addToHistory(text);\n\t\t\tthis.editor.setText(\"\");\n\n\t\t\tif (this.onInputCallback) {\n\t\t\t\tthis.onInputCallback(text);\n\t\t\t}\n\t\t};\n\n\t\tconst origHandleInput = this.editor.handleInput.bind(this.editor);\n\t\tthis.editor.handleInput = (data: string) => {\n\t\t\t// Escape: abort if agent is running or compacting\n\t\t\tif (matchesKey(data, Key.escape)) {\n\t\t\t\tif (this.isBashRunning && this.bashAbortController) {\n\t\t\t\t\tthis.bashAbortController.abort();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (this.isCompacting) {\n\t\t\t\t\tthis.agent.abort();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (this.loadingAnimation) {\n\t\t\t\t\tthis.agent.abort();\n\t\t\t\t\tthis.stopLoading();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Ctrl+C: exit\n\t\t\tif (matchesKey(data, Key.ctrl(\"c\"))) {\n\t\t\t\tthis.shutdown();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Ctrl+D: exit if editor is empty\n\t\t\tif (matchesKey(data, Key.ctrl(\"d\"))) {\n\t\t\t\tif (this.editor.getText().length === 0) {\n\t\t\t\t\tthis.shutdown();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Ctrl+E: toggle tool output expansion\n\t\t\tif (matchesKey(data, Key.ctrl(\"e\"))) {\n\t\t\t\tthis.toggleToolExpansion();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Ctrl+L: select model\n\t\t\tif (matchesKey(data, Key.ctrl(\"l\"))) {\n\t\t\t\tthis.handleModelSelect();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Ctrl+V: paste image from clipboard\n\t\t\tif (matchesKey(data, Key.ctrl(\"v\"))) {\n\t\t\t\tthis.handleClipboardImagePaste();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Alt+Enter (Option+Enter on Mac): follow-up while streaming (or submit normally when idle)\n\t\t\tif (matchesKey(data, Key.alt(\"enter\"))) {\n\t\t\t\tconst text = this.editor.getText().trim();\n\t\t\t\tif (!text) return;\n\n\t\t\t\tif (this.isStreaming) {\n\t\t\t\t\tthis.followUpMessages.push(text);\n\t\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\t\tthis.updatePendingMessagesDisplay();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// Not streaming: treat like regular submit\n\t\t\t\tthis.editor.onSubmit?.(text);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Alt+Up (Option+Up on Mac): dequeue all queued messages back into the editor\n\t\t\tif (matchesKey(data, Key.alt(\"up\"))) {\n\t\t\t\tconst restored = this.clearAllQueues();\n\t\t\t\tif (restored.length > 0) {\n\t\t\t\t\tthis.editor.setText(restored.join(\"\\n\\n\"));\n\t\t\t\t\tthis.updatePendingMessagesDisplay();\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\torigHandleInput(data);\n\t\t};\n\t}\n\n\t// ========================================================================\n\t// User Input\n\t// ========================================================================\n\n\tprivate getUserInput(): Promise<string> {\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.onInputCallback = (text: string) => {\n\t\t\t\tthis.onInputCallback = undefined;\n\t\t\t\tresolve(text);\n\t\t\t};\n\t\t});\n\t}\n\n\tprivate async handleUserInput(input: string): Promise<void> {\n\t\t// Handle commands\n\t\tif (input === \"/help\") {\n\t\t\tthis.showHelp();\n\t\t\treturn;\n\t\t}\n\n\t\tif (input === \"/skills\") {\n\t\t\tthis.showSkills();\n\t\t\treturn;\n\t\t}\n\n\t\tif (input === \"/quit\" || input === \"/exit\") {\n\t\t\tthis.shutdown();\n\t\t\treturn;\n\t\t}\n\n\t\tif (input === \"/model\") {\n\t\t\tawait this.handleModelSelect();\n\t\t\treturn;\n\t\t}\n\n\t\tif (input === \"/login\") {\n\t\t\tawait this.handleLogin();\n\t\t\treturn;\n\t\t}\n\n\t\tif (input === \"/logout\") {\n\t\t\tawait this.handleLogout();\n\t\t\treturn;\n\t\t}\n\n\t\tif (input === \"/compact\" || input.startsWith(\"/compact \")) {\n\t\t\tconst customInstructions = input.startsWith(\"/compact \") ? input.slice(9).trim() : undefined;\n\t\t\tawait this.handleCompactCommand(customInstructions);\n\t\t\treturn;\n\t\t}\n\n\t\tif (input === \"/auto-compact\") {\n\t\t\tthis.toggleAutoCompaction();\n\t\t\treturn;\n\t\t}\n\n\t\tif (input === \"/resume\") {\n\t\t\tawait this.handleResume();\n\t\t\treturn;\n\t\t}\n\n\t\tif (input.startsWith(\"/skill:\")) {\n\t\t\tconst skillName = input.slice(\"/skill:\".length).trim();\n\t\t\tawait this.handleSkillInvocation(skillName);\n\t\t\treturn;\n\t\t}\n\n\t\t// Handle bash commands (! for normal, !! for excluded from context)\n\t\tconst bashParsed = parseBashInput(input);\n\t\tif (bashParsed) {\n\t\t\tif (this.isBashRunning) {\n\t\t\t\tthis.showStatus(chalk.yellow(\"A bash command is already running. Press Escape to cancel it first.\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait this.handleBashCommand(bashParsed.command, bashParsed.excludeFromContext);\n\t\t\tthis.isBashMode = false;\n\t\t\tthis.updateEditorBorderColor();\n\t\t\treturn;\n\t\t}\n\n\t\t// Try expanding prompt templates\n\t\tconst { prompts = [] } = this.options;\n\t\tconst expanded = expandPromptTemplate(input, prompts);\n\n\t\t// Capture and clear pending images\n\t\tconst images = this.pendingImages.length > 0 ? [...this.pendingImages] : undefined;\n\t\tthis.pendingImages = [];\n\n\t\t// Regular message (use expanded text if a prompt template was matched)\n\t\tconst imageLabel = images ? chalk.dim(` (${images.length} image${images.length > 1 ? \"s\" : \"\"})`) : \"\";\n\t\tthis.chatContainer.addChild(new UserMessageComponent(`${expanded}${imageLabel}`, getMarkdownTheme()));\n\t\tthis.ui.requestRender();\n\t\tawait this.streamPrompt(expanded, images);\n\t}\n\n\tprivate async handleBashCommand(command: string, excludeFromContext: boolean): Promise<void> {\n\t\tthis.bashAbortController = new AbortController();\n\t\tthis.isBashRunning = true;\n\n\t\tthis.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext);\n\t\tif (this.toolOutputExpanded) {\n\t\t\tthis.bashComponent.setExpanded(true);\n\t\t}\n\t\tthis.chatContainer.addChild(this.bashComponent);\n\t\tthis.ui.requestRender();\n\n\t\ttry {\n\t\t\tconst result = await executeBashCommand(command, {\n\t\t\t\tsignal: this.bashAbortController.signal,\n\t\t\t\tonChunk: (chunk) => {\n\t\t\t\t\tthis.bashComponent?.appendOutput(chunk);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tthis.bashComponent.setComplete(result.exitCode, result.cancelled, result.truncated, result.fullOutputPath);\n\t\t\tthis.ui.requestRender();\n\n\t\t\tif (!excludeFromContext) {\n\t\t\t\tconst msgText = `Ran \\`${command}\\`\\n\\n\\`\\`\\`\\n${result.output.trimEnd()}\\n\\`\\`\\``;\n\t\t\t\tconst userMsg: ModelMessage = { role: \"user\", content: [{ type: \"text\", text: msgText }] };\n\t\t\t\tthis.agent.setMessages([...this.agent.messages, userMsg]);\n\t\t\t\tthis.agent.sessionManager?.appendMessage(userMsg);\n\t\t\t}\n\t\t} finally {\n\t\t\tthis.isBashRunning = false;\n\t\t\tthis.bashAbortController = null;\n\t\t\tthis.bashComponent = undefined;\n\t\t}\n\t}\n\n\t// ========================================================================\n\t// Autocomplete\n\t// ========================================================================\n\n\tprivate buildAutocompleteProvider(): CombinedAutocompleteProvider {\n\t\tconst { skills = [], prompts = [], fdPath } = this.options;\n\n\t\tconst commands: SlashCommand[] = [\n\t\t\t{ name: \"help\", description: \"Show available commands\" },\n\t\t\t{ name: \"resume\", description: \"Resume a previous session\" },\n\t\t\t{ name: \"compact\", description: \"Manually compact the session context\" },\n\t\t\t{ name: \"auto-compact\", description: \"Toggle automatic context compaction\" },\n\t\t\t{ name: \"login\", description: \"Login to an OAuth provider\" },\n\t\t\t{ name: \"logout\", description: \"Logout from an OAuth provider\" },\n\t\t\t{ name: \"skills\", description: \"List loaded skills\" },\n\t\t\t{ name: \"model\", description: \"Switch model (Ctrl+L)\" },\n\t\t\t{ name: \"quit\", description: \"Exit the CLI\" },\n\t\t\t{ name: \"exit\", description: \"Exit the CLI\" },\n\t\t];\n\n\t\tfor (const skill of skills) {\n\t\t\tcommands.push({\n\t\t\t\tname: `skill:${skill.name}`,\n\t\t\t\tdescription: skill.description,\n\t\t\t});\n\t\t}\n\n\t\t// Add prompt templates as slash commands\n\t\tfor (const prompt of prompts) {\n\t\t\tcommands.push({\n\t\t\t\tname: prompt.name,\n\t\t\t\tdescription: prompt.description,\n\t\t\t});\n\t\t}\n\n\t\treturn new CombinedAutocompleteProvider(commands, process.cwd(), fdPath ?? null);\n\t}\n\n\t// ========================================================================\n\t// Model Selection\n\t// ========================================================================\n\n\tprivate async handleModelSelect(): Promise<void> {\n\t\tconst latestModels = getLatestModels();\n\t\tconst modelOptions: { provider: string; modelId: string; label: string }[] = [];\n\t\tfor (const [provider, models] of Object.entries(latestModels)) {\n\t\t\tfor (const modelId of models) {\n\t\t\t\tmodelOptions.push({ provider, modelId, label: `${provider}/${modelId}` });\n\t\t\t}\n\t\t}\n\n\t\tconst items: SelectItem[] = modelOptions.map((m) => {\n\t\t\tconst current = m.provider === this.currentProvider && m.modelId === this.currentModelId;\n\t\t\treturn {\n\t\t\t\tvalue: `${m.provider}/${m.modelId}`,\n\t\t\t\tlabel: current ? `${m.label} (current)` : m.label,\n\t\t\t};\n\t\t});\n\n\t\tconst selected = await this.showSelectList(\"Switch model\", items);\n\t\tif (!selected) return;\n\n\t\tconst [newProvider, ...modelParts] = selected.split(\"/\");\n\t\tconst newModelId = modelParts.join(\"/\");\n\n\t\tif (newProvider === this.currentProvider && newModelId === this.currentModelId) {\n\t\t\treturn;\n\t\t}\n\n\t\tthis.showStatus(chalk.dim(`Switching to ${newProvider}/${newModelId}...`));\n\n\t\tif (!this.options.onModelChange) {\n\t\t\tthis.showStatus(chalk.yellow(\"Model switching is not available.\"));\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tconst newAgent = await this.options.onModelChange(newProvider, newModelId);\n\t\t\t// Preserve conversation history\n\t\t\tnewAgent.setMessages([...this.agent.messages]);\n\t\t\tthis.agent = newAgent;\n\t\t\tthis.configureAgentCompaction();\n\n\t\t\tthis.currentProvider = newProvider;\n\t\t\tthis.currentModelId = newModelId;\n\t\t\tthis.updateFooter();\n\n\t\t\t// Persist the choice for next startup\n\t\t\tthis.options.settingsManager?.setDefaults(newProvider, newModelId);\n\n\t\t\tthis.showStatus(chalk.green(`Switched to ${newProvider}/${newModelId}`));\n\t\t} catch (error) {\n\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\tthis.showStatus(chalk.red(`Failed to switch model: ${msg}`));\n\t\t}\n\t}\n\n\t// ========================================================================\n\t// Streaming\n\t// ========================================================================\n\n\tprivate async streamPrompt(prompt: string, images?: ClipboardImage[]): Promise<void> {\n\t\tthis.isStreaming = true;\n\t\tthis.updatePendingMessagesDisplay();\n\n\t\t// Build image parts from clipboard images\n\t\tconst imageParts: ImagePart[] = (images ?? []).map((img) => ({\n\t\t\ttype: \"image\" as const,\n\t\t\timage: Buffer.from(img.bytes).toString(\"base64\"),\n\t\t\tmediaType: img.mimeType,\n\t\t}));\n\n\t\t// Start loading animation\n\t\tthis.startLoading();\n\n\t\tthis.streamingComponent = undefined;\n\t\tthis.streamingText = \"\";\n\t\tthis.hadToolResults = false;\n\n\t\tlet errorDisplayed = false;\n\t\tlet streamFailed = false;\n\t\ttry {\n\t\t\tconst result =\n\t\t\t\timageParts.length > 0\n\t\t\t\t\t? await this.agent.stream({\n\t\t\t\t\t\t\tmessages: [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\trole: \"user\" as const,\n\t\t\t\t\t\t\t\t\tcontent: [{ type: \"text\" as const, text: prompt }, ...imageParts],\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t})\n\t\t\t\t\t: await this.agent.stream({ prompt });\n\n\t\t\tfor await (const part of result.fullStream) {\n\t\t\t\tswitch (part.type) {\n\t\t\t\t\tcase \"text-delta\":\n\t\t\t\t\t\t// After tool results, or for the very first text part, start a new assistant message component\n\t\t\t\t\t\t// so each agent step gets its own message bubble\n\t\t\t\t\t\tif (this.hadToolResults || !this.streamingComponent) {\n\t\t\t\t\t\t\tthis.streamingComponent = new AssistantMessageComponent(getMarkdownTheme());\n\t\t\t\t\t\t\tthis.streamingText = \"\";\n\t\t\t\t\t\t\tthis.hadToolResults = false;\n\t\t\t\t\t\t\tthis.chatContainer.addChild(this.streamingComponent);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.streamingText += part.text;\n\t\t\t\t\t\tthis.streamingComponent!.updateText(this.streamingText);\n\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t\tbreak;\n\n\t\t\t\t\tcase \"tool-call\": {\n\t\t\t\t\t\tconst args =\n\t\t\t\t\t\t\ttypeof part.input === \"object\" && part.input !== null\n\t\t\t\t\t\t\t\t? (part.input as Record<string, unknown>)\n\t\t\t\t\t\t\t\t: {};\n\t\t\t\t\t\tconst toolComponent = new ToolExecutionComponent(part.toolName, args);\n\n\t\t\t\t\t\tif (this.toolOutputExpanded) {\n\t\t\t\t\t\t\ttoolComponent.setExpanded(true);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tthis.pendingTools.set(part.toolCallId, toolComponent);\n\t\t\t\t\t\tthis.chatContainer.addChild(toolComponent);\n\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\n\t\t\t\t\tcase \"tool-result\": {\n\t\t\t\t\t\tconst toolComponent = this.pendingTools.get(part.toolCallId);\n\t\t\t\t\t\tif (toolComponent) {\n\t\t\t\t\t\t\tconst toolOutput = extractToolOutput(part.output);\n\t\t\t\t\t\t\ttoolComponent.updateResult(toolOutput, /* isError */ false, /* isPartial */ false);\n\t\t\t\t\t\t\tthis.pendingTools.delete(part.toolCallId);\n\t\t\t\t\t\t\tthis.hadToolResults = true;\n\t\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\n\t\t\t\t\tcase \"error\": {\n\t\t\t\t\t\tconst errorMessage = formatAIError(part.error);\n\t\t\t\t\t\tif (this.streamingComponent) {\n\t\t\t\t\t\t\tthis.streamingComponent.setError(errorMessage);\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tthis.showStatus(chalk.red(`Error: ${errorMessage}`));\n\t\t\t\t\t\t}\n\t\t\t\t\t\terrorDisplayed = true;\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (errorDisplayed) return;\n\n\t\t\t// Wait for stream to complete — the agent auto-updates messages and persists to session\n\t\t\tawait result.response;\n\n\t\t\t// Update footer token stats\n\t\t\tthis.updateFooterTokens();\n\t\t} catch (error) {\n\t\t\tif (errorDisplayed) {\n\t\t\t\tstreamFailed = true;\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tstreamFailed = true;\n\t\t\tif ((error as Error).name === \"AbortError\") {\n\t\t\t\tif (this.streamingComponent) {\n\t\t\t\t\tthis.streamingComponent.setAborted();\n\t\t\t\t} else {\n\t\t\t\t\tthis.showStatus(chalk.dim(\"[aborted]\"));\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tconst msg =\n\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t: typeof error === \"object\" && error !== null\n\t\t\t\t\t\t\t? JSON.stringify(error)\n\t\t\t\t\t\t\t: String(error);\n\t\t\t\tif (this.streamingComponent) {\n\t\t\t\t\tthis.streamingComponent.setError(msg);\n\t\t\t\t} else {\n\t\t\t\t\tthis.showStatus(chalk.red(`Error: ${msg}`));\n\t\t\t\t}\n\t\t\t}\n\t\t} finally {\n\t\t\tthis.stopLoading();\n\t\t\tthis.streamingComponent = undefined;\n\t\t\tthis.streamingText = \"\";\n\t\t\tthis.hadToolResults = false;\n\t\t\tthis.pendingTools.clear();\n\t\t\tthis.isStreaming = false;\n\t\t\tthis.steeringMessages = [];\n\t\t\tif (streamFailed) {\n\t\t\t\tthis.followUpMessages = [];\n\t\t\t}\n\t\t\tthis.updatePendingMessagesDisplay();\n\t\t\tthis.ui.requestRender();\n\t\t}\n\n\t\t// Process queued follow-ups (skipped if stream failed/aborted)\n\t\twhile (this.followUpMessages.length > 0) {\n\t\t\tconst next = this.followUpMessages.shift();\n\t\t\tif (!next) break;\n\n\t\t\tthis.updatePendingMessagesDisplay();\n\n\t\t\tthis.chatContainer.addChild(new UserMessageComponent(next, getMarkdownTheme()));\n\t\t\tthis.ui.requestRender();\n\t\t\tawait this.streamPrompt(next);\n\t\t}\n\t}\n\n\tprivate updatePendingMessagesDisplay(): void {\n\t\tthis.pendingMessagesContainer.clear();\n\n\t\t// If no agent is running, clear pending messages (they've been consumed)\n\t\tif (!this.isStreaming && this.followUpMessages.length === 0 && this.steeringMessages.length === 0) {\n\t\t\tthis.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\tconst lines = formatPendingMessages(this.steeringMessages, this.followUpMessages);\n\n\t\tif (lines.length === 0) {\n\t\t\tthis.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\tthis.pendingMessagesContainer.addChild(new Spacer(1));\n\t\tthis.pendingMessagesContainer.addChild(new Text(lines.map((l) => chalk.dim(l)).join(\"\\n\"), 1, 0));\n\t\tthis.pendingMessagesContainer.addChild(new Text(chalk.dim(\"↳ Alt+Up to edit queued messages\"), 1, 0));\n\t\tthis.pendingMessagesContainer.addChild(new Spacer(1));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate clearAllQueues(): string[] {\n\t\tconst restored = [...this.steeringMessages, ...this.followUpMessages];\n\t\tthis.steeringMessages = [];\n\t\tthis.followUpMessages = [];\n\t\treturn restored;\n\t}\n\n\t// ========================================================================\n\t// Loading Animation\n\t// ========================================================================\n\n\tprivate startLoading(): void {\n\t\tthis.stopLoading();\n\t\tthis.loadingAnimation = new Loader(\n\t\t\tthis.ui,\n\t\t\t(s: string) => chalk.cyan(s),\n\t\t\t(s: string) => chalk.dim(s),\n\t\t\t\"Working...\",\n\t\t);\n\t\tthis.loadingAnimation.start();\n\t\tthis.pendingContainer.addChild(new Spacer(1));\n\t\tthis.pendingContainer.addChild(this.loadingAnimation);\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate stopLoading(): void {\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.stop();\n\t\t\tthis.pendingContainer.clear();\n\t\t\tthis.loadingAnimation = undefined;\n\t\t\tthis.ui.requestRender();\n\t\t}\n\t}\n\n\t// ========================================================================\n\t// Tool Expansion\n\t// ========================================================================\n\n\tprivate toggleToolExpansion(): void {\n\t\tthis.toolOutputExpanded = !this.toolOutputExpanded;\n\n\t\t// Update all tool components and compaction components in the chat\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (child instanceof ToolExecutionComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t} else if (child instanceof CompactionSummaryComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t} else if (child instanceof BashExecutionComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t}\n\t\t}\n\n\t\tthis.ui.requestRender();\n\t}\n\n\t// ========================================================================\n\t// Clipboard Image Paste\n\t// ========================================================================\n\n\tprivate handleClipboardImagePaste(): void {\n\t\ttry {\n\t\t\tconst image = readClipboardImage();\n\t\t\tif (!image) return;\n\t\t\tthis.pendingImages.push(image);\n\t\t\tconst ext = extensionForImageMimeType(image.mimeType) ?? \"image\";\n\t\t\tconst label = `[image ${this.pendingImages.length}: ${ext}]`;\n\t\t\tthis.editor.insertTextAtCursor(label);\n\t\t\tthis.ui.requestRender();\n\t\t} catch {\n\t\t\t// Silently ignore clipboard errors (may not have permission, etc.)\n\t\t}\n\t}\n\n\t// ========================================================================\n\t// Compaction\n\t// ========================================================================\n\n\t/**\n\t * Handle the /compact command.\n\t */\n\tprivate async handleCompactCommand(_customInstructions?: string): Promise<void> {\n\t\ttry {\n\t\t\tconst result = await this.agent.compact();\n\t\t\tif (!result) {\n\t\t\t\tthis.showStatus(chalk.yellow(\"Nothing to compact (already compacted or insufficient history).\"));\n\t\t\t}\n\t\t} catch (error) {\n\t\t\t// Compaction callbacks already report errors in interactive mode.\n\t\t\t// Catch here to avoid unwinding the main TUI loop on abort/provider errors.\n\t\t\tif (!this.agent.compaction?.onCompactionError) {\n\t\t\t\tconst compactionError = error instanceof Error ? error : new Error(String(error));\n\t\t\t\tif (compactionError.name === \"AbortError\" || compactionError.message === \"Compaction cancelled\") {\n\t\t\t\t\tthis.showStatus(chalk.dim(\"Compaction cancelled.\"));\n\t\t\t\t} else {\n\t\t\t\t\tthis.showStatus(chalk.red(`Compaction failed: ${compactionError.message}`));\n\t\t\t\t}\n\t\t\t\tthis.ui.requestRender();\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Toggle auto-compaction on/off.\n\t */\n\tprivate toggleAutoCompaction(): void {\n\t\tthis.autoCompaction = !this.autoCompaction;\n\t\tthis.footer.setAutoCompaction(this.autoCompaction);\n\t\tthis.options.settingsManager?.setCompactionEnabled(this.autoCompaction);\n\t\tif (this.agent.compaction) {\n\t\t\tthis.agent.setCompaction({\n\t\t\t\t...this.agent.compaction,\n\t\t\t\tmode: this.autoCompaction ? \"auto\" : \"manual\",\n\t\t\t});\n\t\t}\n\t\tthis.showStatus(\n\t\t\tthis.autoCompaction ? chalk.green(\"Auto-compaction enabled\") : chalk.dim(\"Auto-compaction disabled\"),\n\t\t);\n\t\tthis.ui.requestRender();\n\t}\n\n\t/**\n\t * Configure agent-level compaction and callbacks for UI updates.\n\t */\n\tprivate configureAgentCompaction(): void {\n\t\tconst mode = this.autoCompaction ? \"auto\" : \"manual\";\n\t\tthis.agent.setCompaction({\n\t\t\tcontextWindow: this.contextWindow,\n\t\t\tmode,\n\t\t\tsettings: {\n\t\t\t\treserveTokens: this.compactionSettings.reserveTokens,\n\t\t\t\tkeepRecentTokens: this.compactionSettings.keepRecentTokens,\n\t\t\t},\n\t\t\tonCompactionStart: () => {\n\t\t\t\tthis.isCompacting = true;\n\t\t\t\tthis.compactionLoader?.stop();\n\t\t\t\tthis.compactionLoader = new Loader(\n\t\t\t\t\tthis.ui,\n\t\t\t\t\t(s: string) => chalk.cyan(s),\n\t\t\t\t\t(s: string) => chalk.dim(s),\n\t\t\t\t\tthis.autoCompaction\n\t\t\t\t\t\t? \"Auto-compacting context... (Escape to cancel)\"\n\t\t\t\t\t\t: \"Compacting context... (Escape to cancel)\",\n\t\t\t\t);\n\t\t\t\tthis.compactionLoader.start();\n\t\t\t\tthis.pendingContainer.clear();\n\t\t\t\tthis.pendingContainer.addChild(new Spacer(1));\n\t\t\t\tthis.pendingContainer.addChild(this.compactionLoader);\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\tonCompactionComplete: (result: CompactionResult) => {\n\t\t\t\tthis.compactionLoader?.stop();\n\t\t\t\tthis.compactionLoader = null;\n\t\t\t\tthis.pendingContainer.clear();\n\t\t\t\tthis.isCompacting = false;\n\n\t\t\t\tthis.rebuildChatFromSession();\n\t\t\t\tconst summaryComponent = new CompactionSummaryComponent(result.tokensBefore, result.summary);\n\t\t\t\tif (this.toolOutputExpanded) {\n\t\t\t\t\tsummaryComponent.setExpanded(true);\n\t\t\t\t}\n\t\t\t\tthis.chatContainer.addChild(summaryComponent);\n\n\t\t\t\tthis.updateFooterTokens();\n\t\t\t\tif (this.options.verbose) {\n\t\t\t\t\tconst tokensAfter = estimateContextTokens([...this.agent.messages]);\n\t\t\t\t\tthis.showStatus(\n\t\t\t\t\t\tchalk.dim(\n\t\t\t\t\t\t\t`Compacted: ${result.tokensBefore.toLocaleString()} -> ${tokensAfter.toLocaleString()} tokens`,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\tonCompactionError: (error: Error) => {\n\t\t\t\tthis.compactionLoader?.stop();\n\t\t\t\tthis.compactionLoader = null;\n\t\t\t\tthis.pendingContainer.clear();\n\t\t\t\tthis.isCompacting = false;\n\n\t\t\t\tif (error.name === \"AbortError\" || error.message === \"Compaction cancelled\") {\n\t\t\t\t\tthis.showStatus(chalk.dim(\"Compaction cancelled.\"));\n\t\t\t\t} else {\n\t\t\t\t\tthis.showStatus(chalk.red(`Compaction failed: ${error.message}`));\n\t\t\t\t}\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t});\n\t}\n\n\t/**\n\t * Rebuild the chat UI from session context after compaction.\n\t */\n\tprivate rebuildChatFromSession(): void {\n\t\tthis.chatContainer.clear();\n\n\t\tconst messages = this.agent.messages;\n\t\tfor (const msg of messages) {\n\t\t\tif (msg.role === \"user\") {\n\t\t\t\t// Check if this is a compaction summary\n\t\t\t\tconst content = msg.content;\n\t\t\t\tif (Array.isArray(content) && content.length > 0) {\n\t\t\t\t\tconst textBlock = content[0] as { type: string; text?: string };\n\t\t\t\t\tif (textBlock.type === \"text\" && textBlock.text?.startsWith('<summary type=\"compaction\"')) {\n\t\t\t\t\t\t// Skip compaction summaries in rebuild (they are injected by buildSessionContext)\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tif (textBlock.type === \"text\" && textBlock.text?.startsWith('<summary type=\"branch\"')) {\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst text = extractTextFromMessage(msg);\n\t\t\t\tif (text) {\n\t\t\t\t\tthis.chatContainer.addChild(new UserMessageComponent(text, getMarkdownTheme()));\n\t\t\t\t}\n\t\t\t} else if (msg.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = msg as import(\"edge-pi\").AssistantModelMessage;\n\t\t\t\tconst textParts: string[] = [];\n\t\t\t\tfor (const block of assistantMsg.content) {\n\t\t\t\t\tconst b = block as {\n\t\t\t\t\t\ttype: string;\n\t\t\t\t\t\ttext?: string;\n\t\t\t\t\t\ttoolName?: string;\n\t\t\t\t\t\tinput?: unknown;\n\t\t\t\t\t\ttoolCallId?: string;\n\t\t\t\t\t};\n\t\t\t\t\tif (b.type === \"text\" && b.text) {\n\t\t\t\t\t\ttextParts.push(b.text);\n\t\t\t\t\t} else if (b.type === \"tool-call\" && b.toolName) {\n\t\t\t\t\t\tconst args =\n\t\t\t\t\t\t\ttypeof b.input === \"object\" && b.input !== null ? (b.input as Record<string, unknown>) : {};\n\t\t\t\t\t\tconst toolComp = new ToolExecutionComponent(b.toolName, args);\n\t\t\t\t\t\tif (this.toolOutputExpanded) {\n\t\t\t\t\t\t\ttoolComp.setExpanded(true);\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Mark as completed (we don't have the result here, just show collapsed)\n\t\t\t\t\t\ttoolComp.updateResult({ text: \"(from history)\" }, false, false);\n\t\t\t\t\t\tthis.chatContainer.addChild(toolComp);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (textParts.length > 0) {\n\t\t\t\t\tconst comp = new AssistantMessageComponent(getMarkdownTheme());\n\t\t\t\t\tcomp.updateText(textParts.join(\"\"));\n\t\t\t\t\tthis.chatContainer.addChild(comp);\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Skip tool messages in UI rebuild - they are consumed by tool-call components\n\t\t}\n\n\t\tthis.ui.requestRender();\n\t}\n\n\t// ========================================================================\n\t// Footer Token Tracking\n\t// ========================================================================\n\n\t/**\n\t * Update the footer with current token count information.\n\t */\n\tprivate updateFooterTokens(): void {\n\t\tconst contextTokens = estimateContextTokens([...this.agent.messages]);\n\t\tthis.footer.setTokenInfo(contextTokens, this.contextWindow);\n\t\tthis.footer.setAutoCompaction(this.autoCompaction);\n\t\tthis.ui?.requestRender();\n\t}\n\n\t/**\n\t * Check if the current provider is using an OAuth subscription credential.\n\t */\n\tprivate isSubscriptionProvider(): boolean {\n\t\tconst { authStorage } = this.options;\n\t\tif (!authStorage) return false;\n\t\tconst cred = authStorage.get(this.currentProvider);\n\t\treturn cred?.type === \"oauth\";\n\t}\n\n\t/**\n\t * Replace the footer component and update token info.\n\t */\n\tprivate updateFooter(): void {\n\t\tthis.footer = new FooterComponent(this.currentProvider, this.currentModelId);\n\t\tthis.footer.setSubscription(this.isSubscriptionProvider());\n\t\tthis.updateFooterTokens();\n\n\t\t// Replace footer in UI\n\t\tconst children = this.ui.children;\n\t\tchildren[children.length - 1] = this.footer;\n\t\tthis.ui.requestRender();\n\t}\n\n\t// ========================================================================\n\t// Startup Resource Display\n\t// ========================================================================\n\n\tprivate formatDisplayPath(p: string): string {\n\t\tconst home = process.env.HOME || process.env.USERPROFILE || \"\";\n\t\tif (home && p.startsWith(home)) {\n\t\t\treturn `~${p.slice(home.length)}`;\n\t\t}\n\t\treturn p;\n\t}\n\n\tprivate showLoadedResources(contextFiles: ContextFile[], skills: Skill[], prompts: PromptTemplate[]): void {\n\t\tconst sectionHeader = (name: string) => chalk.cyan(`[${name}]`);\n\n\t\tif (contextFiles.length > 0) {\n\t\t\tconst contextList = contextFiles.map((f) => chalk.dim(` ${this.formatDisplayPath(f.path)}`)).join(\"\\n\");\n\t\t\tthis.headerContainer.addChild(new Text(`${sectionHeader(\"Context\")}\\n${contextList}`, 0, 0));\n\t\t\tthis.headerContainer.addChild(new Spacer(1));\n\t\t}\n\n\t\tif (skills.length > 0) {\n\t\t\tconst skillList = skills.map((s) => chalk.dim(` ${this.formatDisplayPath(s.filePath)}`)).join(\"\\n\");\n\t\t\tthis.headerContainer.addChild(new Text(`${sectionHeader(\"Skills\")}\\n${skillList}`, 0, 0));\n\t\t\tthis.headerContainer.addChild(new Spacer(1));\n\t\t}\n\n\t\tif (prompts.length > 0) {\n\t\t\tconst promptList = prompts\n\t\t\t\t.map((p) => {\n\t\t\t\t\tconst sourceLabel = chalk.cyan(p.source);\n\t\t\t\t\treturn chalk.dim(` ${sourceLabel} /${p.name}`);\n\t\t\t\t})\n\t\t\t\t.join(\"\\n\");\n\t\t\tthis.headerContainer.addChild(new Text(`${sectionHeader(\"Prompts\")}\\n${promptList}`, 0, 0));\n\t\t\tthis.headerContainer.addChild(new Spacer(1));\n\t\t}\n\t}\n\n\t// ========================================================================\n\t// Commands\n\t// ========================================================================\n\n\tprivate showHelp(): void {\n\t\tconst helpText = [\n\t\t\tchalk.bold(\"Commands:\"),\n\t\t\t\" !<command> Run inline bash and include output in context\",\n\t\t\t\" !!<command> Run inline bash but exclude output from context\",\n\t\t\t\" /resume Resume a previous session\",\n\t\t\t\" /compact [text] Compact the session context (optional instructions)\",\n\t\t\t\" /auto-compact Toggle automatic context compaction\",\n\t\t\t\" /model Switch model (Ctrl+L)\",\n\t\t\t\" /login Login to an OAuth provider\",\n\t\t\t\" /logout Logout from an OAuth provider\",\n\t\t\t\" /skills List loaded skills\",\n\t\t\t\" /skill:<name> Invoke a skill by name\",\n\t\t\t\" /quit, /exit Exit the CLI\",\n\t\t].join(\"\\n\");\n\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(helpText, 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate showSkills(): void {\n\t\tconst { skills = [] } = this.options;\n\n\t\tif (skills.length === 0) {\n\t\t\tthis.showStatus(chalk.dim(\"No skills loaded.\"));\n\t\t\treturn;\n\t\t}\n\n\t\tconst lines: string[] = [];\n\t\tfor (const skill of skills) {\n\t\t\tconst hidden = skill.disableModelInvocation ? chalk.dim(\" (hidden from model)\") : \"\";\n\t\t\tlines.push(` ${chalk.bold(skill.name)}${hidden}`);\n\t\t\tlines.push(chalk.dim(` ${skill.description}`));\n\t\t\tlines.push(chalk.dim(` ${skill.filePath}`));\n\t\t}\n\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(lines.join(\"\\n\"), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate async handleSkillInvocation(skillName: string): Promise<void> {\n\t\tconst { skills = [] } = this.options;\n\t\tconst skill = skills.find((s) => s.name === skillName);\n\n\t\tif (!skill) {\n\t\t\tthis.showStatus(chalk.red(`Skill \"${skillName}\" not found.`));\n\t\t\treturn;\n\t\t}\n\n\t\tconst skillPrompt = `Please read and follow the instructions in the skill file: ${skill.filePath}`;\n\t\tthis.chatContainer.addChild(new UserMessageComponent(skillPrompt, getMarkdownTheme()));\n\t\tthis.ui.requestRender();\n\t\tawait this.streamPrompt(skillPrompt);\n\t}\n\n\t// ========================================================================\n\t// OAuth Login/Logout\n\t// ========================================================================\n\n\tprivate async handleLogin(): Promise<void> {\n\t\tconst { authStorage } = this.options;\n\t\tif (!authStorage) {\n\t\t\tthis.showStatus(chalk.red(\"Auth storage not available.\"));\n\t\t\treturn;\n\t\t}\n\n\t\tconst providers = authStorage.getProviders();\n\t\tif (providers.length === 0) {\n\t\t\tthis.showStatus(chalk.dim(\"No OAuth providers registered.\"));\n\t\t\treturn;\n\t\t}\n\n\t\t// Use SelectList overlay for provider selection\n\t\tconst items: SelectItem[] = providers.map((p) => {\n\t\t\tconst loggedIn = authStorage.get(p.id)?.type === \"oauth\" ? \" (logged in)\" : \"\";\n\t\t\treturn { value: p.id, label: `${p.name}${loggedIn}` };\n\t\t});\n\n\t\tconst selected = await this.showSelectList(\"Login to OAuth provider\", items);\n\t\tif (!selected) return;\n\n\t\tconst provider = providers.find((p) => p.id === selected);\n\t\tif (!provider) return;\n\n\t\tthis.showStatus(chalk.dim(`Logging in to ${provider.name}...`));\n\n\t\ttry {\n\t\t\tawait authStorage.login(provider.id, {\n\t\t\t\tonAuth: (info) => {\n\t\t\t\t\tconst lines = [chalk.bold(\"Open this URL in your browser:\"), chalk.cyan(info.url)];\n\t\t\t\t\tif (info.instructions) {\n\t\t\t\t\t\tlines.push(chalk.dim(info.instructions));\n\t\t\t\t\t}\n\t\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\t\tthis.chatContainer.addChild(new Text(lines.join(\"\\n\"), 1, 0));\n\t\t\t\t\tthis.ui.requestRender();\n\n\t\t\t\t\t// Try to open browser\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst { execSync } = require(\"node:child_process\") as typeof import(\"node:child_process\");\n\t\t\t\t\t\tconst platform = process.platform;\n\t\t\t\t\t\tif (platform === \"darwin\") {\n\t\t\t\t\t\t\texecSync(`open \"${info.url}\"`, { stdio: \"ignore\" });\n\t\t\t\t\t\t} else if (platform === \"linux\") {\n\t\t\t\t\t\t\texecSync(`xdg-open \"${info.url}\" 2>/dev/null || sensible-browser \"${info.url}\" 2>/dev/null`, {\n\t\t\t\t\t\t\t\tstdio: \"ignore\",\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t} else if (platform === \"win32\") {\n\t\t\t\t\t\t\texecSync(`start \"\" \"${info.url}\"`, { stdio: \"ignore\" });\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// Silently fail - user can open manually\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\tonPrompt: async (promptInfo) => {\n\t\t\t\t\t// Show prompt message and wait for user input\n\t\t\t\t\tthis.showStatus(chalk.dim(promptInfo.message));\n\t\t\t\t\tconst answer = await this.getUserInput();\n\t\t\t\t\treturn answer.trim();\n\t\t\t\t},\n\t\t\t\tonProgress: (message) => {\n\t\t\t\t\tthis.showStatus(chalk.dim(message));\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tthis.footer.setSubscription(this.isSubscriptionProvider());\n\t\t\tthis.ui.requestRender();\n\t\t\tthis.showStatus(chalk.green(`Logged in to ${provider.name}. Credentials saved.`));\n\t\t} catch (error) {\n\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\tif (msg !== \"Login cancelled\") {\n\t\t\t\tthis.showStatus(chalk.red(`Login failed: ${msg}`));\n\t\t\t} else {\n\t\t\t\tthis.showStatus(chalk.dim(\"Login cancelled.\"));\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate async handleLogout(): Promise<void> {\n\t\tconst { authStorage } = this.options;\n\t\tif (!authStorage) {\n\t\t\tthis.showStatus(chalk.red(\"Auth storage not available.\"));\n\t\t\treturn;\n\t\t}\n\n\t\tconst loggedIn = authStorage\n\t\t\t.list()\n\t\t\t.filter((id) => authStorage.get(id)?.type === \"oauth\")\n\t\t\t.map((id) => {\n\t\t\t\tconst provider = authStorage.getProvider(id);\n\t\t\t\treturn { id, name: provider?.name ?? id };\n\t\t\t});\n\n\t\tif (loggedIn.length === 0) {\n\t\t\tthis.showStatus(chalk.dim(\"No OAuth providers logged in. Use /login first.\"));\n\t\t\treturn;\n\t\t}\n\n\t\tconst items: SelectItem[] = loggedIn.map((p) => ({\n\t\t\tvalue: p.id,\n\t\t\tlabel: p.name,\n\t\t}));\n\n\t\tconst selected = await this.showSelectList(\"Logout from OAuth provider\", items);\n\t\tif (!selected) return;\n\n\t\tconst entry = loggedIn.find((p) => p.id === selected);\n\t\tif (!entry) return;\n\n\t\tauthStorage.logout(entry.id);\n\t\tthis.showStatus(chalk.green(`Logged out of ${entry.name}.`));\n\t}\n\n\t// ========================================================================\n\t// Resume Session\n\t// ========================================================================\n\n\t/**\n\t * List session files from the session directory, sorted by modification time (newest first).\n\t * Returns metadata for each session including the first user message as a preview.\n\t */\n\tprivate listAvailableSessions(): { path: string; mtime: number; preview: string; timestamp: string }[] {\n\t\tconst { sessionDir } = this.options;\n\t\tif (!sessionDir || !existsSync(sessionDir)) return [];\n\n\t\ttry {\n\t\t\tconst files = readdirSync(sessionDir)\n\t\t\t\t.filter((f: string) => f.endsWith(\".jsonl\"))\n\t\t\t\t.map((f: string) => {\n\t\t\t\t\tconst filePath = join(sessionDir, f);\n\t\t\t\t\tconst mtime = statSync(filePath).mtime.getTime();\n\t\t\t\t\treturn { name: f, path: filePath, mtime };\n\t\t\t\t})\n\t\t\t\t.sort((a, b) => b.mtime - a.mtime);\n\n\t\t\tconst sessions: { path: string; mtime: number; preview: string; timestamp: string }[] = [];\n\t\t\tfor (const file of files) {\n\t\t\t\t// Skip the current session file\n\t\t\t\tif (this.agent.sessionManager?.getSessionFile() === file.path) continue;\n\n\t\t\t\tconst preview = this.getSessionPreview(file.path);\n\t\t\t\tconst timestamp = new Date(file.mtime).toLocaleString();\n\t\t\t\tsessions.push({ path: file.path, mtime: file.mtime, preview, timestamp });\n\t\t\t}\n\t\t\treturn sessions;\n\t\t} catch {\n\t\t\treturn [];\n\t\t}\n\t}\n\n\t/**\n\t * Extract the first user message from a session file for preview.\n\t */\n\tprivate getSessionPreview(filePath: string): string {\n\t\ttry {\n\t\t\tconst content = readFileSync(filePath, \"utf-8\");\n\t\t\tconst lines = content.trim().split(\"\\n\");\n\t\t\tfor (const line of lines) {\n\t\t\t\tif (!line.trim()) continue;\n\t\t\t\ttry {\n\t\t\t\t\tconst entry = JSON.parse(line);\n\t\t\t\t\tif (entry.type === \"message\" && entry.message?.role === \"user\") {\n\t\t\t\t\t\tconst msg = entry.message;\n\t\t\t\t\t\tlet text = \"\";\n\t\t\t\t\t\tif (typeof msg.content === \"string\") {\n\t\t\t\t\t\t\ttext = msg.content;\n\t\t\t\t\t\t} else if (Array.isArray(msg.content)) {\n\t\t\t\t\t\t\tfor (const block of msg.content) {\n\t\t\t\t\t\t\t\tif (block.type === \"text\" && block.text) {\n\t\t\t\t\t\t\t\t\ttext = block.text;\n\t\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Truncate and clean up for display\n\t\t\t\t\t\ttext = text.replace(/\\n/g, \" \").trim();\n\t\t\t\t\t\tif (text.length > 80) {\n\t\t\t\t\t\t\ttext = `${text.slice(0, 77)}...`;\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn text || \"(empty message)\";\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// Skip malformed lines\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn \"(no messages)\";\n\t\t} catch {\n\t\t\treturn \"(unreadable)\";\n\t\t}\n\t}\n\n\t/**\n\t * Format a relative time string (e.g. \"2 hours ago\", \"3 days ago\").\n\t */\n\tprivate formatRelativeTime(mtime: number): string {\n\t\tconst now = Date.now();\n\t\tconst diffMs = now - mtime;\n\t\tconst diffSec = Math.floor(diffMs / 1000);\n\t\tconst diffMin = Math.floor(diffSec / 60);\n\t\tconst diffHour = Math.floor(diffMin / 60);\n\t\tconst diffDay = Math.floor(diffHour / 24);\n\n\t\tif (diffMin < 1) return \"just now\";\n\t\tif (diffMin < 60) return `${diffMin}m ago`;\n\t\tif (diffHour < 24) return `${diffHour}h ago`;\n\t\tif (diffDay < 30) return `${diffDay}d ago`;\n\t\treturn new Date(mtime).toLocaleDateString();\n\t}\n\n\t/**\n\t * Handle the /resume command: show a list of previous sessions and load the selected one.\n\t */\n\tprivate async handleResume(): Promise<void> {\n\t\tconst sessions = this.listAvailableSessions();\n\t\tif (sessions.length === 0) {\n\t\t\tthis.showStatus(chalk.yellow(\"No previous sessions found.\"));\n\t\t\treturn;\n\t\t}\n\n\t\tconst items: SelectItem[] = sessions.map((s) => ({\n\t\t\tvalue: s.path,\n\t\t\tlabel: `${chalk.dim(this.formatRelativeTime(s.mtime))} ${s.preview}`,\n\t\t}));\n\n\t\tconst selected = await this.showSelectList(\"Resume session\", items);\n\t\tif (!selected) return;\n\n\t\tconst session = sessions.find((s) => s.path === selected);\n\t\tif (!session) return;\n\n\t\ttry {\n\t\t\t// Open the selected session and set on agent (auto-restores messages)\n\t\t\tconst sessionDir = this.options.sessionDir!;\n\t\t\tconst newSessionManager = SessionManagerClass.open(selected, sessionDir);\n\t\t\tthis.agent.sessionManager = newSessionManager;\n\n\t\t\t// Rebuild the chat UI\n\t\t\tthis.chatContainer.clear();\n\t\t\tthis.rebuildChatFromSession();\n\n\t\t\t// Update footer tokens\n\t\t\tthis.updateFooterTokens();\n\n\t\t\tconst msgCount = this.agent.messages.length;\n\t\t\tthis.showStatus(chalk.green(`Resumed session (${msgCount} messages)`));\n\t\t} catch (error) {\n\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\tthis.showStatus(chalk.red(`Failed to resume session: ${msg}`));\n\t\t}\n\t}\n\n\t// ========================================================================\n\t// Select List (overlay pattern from pi-coding-agent)\n\t// ========================================================================\n\n\tprivate showSelectList(title: string, items: SelectItem[]): Promise<string | null> {\n\t\treturn new Promise((resolve) => {\n\t\t\tconst container = new Container();\n\n\t\t\tcontainer.addChild(new Spacer(1));\n\t\t\tcontainer.addChild(new Text(chalk.bold.cyan(title), 1, 0));\n\n\t\t\tconst selectList = new SelectList(items, Math.min(items.length, 10), getSelectListTheme());\n\t\t\tselectList.onSelect = (item) => {\n\t\t\t\t// Restore normal UI\n\t\t\t\tthis.pendingContainer.clear();\n\t\t\t\tthis.editorContainer.addChild(this.editor);\n\t\t\t\tthis.ui.setFocus(this.editor);\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tresolve(item.value);\n\t\t\t};\n\t\t\tselectList.onCancel = () => {\n\t\t\t\tthis.pendingContainer.clear();\n\t\t\t\tthis.editorContainer.addChild(this.editor);\n\t\t\t\tthis.ui.setFocus(this.editor);\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tresolve(null);\n\t\t\t};\n\t\t\tcontainer.addChild(selectList);\n\t\t\tcontainer.addChild(new Text(chalk.dim(\"↑↓ navigate • enter select • esc cancel\"), 1, 0));\n\t\t\tcontainer.addChild(new Spacer(1));\n\n\t\t\t// Replace editor area with select list\n\t\t\tthis.editorContainer.clear();\n\t\t\tthis.pendingContainer.clear();\n\t\t\tthis.pendingContainer.addChild(container);\n\t\t\tthis.ui.setFocus(selectList);\n\t\t\tthis.ui.requestRender();\n\t\t});\n\t}\n\n\t// ========================================================================\n\t// Status & Utilities\n\t// ========================================================================\n\n\tprivate showStatus(text: string): void {\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(text, 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate updateEditorBorderColor(): void {\n\t\tthis.editorTheme.borderColor = this.isBashMode ? (s: string) => chalk.yellow(s) : (s: string) => chalk.gray(s);\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate shutdown(): void {\n\t\tthis.ui.stop();\n\t\tconsole.log(chalk.dim(\"\\nGoodbye.\"));\n\t\tprocess.exit(0);\n\t}\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction extractTextFromMessage(msg: ModelMessage): string {\n\tif (msg.role === \"user\") {\n\t\tconst content = (msg as import(\"edge-pi\").UserModelMessage).content;\n\t\tif (typeof content === \"string\") return content;\n\t\tif (Array.isArray(content)) {\n\t\t\treturn content\n\t\t\t\t.filter((c): c is { type: \"text\"; text: string } => (c as { type: string }).type === \"text\")\n\t\t\t\t.map((c) => c.text)\n\t\t\t\t.join(\"\");\n\t\t}\n\t}\n\treturn \"\";\n}\n"]}
@@ -14,7 +14,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { CombinedAutocompleteProvider, Container, Editor, Key, Loader, matchesKey, ProcessTerminal, SelectList, Spacer, Text, TUI, } from "@mariozechner/pi-tui";
16
16
  import chalk from "chalk";
17
- import { compact, DEFAULT_COMPACTION_SETTINGS, estimateContextTokens, prepareCompaction, SessionManager as SessionManagerClass, shouldCompact, } from "edge-pi";
17
+ import { estimateContextTokens, SessionManager as SessionManagerClass } from "edge-pi";
18
18
  import { getLatestModels } from "../../model-factory.js";
19
19
  import { expandPromptTemplate } from "../../prompts.js";
20
20
  import { executeBashCommand } from "../../utils/bash-executor.js";
@@ -30,6 +30,10 @@ import { UserMessageComponent } from "./components/user-message.js";
30
30
  import { getEditorTheme, getMarkdownTheme, getSelectListTheme } from "./theme.js";
31
31
  /** Default context window size (used when model doesn't report one). */
32
32
  const DEFAULT_CONTEXT_WINDOW = 200_000;
33
+ const DEFAULT_COMPACTION_SETTINGS = {
34
+ reserveTokens: 16384,
35
+ keepRecentTokens: 20000,
36
+ };
33
37
  /** Extract display-friendly output from a tool result. Handles both plain strings and structured objects with text/image fields. */
34
38
  function extractToolOutput(output) {
35
39
  if (typeof output === "string") {
@@ -96,7 +100,7 @@ class InteractiveMode {
96
100
  compactionSettings;
97
101
  autoCompaction = true;
98
102
  isCompacting = false;
99
- compactionAbortController = null;
103
+ compactionLoader = null;
100
104
  constructor(agent, options) {
101
105
  this.agent = agent;
102
106
  this.options = options;
@@ -111,6 +115,7 @@ class InteractiveMode {
111
115
  ...(savedCompaction?.keepRecentTokens !== undefined && { keepRecentTokens: savedCompaction.keepRecentTokens }),
112
116
  };
113
117
  this.autoCompaction = options.settingsManager?.getCompactionEnabled() ?? true;
118
+ this.configureAgentCompaction();
114
119
  }
115
120
  async run() {
116
121
  this.initUI();
@@ -229,8 +234,8 @@ class InteractiveMode {
229
234
  this.bashAbortController.abort();
230
235
  return;
231
236
  }
232
- if (this.isCompacting && this.compactionAbortController) {
233
- this.compactionAbortController.abort();
237
+ if (this.isCompacting) {
238
+ this.agent.abort();
234
239
  return;
235
240
  }
236
241
  if (this.loadingAnimation) {
@@ -472,6 +477,7 @@ class InteractiveMode {
472
477
  // Preserve conversation history
473
478
  newAgent.setMessages([...this.agent.messages]);
474
479
  this.agent = newAgent;
480
+ this.configureAgentCompaction();
475
481
  this.currentProvider = newProvider;
476
482
  this.currentModelId = newModelId;
477
483
  this.updateFooter();
@@ -572,8 +578,6 @@ class InteractiveMode {
572
578
  await result.response;
573
579
  // Update footer token stats
574
580
  this.updateFooterTokens();
575
- // Check for auto-compaction after successful response
576
- await this.checkAutoCompaction();
577
581
  }
578
582
  catch (error) {
579
583
  if (errorDisplayed) {
@@ -715,12 +719,26 @@ class InteractiveMode {
715
719
  * Handle the /compact command.
716
720
  */
717
721
  async handleCompactCommand(_customInstructions) {
718
- const messages = this.agent.messages;
719
- if (messages.length < 2) {
720
- this.showStatus(chalk.yellow("Nothing to compact (not enough messages)."));
721
- return;
722
+ try {
723
+ const result = await this.agent.compact();
724
+ if (!result) {
725
+ this.showStatus(chalk.yellow("Nothing to compact (already compacted or insufficient history)."));
726
+ }
727
+ }
728
+ catch (error) {
729
+ // Compaction callbacks already report errors in interactive mode.
730
+ // Catch here to avoid unwinding the main TUI loop on abort/provider errors.
731
+ if (!this.agent.compaction?.onCompactionError) {
732
+ const compactionError = error instanceof Error ? error : new Error(String(error));
733
+ if (compactionError.name === "AbortError" || compactionError.message === "Compaction cancelled") {
734
+ this.showStatus(chalk.dim("Compaction cancelled."));
735
+ }
736
+ else {
737
+ this.showStatus(chalk.red(`Compaction failed: ${compactionError.message}`));
738
+ }
739
+ this.ui.requestRender();
740
+ }
722
741
  }
723
- await this.executeCompaction(false);
724
742
  }
725
743
  /**
726
744
  * Toggle auto-compaction on/off.
@@ -729,146 +747,72 @@ class InteractiveMode {
729
747
  this.autoCompaction = !this.autoCompaction;
730
748
  this.footer.setAutoCompaction(this.autoCompaction);
731
749
  this.options.settingsManager?.setCompactionEnabled(this.autoCompaction);
750
+ if (this.agent.compaction) {
751
+ this.agent.setCompaction({
752
+ ...this.agent.compaction,
753
+ mode: this.autoCompaction ? "auto" : "manual",
754
+ });
755
+ }
732
756
  this.showStatus(this.autoCompaction ? chalk.green("Auto-compaction enabled") : chalk.dim("Auto-compaction disabled"));
733
757
  this.ui.requestRender();
734
758
  }
735
759
  /**
736
- * Check if auto-compaction should trigger after an agent response.
760
+ * Configure agent-level compaction and callbacks for UI updates.
737
761
  */
738
- async checkAutoCompaction() {
739
- if (!this.autoCompaction)
740
- return;
741
- const contextTokens = estimateContextTokens([...this.agent.messages]);
742
- if (!shouldCompact(contextTokens, this.contextWindow, this.compactionSettings))
743
- return;
744
- await this.executeCompaction(true);
745
- }
746
- /**
747
- * Execute compaction (used by both manual /compact and auto mode).
748
- */
749
- async executeCompaction(isAuto) {
750
- if (this.isCompacting)
751
- return undefined;
752
- const sessionManager = this.agent.sessionManager;
753
- // Build path entries from session if available, otherwise from agent messages
754
- const pathEntries = sessionManager ? sessionManager.getBranch() : this.buildSessionEntriesFromMessages();
755
- if (pathEntries.length < 2) {
756
- if (!isAuto) {
757
- this.showStatus(chalk.yellow("Nothing to compact (not enough messages)."));
758
- }
759
- return undefined;
760
- }
761
- // Prepare compaction
762
- const preparation = prepareCompaction(pathEntries, this.compactionSettings);
763
- if (!preparation) {
764
- if (!isAuto) {
765
- this.showStatus(chalk.yellow("Nothing to compact (already compacted or insufficient history)."));
766
- }
767
- return undefined;
768
- }
769
- if (preparation.messagesToSummarize.length === 0) {
770
- if (!isAuto) {
771
- this.showStatus(chalk.yellow("Nothing to compact (no messages to summarize)."));
772
- }
773
- return undefined;
774
- }
775
- this.isCompacting = true;
776
- this.compactionAbortController = new AbortController();
777
- // Show compaction indicator
778
- const label = isAuto
779
- ? "Auto-compacting context... (Escape to cancel)"
780
- : "Compacting context... (Escape to cancel)";
781
- const compactingLoader = new Loader(this.ui, (s) => chalk.cyan(s), (s) => chalk.dim(s), label);
782
- compactingLoader.start();
783
- this.pendingContainer.clear();
784
- this.pendingContainer.addChild(new Spacer(1));
785
- this.pendingContainer.addChild(compactingLoader);
786
- this.ui.requestRender();
787
- let result;
788
- try {
789
- // We need a LanguageModel for summarization. Use the agent's model
790
- // by extracting it from the config. The model is accessible through
791
- // the onModelChange callback pattern, but for simplicity we create
792
- // a model via the same factory used at startup.
793
- const { model } = await this.getCompactionModel();
794
- result = await compact(preparation, model, this.options.agentConfig?.providerOptions, this.compactionAbortController.signal);
795
- // Record compaction in session
796
- if (sessionManager) {
797
- sessionManager.appendCompaction(result.summary, result.firstKeptEntryId, result.tokensBefore, result.details);
798
- }
799
- // Rebuild agent messages from the session context
800
- if (sessionManager) {
801
- const context = sessionManager.buildSessionContext();
802
- this.agent.setMessages(context.messages);
803
- }
804
- // Rebuild the chat UI
805
- this.rebuildChatFromSession();
806
- // Add compaction summary component so user sees it
807
- const summaryComponent = new CompactionSummaryComponent(result.tokensBefore, result.summary);
808
- if (this.toolOutputExpanded) {
809
- summaryComponent.setExpanded(true);
810
- }
811
- this.chatContainer.addChild(summaryComponent);
812
- // Update footer tokens
813
- this.updateFooterTokens();
814
- if (this.options.verbose) {
815
- const tokensAfter = estimateContextTokens([...this.agent.messages]);
816
- this.showStatus(chalk.dim(`Compacted: ${result.tokensBefore.toLocaleString()} -> ${tokensAfter.toLocaleString()} tokens`));
817
- }
818
- }
819
- catch (error) {
820
- const message = error instanceof Error ? error.message : String(error);
821
- if (this.compactionAbortController.signal.aborted ||
822
- message === "Compaction cancelled" ||
823
- (error instanceof Error && error.name === "AbortError")) {
824
- this.showStatus(chalk.dim("Compaction cancelled."));
825
- }
826
- else {
827
- this.showStatus(chalk.red(`Compaction failed: ${message}`));
828
- }
829
- }
830
- finally {
831
- compactingLoader.stop();
832
- this.pendingContainer.clear();
833
- this.isCompacting = false;
834
- this.compactionAbortController = null;
835
- this.ui.requestRender();
836
- }
837
- return result;
838
- }
839
- /**
840
- * Get the language model for compaction summarization.
841
- * Uses the same model creation path as the main agent.
842
- */
843
- async getCompactionModel() {
844
- const { createModel } = await import("../../model-factory.js");
845
- return createModel({
846
- provider: this.currentProvider,
847
- model: this.currentModelId,
848
- authStorage: this.options.authStorage,
762
+ configureAgentCompaction() {
763
+ const mode = this.autoCompaction ? "auto" : "manual";
764
+ this.agent.setCompaction({
765
+ contextWindow: this.contextWindow,
766
+ mode,
767
+ settings: {
768
+ reserveTokens: this.compactionSettings.reserveTokens,
769
+ keepRecentTokens: this.compactionSettings.keepRecentTokens,
770
+ },
771
+ onCompactionStart: () => {
772
+ this.isCompacting = true;
773
+ this.compactionLoader?.stop();
774
+ this.compactionLoader = new Loader(this.ui, (s) => chalk.cyan(s), (s) => chalk.dim(s), this.autoCompaction
775
+ ? "Auto-compacting context... (Escape to cancel)"
776
+ : "Compacting context... (Escape to cancel)");
777
+ this.compactionLoader.start();
778
+ this.pendingContainer.clear();
779
+ this.pendingContainer.addChild(new Spacer(1));
780
+ this.pendingContainer.addChild(this.compactionLoader);
781
+ this.ui.requestRender();
782
+ },
783
+ onCompactionComplete: (result) => {
784
+ this.compactionLoader?.stop();
785
+ this.compactionLoader = null;
786
+ this.pendingContainer.clear();
787
+ this.isCompacting = false;
788
+ this.rebuildChatFromSession();
789
+ const summaryComponent = new CompactionSummaryComponent(result.tokensBefore, result.summary);
790
+ if (this.toolOutputExpanded) {
791
+ summaryComponent.setExpanded(true);
792
+ }
793
+ this.chatContainer.addChild(summaryComponent);
794
+ this.updateFooterTokens();
795
+ if (this.options.verbose) {
796
+ const tokensAfter = estimateContextTokens([...this.agent.messages]);
797
+ this.showStatus(chalk.dim(`Compacted: ${result.tokensBefore.toLocaleString()} -> ${tokensAfter.toLocaleString()} tokens`));
798
+ }
799
+ this.ui.requestRender();
800
+ },
801
+ onCompactionError: (error) => {
802
+ this.compactionLoader?.stop();
803
+ this.compactionLoader = null;
804
+ this.pendingContainer.clear();
805
+ this.isCompacting = false;
806
+ if (error.name === "AbortError" || error.message === "Compaction cancelled") {
807
+ this.showStatus(chalk.dim("Compaction cancelled."));
808
+ }
809
+ else {
810
+ this.showStatus(chalk.red(`Compaction failed: ${error.message}`));
811
+ }
812
+ this.ui.requestRender();
813
+ },
849
814
  });
850
815
  }
851
- /**
852
- * Build session entries from agent messages (when no session manager).
853
- * Creates synthetic SessionEntry objects for the compaction algorithm.
854
- */
855
- buildSessionEntriesFromMessages() {
856
- const messages = this.agent.messages;
857
- const entries = [];
858
- let parentId = null;
859
- for (let i = 0; i < messages.length; i++) {
860
- const id = `msg-${i}`;
861
- entries.push({
862
- type: "message",
863
- id,
864
- parentId,
865
- timestamp: new Date().toISOString(),
866
- message: messages[i],
867
- });
868
- parentId = id;
869
- }
870
- return entries;
871
- }
872
816
  /**
873
817
  * Rebuild the chat UI from session context after compaction.
874
818
  */