From c5083bb7cb21e4c3868d46f724e7026abcb81918 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Nov 2025 19:27:58 +0100 Subject: [PATCH] Fix markdown streaming duplication by splitting newlines first - Added string-width library for proper terminal column width calculation - Fixed wrapLine() to split by newlines before wrapping (like Text component) - Fixed Loader interval leak by stopping before container removal - Changed loader message from 'Loading...' to 'Working...' --- package-lock.json | 58 +++- packages/agent/src/agent.ts | 72 +++-- packages/agent/src/types.ts | 26 +- packages/agent/test/agent.test.ts | 20 +- packages/agent/test/e2e.test.ts | 29 +- packages/coding-agent/DEBUG.md | 170 ---------- packages/coding-agent/src/main.ts | 29 +- packages/coding-agent/src/session-manager.ts | 2 +- packages/coding-agent/src/tui-renderer.ts | 314 ++++++++++++------- packages/tui/package.json | 3 +- packages/tui/src/components/input.ts | 6 +- packages/tui/src/components/markdown.ts | 34 +- packages/tui/src/components/text.ts | 16 +- packages/tui/src/index.ts | 4 +- packages/tui/src/tui.ts | 6 + packages/tui/src/utils.ts | 12 + 16 files changed, 429 insertions(+), 372 deletions(-) delete mode 100644 packages/coding-agent/DEBUG.md create mode 100644 packages/tui/src/utils.ts diff --git a/package-lock.json b/package-lock.json index 402cae53..850cc159 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3132,6 +3132,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-tsconfig": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", @@ -5513,7 +5525,8 @@ "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "marked": "^15.0.12", - "mime-types": "^3.0.1" + "mime-types": "^3.0.1", + "string-width": "^8.1.0" }, "devDependencies": { "@xterm/headless": "^5.5.0", @@ -5523,6 +5536,18 @@ "node": ">=20.0.0" } }, + "packages/tui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "packages/tui/node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -5545,6 +5570,37 @@ "node": ">= 18" } }, + "packages/tui/node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/tui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", "version": "0.5.48", diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 617ff6e1..fa13ec76 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -87,33 +87,32 @@ export class Agent { subscribe(fn: (e: AgentEvent) => void): () => void { this.listeners.add(fn); - fn({ type: "state-update", state: this._state }); return () => this.listeners.delete(fn); } - // State mutators + // State mutators - update internal state without emitting events setSystemPrompt(v: string) { - this.patch({ systemPrompt: v }); + this._state.systemPrompt = v; } setModel(m: typeof this._state.model) { - this.patch({ model: m }); + this._state.model = m; } setThinkingLevel(l: ThinkingLevel) { - this.patch({ thinkingLevel: l }); + this._state.thinkingLevel = l; } setTools(t: typeof this._state.tools) { - this.patch({ tools: t }); + this._state.tools = t; } replaceMessages(ms: AppMessage[]) { - this.patch({ messages: ms.slice() }); + this._state.messages = ms.slice(); } appendMessage(m: AppMessage) { - this.patch({ messages: [...this._state.messages, m] }); + this._state.messages = [...this._state.messages, m]; } async queueMessage(m: AppMessage) { @@ -126,7 +125,7 @@ export class Agent { } clearMessages() { - this.patch({ messages: [] }); + this._state.messages = []; } abort() { @@ -163,8 +162,12 @@ export class Agent { }; this.abortController = new AbortController(); - this.patch({ isStreaming: true, streamMessage: null, error: undefined }); - this.emit({ type: "started" }); + this._state.isStreaming = true; + this._state.streamMessage = null; + this._state.error = undefined; + + // Emit agent_start + this.emit({ type: "agent_start" }); const reasoning = this._state.thinkingLevel === "off" @@ -186,6 +189,9 @@ export class Agent { }, }; + // Track all messages generated in this prompt + const generatedMessages: AppMessage[] = []; + try { let partial: Message | null = null; @@ -198,38 +204,51 @@ export class Agent { cfg, this.abortController.signal, )) { + // Pass through all events directly + this.emit(ev as AgentEvent); + + // Update internal state as needed switch (ev.type) { - case "message_start": - case "message_update": { + case "message_start": { + // Track streaming message partial = ev.message; - this.patch({ streamMessage: ev.message }); + this._state.streamMessage = ev.message; + break; + } + case "message_update": { + // Update streaming message + partial = ev.message; + this._state.streamMessage = ev.message; break; } case "message_end": { + // Add completed message to state partial = null; + this._state.streamMessage = null; this.appendMessage(ev.message as AppMessage); - this.patch({ streamMessage: null }); + generatedMessages.push(ev.message as AppMessage); break; } case "tool_execution_start": { const s = new Set(this._state.pendingToolCalls); s.add(ev.toolCallId); - this.patch({ pendingToolCalls: s }); + this._state.pendingToolCalls = s; break; } case "tool_execution_end": { const s = new Set(this._state.pendingToolCalls); s.delete(ev.toolCallId); - this.patch({ pendingToolCalls: s }); + this._state.pendingToolCalls = s; break; } case "agent_end": { - this.patch({ streamMessage: null }); + this._state.streamMessage = null; break; } } } + // Handle any remaining partial message if (partial && partial.role === "assistant" && partial.content.length > 0) { const onlyEmpty = !partial.content.some( (c) => @@ -239,6 +258,7 @@ export class Agent { ); if (!onlyEmpty) { this.appendMessage(partial as AppMessage); + generatedMessages.push(partial as AppMessage); } else { if (this.abortController?.signal.aborted) { throw new Error("Request was aborted"); @@ -264,17 +284,17 @@ export class Agent { timestamp: Date.now(), }; this.appendMessage(msg as AppMessage); - this.patch({ error: err?.message || String(err) }); + generatedMessages.push(msg as AppMessage); + this._state.error = err?.message || String(err); } finally { - this.patch({ isStreaming: false, streamMessage: null, pendingToolCalls: new Set() }); + this._state.isStreaming = false; + this._state.streamMessage = null; + this._state.pendingToolCalls = new Set(); this.abortController = undefined; - this.emit({ type: "completed" }); - } - } - private patch(p: Partial): void { - this._state = { ...this._state, ...p }; - this.emit({ type: "state-update", state: this._state }); + // Emit agent_end with all generated messages + this.emit({ type: "agent_end", messages: generatedMessages }); + } } private emit(e: AgentEvent) { diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index a2a0c33d..33b2d032 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -1,4 +1,11 @@ -import type { AgentTool, AssistantMessage, Message, Model, UserMessage } from "@mariozechner/pi-ai"; +import type { + AgentTool, + AssistantMessage, + AssistantMessageEvent, + Message, + Model, + UserMessage, +} from "@mariozechner/pi-ai"; /** * Attachment type definition. @@ -71,5 +78,20 @@ export interface AgentState { /** * Events emitted by the Agent for UI updates. + * These events provide fine-grained lifecycle information for messages, turns, and tool executions. */ -export type AgentEvent = { type: "state-update"; state: AgentState } | { type: "started" } | { type: "completed" }; +export type AgentEvent = + // Agent lifecycle + | { type: "agent_start" } + | { type: "agent_end"; messages: AppMessage[] } + // Turn lifecycle - a turn is one assistant response + any tool calls/results + | { type: "turn_start" } + | { type: "turn_end"; message: AppMessage; toolResults: AppMessage[] } + // Message lifecycle - emitted for user, assistant, and toolResult messages + | { type: "message_start"; message: AppMessage } + // Only emitted for assistant messages during streaming + | { type: "message_update"; message: AppMessage; assistantMessageEvent: AssistantMessageEvent } + | { type: "message_end"; message: AppMessage } + // Tool execution lifecycle + | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any } + | { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError: boolean }; diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 9c0c8a36..235136cf 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -36,30 +36,28 @@ describe("Agent", () => { expect(agent.state.thinkingLevel).toBe("low"); }); - it("should subscribe to state updates", () => { + it("should subscribe to events", () => { const agent = new Agent({ transport: new ProviderTransport(), }); - let updateCount = 0; - const unsubscribe = agent.subscribe((event) => { - if (event.type === "state-update") { - updateCount++; - } + let eventCount = 0; + const unsubscribe = agent.subscribe((_event) => { + eventCount++; }); - // Initial state update on subscribe - expect(updateCount).toBe(1); + // No initial event on subscribe + expect(eventCount).toBe(0); - // Update state + // State mutators don't emit events agent.setSystemPrompt("Test prompt"); - expect(updateCount).toBe(2); + expect(eventCount).toBe(0); expect(agent.state.systemPrompt).toBe("Test prompt"); // Unsubscribe should work unsubscribe(); agent.setSystemPrompt("Another prompt"); - expect(updateCount).toBe(2); // Should not increase + expect(eventCount).toBe(0); // Should not increase }); it("should update state with mutators", () => { diff --git a/packages/agent/test/e2e.test.ts b/packages/agent/test/e2e.test.ts index 332be2eb..25475c3a 100644 --- a/packages/agent/test/e2e.test.ts +++ b/packages/agent/test/e2e.test.ts @@ -167,29 +167,26 @@ async function stateUpdates(model: Model) { }), }); - const stateSnapshots: Array<{ isStreaming: boolean; messageCount: number; hasStreamMessage: boolean }> = []; + const events: Array = []; agent.subscribe((event) => { - if (event.type === "state-update") { - stateSnapshots.push({ - isStreaming: event.state.isStreaming, - messageCount: event.state.messages.length, - hasStreamMessage: event.state.streamMessage !== null, - }); - } + events.push(event.type); }); await agent.prompt("Count from 1 to 5."); - const streamingStates = stateSnapshots.filter((s) => s.isStreaming); - const nonStreamingStates = stateSnapshots.filter((s) => !s.isStreaming); + // Should have received lifecycle events + expect(events).toContain("agent_start"); + expect(events).toContain("agent_end"); + expect(events).toContain("message_start"); + expect(events).toContain("message_end"); + // May have message_update events during streaming + const hasMessageUpdates = events.some((e) => e === "message_update"); + expect(hasMessageUpdates).toBe(true); - expect(streamingStates.length).toBeGreaterThan(0); - expect(nonStreamingStates.length).toBeGreaterThan(0); - - const finalState = stateSnapshots[stateSnapshots.length - 1]; - expect(finalState.isStreaming).toBe(false); - expect(finalState.messageCount).toBe(2); + // Check final state + expect(agent.state.isStreaming).toBe(false); + expect(agent.state.messages.length).toBe(2); // User message + assistant response } async function multiTurnConversation(model: Model) { diff --git a/packages/coding-agent/DEBUG.md b/packages/coding-agent/DEBUG.md deleted file mode 100644 index e90126f0..00000000 --- a/packages/coding-agent/DEBUG.md +++ /dev/null @@ -1,170 +0,0 @@ -# Debug Mode Guide - -## Enabling Debug Output - -Debug logs are written to files in `/tmp/` to avoid interfering with TUI rendering. - -There are three ways to enable debug output: - -1. **CLI flag**: `--debug` or `-d` - ```bash - coding-agent --debug --script "Hello" - ``` - This will print log file locations: - ``` - [TUI] Debug logging to: /tmp/tui-debug-1234567890.log - [RENDERER] Debug logging to: /tmp/agent-debug-1234567890.log - ``` - -2. **Environment variables**: - ```bash - TUI_DEBUG=1 AGENT_DEBUG=1 coding-agent - ``` - -3. **Individual components**: - ```bash - TUI_DEBUG=1 coding-agent # Only TUI debug - AGENT_DEBUG=1 coding-agent # Only agent/renderer debug - ``` - -## Viewing Debug Logs - -Debug logs are written to `/tmp/` with timestamps: -- `/tmp/tui-debug-.log` - TUI rendering events -- `/tmp/agent-debug-.log` - Agent/renderer events - -To tail the logs while the agent runs: -```bash -# In one terminal -coding-agent --debug --script "Hello" - -# In another terminal (use the path printed above) -tail -f /tmp/tui-debug-*.log -tail -f /tmp/agent-debug-*.log -``` - -## Scripted Messages for Testing - -Use `--script` to replay messages automatically in interactive mode: - -```bash -# Single scripted message -coding-agent --debug --script "What files are in this directory?" - -# Multiple scripted messages -coding-agent --debug --script "Hello" --script "List the files" --script "Read package.json" -``` - -The agent will: -1. Type the message into the editor -2. Submit it -3. Wait for the agent to complete its response -4. Move to the next message -5. Exit after all messages are processed - -## Debug Output Reference - -### TUI Debug Messages - -**`[TUI DEBUG]`** - Low-level terminal UI rendering events - -- **`requestRender() called but TUI not started`** - Render requested before TUI initialization (usually benign) -- **`Render queued`** - A render has been scheduled for next tick -- **`Executing queued render`** - About to perform the actual render -- **`renderToScreen() called: resize=X, termWidth=Y, termHeight=Z`** - Starting render cycle -- **`Reset for resize`** - Terminal was resized, clearing buffers -- **`Collected N render commands, total lines: M`** - Gathered all component output (N components, M total lines) -- **`Performing initial render`** - First render (full screen write) -- **`Performing line-based render`** - Differential render (only changed lines) -- **`Render complete. Total renders: X, avg lines redrawn: Y`** - Render finished with performance stats - -### Renderer Debug Messages - -**`[RENDERER DEBUG]`** - Agent renderer (TuiRenderer) events - -- **`handleStateUpdate: isStreaming=X, messages=N, pendingToolCalls=M`** - Agent state changed - - `isStreaming=true` - Agent is currently responding - - `messages=N` - Total messages in conversation - - `pendingToolCalls=M` - Number of tool calls waiting to execute - -- **`Adding N new stable messages`** - N messages were finalized and added to chat history -- **`Streaming message role=X`** - Currently streaming a message with role X (user/assistant/toolResult) -- **`Starting loading animation`** - Spinner started because agent is thinking -- **`Creating streaming component`** - Creating UI component to show live message updates -- **`Streaming stopped`** - Agent finished responding -- **`Requesting render`** - Asking TUI to redraw the screen -- **`simulateInput: "text"`** - Scripted message being typed -- **`Triggering onInputCallback`** - Submitting the scripted message - -### Script Debug Messages - -**`[SCRIPT]`** - Scripted message playback - -- **`Sending message N/M: text`** - Sending message N out of M total -- **`All N messages completed. Exiting.`** - Finished all scripted messages - -**`[AGENT]`** - Agent execution - -- **`Completed response to: "text"`** - Agent finished processing this message - -## Interpreting Debug Output - -### Normal Message Flow - -``` -[RENDERER DEBUG] handleStateUpdate: isStreaming=false, messages=0, pendingToolCalls=0 -[SCRIPT] Sending message 1/1: Hello -[RENDERER DEBUG] simulateInput: "Hello" -[RENDERER DEBUG] Triggering onInputCallback -[RENDERER DEBUG] handleStateUpdate: isStreaming=true, messages=1, pendingToolCalls=0 -[RENDERER DEBUG] Streaming message role=user -[RENDERER DEBUG] Starting loading animation -[RENDERER DEBUG] Requesting render -[TUI DEBUG] Render queued -[TUI DEBUG] Executing queued render -[TUI DEBUG] renderToScreen() called: resize=false, termWidth=120, termHeight=40 -[TUI DEBUG] Collected 4 render commands, total lines: 8 -[TUI DEBUG] Performing line-based render -[TUI DEBUG] Render complete. Total renders: 5, avg lines redrawn: 12.4 -[RENDERER DEBUG] handleStateUpdate: isStreaming=true, messages=1, pendingToolCalls=0 -[RENDERER DEBUG] Streaming message role=assistant -... -[RENDERER DEBUG] handleStateUpdate: isStreaming=false, messages=2, pendingToolCalls=0 -[RENDERER DEBUG] Streaming stopped -[RENDERER DEBUG] Adding 1 new stable messages -[AGENT] Completed response to: "Hello" -``` - -### What to Look For - -**Rendering Issues:** -- If `Render queued` appears but no `Executing queued render` → render loop broken -- If `total lines` is 0 or unexpectedly small → components not rendering -- If `avg lines redrawn` is huge → too many full redraws (performance issue) -- If no `[TUI DEBUG]` messages → TUI debug not enabled or TUI not starting - -**Message Flow Issues:** -- If messages increase but no "Adding N new stable messages" → renderer not detecting changes -- If `isStreaming=true` never becomes `false` → agent hanging -- If `pendingToolCalls` stays > 0 → tool execution stuck -- If `Streaming stopped` never appears → streaming never completes - -**Scripted Message Issues:** -- If `simulateInput` appears but no `Triggering onInputCallback` → callback not registered yet -- If `Sending message` appears but no `Completed response` → agent not responding -- If no `[SCRIPT]` messages → script messages not being processed - -## Example Debug Session - -```bash -# Test basic rendering with a simple scripted message -coding-agent --debug --script "Hello" - -# Test multi-turn conversation -coding-agent --debug --script "Hi" --script "What files are here?" --script "Thanks" - -# Test tool execution -coding-agent --debug --script "List all TypeScript files" -``` - -Look for the flow: script → simulateInput → handleStateUpdate → render → completed diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 4984d5d1..f1c30c1d 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -116,11 +116,10 @@ async function runInteractiveMode(agent: Agent, _sessionManager: SessionManager) agent.abort(); }); - // Subscribe to agent state updates + // Subscribe to agent events agent.subscribe(async (event) => { - if (event.type === "state-update") { - await renderer.handleStateUpdate(event.state); - } + // Pass all events to the renderer + await renderer.handleEvent(event, agent.state); }); // Interactive loop @@ -168,8 +167,8 @@ export async function main(args: string[]) { const sessionManager = new SessionManager(parsed.continue); // Determine provider and model - const provider = (parsed.provider || "google") as any; - const modelId = parsed.model || "gemini-2.5-flash"; + const provider = (parsed.provider || "anthropic") as any; + const modelId = parsed.model || "claude-sonnet-4-5"; // Get API key let apiKey = parsed.apiKey; @@ -177,7 +176,7 @@ export async function main(args: string[]) { const envVarMap: Record = { google: "GEMINI_API_KEY", openai: "OPENAI_API_KEY", - anthropic: "ANTHROPIC_API_KEY", + anthropic: "ANTHROPIC_OAUTH_TOKEN", xai: "XAI_API_KEY", groq: "GROQ_API_KEY", cerebras: "CEREBRAS_API_KEY", @@ -221,20 +220,14 @@ export async function main(args: string[]) { // Start session sessionManager.startSession(agent.state); - // Subscribe to state updates to save messages + // Subscribe to agent events to save messages and log events agent.subscribe((event) => { - if (event.type === "state-update") { - // Save any new messages - const currentMessages = event.state.messages; - const loadedMessages = sessionManager.loadMessages(); - - if (currentMessages.length > loadedMessages.length) { - for (let i = loadedMessages.length; i < currentMessages.length; i++) { - sessionManager.saveMessage(currentMessages[i]); - } - } + // Save messages on completion + if (event.type === "message_end") { + sessionManager.saveMessage(event.message); } + // Log all events sessionManager.saveEvent(event); }); diff --git a/packages/coding-agent/src/session-manager.ts b/packages/coding-agent/src/session-manager.ts index 072fef5a..21f5a791 100644 --- a/packages/coding-agent/src/session-manager.ts +++ b/packages/coding-agent/src/session-manager.ts @@ -58,7 +58,7 @@ export class SessionManager { const cwd = process.cwd(); const safePath = "--" + cwd.replace(/^\//, "").replace(/\//g, "-") + "--"; - const configDir = resolve(process.env.CODING_AGENT_DIR || join(homedir(), ".coding-agent")); + const configDir = resolve(process.env.CODING_AGENT_DIR || join(homedir(), ".pi/agent/")); const sessionDir = join(configDir, "sessions", safePath); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); diff --git a/packages/coding-agent/src/tui-renderer.ts b/packages/coding-agent/src/tui-renderer.ts index e0dbce19..527a55d5 100644 --- a/packages/coding-agent/src/tui-renderer.ts +++ b/packages/coding-agent/src/tui-renderer.ts @@ -7,7 +7,6 @@ import { Loader, Markdown, ProcessTerminal, - Spacer, Text, TUI, } from "@mariozechner/pi-tui"; @@ -42,14 +41,17 @@ class CustomEditor extends Editor { * Component that renders a streaming message with live updates */ class StreamingMessageComponent extends Container { - private textComponent: Markdown | null = null; - private toolCallsContainer: Container | null = null; - private currentContent = ""; - private currentToolCalls: any[] = []; + private markdown: Markdown; + + constructor() { + super(); + this.markdown = new Markdown(""); + this.addChild(this.markdown); + } updateContent(message: Message | null) { if (!message) { - this.clear(); + this.markdown.setText(""); return; } @@ -61,35 +63,95 @@ class StreamingMessageComponent extends Container { .filter((c) => c.type === "text") .map((c) => c.text) .join(""); - if (textContent !== this.currentContent) { - this.currentContent = textContent; - if (this.textComponent) { - this.removeChild(this.textComponent); + + this.markdown.setText(textContent); + } + } +} + +/** + * Component that renders a tool call with its result + */ +class ToolExecutionComponent extends Container { + private markdown: Markdown; + + constructor(toolName: string, args: any, result?: { output: string; isError: boolean }) { + super(); + const bgColor = result + ? 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); + } + + private formatToolExecution(toolName: string, args: any, result?: { output: string; isError: boolean }): string { + let text = ""; + + // Format based on tool type + if (toolName === "bash") { + const command = args.command || ""; + text = `**$ ${command}**`; + if (result) { + const lines = result.output.split("\n"); + const maxLines = 5; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + + text += "\n```\n" + displayLines.join("\n"); + if (remaining > 0) { + text += `\n... (${remaining} more lines)`; } - if (textContent) { - this.textComponent = new Markdown(textContent); - this.addChild(this.textComponent); + text += "\n```"; + + if (result.isError) { + text += " ❌"; } } + } else if (toolName === "read") { + const path = args.path || ""; + text = `**read** \`${path}\``; + if (result) { + const lines = result.output.split("\n"); + const maxLines = 5; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; - // Update tool calls - const toolCalls = assistantMsg.content.filter((c) => c.type === "toolCall"); - if (JSON.stringify(toolCalls) !== JSON.stringify(this.currentToolCalls)) { - this.currentToolCalls = toolCalls; - if (this.toolCallsContainer) { - this.removeChild(this.toolCallsContainer); + text += "\n```\n" + displayLines.join("\n"); + if (remaining > 0) { + text += `\n... (${remaining} more lines)`; } - if (toolCalls.length > 0) { - this.toolCallsContainer = new Container(); - for (const toolCall of toolCalls) { - const argsStr = - typeof toolCall.arguments === "string" ? toolCall.arguments : JSON.stringify(toolCall.arguments); - this.toolCallsContainer.addChild(new Text(chalk.yellow(`[tool] ${toolCall.name}(${argsStr})`))); - } - this.addChild(this.toolCallsContainer); + text += "\n```"; + + if (result.isError) { + text += " ❌"; } } + } else if (toolName === "write") { + const path = args.path || ""; + const content = args.content || ""; + const lines = content.split("\n"); + text = `**write** \`${path}\` (${lines.length} lines)`; + if (result) { + text += result.isError ? " ❌" : " ✓"; + } + } else if (toolName === "edit") { + const path = args.path || ""; + text = `**edit** \`${path}\``; + if (result) { + text += 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 ? " ❌" : " ✓"; + } } + + return text; } } @@ -107,10 +169,12 @@ export class TuiRenderer { private onInterruptCallback?: () => void; private lastSigintTime = 0; - // Message tracking - private lastStableMessageCount = 0; + // Streaming message tracking private streamingComponent: StreamingMessageComponent | null = null; + // Tool execution tracking: toolCallId -> { component, toolName, args } + private pendingTools = new Map(); + constructor() { this.ui = new TUI(new ProcessTerminal()); this.chatContainer = new Container(); @@ -127,20 +191,16 @@ export class TuiRenderer { // Add header with instructions const header = new Text( - chalk.blueBright(">> coding-agent interactive <<") + - "\n" + - chalk.dim("Press Escape to interrupt while processing") + - "\n" + - chalk.dim("Press CTRL+C to clear the text editor") + - "\n" + - chalk.dim("Press CTRL+C twice quickly to exit"), + ">> 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", ); // Setup UI layout this.ui.addChild(header); this.ui.addChild(this.chatContainer); this.ui.addChild(this.statusContainer); - this.ui.addChild(new Spacer(1)); this.ui.addChild(this.editor); this.ui.setFocus(this.editor); @@ -183,108 +243,146 @@ export class TuiRenderer { this.isInitialized = true; } - async handleStateUpdate(state: AgentState): Promise { + async handleEvent(event: import("@mariozechner/pi-agent").AgentEvent, _state: AgentState): Promise { if (!this.isInitialized) { await this.init(); } - // Count stable messages (exclude the streaming one if streaming) - const stableMessageCount = state.isStreaming ? state.messages.length - 1 : state.messages.length; - - // Add any NEW stable messages - if (stableMessageCount > this.lastStableMessageCount) { - for (let i = this.lastStableMessageCount; i < stableMessageCount; i++) { - const message = state.messages[i]; - this.addMessageToChat(message); - } - this.lastStableMessageCount = stableMessageCount; - } - - // Handle streaming message - if (state.isStreaming) { - const streamingMessage = state.messages[state.messages.length - 1]; - - // Show loading animation if we just started streaming - if (!this.loadingAnimation) { + 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); + this.loadingAnimation = new Loader(this.ui, "Working..."); 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 streaming component for assistant messages + this.streamingComponent = new StreamingMessageComponent(); + this.chatContainer.addChild(this.streamingComponent); + this.streamingComponent.updateContent(event.message); + this.ui.requestRender(); + } + break; + + case "message_update": + // Update streaming component + if (this.streamingComponent && event.message.role === "assistant") { + this.streamingComponent.updateContent(event.message); + 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") { + this.chatContainer.removeChild(this.streamingComponent); + this.streamingComponent = null; + } + // Show final assistant message + this.addMessageToChat(event.message); + this.ui.requestRender(); + break; + + case "tool_execution_start": { + // 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.ui.requestRender(); + break; } - // Create or update streaming component - if (!this.streamingComponent) { - this.streamingComponent = new StreamingMessageComponent(); - this.chatContainer.addChild(this.streamingComponent); - } - this.streamingComponent.updateContent(streamingMessage); - } else { - // Streaming stopped - if (this.loadingAnimation) { - this.loadingAnimation.stop(); - this.loadingAnimation = null; - this.statusContainer.clear(); + 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, { + output: typeof event.result === "string" ? event.result : event.result.output, + isError: event.isError, + }); + this.chatContainer.addChild(updatedComponent); + this.pendingTools.delete(event.toolCallId); + this.ui.requestRender(); + } + break; } - if (this.streamingComponent) { - this.chatContainer.removeChild(this.streamingComponent); - this.streamingComponent = null; - } - - this.editor.disableSubmit = false; + 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; } - - this.ui.requestRender(); } private addMessageToChat(message: Message): void { if (message.role === "user") { - this.chatContainer.addChild(new Text(chalk.green("[user]"))); const userMsg = message as any; - const textContent = userMsg.content?.map((c: any) => c.text || "").join("") || message.content || ""; - this.chatContainer.addChild(new Text(textContent)); - this.chatContainer.addChild(new Spacer(1)); + // 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) { + // User messages with dark gray background + this.chatContainer.addChild(new Markdown(textContent, undefined, undefined, { r: 52, g: 53, b: 65 })); + } } else if (message.role === "assistant") { - this.chatContainer.addChild(new Text(chalk.hex("#FFA500")("[assistant]"))); const assistantMsg = message as AssistantMessage; - // Render text content + // Render text content first (tool calls handled by events) const textContent = assistantMsg.content .filter((c) => c.type === "text") .map((c) => c.text) .join(""); if (textContent) { + // Assistant messages with no background this.chatContainer.addChild(new Markdown(textContent)); } - // Render tool calls - const toolCalls = assistantMsg.content.filter((c) => c.type === "toolCall"); - for (const toolCall of toolCalls) { - const argsStr = - typeof toolCall.arguments === "string" ? toolCall.arguments : JSON.stringify(toolCall.arguments); - this.chatContainer.addChild(new Text(chalk.yellow(`[tool] ${toolCall.name}(${argsStr})`))); + // Check if aborted - show after partial content + if (assistantMsg.stopReason === "aborted") { + // Show red "Aborted" message after partial content + const abortedText = new Text(chalk.red("Aborted")); + this.chatContainer.addChild(abortedText); + return; } - this.chatContainer.addChild(new Spacer(1)); - } else if (message.role === "toolResult") { - const toolResultMsg = message as any; - const output = toolResultMsg.result?.output || toolResultMsg.result || ""; - - // Truncate long outputs - const lines = output.split("\n"); - const maxLines = 10; - const truncated = lines.length > maxLines; - const toShow = truncated ? lines.slice(0, maxLines) : lines; - - for (const line of toShow) { - this.chatContainer.addChild(new Text(chalk.gray(line))); + if (assistantMsg.stopReason === "error") { + // Show red error message after partial content + const errorMsg = assistantMsg.errorMessage || "Unknown error"; + const errorText = new Text(chalk.red(`Error: ${errorMsg}`)); + this.chatContainer.addChild(errorText); + return; } - - if (truncated) { - this.chatContainer.addChild(new Text(chalk.dim(`... (${lines.length - maxLines} more lines)`))); - } - this.chatContainer.addChild(new Spacer(1)); } + // Note: tool calls and results are now handled via tool_execution_start/end events } async getUserInput(): Promise { @@ -303,7 +401,7 @@ export class TuiRenderer { clearEditor(): void { this.editor.setText(""); this.statusContainer.clear(); - const hint = new Text(chalk.dim("Press Ctrl+C again to exit")); + const hint = new Text("Press Ctrl+C again to exit"); this.statusContainer.addChild(hint); this.ui.requestRender(); diff --git a/packages/tui/package.json b/packages/tui/package.json index ba64c9ac..9845c32a 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -40,7 +40,8 @@ "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "marked": "^15.0.12", - "mime-types": "^3.0.1" + "mime-types": "^3.0.1", + "string-width": "^8.1.0" }, "devDependencies": { "@xterm/headless": "^5.5.0", diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index e325010c..b92e57a7 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -1,5 +1,5 @@ -import { stripVTControlCharacters } from "node:util"; import type { Component } from "../tui.js"; +import { visibleWidth } from "../utils.js"; /** * Input component - single-line text input with horizontal scrolling @@ -127,8 +127,8 @@ export class Input implements Component { const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal const textWithCursor = beforeCursor + cursorChar + afterCursor; - // Calculate visual width (strip ANSI codes to measure actual displayed characters) - const visualLength = stripVTControlCharacters(textWithCursor).length; + // Calculate visual width + const visualLength = visibleWidth(textWithCursor); const padding = " ".repeat(Math.max(0, availableWidth - visualLength)); const line = prompt + textWithCursor + padding; diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 8cc49225..8a0dba46 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -1,7 +1,7 @@ -import { stripVTControlCharacters } from "node:util"; import chalk from "chalk"; import { marked, type Token } from "marked"; import type { Component } from "../tui.js"; +import { visibleWidth } from "../utils.js"; type Color = | "black" @@ -109,8 +109,8 @@ export class Markdown implements Component { const paddedLines: string[] = []; for (const line of wrappedLines) { - // Calculate visible length (strip ANSI codes) - const visibleLength = stripVTControlCharacters(line).length; + // Calculate visible length + const visibleLength = visibleWidth(line); // Right padding to fill to width (accounting for left padding and content) const rightPadLength = Math.max(0, width - this.paddingX - visibleLength); const rightPad = " ".repeat(rightPadLength); @@ -328,12 +328,26 @@ export class Markdown implements Component { return [""]; } - // If line fits within width, return as-is - const visibleLength = stripVTControlCharacters(line).length; - if (visibleLength <= width) { - return [line]; + // Split by newlines first - wrap each line individually + const splitLines = line.split("\n"); + for (const splitLine of splitLines) { + const visibleLength = visibleWidth(splitLine); + + if (visibleLength <= width) { + wrapped.push(splitLine); + continue; + } + + // This line needs wrapping + wrapped.push(...this.wrapSingleLine(splitLine, width)); } + return wrapped.length > 0 ? wrapped : [""]; + } + + private wrapSingleLine(line: string, width: number): string[] { + const wrapped: string[] = []; + // Track active ANSI codes to preserve them across wrapped lines const activeAnsiCodes: string[] = []; let currentLine = ""; @@ -381,8 +395,10 @@ export class Markdown implements Component { } currentLength = 0; } - currentLine += line[i]; - currentLength++; + const char = line[i]; + currentLine += char; + // Count actual terminal column width, not string length + currentLength += visibleWidth(char); i++; } } diff --git a/packages/tui/src/components/text.ts b/packages/tui/src/components/text.ts index 6a5d654c..5c8d69a5 100644 --- a/packages/tui/src/components/text.ts +++ b/packages/tui/src/components/text.ts @@ -1,5 +1,5 @@ -import { stripVTControlCharacters } from "node:util"; import type { Component } from "../tui.js"; +import { visibleWidth } from "../utils.js"; /** * Text component - displays multi-line text with word wrapping @@ -50,7 +50,10 @@ export class Text implements Component { const textLines = this.text.split("\n"); for (const line of textLines) { - if (line.length <= contentWidth) { + // Measure visible length (strip ANSI codes) + const visibleLineLength = visibleWidth(line); + + if (visibleLineLength <= contentWidth) { lines.push(line); } else { // Word wrap @@ -58,9 +61,12 @@ export class Text implements Component { let currentLine = ""; for (const word of words) { - if (currentLine.length === 0) { + const currentVisible = visibleWidth(currentLine); + const wordVisible = visibleWidth(word); + + if (currentVisible === 0) { currentLine = word; - } else if (currentLine.length + 1 + word.length <= contentWidth) { + } else if (currentVisible + 1 + wordVisible <= contentWidth) { currentLine += " " + word; } else { lines.push(currentLine); @@ -80,7 +86,7 @@ export class Text implements Component { for (const line of lines) { // Calculate visible length (strip ANSI codes) - const visibleLength = stripVTControlCharacters(line).length; + const visibleLength = visibleWidth(line); // Right padding to fill to width (accounting for left padding and content) const rightPadLength = Math.max(0, width - this.paddingX - visibleLength); const rightPad = " ".repeat(rightPadLength); diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 6d8b8346..4332ef87 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -17,4 +17,6 @@ export { Spacer } from "./components/spacer.js"; export { Text } from "./components/text.js"; // Terminal interface and implementations export { ProcessTerminal, type Terminal } from "./terminal.js"; -export { Component, Container, TUI } from "./tui.js"; +export { type Component, Container, TUI } from "./tui.js"; +// Utilities +export { visibleWidth } from "./utils.js"; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index d99c8a3d..68e42b4b 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -3,6 +3,7 @@ */ import type { Terminal } from "./terminal.js"; +import { visibleWidth } from "./utils.js"; /** * Component interface - all components must implement this @@ -21,6 +22,8 @@ export interface Component { handleInput?(data: string): void; } +export { visibleWidth }; + /** * Container - a component that contains other components */ @@ -211,6 +214,9 @@ export class TUI extends Container { // Render from first changed line to end for (let i = firstChanged; i < newLines.length; i++) { if (i > firstChanged) buffer += "\r\n"; + if (visibleWidth(newLines[i]) > width) { + throw new Error("Rendered line exceeds terminal width"); + } buffer += newLines[i]; } diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts new file mode 100644 index 00000000..64b8d778 --- /dev/null +++ b/packages/tui/src/utils.ts @@ -0,0 +1,12 @@ +import stringWidth from "string-width"; + +/** + * Calculate the visible width of a string in terminal columns. + * This correctly handles: + * - ANSI escape codes (ignored) + * - Emojis and wide characters (counted as 2 columns) + * - Combining characters (counted correctly) + */ +export function visibleWidth(str: string): number { + return stringWidth(str); +}