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_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",

View file

@ -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<string>() });
this._state.isStreaming = false;
this._state.streamMessage = null;
this._state.pendingToolCalls = new Set<string>();
this.abortController = undefined;
this.emit({ type: "completed" });
}
}
private patch(p: Partial<AgentState>): 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) {

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

View file

@ -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", () => {

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) => {
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<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();
});
// 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<string, string> = {
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);
});

View file

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

View file

@ -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<string, { component: ToolExecutionComponent; toolName: string; args: any }>();
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<void> {
async handleEvent(event: import("@mariozechner/pi-agent").AgentEvent, _state: AgentState): Promise<void> {
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<string> {
@ -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();

View file

@ -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",

View file

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

View file

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

View file

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

View file

@ -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";

View file

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

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