From dc1e2f928b7272ecce3efa2ec11f9d05995ec3d3 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Nov 2025 20:28:10 +0100 Subject: [PATCH] Add /thinking command and improve TUI UX - Add /thinking slash command with autocomplete for setting reasoning levels (off, minimal, low, medium, high) - Fix Ctrl+C behavior: remove hardcoded exit in TUI, let focused component handle it - Add empty lines before and after tool execution components for better visual separation - Fix stats rendering: display stats AFTER tool executions complete (matches web-ui behavior) - Remove "Press Ctrl+C again to exit" message, show "(esc to interrupt)" in loader instead - Add bash tool abort signal support with immediate SIGKILL on interrupt - Make Text and Markdown components return empty arrays when no actual text content - Add setCustomBgRgb() method to Markdown for dynamic background colors --- packages/ai/src/models.generated.ts | 146 ++++---- packages/coding-agent/src/main.ts | 5 +- packages/coding-agent/src/tools/bash.ts | 64 +++- packages/coding-agent/src/tui-renderer.ts | 435 ++++++++++++++++++---- packages/tui/src/components/markdown.ts | 18 + packages/tui/src/components/text.ts | 5 +- packages/tui/src/tui.ts | 9 +- 7 files changed, 516 insertions(+), 166 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index f31fc652..4adea16a 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -1840,7 +1840,7 @@ export const MODELS = { openrouter: { "kwaipilot/kat-coder-pro:free": { id: "kwaipilot/kat-coder-pro:free", - name: "Kwaipilot: Kat Coder (free)", + name: "Kwaipilot: KAT-Coder-Pro V1 (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -4470,7 +4470,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 256000, maxTokens: 4096, } satisfies Model<"openai-completions">, "deepseek/deepseek-chat": { @@ -4745,23 +4745,6 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "mistralai/ministral-3b": { - id: "mistralai/ministral-3b", - name: "Mistral: Ministral 3B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.04, - output: 0.04, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "mistralai/ministral-8b": { id: "mistralai/ministral-8b", name: "Mistral: Ministral 8B", @@ -4779,6 +4762,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, + "mistralai/ministral-3b": { + id: "mistralai/ministral-3b", + name: "Mistral: Ministral 3B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.04, + output: 0.04, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "qwen/qwen-2.5-7b-instruct": { id: "qwen/qwen-2.5-7b-instruct", name: "Qwen: Qwen2.5 7B Instruct", @@ -4983,23 +4983,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-405b-instruct": { - id: "meta-llama/llama-3.1-405b-instruct", - name: "Meta: Llama 3.1 405B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3.5, - output: 3.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 130815, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-8b-instruct": { id: "meta-llama/llama-3.1-8b-instruct", name: "Meta: Llama 3.1 8B Instruct", @@ -5017,6 +5000,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-405b-instruct": { + id: "meta-llama/llama-3.1-405b-instruct", + name: "Meta: Llama 3.1 405B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3.5, + output: 3.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 130815, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-70b-instruct": { id: "meta-llama/llama-3.1-70b-instruct", name: "Meta: Llama 3.1 70B Instruct", @@ -5187,6 +5187,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -5221,22 +5238,22 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", + "meta-llama/llama-3-70b-instruct": { + id: "meta-llama/llama-3-70b-instruct", + name: "Meta: Llama 3 70B Instruct", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, - input: ["text", "image"], + input: ["text"], cost: { - input: 5, - output: 15, + input: 0.3, + output: 0.39999999999999997, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 4096, + contextWindow: 8192, + maxTokens: 16384, } satisfies Model<"openai-completions">, "meta-llama/llama-3-8b-instruct": { id: "meta-llama/llama-3-8b-instruct", @@ -5255,23 +5272,6 @@ export const MODELS = { contextWindow: 8192, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3-70b-instruct": { - id: "meta-llama/llama-3-70b-instruct", - name: "Meta: Llama 3 70B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.3, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "mistralai/mixtral-8x22b-instruct": { id: "mistralai/mixtral-8x22b-instruct", name: "Mistral: Mixtral 8x22B Instruct", @@ -5493,21 +5493,21 @@ export const MODELS = { contextWindow: 16385, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo": { - id: "openai/gpt-3.5-turbo", - name: "OpenAI: GPT-3.5 Turbo", + "openai/gpt-4-0314": { + id: "openai/gpt-4-0314", + name: "OpenAI: GPT-4 (older v0314)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], cost: { - input: 0.5, - output: 1.5, + input: 30, + output: 60, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 16385, + contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, "openai/gpt-4": { @@ -5527,21 +5527,21 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4-0314": { - id: "openai/gpt-4-0314", - name: "OpenAI: GPT-4 (older v0314)", + "openai/gpt-3.5-turbo": { + id: "openai/gpt-3.5-turbo", + name: "OpenAI: GPT-3.5 Turbo", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], cost: { - input: 30, - output: 60, + input: 0.5, + output: 1.5, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 8191, + contextWindow: 16385, maxTokens: 4096, } satisfies Model<"openai-completions">, }, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index f1c30c1d..fa96ae22 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -106,7 +106,7 @@ Guidelines: Current directory: ${process.cwd()}`; async function runInteractiveMode(agent: Agent, _sessionManager: SessionManager): Promise { - const renderer = new TuiRenderer(); + const renderer = new TuiRenderer(agent); // Initialize TUI await renderer.init(); @@ -116,6 +116,9 @@ async function runInteractiveMode(agent: Agent, _sessionManager: SessionManager) agent.abort(); }); + // Render any existing messages (from --continue mode) + renderer.renderInitialMessages(agent.state); + // Subscribe to agent events agent.subscribe(async (event) => { // Pass all events to the renderer diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 8c6042ef..ecbcf5e1 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -1,9 +1,6 @@ import type { AgentTool } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { exec } from "child_process"; -import { promisify } from "util"; - -const execAsync = promisify(exec); const bashSchema = Type.Object({ command: Type.String({ description: "Bash command to execute" }), @@ -15,23 +12,54 @@ export const bashTool: AgentTool = { description: "Execute a bash command in the current working directory. Returns stdout and stderr. Commands run with a 30 second timeout.", parameters: bashSchema, - execute: async (_toolCallId: string, { command }: { command: string }) => { - try { - const { stdout, stderr } = await execAsync(command, { - timeout: 30000, - maxBuffer: 10 * 1024 * 1024, // 10MB - }); + execute: async (_toolCallId: string, { command }: { command: string }, signal?: AbortSignal) => { + return new Promise((resolve) => { + const child = exec( + command, + { + timeout: 30000, + maxBuffer: 10 * 1024 * 1024, // 10MB + }, + (error, stdout, stderr) => { + if (signal) { + signal.removeEventListener("abort", onAbort); + } - let output = ""; - if (stdout) output += stdout; - if (stderr) output += stderr ? `\nSTDERR:\n${stderr}` : ""; + if (signal?.aborted) { + resolve({ + output: `Command aborted by user\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`, + details: undefined, + }); + return; + } - return { output: output || "(no output)", details: undefined }; - } catch (error: any) { - return { - output: `Error executing command: ${error.message}\nSTDOUT: ${error.stdout || ""}\nSTDERR: ${error.stderr || ""}`, - details: undefined, + let output = ""; + if (stdout) output += stdout; + if (stderr) output += stderr ? `\nSTDERR:\n${stderr}` : ""; + + if (error && !error.killed) { + resolve({ + output: `Error executing command: ${error.message}\n${output}`, + details: undefined, + }); + } else { + resolve({ output: output || "(no output)", details: undefined }); + } + }, + ); + + // Handle abort signal + const onAbort = () => { + child.kill("SIGKILL"); }; - } + + if (signal) { + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } + }); }, }; diff --git a/packages/coding-agent/src/tui-renderer.ts b/packages/coding-agent/src/tui-renderer.ts index 527a55d5..abd77a49 100644 --- a/packages/coding-agent/src/tui-renderer.ts +++ b/packages/coding-agent/src/tui-renderer.ts @@ -1,5 +1,6 @@ -import type { AgentState } from "@mariozechner/pi-agent"; +import type { Agent, AgentState, ThinkingLevel } from "@mariozechner/pi-agent"; import type { AssistantMessage, Message } from "@mariozechner/pi-ai"; +import type { SlashCommand } from "@mariozechner/pi-tui"; import { CombinedAutocompleteProvider, Container, @@ -42,16 +43,20 @@ class CustomEditor extends Editor { */ class StreamingMessageComponent extends Container { private markdown: Markdown; + private statsText: Text; constructor() { super(); this.markdown = new Markdown(""); + this.statsText = new Text("", 1, 0); this.addChild(this.markdown); + this.addChild(this.statsText); } updateContent(message: Message | null) { if (!message) { this.markdown.setText(""); + this.statsText.setText(""); return; } @@ -65,36 +70,74 @@ class StreamingMessageComponent extends Container { .join(""); this.markdown.setText(textContent); + + // Update usage stats + const usage = assistantMsg.usage; + if (usage) { + // Format token counts (similar to web-ui) + const formatTokens = (count: number): string => { + if (count < 1000) return count.toString(); + if (count < 10000) return (count / 1000).toFixed(1) + "k"; + return Math.round(count / 1000) + "k"; + }; + + const statsParts = []; + if (usage.input) statsParts.push(`↑${formatTokens(usage.input)}`); + if (usage.output) statsParts.push(`↓${formatTokens(usage.output)}`); + if (usage.cacheRead) statsParts.push(`R${formatTokens(usage.cacheRead)}`); + if (usage.cacheWrite) statsParts.push(`W${formatTokens(usage.cacheWrite)}`); + if (usage.cost?.total) statsParts.push(`$${usage.cost.total.toFixed(3)}`); + + this.statsText.setText(chalk.dim(statsParts.join(" "))); + } else { + this.statsText.setText(""); + } } } } /** - * Component that renders a tool call with its result + * Component that renders a tool call with its result (updateable) */ class ToolExecutionComponent extends Container { private markdown: Markdown; + private toolName: string; + private args: any; + private result?: { output: string; isError: boolean }; - constructor(toolName: string, args: any, result?: { output: string; isError: boolean }) { + constructor(toolName: string, args: any) { super(); - const bgColor = result - ? result.isError + this.toolName = toolName; + this.args = args; + this.markdown = new Markdown("", undefined, undefined, { r: 40, g: 40, b: 50 }); + this.addChild(this.markdown); + this.updateDisplay(); + } + + updateResult(result: { output: string; isError: boolean }): void { + this.result = result; + this.updateDisplay(); + } + + private updateDisplay(): void { + const bgColor = this.result + ? this.result.isError ? { r: 60, g: 40, b: 40 } : { r: 40, g: 50, b: 40 } : { r: 40, g: 40, b: 50 }; - this.markdown = new Markdown(this.formatToolExecution(toolName, args, result), undefined, undefined, bgColor); - this.addChild(this.markdown); + this.markdown.setCustomBgRgb(bgColor); + this.markdown.setText(this.formatToolExecution()); } - private formatToolExecution(toolName: string, args: any, result?: { output: string; isError: boolean }): string { + private formatToolExecution(): string { let text = ""; // Format based on tool type - if (toolName === "bash") { - const command = args.command || ""; + if (this.toolName === "bash") { + const command = this.args.command || ""; text = `**$ ${command}**`; - if (result) { - const lines = result.output.split("\n"); + if (this.result) { + const lines = this.result.output.split("\n"); const maxLines = 5; const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; @@ -105,15 +148,15 @@ class ToolExecutionComponent extends Container { } text += "\n```"; - if (result.isError) { + if (this.result.isError) { text += " ❌"; } } - } else if (toolName === "read") { - const path = args.path || ""; + } else if (this.toolName === "read") { + const path = this.args.path || ""; text = `**read** \`${path}\``; - if (result) { - const lines = result.output.split("\n"); + if (this.result) { + const lines = this.result.output.split("\n"); const maxLines = 5; const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; @@ -124,30 +167,30 @@ class ToolExecutionComponent extends Container { } text += "\n```"; - if (result.isError) { + if (this.result.isError) { text += " ❌"; } } - } else if (toolName === "write") { - const path = args.path || ""; - const content = args.content || ""; + } else if (this.toolName === "write") { + const path = this.args.path || ""; + const content = this.args.content || ""; const lines = content.split("\n"); text = `**write** \`${path}\` (${lines.length} lines)`; - if (result) { - text += result.isError ? " ❌" : " ✓"; + if (this.result) { + text += this.result.isError ? " ❌" : " ✓"; } - } else if (toolName === "edit") { - const path = args.path || ""; + } else if (this.toolName === "edit") { + const path = this.args.path || ""; text = `**edit** \`${path}\``; - if (result) { - text += result.isError ? " ❌" : " ✓"; + if (this.result) { + text += this.result.isError ? " ❌" : " ✓"; } } else { // Generic tool - text = `**${toolName}**\n\`\`\`json\n${JSON.stringify(args, null, 2)}\n\`\`\``; - if (result) { - text += `\n\`\`\`\n${result.output}\n\`\`\``; - text += result.isError ? " ❌" : " ✓"; + text = `**${this.toolName}**\n\`\`\`json\n${JSON.stringify(this.args, null, 2)}\n\`\`\``; + if (this.result) { + text += `\n\`\`\`\n${this.result.output}\n\`\`\``; + text += this.result.isError ? " ❌" : " ✓"; } } @@ -155,6 +198,82 @@ class ToolExecutionComponent extends Container { } } +/** + * Footer component that shows pwd, token stats, and context usage + */ +class FooterComponent { + private state: AgentState; + + constructor(state: AgentState) { + this.state = state; + } + + updateState(state: AgentState): void { + this.state = state; + } + + render(width: number): string[] { + // Calculate cumulative usage from all assistant messages + let totalInput = 0; + let totalOutput = 0; + let totalCacheRead = 0; + let totalCacheWrite = 0; + let totalCost = 0; + + for (const message of this.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; + } + } + + // Calculate total tokens and % of context window + const totalTokens = totalInput + totalOutput; + const contextWindow = this.state.model.contextWindow; + const contextPercent = contextWindow > 0 ? ((totalTokens / contextWindow) * 100).toFixed(1) : "0.0"; + + // Format token counts (similar to web-ui) + const formatTokens = (count: number): string => { + if (count < 1000) return count.toString(); + if (count < 10000) return (count / 1000).toFixed(1) + "k"; + return Math.round(count / 1000) + "k"; + }; + + // Replace home directory with ~ + let pwd = process.cwd(); + const home = process.env.HOME || process.env.USERPROFILE; + if (home && pwd.startsWith(home)) { + pwd = "~" + pwd.slice(home.length); + } + + // Truncate path if too long to fit width + const maxPathLength = Math.max(20, width - 10); // Leave some margin + if (pwd.length > maxPathLength) { + const start = pwd.slice(0, Math.floor(maxPathLength / 2) - 2); + const end = pwd.slice(-(Math.floor(maxPathLength / 2) - 1)); + pwd = `${start}...${end}`; + } + + // Build stats line + const statsParts = []; + if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`); + if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`); + if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`); + if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`); + if (totalCost) statsParts.push(`$${totalCost.toFixed(3)}`); + statsParts.push(`${contextPercent}%`); + + const statsLine = statsParts.join(" "); + + // Return two lines: pwd and stats + return [chalk.dim(pwd), chalk.dim(statsLine)]; + } +} + /** * TUI renderer for the coding agent */ @@ -163,6 +282,8 @@ export class TuiRenderer { private chatContainer: Container; private statusContainer: Container; private editor: CustomEditor; + private footer: FooterComponent; + private agent: Agent; private isInitialized = false; private onInputCallback?: (text: string) => void; private loadingAnimation: Loader | null = null; @@ -172,17 +293,38 @@ export class TuiRenderer { // Streaming message tracking private streamingComponent: StreamingMessageComponent | null = null; - // Tool execution tracking: toolCallId -> { component, toolName, args } - private pendingTools = new Map(); + // Tool execution tracking: toolCallId -> component + private pendingTools = new Map(); - constructor() { + // Track assistant message with tool calls that needs stats shown after tools complete + private deferredStats: { usage: any; toolCallIds: Set } | null = null; + + constructor(agent: Agent) { + this.agent = agent; this.ui = new TUI(new ProcessTerminal()); this.chatContainer = new Container(); this.statusContainer = new Container(); this.editor = new CustomEditor(); + this.footer = new FooterComponent(agent.state); + + // Define slash commands + const thinkingCommand: SlashCommand = { + name: "thinking", + description: "Set reasoning level (off, minimal, low, medium, high)", + getArgumentCompletions: (argumentPrefix: string) => { + const levels = ["off", "minimal", "low", "medium", "high"]; + return levels + .filter((level) => level.toLowerCase().startsWith(argumentPrefix.toLowerCase())) + .map((level) => ({ + value: level, + label: level, + description: `Set thinking level to ${level}`, + })); + }, + }; // Setup autocomplete for file paths and slash commands - const autocompleteProvider = new CombinedAutocompleteProvider([], process.cwd()); + const autocompleteProvider = new CombinedAutocompleteProvider([thinkingCommand], process.cwd()); this.editor.setAutocompleteProvider(autocompleteProvider); } @@ -193,7 +335,6 @@ export class TuiRenderer { const header = new Text( ">> coding-agent interactive <<\n" + "Press Escape to interrupt while processing\n" + - "Press CTRL+C to clear the text editor\n" + "Press CTRL+C twice quickly to exit\n", ); @@ -202,6 +343,7 @@ export class TuiRenderer { this.ui.addChild(this.chatContainer); this.ui.addChild(this.statusContainer); this.ui.addChild(this.editor); + this.ui.addChild(this.footer); this.ui.setFocus(this.editor); // Set up custom key handlers on the editor @@ -213,19 +355,7 @@ export class TuiRenderer { }; this.editor.onCtrlC = () => { - // Handle Ctrl+C (raw mode sends \x03) - 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; - } + this.handleCtrlC(); }; // Handle editor submission @@ -233,6 +363,32 @@ export class TuiRenderer { text = text.trim(); if (!text) return; + // Check for slash commands + if (text.startsWith("/thinking ")) { + const level = text.slice("/thinking ".length).trim() as ThinkingLevel; + const validLevels: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"]; + if (validLevels.includes(level)) { + this.agent.setThinkingLevel(level); + // Show confirmation message + const confirmText = new Text(chalk.dim(`Thinking level set to: ${level}`), 1, 0); + this.chatContainer.addChild(confirmText); + this.ui.requestRender(); + this.editor.setText(""); + return; + } else { + // Show error message + const errorText = new Text( + chalk.red(`Invalid thinking level: ${level}. Use: off, minimal, low, medium, high`), + 1, + 0, + ); + this.chatContainer.addChild(errorText); + this.ui.requestRender(); + this.editor.setText(""); + return; + } + } + if (this.onInputCallback) { this.onInputCallback(text); } @@ -243,11 +399,14 @@ export class TuiRenderer { this.isInitialized = true; } - async handleEvent(event: import("@mariozechner/pi-agent").AgentEvent, _state: AgentState): Promise { + async handleEvent(event: import("@mariozechner/pi-agent").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 @@ -257,7 +416,7 @@ export class TuiRenderer { this.loadingAnimation.stop(); } this.statusContainer.clear(); - this.loadingAnimation = new Loader(this.ui, "Working..."); + this.loadingAnimation = new Loader(this.ui, "Working... (esc to interrupt)"); this.statusContainer.addChild(this.loadingAnimation); this.ui.requestRender(); break; @@ -300,26 +459,39 @@ export class TuiRenderer { break; case "tool_execution_start": { + // Add empty line before tool execution + this.chatContainer.addChild(new Text("", 0, 0)); // Create tool execution component and add it const component = new ToolExecutionComponent(event.toolName, event.args); this.chatContainer.addChild(component); - this.pendingTools.set(event.toolCallId, { component, toolName: event.toolName, args: event.args }); + this.pendingTools.set(event.toolCallId, component); this.ui.requestRender(); break; } case "tool_execution_end": { // Update the existing tool component with the result - const pending = this.pendingTools.get(event.toolCallId); - if (pending) { - // Re-render the component with result - this.chatContainer.removeChild(pending.component); - const updatedComponent = new ToolExecutionComponent(pending.toolName, pending.args, { + const component = this.pendingTools.get(event.toolCallId); + if (component) { + // Update the component with the result + component.updateResult({ output: typeof event.result === "string" ? event.result : event.result.output, isError: event.isError, }); - this.chatContainer.addChild(updatedComponent); + // Add empty line after tool execution + this.chatContainer.addChild(new Text("", 0, 0)); this.pendingTools.delete(event.toolCallId); + + // Check if this was part of deferred stats and all tools are complete + if (this.deferredStats) { + this.deferredStats.toolCallIds.delete(event.toolCallId); + if (this.deferredStats.toolCallIds.size === 0) { + // All tools complete - show stats now + this.addStatsComponent(this.deferredStats.usage); + this.deferredStats = null; + } + } + this.ui.requestRender(); } break; @@ -337,6 +509,7 @@ export class TuiRenderer { this.streamingComponent = null; } this.pendingTools.clear(); + this.deferredStats = null; // Clear any deferred stats this.editor.disableSubmit = false; this.ui.requestRender(); break; @@ -381,10 +554,133 @@ export class TuiRenderer { this.chatContainer.addChild(errorText); return; } + + // Check if this message has tool calls + const hasToolCalls = assistantMsg.content.some((c) => c.type === "toolCall"); + + if (hasToolCalls) { + // Defer stats until after tool executions complete + const toolCallIds = new Set(); + for (const content of assistantMsg.content) { + if (content.type === "toolCall") { + toolCallIds.add(content.id); + } + } + this.deferredStats = { usage: assistantMsg.usage, toolCallIds }; + } else { + // No tool calls - show stats immediately + this.addStatsComponent(assistantMsg.usage); + } } // Note: tool calls and results are now handled via tool_execution_start/end events } + private addStatsComponent(usage: any): void { + if (!usage) return; + + // Format token counts (similar to web-ui) + const formatTokens = (count: number): string => { + if (count < 1000) return count.toString(); + if (count < 10000) return (count / 1000).toFixed(1) + "k"; + return Math.round(count / 1000) + "k"; + }; + + const statsParts = []; + if (usage.input) statsParts.push(`↑${formatTokens(usage.input)}`); + if (usage.output) statsParts.push(`↓${formatTokens(usage.output)}`); + if (usage.cacheRead) statsParts.push(`R${formatTokens(usage.cacheRead)}`); + if (usage.cacheWrite) statsParts.push(`W${formatTokens(usage.cacheWrite)}`); + if (usage.cost?.total) statsParts.push(`$${usage.cost.total.toFixed(3)}`); + + if (statsParts.length > 0) { + const statsText = new Text(chalk.dim(statsParts.join(" ")), 1, 0); + this.chatContainer.addChild(statsText); + // Add empty line after stats + this.chatContainer.addChild(new Text("", 0, 0)); + } + } + + renderInitialMessages(state: AgentState): void { + // Render all existing messages (for --continue mode) + // Track assistant messages with their tool calls to show stats after tools + const assistantWithTools = new Map< + number, + { usage: any; toolCallIds: Set; remainingToolCallIds: Set } + >(); + + // First pass: identify assistant messages with tool calls + for (let i = 0; i < state.messages.length; i++) { + const message = state.messages[i]; + if (message.role === "assistant") { + const assistantMsg = message as AssistantMessage; + const toolCallIds = new Set(); + for (const content of assistantMsg.content) { + if (content.type === "toolCall") { + toolCallIds.add(content.id); + } + } + if (toolCallIds.size > 0) { + assistantWithTools.set(i, { + usage: assistantMsg.usage, + toolCallIds, + remainingToolCallIds: new Set(toolCallIds), + }); + } + } + } + + // Second pass: render messages + for (let i = 0; i < state.messages.length; i++) { + const message = state.messages[i]; + + if (message.role === "user" || message.role === "assistant") { + // Temporarily disable deferred stats for initial render + const savedDeferredStats = this.deferredStats; + this.deferredStats = null; + this.addMessageToChat(message); + this.deferredStats = savedDeferredStats; + } else if (message.role === "toolResult") { + // Render tool calls that have already completed + const toolResultMsg = message as any; + const assistantMsgIndex = state.messages.findIndex( + (m) => + m.role === "assistant" && + m.content.some((c: any) => c.type === "toolCall" && c.id === toolResultMsg.toolCallId), + ); + + if (assistantMsgIndex !== -1) { + const assistantMsg = state.messages[assistantMsgIndex] as AssistantMessage; + const toolCall = assistantMsg.content.find( + (c) => c.type === "toolCall" && c.id === toolResultMsg.toolCallId, + ) as any; + if (toolCall) { + // Add empty line before tool execution + this.chatContainer.addChild(new Text("", 0, 0)); + const component = new ToolExecutionComponent(toolCall.name, toolCall.arguments); + component.updateResult({ + output: toolResultMsg.output, + isError: toolResultMsg.isError, + }); + this.chatContainer.addChild(component); + // Add empty line after tool execution + this.chatContainer.addChild(new Text("", 0, 0)); + + // Check if this was the last tool call for this assistant message + const assistantData = assistantWithTools.get(assistantMsgIndex); + if (assistantData) { + assistantData.remainingToolCallIds.delete(toolResultMsg.toolCallId); + if (assistantData.remainingToolCallIds.size === 0) { + // All tools for this assistant message are complete - show stats + this.addStatsComponent(assistantData.usage); + } + } + } + } + } + } + this.ui.requestRender(); + } + async getUserInput(): Promise { return new Promise((resolve) => { this.onInputCallback = (text: string) => { @@ -398,17 +694,26 @@ export class TuiRenderer { 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.statusContainer.clear(); - const hint = new Text("Press Ctrl+C again to exit"); - this.statusContainer.addChild(hint); this.ui.requestRender(); - - setTimeout(() => { - this.statusContainer.clear(); - this.ui.requestRender(); - }, 500); } stop(): void { diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 8a0dba46..50045a34 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -76,6 +76,14 @@ export class Markdown implements Component { this.cachedLines = undefined; } + setCustomBgRgb(customBgRgb?: { r: number; g: number; b: number }): void { + this.customBgRgb = customBgRgb; + // Invalidate cache when color changes + this.cachedText = undefined; + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + render(width: number): string[] { // Check cache if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) { @@ -85,6 +93,16 @@ export class Markdown implements Component { // Calculate available width for content (subtract horizontal padding) const contentWidth = Math.max(1, width - this.paddingX * 2); + // Don't render anything if there's no actual text + if (!this.text || this.text.trim() === "") { + const result: string[] = []; + // Update cache + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = result; + return result; + } + // Parse markdown to HTML-like tokens const tokens = marked.lexer(this.text); diff --git a/packages/tui/src/components/text.ts b/packages/tui/src/components/text.ts index 5c8d69a5..79f805c5 100644 --- a/packages/tui/src/components/text.ts +++ b/packages/tui/src/components/text.ts @@ -37,8 +37,9 @@ export class Text implements Component { // Calculate available width for content (subtract horizontal padding) const contentWidth = Math.max(1, width - this.paddingX * 2); - if (!this.text) { - const result = [""]; + // Don't render anything if there's no actual text + if (!this.text || this.text.trim() === "") { + const result: string[] = []; // Update cache this.cachedText = this.text; this.cachedWidth = width; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 68e42b4b..d529cac4 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -98,13 +98,8 @@ export class TUI extends Container { } private handleInput(data: string): void { - // Exit on Ctrl+C - if (data === "\x03") { - this.stop(); - process.exit(0); - } - - // Pass input to focused component + // Pass input to focused component (including Ctrl+C) + // The focused component can decide how to handle Ctrl+C if (this.focusedComponent?.handleInput) { this.focusedComponent.handleInput(data); this.requestRender();