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

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