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...'
This commit is contained in:
Mario Zechner 2025-11-11 19:27:58 +01:00
parent 985f955ea0
commit c5083bb7cb
16 changed files with 429 additions and 372 deletions

58
package-lock.json generated
View file

@ -3132,6 +3132,18 @@
"node": "6.* || 8.* || >= 10.*" "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": { "node_modules/get-tsconfig": {
"version": "4.10.1", "version": "4.10.1",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
@ -5513,7 +5525,8 @@
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"chalk": "^5.5.0", "chalk": "^5.5.0",
"marked": "^15.0.12", "marked": "^15.0.12",
"mime-types": "^3.0.1" "mime-types": "^3.0.1",
"string-width": "^8.1.0"
}, },
"devDependencies": { "devDependencies": {
"@xterm/headless": "^5.5.0", "@xterm/headless": "^5.5.0",
@ -5523,6 +5536,18 @@
"node": ">=20.0.0" "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": { "packages/tui/node_modules/chalk": {
"version": "5.6.2", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@ -5545,6 +5570,37 @@
"node": ">= 18" "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": { "packages/web-ui": {
"name": "@mariozechner/pi-web-ui", "name": "@mariozechner/pi-web-ui",
"version": "0.5.48", "version": "0.5.48",

View file

@ -87,33 +87,32 @@ export class Agent {
subscribe(fn: (e: AgentEvent) => void): () => void { subscribe(fn: (e: AgentEvent) => void): () => void {
this.listeners.add(fn); this.listeners.add(fn);
fn({ type: "state-update", state: this._state });
return () => this.listeners.delete(fn); return () => this.listeners.delete(fn);
} }
// State mutators // State mutators - update internal state without emitting events
setSystemPrompt(v: string) { setSystemPrompt(v: string) {
this.patch({ systemPrompt: v }); this._state.systemPrompt = v;
} }
setModel(m: typeof this._state.model) { setModel(m: typeof this._state.model) {
this.patch({ model: m }); this._state.model = m;
} }
setThinkingLevel(l: ThinkingLevel) { setThinkingLevel(l: ThinkingLevel) {
this.patch({ thinkingLevel: l }); this._state.thinkingLevel = l;
} }
setTools(t: typeof this._state.tools) { setTools(t: typeof this._state.tools) {
this.patch({ tools: t }); this._state.tools = t;
} }
replaceMessages(ms: AppMessage[]) { replaceMessages(ms: AppMessage[]) {
this.patch({ messages: ms.slice() }); this._state.messages = ms.slice();
} }
appendMessage(m: AppMessage) { appendMessage(m: AppMessage) {
this.patch({ messages: [...this._state.messages, m] }); this._state.messages = [...this._state.messages, m];
} }
async queueMessage(m: AppMessage) { async queueMessage(m: AppMessage) {
@ -126,7 +125,7 @@ export class Agent {
} }
clearMessages() { clearMessages() {
this.patch({ messages: [] }); this._state.messages = [];
} }
abort() { abort() {
@ -163,8 +162,12 @@ export class Agent {
}; };
this.abortController = new AbortController(); this.abortController = new AbortController();
this.patch({ isStreaming: true, streamMessage: null, error: undefined }); this._state.isStreaming = true;
this.emit({ type: "started" }); this._state.streamMessage = null;
this._state.error = undefined;
// Emit agent_start
this.emit({ type: "agent_start" });
const reasoning = const reasoning =
this._state.thinkingLevel === "off" this._state.thinkingLevel === "off"
@ -186,6 +189,9 @@ export class Agent {
}, },
}; };
// Track all messages generated in this prompt
const generatedMessages: AppMessage[] = [];
try { try {
let partial: Message | null = null; let partial: Message | null = null;
@ -198,38 +204,51 @@ export class Agent {
cfg, cfg,
this.abortController.signal, this.abortController.signal,
)) { )) {
// Pass through all events directly
this.emit(ev as AgentEvent);
// Update internal state as needed
switch (ev.type) { switch (ev.type) {
case "message_start": case "message_start": {
case "message_update": { // Track streaming message
partial = ev.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; break;
} }
case "message_end": { case "message_end": {
// Add completed message to state
partial = null; partial = null;
this._state.streamMessage = null;
this.appendMessage(ev.message as AppMessage); this.appendMessage(ev.message as AppMessage);
this.patch({ streamMessage: null }); generatedMessages.push(ev.message as AppMessage);
break; break;
} }
case "tool_execution_start": { case "tool_execution_start": {
const s = new Set(this._state.pendingToolCalls); const s = new Set(this._state.pendingToolCalls);
s.add(ev.toolCallId); s.add(ev.toolCallId);
this.patch({ pendingToolCalls: s }); this._state.pendingToolCalls = s;
break; break;
} }
case "tool_execution_end": { case "tool_execution_end": {
const s = new Set(this._state.pendingToolCalls); const s = new Set(this._state.pendingToolCalls);
s.delete(ev.toolCallId); s.delete(ev.toolCallId);
this.patch({ pendingToolCalls: s }); this._state.pendingToolCalls = s;
break; break;
} }
case "agent_end": { case "agent_end": {
this.patch({ streamMessage: null }); this._state.streamMessage = null;
break; break;
} }
} }
} }
// Handle any remaining partial message
if (partial && partial.role === "assistant" && partial.content.length > 0) { if (partial && partial.role === "assistant" && partial.content.length > 0) {
const onlyEmpty = !partial.content.some( const onlyEmpty = !partial.content.some(
(c) => (c) =>
@ -239,6 +258,7 @@ export class Agent {
); );
if (!onlyEmpty) { if (!onlyEmpty) {
this.appendMessage(partial as AppMessage); this.appendMessage(partial as AppMessage);
generatedMessages.push(partial as AppMessage);
} else { } else {
if (this.abortController?.signal.aborted) { if (this.abortController?.signal.aborted) {
throw new Error("Request was aborted"); throw new Error("Request was aborted");
@ -264,17 +284,17 @@ export class Agent {
timestamp: Date.now(), timestamp: Date.now(),
}; };
this.appendMessage(msg as AppMessage); 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 { } finally {
this.patch({ isStreaming: false, streamMessage: null, pendingToolCalls: new Set<string>() }); this._state.isStreaming = false;
this._state.streamMessage = null;
this._state.pendingToolCalls = new Set<string>();
this.abortController = undefined; this.abortController = undefined;
this.emit({ type: "completed" });
}
}
private patch(p: Partial<AgentState>): void { // Emit agent_end with all generated messages
this._state = { ...this._state, ...p }; this.emit({ type: "agent_end", messages: generatedMessages });
this.emit({ type: "state-update", state: this._state }); }
} }
private emit(e: AgentEvent) { private emit(e: AgentEvent) {

View file

@ -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. * Attachment type definition.
@ -71,5 +78,20 @@ export interface AgentState {
/** /**
* Events emitted by the Agent for UI updates. * 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 };

View file

@ -36,30 +36,28 @@ describe("Agent", () => {
expect(agent.state.thinkingLevel).toBe("low"); expect(agent.state.thinkingLevel).toBe("low");
}); });
it("should subscribe to state updates", () => { it("should subscribe to events", () => {
const agent = new Agent({ const agent = new Agent({
transport: new ProviderTransport(), transport: new ProviderTransport(),
}); });
let updateCount = 0; let eventCount = 0;
const unsubscribe = agent.subscribe((event) => { const unsubscribe = agent.subscribe((_event) => {
if (event.type === "state-update") { eventCount++;
updateCount++;
}
}); });
// Initial state update on subscribe // No initial event on subscribe
expect(updateCount).toBe(1); expect(eventCount).toBe(0);
// Update state // State mutators don't emit events
agent.setSystemPrompt("Test prompt"); agent.setSystemPrompt("Test prompt");
expect(updateCount).toBe(2); expect(eventCount).toBe(0);
expect(agent.state.systemPrompt).toBe("Test prompt"); expect(agent.state.systemPrompt).toBe("Test prompt");
// Unsubscribe should work // Unsubscribe should work
unsubscribe(); unsubscribe();
agent.setSystemPrompt("Another prompt"); agent.setSystemPrompt("Another prompt");
expect(updateCount).toBe(2); // Should not increase expect(eventCount).toBe(0); // Should not increase
}); });
it("should update state with mutators", () => { it("should update state with mutators", () => {

View file

@ -167,29 +167,26 @@ async function stateUpdates(model: Model<any>) {
}), }),
}); });
const stateSnapshots: Array<{ isStreaming: boolean; messageCount: number; hasStreamMessage: boolean }> = []; const events: Array<string> = [];
agent.subscribe((event) => { agent.subscribe((event) => {
if (event.type === "state-update") { events.push(event.type);
stateSnapshots.push({
isStreaming: event.state.isStreaming,
messageCount: event.state.messages.length,
hasStreamMessage: event.state.streamMessage !== null,
});
}
}); });
await agent.prompt("Count from 1 to 5."); await agent.prompt("Count from 1 to 5.");
const streamingStates = stateSnapshots.filter((s) => s.isStreaming); // Should have received lifecycle events
const nonStreamingStates = stateSnapshots.filter((s) => !s.isStreaming); 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); // Check final state
expect(nonStreamingStates.length).toBeGreaterThan(0); expect(agent.state.isStreaming).toBe(false);
expect(agent.state.messages.length).toBe(2); // User message + assistant response
const finalState = stateSnapshots[stateSnapshots.length - 1];
expect(finalState.isStreaming).toBe(false);
expect(finalState.messageCount).toBe(2);
} }
async function multiTurnConversation(model: Model<any>) { async function multiTurnConversation(model: Model<any>) {

View file

@ -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-<timestamp>.log` - TUI rendering events
- `/tmp/agent-debug-<timestamp>.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

View file

@ -116,11 +116,10 @@ async function runInteractiveMode(agent: Agent, _sessionManager: SessionManager)
agent.abort(); agent.abort();
}); });
// Subscribe to agent state updates // Subscribe to agent events
agent.subscribe(async (event) => { agent.subscribe(async (event) => {
if (event.type === "state-update") { // Pass all events to the renderer
await renderer.handleStateUpdate(event.state); await renderer.handleEvent(event, agent.state);
}
}); });
// Interactive loop // Interactive loop
@ -168,8 +167,8 @@ export async function main(args: string[]) {
const sessionManager = new SessionManager(parsed.continue); const sessionManager = new SessionManager(parsed.continue);
// Determine provider and model // Determine provider and model
const provider = (parsed.provider || "google") as any; const provider = (parsed.provider || "anthropic") as any;
const modelId = parsed.model || "gemini-2.5-flash"; const modelId = parsed.model || "claude-sonnet-4-5";
// Get API key // Get API key
let apiKey = parsed.apiKey; let apiKey = parsed.apiKey;
@ -177,7 +176,7 @@ export async function main(args: string[]) {
const envVarMap: Record<string, string> = { const envVarMap: Record<string, string> = {
google: "GEMINI_API_KEY", google: "GEMINI_API_KEY",
openai: "OPENAI_API_KEY", openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY", anthropic: "ANTHROPIC_OAUTH_TOKEN",
xai: "XAI_API_KEY", xai: "XAI_API_KEY",
groq: "GROQ_API_KEY", groq: "GROQ_API_KEY",
cerebras: "CEREBRAS_API_KEY", cerebras: "CEREBRAS_API_KEY",
@ -221,20 +220,14 @@ export async function main(args: string[]) {
// Start session // Start session
sessionManager.startSession(agent.state); sessionManager.startSession(agent.state);
// Subscribe to state updates to save messages // Subscribe to agent events to save messages and log events
agent.subscribe((event) => { agent.subscribe((event) => {
if (event.type === "state-update") { // Save messages on completion
// Save any new messages if (event.type === "message_end") {
const currentMessages = event.state.messages; sessionManager.saveMessage(event.message);
const loadedMessages = sessionManager.loadMessages();
if (currentMessages.length > loadedMessages.length) {
for (let i = loadedMessages.length; i < currentMessages.length; i++) {
sessionManager.saveMessage(currentMessages[i]);
}
}
} }
// Log all events
sessionManager.saveEvent(event); sessionManager.saveEvent(event);
}); });

View file

@ -58,7 +58,7 @@ export class SessionManager {
const cwd = process.cwd(); const cwd = process.cwd();
const safePath = "--" + cwd.replace(/^\//, "").replace(/\//g, "-") + "--"; 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); const sessionDir = join(configDir, "sessions", safePath);
if (!existsSync(sessionDir)) { if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true }); mkdirSync(sessionDir, { recursive: true });

View file

@ -7,7 +7,6 @@ import {
Loader, Loader,
Markdown, Markdown,
ProcessTerminal, ProcessTerminal,
Spacer,
Text, Text,
TUI, TUI,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
@ -42,14 +41,17 @@ class CustomEditor extends Editor {
* Component that renders a streaming message with live updates * Component that renders a streaming message with live updates
*/ */
class StreamingMessageComponent extends Container { class StreamingMessageComponent extends Container {
private textComponent: Markdown | null = null; private markdown: Markdown;
private toolCallsContainer: Container | null = null;
private currentContent = ""; constructor() {
private currentToolCalls: any[] = []; super();
this.markdown = new Markdown("");
this.addChild(this.markdown);
}
updateContent(message: Message | null) { updateContent(message: Message | null) {
if (!message) { if (!message) {
this.clear(); this.markdown.setText("");
return; return;
} }
@ -61,35 +63,95 @@ class StreamingMessageComponent extends Container {
.filter((c) => c.type === "text") .filter((c) => c.type === "text")
.map((c) => c.text) .map((c) => c.text)
.join(""); .join("");
if (textContent !== this.currentContent) {
this.currentContent = textContent; this.markdown.setText(textContent);
if (this.textComponent) { }
this.removeChild(this.textComponent); }
}
/**
* 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) { text += "\n```";
this.textComponent = new Markdown(textContent);
this.addChild(this.textComponent); 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 text += "\n```\n" + displayLines.join("\n");
const toolCalls = assistantMsg.content.filter((c) => c.type === "toolCall"); if (remaining > 0) {
if (JSON.stringify(toolCalls) !== JSON.stringify(this.currentToolCalls)) { text += `\n... (${remaining} more lines)`;
this.currentToolCalls = toolCalls;
if (this.toolCallsContainer) {
this.removeChild(this.toolCallsContainer);
} }
if (toolCalls.length > 0) { text += "\n```";
this.toolCallsContainer = new Container();
for (const toolCall of toolCalls) { if (result.isError) {
const argsStr = text += " ❌";
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);
} }
} }
} 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 onInterruptCallback?: () => void;
private lastSigintTime = 0; private lastSigintTime = 0;
// Message tracking // Streaming message tracking
private lastStableMessageCount = 0;
private streamingComponent: StreamingMessageComponent | null = null; private streamingComponent: StreamingMessageComponent | null = null;
// Tool execution tracking: toolCallId -> { component, toolName, args }
private pendingTools = new Map<string, { component: ToolExecutionComponent; toolName: string; args: any }>();
constructor() { constructor() {
this.ui = new TUI(new ProcessTerminal()); this.ui = new TUI(new ProcessTerminal());
this.chatContainer = new Container(); this.chatContainer = new Container();
@ -127,20 +191,16 @@ export class TuiRenderer {
// Add header with instructions // Add header with instructions
const header = new Text( const header = new Text(
chalk.blueBright(">> coding-agent interactive <<") + ">> coding-agent interactive <<\n" +
"\n" + "Press Escape to interrupt while processing\n" +
chalk.dim("Press Escape to interrupt while processing") + "Press CTRL+C to clear the text editor\n" +
"\n" + "Press CTRL+C twice quickly to exit\n",
chalk.dim("Press CTRL+C to clear the text editor") +
"\n" +
chalk.dim("Press CTRL+C twice quickly to exit"),
); );
// Setup UI layout // Setup UI layout
this.ui.addChild(header); this.ui.addChild(header);
this.ui.addChild(this.chatContainer); this.ui.addChild(this.chatContainer);
this.ui.addChild(this.statusContainer); this.ui.addChild(this.statusContainer);
this.ui.addChild(new Spacer(1));
this.ui.addChild(this.editor); this.ui.addChild(this.editor);
this.ui.setFocus(this.editor); this.ui.setFocus(this.editor);
@ -183,108 +243,146 @@ export class TuiRenderer {
this.isInitialized = true; this.isInitialized = true;
} }
async handleStateUpdate(state: AgentState): Promise<void> { async handleEvent(event: import("@mariozechner/pi-agent").AgentEvent, _state: AgentState): Promise<void> {
if (!this.isInitialized) { if (!this.isInitialized) {
await this.init(); await this.init();
} }
// Count stable messages (exclude the streaming one if streaming) switch (event.type) {
const stableMessageCount = state.isStreaming ? state.messages.length - 1 : state.messages.length; case "agent_start":
// Show loading animation
// 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) {
this.editor.disableSubmit = true; this.editor.disableSubmit = true;
// Stop old loader before clearing
if (this.loadingAnimation) {
this.loadingAnimation.stop();
}
this.statusContainer.clear(); this.statusContainer.clear();
this.loadingAnimation = new Loader(this.ui); this.loadingAnimation = new Loader(this.ui, "Working...");
this.statusContainer.addChild(this.loadingAnimation); 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 case "tool_execution_end": {
if (!this.streamingComponent) { // Update the existing tool component with the result
this.streamingComponent = new StreamingMessageComponent(); const pending = this.pendingTools.get(event.toolCallId);
this.chatContainer.addChild(this.streamingComponent); if (pending) {
} // Re-render the component with result
this.streamingComponent.updateContent(streamingMessage); this.chatContainer.removeChild(pending.component);
} else { const updatedComponent = new ToolExecutionComponent(pending.toolName, pending.args, {
// Streaming stopped output: typeof event.result === "string" ? event.result : event.result.output,
if (this.loadingAnimation) { isError: event.isError,
this.loadingAnimation.stop(); });
this.loadingAnimation = null; this.chatContainer.addChild(updatedComponent);
this.statusContainer.clear(); this.pendingTools.delete(event.toolCallId);
this.ui.requestRender();
}
break;
} }
if (this.streamingComponent) { case "agent_end":
this.chatContainer.removeChild(this.streamingComponent); // Stop loading animation
this.streamingComponent = null; if (this.loadingAnimation) {
} this.loadingAnimation.stop();
this.loadingAnimation = null;
this.editor.disableSubmit = false; 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 { private addMessageToChat(message: Message): void {
if (message.role === "user") { if (message.role === "user") {
this.chatContainer.addChild(new Text(chalk.green("[user]")));
const userMsg = message as any; const userMsg = message as any;
const textContent = userMsg.content?.map((c: any) => c.text || "").join("") || message.content || ""; // Extract text content from content blocks
this.chatContainer.addChild(new Text(textContent)); const textBlocks = userMsg.content.filter((c: any) => c.type === "text");
this.chatContainer.addChild(new Spacer(1)); 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") { } else if (message.role === "assistant") {
this.chatContainer.addChild(new Text(chalk.hex("#FFA500")("[assistant]")));
const assistantMsg = message as AssistantMessage; const assistantMsg = message as AssistantMessage;
// Render text content // Render text content first (tool calls handled by events)
const textContent = assistantMsg.content const textContent = assistantMsg.content
.filter((c) => c.type === "text") .filter((c) => c.type === "text")
.map((c) => c.text) .map((c) => c.text)
.join(""); .join("");
if (textContent) { if (textContent) {
// Assistant messages with no background
this.chatContainer.addChild(new Markdown(textContent)); this.chatContainer.addChild(new Markdown(textContent));
} }
// Render tool calls // Check if aborted - show after partial content
const toolCalls = assistantMsg.content.filter((c) => c.type === "toolCall"); if (assistantMsg.stopReason === "aborted") {
for (const toolCall of toolCalls) { // Show red "Aborted" message after partial content
const argsStr = const abortedText = new Text(chalk.red("Aborted"));
typeof toolCall.arguments === "string" ? toolCall.arguments : JSON.stringify(toolCall.arguments); this.chatContainer.addChild(abortedText);
this.chatContainer.addChild(new Text(chalk.yellow(`[tool] ${toolCall.name}(${argsStr})`))); return;
} }
this.chatContainer.addChild(new Spacer(1)); if (assistantMsg.stopReason === "error") {
} else if (message.role === "toolResult") { // Show red error message after partial content
const toolResultMsg = message as any; const errorMsg = assistantMsg.errorMessage || "Unknown error";
const output = toolResultMsg.result?.output || toolResultMsg.result || ""; const errorText = new Text(chalk.red(`Error: ${errorMsg}`));
this.chatContainer.addChild(errorText);
// Truncate long outputs return;
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 (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<string> { async getUserInput(): Promise<string> {
@ -303,7 +401,7 @@ export class TuiRenderer {
clearEditor(): void { clearEditor(): void {
this.editor.setText(""); this.editor.setText("");
this.statusContainer.clear(); 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.statusContainer.addChild(hint);
this.ui.requestRender(); this.ui.requestRender();

View file

@ -40,7 +40,8 @@
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"chalk": "^5.5.0", "chalk": "^5.5.0",
"marked": "^15.0.12", "marked": "^15.0.12",
"mime-types": "^3.0.1" "mime-types": "^3.0.1",
"string-width": "^8.1.0"
}, },
"devDependencies": { "devDependencies": {
"@xterm/headless": "^5.5.0", "@xterm/headless": "^5.5.0",

View file

@ -1,5 +1,5 @@
import { stripVTControlCharacters } from "node:util";
import type { Component } from "../tui.js"; import type { Component } from "../tui.js";
import { visibleWidth } from "../utils.js";
/** /**
* Input component - single-line text input with horizontal scrolling * 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 cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal
const textWithCursor = beforeCursor + cursorChar + afterCursor; const textWithCursor = beforeCursor + cursorChar + afterCursor;
// Calculate visual width (strip ANSI codes to measure actual displayed characters) // Calculate visual width
const visualLength = stripVTControlCharacters(textWithCursor).length; const visualLength = visibleWidth(textWithCursor);
const padding = " ".repeat(Math.max(0, availableWidth - visualLength)); const padding = " ".repeat(Math.max(0, availableWidth - visualLength));
const line = prompt + textWithCursor + padding; const line = prompt + textWithCursor + padding;

View file

@ -1,7 +1,7 @@
import { stripVTControlCharacters } from "node:util";
import chalk from "chalk"; import chalk from "chalk";
import { marked, type Token } from "marked"; import { marked, type Token } from "marked";
import type { Component } from "../tui.js"; import type { Component } from "../tui.js";
import { visibleWidth } from "../utils.js";
type Color = type Color =
| "black" | "black"
@ -109,8 +109,8 @@ export class Markdown implements Component {
const paddedLines: string[] = []; const paddedLines: string[] = [];
for (const line of wrappedLines) { for (const line of wrappedLines) {
// Calculate visible length (strip ANSI codes) // Calculate visible length
const visibleLength = stripVTControlCharacters(line).length; const visibleLength = visibleWidth(line);
// Right padding to fill to width (accounting for left padding and content) // Right padding to fill to width (accounting for left padding and content)
const rightPadLength = Math.max(0, width - this.paddingX - visibleLength); const rightPadLength = Math.max(0, width - this.paddingX - visibleLength);
const rightPad = " ".repeat(rightPadLength); const rightPad = " ".repeat(rightPadLength);
@ -328,12 +328,26 @@ export class Markdown implements Component {
return [""]; return [""];
} }
// If line fits within width, return as-is // Split by newlines first - wrap each line individually
const visibleLength = stripVTControlCharacters(line).length; const splitLines = line.split("\n");
if (visibleLength <= width) { for (const splitLine of splitLines) {
return [line]; 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 // Track active ANSI codes to preserve them across wrapped lines
const activeAnsiCodes: string[] = []; const activeAnsiCodes: string[] = [];
let currentLine = ""; let currentLine = "";
@ -381,8 +395,10 @@ export class Markdown implements Component {
} }
currentLength = 0; currentLength = 0;
} }
currentLine += line[i]; const char = line[i];
currentLength++; currentLine += char;
// Count actual terminal column width, not string length
currentLength += visibleWidth(char);
i++; i++;
} }
} }

View file

@ -1,5 +1,5 @@
import { stripVTControlCharacters } from "node:util";
import type { Component } from "../tui.js"; import type { Component } from "../tui.js";
import { visibleWidth } from "../utils.js";
/** /**
* Text component - displays multi-line text with word wrapping * Text component - displays multi-line text with word wrapping
@ -50,7 +50,10 @@ export class Text implements Component {
const textLines = this.text.split("\n"); const textLines = this.text.split("\n");
for (const line of textLines) { 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); lines.push(line);
} else { } else {
// Word wrap // Word wrap
@ -58,9 +61,12 @@ export class Text implements Component {
let currentLine = ""; let currentLine = "";
for (const word of words) { for (const word of words) {
if (currentLine.length === 0) { const currentVisible = visibleWidth(currentLine);
const wordVisible = visibleWidth(word);
if (currentVisible === 0) {
currentLine = word; currentLine = word;
} else if (currentLine.length + 1 + word.length <= contentWidth) { } else if (currentVisible + 1 + wordVisible <= contentWidth) {
currentLine += " " + word; currentLine += " " + word;
} else { } else {
lines.push(currentLine); lines.push(currentLine);
@ -80,7 +86,7 @@ export class Text implements Component {
for (const line of lines) { for (const line of lines) {
// Calculate visible length (strip ANSI codes) // 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) // Right padding to fill to width (accounting for left padding and content)
const rightPadLength = Math.max(0, width - this.paddingX - visibleLength); const rightPadLength = Math.max(0, width - this.paddingX - visibleLength);
const rightPad = " ".repeat(rightPadLength); const rightPad = " ".repeat(rightPadLength);

View file

@ -17,4 +17,6 @@ export { Spacer } from "./components/spacer.js";
export { Text } from "./components/text.js"; export { Text } from "./components/text.js";
// Terminal interface and implementations // Terminal interface and implementations
export { ProcessTerminal, type Terminal } from "./terminal.js"; 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";

View file

@ -3,6 +3,7 @@
*/ */
import type { Terminal } from "./terminal.js"; import type { Terminal } from "./terminal.js";
import { visibleWidth } from "./utils.js";
/** /**
* Component interface - all components must implement this * Component interface - all components must implement this
@ -21,6 +22,8 @@ export interface Component {
handleInput?(data: string): void; handleInput?(data: string): void;
} }
export { visibleWidth };
/** /**
* Container - a component that contains other components * Container - a component that contains other components
*/ */
@ -211,6 +214,9 @@ export class TUI extends Container {
// Render from first changed line to end // Render from first changed line to end
for (let i = firstChanged; i < newLines.length; i++) { for (let i = firstChanged; i < newLines.length; i++) {
if (i > firstChanged) buffer += "\r\n"; if (i > firstChanged) buffer += "\r\n";
if (visibleWidth(newLines[i]) > width) {
throw new Error("Rendered line exceeds terminal width");
}
buffer += newLines[i]; buffer += newLines[i];
} }

12
packages/tui/src/utils.ts Normal file
View file

@ -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);
}