import type { Agent, AgentEvent, AgentState } from "@mariozechner/pi-agent"; import type { AssistantMessage, Message } from "@mariozechner/pi-ai"; import type { SlashCommand } from "@mariozechner/pi-tui"; import { CombinedAutocompleteProvider, Container, Loader, Markdown, ProcessTerminal, Spacer, Text, TUI, } from "@mariozechner/pi-tui"; import chalk from "chalk"; import { getChangelogPath, parseChangelog } from "../changelog.js"; import { exportSessionToHtml } from "../export-html.js"; import type { SessionManager } from "../session-manager.js"; import { AssistantMessageComponent } from "./assistant-message.js"; import { CustomEditor } from "./custom-editor.js"; import { DynamicBorder } from "./dynamic-border.js"; import { FooterComponent } from "./footer.js"; import { ModelSelectorComponent } from "./model-selector.js"; import { ThinkingSelectorComponent } from "./thinking-selector.js"; import { ToolExecutionComponent } from "./tool-execution.js"; import { UserMessageComponent } from "./user-message.js"; import { UserMessageSelectorComponent } from "./user-message-selector.js"; /** * TUI renderer for the coding agent */ export class TuiRenderer { private ui: TUI; private chatContainer: Container; private statusContainer: Container; private editor: CustomEditor; private editorContainer: Container; // Container to swap between editor and selector private footer: FooterComponent; private agent: Agent; private sessionManager: SessionManager; private version: string; private isInitialized = false; private onInputCallback?: (text: string) => void; private loadingAnimation: Loader | null = null; private onInterruptCallback?: () => void; private lastSigintTime = 0; private changelogMarkdown: string | null = null; // Streaming message tracking private streamingComponent: AssistantMessageComponent | null = null; // Tool execution tracking: toolCallId -> component private pendingTools = new Map(); // Thinking level selector private thinkingSelector: ThinkingSelectorComponent | null = null; // Model selector private modelSelector: ModelSelectorComponent | null = null; // User message selector (for branching) private userMessageSelector: UserMessageSelectorComponent | null = null; // Track if this is the first user message (to skip spacer) private isFirstUserMessage = true; constructor(agent: Agent, sessionManager: SessionManager, version: string, changelogMarkdown: string | null = null) { this.agent = agent; this.sessionManager = sessionManager; this.version = version; this.changelogMarkdown = changelogMarkdown; this.ui = new TUI(new ProcessTerminal()); this.chatContainer = new Container(); this.statusContainer = new Container(); this.editor = new CustomEditor(); this.editorContainer = new Container(); // Container to hold editor or selector this.editorContainer.addChild(this.editor); // Start with editor this.footer = new FooterComponent(agent.state); // Define slash commands const thinkingCommand: SlashCommand = { name: "thinking", description: "Select reasoning level (opens selector UI)", }; const modelCommand: SlashCommand = { name: "model", description: "Select model (opens selector UI)", }; const exportCommand: SlashCommand = { name: "export", description: "Export session to HTML file", }; const sessionCommand: SlashCommand = { name: "session", description: "Show session info and stats", }; const changelogCommand: SlashCommand = { name: "changelog", description: "Show changelog entries", }; const branchCommand: SlashCommand = { name: "branch", description: "Create a new branch from a previous message", }; // Setup autocomplete for file paths and slash commands const autocompleteProvider = new CombinedAutocompleteProvider( [thinkingCommand, modelCommand, exportCommand, sessionCommand, changelogCommand, branchCommand], process.cwd(), ); this.editor.setAutocompleteProvider(autocompleteProvider); } async init(): Promise { if (this.isInitialized) return; // Add header with logo and instructions const logo = chalk.bold.cyan("pi") + chalk.dim(` v${this.version}`); const instructions = chalk.dim("esc") + chalk.gray(" to interrupt") + "\n" + chalk.dim("ctrl+c") + chalk.gray(" to clear") + "\n" + chalk.dim("ctrl+c twice") + chalk.gray(" to exit") + "\n" + chalk.dim("ctrl+k") + chalk.gray(" to delete line") + "\n" + chalk.dim("/") + chalk.gray(" for commands") + "\n" + chalk.dim("drop files") + chalk.gray(" to attach"); const header = new Text(logo + "\n" + instructions, 1, 0); // Setup UI layout this.ui.addChild(new Spacer(1)); this.ui.addChild(header); this.ui.addChild(new Spacer(1)); // Add changelog if provided if (this.changelogMarkdown) { this.ui.addChild(new DynamicBorder(chalk.cyan)); this.ui.addChild(new Text(chalk.bold.cyan("What's New"), 1, 0)); this.ui.addChild(new Spacer(1)); this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), undefined, undefined, undefined, 1, 0)); this.ui.addChild(new Spacer(1)); this.ui.addChild(new DynamicBorder(chalk.cyan)); } this.ui.addChild(this.chatContainer); this.ui.addChild(this.statusContainer); this.ui.addChild(new Spacer(1)); this.ui.addChild(this.editorContainer); // Use container that can hold editor or selector this.ui.addChild(this.footer); this.ui.setFocus(this.editor); // Set up custom key handlers on the editor this.editor.onEscape = () => { // Intercept Escape key when processing if (this.loadingAnimation && this.onInterruptCallback) { this.onInterruptCallback(); } }; this.editor.onCtrlC = () => { this.handleCtrlC(); }; // Handle editor submission this.editor.onSubmit = (text: string) => { text = text.trim(); if (!text) return; // Check for /thinking command if (text === "/thinking") { // Show thinking level selector this.showThinkingSelector(); this.editor.setText(""); return; } // Check for /model command if (text === "/model") { // Show model selector this.showModelSelector(); this.editor.setText(""); return; } // Check for /export command if (text.startsWith("/export")) { this.handleExportCommand(text); this.editor.setText(""); return; } // Check for /session command if (text === "/session") { this.handleSessionCommand(); this.editor.setText(""); return; } // Check for /changelog command if (text === "/changelog") { this.handleChangelogCommand(); this.editor.setText(""); return; } // Check for /branch command if (text === "/branch") { this.showUserMessageSelector(); this.editor.setText(""); return; } if (this.onInputCallback) { this.onInputCallback(text); } }; // Start the UI this.ui.start(); this.isInitialized = true; } async handleEvent(event: AgentEvent, state: AgentState): Promise { if (!this.isInitialized) { await this.init(); } // Update footer with current stats this.footer.updateState(state); switch (event.type) { case "agent_start": // Show loading animation this.editor.disableSubmit = true; // Stop old loader before clearing if (this.loadingAnimation) { this.loadingAnimation.stop(); } this.statusContainer.clear(); this.loadingAnimation = new Loader(this.ui, "Working... (esc to interrupt)"); this.statusContainer.addChild(this.loadingAnimation); this.ui.requestRender(); break; case "message_start": if (event.message.role === "user") { // Show user message immediately and clear editor this.addMessageToChat(event.message); this.editor.setText(""); this.ui.requestRender(); } else if (event.message.role === "assistant") { // Create assistant component for streaming this.streamingComponent = new AssistantMessageComponent(); this.chatContainer.addChild(this.streamingComponent); this.streamingComponent.updateContent(event.message as AssistantMessage); this.ui.requestRender(); } break; case "message_update": // Update streaming component if (this.streamingComponent && event.message.role === "assistant") { const assistantMsg = event.message as AssistantMessage; this.streamingComponent.updateContent(assistantMsg); // Create tool execution components as soon as we see tool calls for (const content of assistantMsg.content) { if (content.type === "toolCall") { // Only create if we haven't created it yet if (!this.pendingTools.has(content.id)) { this.chatContainer.addChild(new Text("", 0, 0)); const component = new ToolExecutionComponent(content.name, content.arguments); this.chatContainer.addChild(component); this.pendingTools.set(content.id, component); } else { // Update existing component with latest arguments as they stream const component = this.pendingTools.get(content.id); if (component) { component.updateArgs(content.arguments); } } } } this.ui.requestRender(); } break; case "message_end": // Skip user messages (already shown in message_start) if (event.message.role === "user") { break; } if (this.streamingComponent && event.message.role === "assistant") { const assistantMsg = event.message as AssistantMessage; // Update streaming component with final message (includes stopReason) this.streamingComponent.updateContent(assistantMsg); // If message was aborted or errored, mark all pending tool components as failed if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") { const errorMessage = assistantMsg.stopReason === "aborted" ? "Operation aborted" : assistantMsg.errorMessage || "Error"; for (const [toolCallId, component] of this.pendingTools.entries()) { component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true, }); } this.pendingTools.clear(); } // Keep the streaming component - it's now the final assistant message this.streamingComponent = null; } this.ui.requestRender(); break; case "tool_execution_start": { // Component should already exist from message_update, but create if missing if (!this.pendingTools.has(event.toolCallId)) { const component = new ToolExecutionComponent(event.toolName, event.args); this.chatContainer.addChild(component); this.pendingTools.set(event.toolCallId, component); this.ui.requestRender(); } break; } case "tool_execution_end": { // Update the existing tool component with the result const component = this.pendingTools.get(event.toolCallId); if (component) { component.updateResult(event.result); this.pendingTools.delete(event.toolCallId); this.ui.requestRender(); } break; } case "agent_end": // Stop loading animation if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = null; this.statusContainer.clear(); } if (this.streamingComponent) { this.chatContainer.removeChild(this.streamingComponent); this.streamingComponent = null; } this.pendingTools.clear(); this.editor.disableSubmit = false; this.ui.requestRender(); break; } } private addMessageToChat(message: Message): void { if (message.role === "user") { const userMsg = message as any; // Extract text content from content blocks const textBlocks = userMsg.content.filter((c: any) => c.type === "text"); const textContent = textBlocks.map((c: any) => c.text).join(""); if (textContent) { const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage); this.chatContainer.addChild(userComponent); this.isFirstUserMessage = false; } } else if (message.role === "assistant") { const assistantMsg = message as AssistantMessage; // Add assistant message component const assistantComponent = new AssistantMessageComponent(assistantMsg); this.chatContainer.addChild(assistantComponent); } // Note: tool calls and results are now handled via tool_execution_start/end events } renderInitialMessages(state: AgentState): void { // Render all existing messages (for --continue mode) // Reset first user message flag for initial render this.isFirstUserMessage = true; // Update footer with loaded state this.footer.updateState(state); // Render messages for (let i = 0; i < state.messages.length; i++) { const message = state.messages[i]; if (message.role === "user") { const userMsg = message as any; const textBlocks = userMsg.content.filter((c: any) => c.type === "text"); const textContent = textBlocks.map((c: any) => c.text).join(""); if (textContent) { const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage); this.chatContainer.addChild(userComponent); this.isFirstUserMessage = false; } } else if (message.role === "assistant") { const assistantMsg = message as AssistantMessage; const assistantComponent = new AssistantMessageComponent(assistantMsg); this.chatContainer.addChild(assistantComponent); // Create tool execution components for any tool calls for (const content of assistantMsg.content) { if (content.type === "toolCall") { const component = new ToolExecutionComponent(content.name, content.arguments); this.chatContainer.addChild(component); // If message was aborted/errored, immediately mark tool as failed if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") { const errorMessage = assistantMsg.stopReason === "aborted" ? "Operation aborted" : assistantMsg.errorMessage || "Error"; component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true, }); } else { // Store in map so we can update with results later this.pendingTools.set(content.id, component); } } } } else if (message.role === "toolResult") { // Update existing tool execution component with results ; const component = this.pendingTools.get(message.toolCallId); if (component) { component.updateResult({ content: message.content, details: message.details, isError: message.isError, }); // Remove from pending map since it's complete this.pendingTools.delete(message.toolCallId); } } } // Clear pending tools after rendering initial messages this.pendingTools.clear(); this.ui.requestRender(); } async getUserInput(): Promise { return new Promise((resolve) => { this.onInputCallback = (text: string) => { this.onInputCallback = undefined; resolve(text); }; }); } setInterruptCallback(callback: () => void): void { this.onInterruptCallback = callback; } private handleCtrlC(): void { // Handle Ctrl+C double-press logic const now = Date.now(); const timeSinceLastCtrlC = now - this.lastSigintTime; if (timeSinceLastCtrlC < 500) { // Second Ctrl+C within 500ms - exit this.stop(); process.exit(0); } else { // First Ctrl+C - clear the editor this.clearEditor(); this.lastSigintTime = now; } } clearEditor(): void { this.editor.setText(""); this.ui.requestRender(); } showError(errorMessage: string): void { // Show error message in the chat this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(chalk.red(`Error: ${errorMessage}`), 1, 0)); this.ui.requestRender(); } private showThinkingSelector(): void { // Create thinking selector with current level this.thinkingSelector = new ThinkingSelectorComponent( this.agent.state.thinkingLevel, (level) => { // Apply the selected thinking level this.agent.setThinkingLevel(level); // Save thinking level change to session this.sessionManager.saveThinkingLevelChange(level); // Show confirmation message with proper spacing this.chatContainer.addChild(new Spacer(1)); const confirmText = new Text(chalk.dim(`Thinking level: ${level}`), 1, 0); this.chatContainer.addChild(confirmText); // Hide selector and show editor again this.hideThinkingSelector(); this.ui.requestRender(); }, () => { // Just hide the selector this.hideThinkingSelector(); this.ui.requestRender(); }, ); // Replace editor with selector this.editorContainer.clear(); this.editorContainer.addChild(this.thinkingSelector); this.ui.setFocus(this.thinkingSelector.getSelectList()); this.ui.requestRender(); } private hideThinkingSelector(): void { // Replace selector with editor in the container this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.thinkingSelector = null; this.ui.setFocus(this.editor); } private showModelSelector(): void { // Create model selector with current model this.modelSelector = new ModelSelectorComponent( this.agent.state.model, (model) => { // Apply the selected model this.agent.setModel(model); // Save model change to session this.sessionManager.saveModelChange(model.provider, model.id); // Show confirmation message with proper spacing this.chatContainer.addChild(new Spacer(1)); const confirmText = new Text(chalk.dim(`Model: ${model.id}`), 1, 0); this.chatContainer.addChild(confirmText); // Hide selector and show editor again this.hideModelSelector(); this.ui.requestRender(); }, () => { // Just hide the selector this.hideModelSelector(); this.ui.requestRender(); }, ); // Replace editor with selector this.editorContainer.clear(); this.editorContainer.addChild(this.modelSelector); this.ui.setFocus(this.modelSelector); this.ui.requestRender(); } private hideModelSelector(): void { // Replace selector with editor in the container this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.modelSelector = null; this.ui.setFocus(this.editor); } private showUserMessageSelector(): void { // Extract all user messages from the current state const userMessages: Array<{ index: number; text: string }> = []; for (let i = 0; i < this.agent.state.messages.length; i++) { const message = this.agent.state.messages[i]; if (message.role === "user") { const userMsg = message as any; const textBlocks = userMsg.content.filter((c: any) => c.type === "text"); const textContent = textBlocks.map((c: any) => c.text).join(""); if (textContent) { userMessages.push({ index: i, text: textContent }); } } } // Don't show selector if there are no messages or only one message if (userMessages.length <= 1) { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(chalk.dim("No messages to branch from"), 1, 0)); this.ui.requestRender(); return; } // Create user message selector this.userMessageSelector = new UserMessageSelectorComponent( userMessages, (messageIndex) => { // Get the selected user message text to put in the editor const selectedMessage = this.agent.state.messages[messageIndex]; const selectedUserMsg = selectedMessage as any; const textBlocks = selectedUserMsg.content.filter((c: any) => c.type === "text"); const selectedText = textBlocks.map((c: any) => c.text).join(""); // Create a branched session with messages UP TO (but not including) the selected message const newSessionFile = this.sessionManager.createBranchedSession(this.agent.state, messageIndex - 1); // Set the new session file as active this.sessionManager.setSessionFile(newSessionFile); // Truncate messages in agent state to before the selected message const truncatedMessages = this.agent.state.messages.slice(0, messageIndex); this.agent.replaceMessages(truncatedMessages); // Clear and re-render the chat this.chatContainer.clear(); this.isFirstUserMessage = true; this.renderInitialMessages(this.agent.state); // Show confirmation message this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( new Text(chalk.dim(`Branched to new session from message ${messageIndex}`), 1, 0), ); // Put the selected message in the editor this.editor.setText(selectedText); // Hide selector and show editor again this.hideUserMessageSelector(); this.ui.requestRender(); }, () => { // Just hide the selector this.hideUserMessageSelector(); this.ui.requestRender(); }, ); // Replace editor with selector this.editorContainer.clear(); this.editorContainer.addChild(this.userMessageSelector); this.ui.setFocus(this.userMessageSelector.getMessageList()); this.ui.requestRender(); } private hideUserMessageSelector(): void { // Replace selector with editor in the container this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.userMessageSelector = null; this.ui.setFocus(this.editor); } private handleExportCommand(text: string): void { // Parse optional filename from command: /export [filename] const parts = text.split(/\s+/); const outputPath = parts.length > 1 ? parts[1] : undefined; try { // Export session to HTML const filePath = exportSessionToHtml(this.sessionManager, this.agent.state, outputPath); // Show success message in chat - matching thinking level style this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(chalk.dim(`Session exported to: ${filePath}`), 1, 0)); this.ui.requestRender(); } catch (error: any) { // Show error message in chat this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( new Text(chalk.red(`Failed to export session: ${error.message || "Unknown error"}`), 1, 0), ); this.ui.requestRender(); } } private handleSessionCommand(): void { // Get session info const sessionFile = this.sessionManager.getSessionFile(); const state = this.agent.state; // Count messages const userMessages = state.messages.filter((m) => m.role === "user").length; const assistantMessages = state.messages.filter((m) => m.role === "assistant").length; const toolResults = state.messages.filter((m) => m.role === "toolResult").length; const totalMessages = state.messages.length; // Count tool calls from assistant messages let toolCalls = 0; for (const message of state.messages) { if (message.role === "assistant") { const assistantMsg = message as AssistantMessage; toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length; } } // Calculate cumulative usage from all assistant messages (same as footer) let totalInput = 0; let totalOutput = 0; let totalCacheRead = 0; let totalCacheWrite = 0; let totalCost = 0; for (const message of state.messages) { if (message.role === "assistant") { const assistantMsg = message as AssistantMessage; totalInput += assistantMsg.usage.input; totalOutput += assistantMsg.usage.output; totalCacheRead += assistantMsg.usage.cacheRead; totalCacheWrite += assistantMsg.usage.cacheWrite; totalCost += assistantMsg.usage.cost.total; } } const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite; // Build info text let info = `${chalk.bold("Session Info")}\n\n`; info += `${chalk.dim("File:")} ${sessionFile}\n`; info += `${chalk.dim("ID:")} ${this.sessionManager.getSessionId()}\n\n`; info += `${chalk.bold("Messages")}\n`; info += `${chalk.dim("User:")} ${userMessages}\n`; info += `${chalk.dim("Assistant:")} ${assistantMessages}\n`; info += `${chalk.dim("Tool Calls:")} ${toolCalls}\n`; info += `${chalk.dim("Tool Results:")} ${toolResults}\n`; info += `${chalk.dim("Total:")} ${totalMessages}\n\n`; info += `${chalk.bold("Tokens")}\n`; info += `${chalk.dim("Input:")} ${totalInput.toLocaleString()}\n`; info += `${chalk.dim("Output:")} ${totalOutput.toLocaleString()}\n`; if (totalCacheRead > 0) { info += `${chalk.dim("Cache Read:")} ${totalCacheRead.toLocaleString()}\n`; } if (totalCacheWrite > 0) { info += `${chalk.dim("Cache Write:")} ${totalCacheWrite.toLocaleString()}\n`; } info += `${chalk.dim("Total:")} ${totalTokens.toLocaleString()}\n`; if (totalCost > 0) { info += `\n${chalk.bold("Cost")}\n`; info += `${chalk.dim("Total:")} ${totalCost.toFixed(4)}`; } // Show info in chat this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(info, 1, 0)); this.ui.requestRender(); } private handleChangelogCommand(): void { const changelogPath = getChangelogPath(); const allEntries = parseChangelog(changelogPath); // Show all entries in reverse order (oldest first, newest last) const changelogMarkdown = allEntries.length > 0 ? allEntries .reverse() .map((e) => e.content) .join("\n\n") : "No changelog entries found."; // Display in chat this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new DynamicBorder(chalk.cyan)); this.ui.addChild(new Text(chalk.bold.cyan("What's New"), 1, 0)); this.ui.addChild(new Spacer(1)); this.chatContainer.addChild(new Markdown(changelogMarkdown)); this.chatContainer.addChild(new DynamicBorder(chalk.cyan)); this.ui.requestRender(); } stop(): void { if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = null; } if (this.isInitialized) { this.ui.stop(); this.isInitialized = false; } } }