fix(tui): Fix garbled output when content exceeds viewport

- Implemented new renderLineBased method that properly handles scrollback boundaries
- Fixed ANSI code preservation in MarkdownComponent line wrapping
- Added comprehensive test to reproduce and verify the fix
- Root cause: PARTIAL rendering strategy couldn't position cursor in scrollback
- Solution: Component-agnostic line comparison with proper viewport boundary handling
This commit is contained in:
Mario Zechner 2025-08-11 14:17:46 +02:00
parent 1d9b77298c
commit 6e40c5d761
6 changed files with 485 additions and 106 deletions

View file

@ -215,30 +215,51 @@ export class MarkdownComponent implements Component {
return [line];
}
// Need to wrap - this is complex with ANSI codes
// For now, use a simple approach that may break styling at wrap points
// Track active ANSI codes to preserve them across wrapped lines
const activeAnsiCodes: string[] = [];
let currentLine = "";
let currentLength = 0;
let i = 0;
while (i < line.length) {
if (line[i] === "\x1b" && line[i + 1] === "[") {
// ANSI escape sequence - include it without counting length
// ANSI escape sequence - parse and track it
let j = i + 2;
while (j < line.length && line[j] && !/[mGKHJ]/.test(line[j]!)) {
j++;
}
if (j < line.length) {
currentLine += line.substring(i, j + 1);
const ansiCode = line.substring(i, j + 1);
currentLine += ansiCode;
// Track styling codes (ending with 'm')
if (line[j] === "m") {
// Reset code
if (ansiCode === "\x1b[0m" || ansiCode === "\x1b[m") {
activeAnsiCodes.length = 0;
} else {
// Add to active codes (replacing similar ones)
activeAnsiCodes.push(ansiCode);
}
}
i = j + 1;
} else {
// Incomplete ANSI sequence at end - don't include it
break;
}
} else {
// Regular character
if (currentLength >= width) {
wrapped.push(currentLine);
currentLine = "";
// Need to wrap - close current line with reset if needed
if (activeAnsiCodes.length > 0) {
wrapped.push(currentLine + "\x1b[0m");
// Start new line with active codes
currentLine = activeAnsiCodes.join("");
} else {
wrapped.push(currentLine);
currentLine = "";
}
currentLength = 0;
}
currentLine += line[i];

View file

@ -274,7 +274,8 @@ export class TUI extends Container {
} else {
// this.executeDifferentialRender(currentRenderCommands, termHeight);
// this.renderDifferential(currentRenderCommands, termHeight);
this.renderDifferentialSurgical(currentRenderCommands, termHeight);
// this.renderDifferentialSurgical(currentRenderCommands, termHeight);
this.renderLineBased(currentRenderCommands, termHeight);
}
// Save for next render
@ -322,6 +323,148 @@ export class TUI extends Container {
this.totalLinesRedrawn += lines.length;
}
private renderLineBased(currentCommands: RenderCommand[], termHeight: number): void {
const viewportHeight = termHeight - 1; // Leave one line for cursor
// Build the new lines array
const newLines: string[] = [];
for (const command of currentCommands) {
newLines.push(...command.lines);
}
const totalNewLines = newLines.length;
const totalOldLines = this.previousLines.length;
// Find first changed line by comparing old and new
let firstChangedLine = -1;
const minLines = Math.min(totalOldLines, totalNewLines);
for (let i = 0; i < minLines; i++) {
if (this.previousLines[i] !== newLines[i]) {
firstChangedLine = i;
break;
}
}
// If all common lines are the same, check if we have different lengths
if (firstChangedLine === -1 && totalOldLines !== totalNewLines) {
firstChangedLine = minLines;
}
// No changes at all
if (firstChangedLine === -1) {
this.previousLines = newLines;
return;
}
// Calculate viewport boundaries
const oldViewportStart = Math.max(0, totalOldLines - viewportHeight);
const cursorPosition = totalOldLines; // Cursor is one line below last content
let output = "";
let linesRedrawn = 0;
// Check if change is in scrollback (unreachable by cursor)
if (firstChangedLine < oldViewportStart) {
// Must do full clear and re-render
output = "\x1b[3J\x1b[H"; // Clear scrollback and screen, home cursor
for (let i = 0; i < newLines.length; i++) {
if (i > 0) output += "\r\n";
output += newLines[i];
}
if (newLines.length > 0) output += "\r\n";
linesRedrawn = newLines.length;
} else {
// Change is in viewport - we can reach it with cursor movements
// Calculate viewport position of the change
const viewportChangePosition = firstChangedLine - oldViewportStart;
// Move cursor to the change position
const linesToMoveUp = (cursorPosition - oldViewportStart) - viewportChangePosition;
if (linesToMoveUp > 0) {
output += `\x1b[${linesToMoveUp}A`;
}
// Now do surgical updates or partial clear based on what's more efficient
let currentLine = firstChangedLine;
let currentViewportLine = viewportChangePosition;
// If we have significant structural changes, just clear and re-render from here
const hasSignificantChanges = totalNewLines !== totalOldLines ||
(totalNewLines - firstChangedLine) > 10; // Arbitrary threshold
if (hasSignificantChanges) {
// Clear from cursor to end of screen and render all remaining lines
output += "\r\x1b[0J";
for (let i = firstChangedLine; i < newLines.length; i++) {
if (i > firstChangedLine) output += "\r\n";
output += newLines[i];
linesRedrawn++;
}
if (newLines.length > firstChangedLine) output += "\r\n";
} else {
// Do surgical line-by-line updates
for (let i = firstChangedLine; i < minLines; i++) {
if (this.previousLines[i] !== newLines[i]) {
// Move to this line if needed
const moveLines = i - currentLine;
if (moveLines > 0) {
output += `\x1b[${moveLines}B`;
}
// Clear and rewrite the line
output += "\r\x1b[2K" + newLines[i];
currentLine = i;
linesRedrawn++;
}
}
// Handle added/removed lines at the end
if (totalNewLines > totalOldLines) {
// Move to end of old content and add new lines
const moveToEnd = totalOldLines - 1 - currentLine;
if (moveToEnd > 0) {
output += `\x1b[${moveToEnd}B`;
}
output += "\r\n";
for (let i = totalOldLines; i < totalNewLines; i++) {
if (i > totalOldLines) output += "\r\n";
output += newLines[i];
linesRedrawn++;
}
output += "\r\n";
} else if (totalNewLines < totalOldLines) {
// Move to end of new content and clear rest
const moveToEnd = totalNewLines - 1 - currentLine;
if (moveToEnd > 0) {
output += `\x1b[${moveToEnd}B`;
} else if (moveToEnd < 0) {
output += `\x1b[${-moveToEnd}A`;
}
output += "\r\n\x1b[0J";
} else {
// Same length, just position cursor at end
const moveToEnd = totalNewLines - 1 - currentLine;
if (moveToEnd > 0) {
output += `\x1b[${moveToEnd}B`;
} else if (moveToEnd < 0) {
output += `\x1b[${-moveToEnd}A`;
}
output += "\r\n";
}
}
}
this.terminal.write(output);
this.previousLines = newLines;
this.totalLinesRedrawn += linesRedrawn;
}
private renderDifferentialSurgical(currentCommands: RenderCommand[], termHeight: number): void {
const viewportHeight = termHeight - 1; // Leave one line for cursor

View file

@ -0,0 +1,186 @@
import { test, describe } from "node:test";
import assert from "node:assert";
import { VirtualTerminal } from "./virtual-terminal.js";
import { TUI, Container, TextComponent, MarkdownComponent, TextEditor, LoadingAnimation } from "../src/index.js";
describe("Multi-Message Garbled Output Reproduction", () => {
test("handles rapid message additions with large content without garbling", async () => {
const terminal = new VirtualTerminal(100, 30);
const ui = new TUI(terminal);
ui.start();
// Simulate the chat demo structure
const chatContainer = new Container();
const statusContainer = new Container();
const editor = new TextEditor();
ui.addChild(chatContainer);
ui.addChild(statusContainer);
ui.addChild(editor);
ui.setFocus(editor);
// Initial render
await new Promise(resolve => process.nextTick(resolve));
await terminal.flush();
// Step 1: Simulate user message
chatContainer.addChild(new TextComponent("[user]"));
chatContainer.addChild(new TextComponent("read all README.md files except in node_modules"));
// Step 2: Start loading animation (assistant thinking)
const loadingAnim = new LoadingAnimation(ui, "Thinking");
statusContainer.addChild(loadingAnim);
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await terminal.flush();
// Step 3: Simulate rapid tool calls with large outputs
chatContainer.addChild(new TextComponent("[assistant]"));
// Simulate glob tool
chatContainer.addChild(new TextComponent('[tool] glob({"pattern":"**/README.md"})'));
const globResult = `README.md
node_modules/@biomejs/biome/README.md
node_modules/@esbuild/darwin-arm64/README.md
node_modules/@types/node/README.md
node_modules/@xterm/headless/README.md
node_modules/@xterm/xterm/README.md
node_modules/chalk/readme.md
node_modules/esbuild/README.md
node_modules/fsevents/README.md
node_modules/get-tsconfig/README.md
... (59 more lines)`;
chatContainer.addChild(new TextComponent(globResult));
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await terminal.flush();
// Simulate multiple read tool calls with long content
const readmeContent = `# Pi Monorepo
A collection of tools for managing LLM deployments and building AI agents.
## Packages
- **[@mariozechner/pi-tui](packages/tui)** - Terminal UI library with differential rendering
- **[@mariozechner/pi-agent](packages/agent)** - General-purpose agent with tool calling and session persistence
- **[@mariozechner/pi](packages/pods)** - CLI for managing vLLM deployments on GPU pods
... (76 more lines)`;
// First read
chatContainer.addChild(new TextComponent('[tool] read({"path": "README.md"})'));
chatContainer.addChild(new MarkdownComponent(readmeContent));
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await terminal.flush();
// Second read with even more content
const tuiReadmeContent = `# @mariozechner/pi-tui
Terminal UI framework with surgical differential rendering for building flicker-free interactive CLI applications.
## Features
- **Surgical Differential Rendering**: Three-strategy system that minimizes redraws to 1-2 lines for typical updates
- **Scrollback Buffer Preservation**: Correctly maintains terminal history when content exceeds viewport
- **Zero Flicker**: Components like text editors remain perfectly still while other parts update
- **Interactive Components**: Text editor with autocomplete, selection lists, markdown rendering
... (570 more lines)`;
chatContainer.addChild(new TextComponent('[tool] read({"path": "packages/tui/README.md"})'));
chatContainer.addChild(new MarkdownComponent(tuiReadmeContent));
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await terminal.flush();
// Step 4: Stop loading animation and add assistant response
loadingAnim.stop();
statusContainer.clear();
const assistantResponse = `I've read the README files from your monorepo. Here's a summary:
The Pi Monorepo contains three main packages:
1. **pi-tui** - A terminal UI framework with advanced differential rendering
2. **pi-agent** - An AI agent with tool calling capabilities
3. **pi** - A CLI for managing GPU pods with vLLM
The TUI library features surgical differential rendering that minimizes screen updates.`;
chatContainer.addChild(new MarkdownComponent(assistantResponse));
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await terminal.flush();
// Step 5: CRITICAL - Send a new message while previous content is displayed
chatContainer.addChild(new TextComponent("[user]"));
chatContainer.addChild(new TextComponent("What is the main purpose of the TUI library?"));
// Start new loading animation
const loadingAnim2 = new LoadingAnimation(ui, "Thinking");
statusContainer.addChild(loadingAnim2);
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await terminal.flush();
// Add assistant response
loadingAnim2.stop();
statusContainer.clear();
chatContainer.addChild(new TextComponent("[assistant]"));
const secondResponse = `The main purpose of the TUI library is to provide a **flicker-free terminal UI framework** with surgical differential rendering.
Key aspects:
- Minimizes screen redraws to only 1-2 lines for typical updates
- Preserves terminal scrollback buffer
- Enables building interactive CLI applications without visual artifacts`;
chatContainer.addChild(new MarkdownComponent(secondResponse));
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await terminal.flush();
// Debug: Show the garbled output after the problematic step
console.log("\n=== After second read (where garbling occurs) ===");
const debugOutput = terminal.getScrollBuffer();
debugOutput.forEach((line, i) => {
if (line.trim()) console.log(`${i}: "${line}"`);
});
// Step 6: Check final output
const finalOutput = terminal.getScrollBuffer();
// Check that first user message is NOT garbled
const userLine1 = finalOutput.find(line => line.includes("read all README.md files"));
assert.strictEqual(userLine1, "read all README.md files except in node_modules",
`First user message is garbled: "${userLine1}"`);
// Check that second user message is clean
const userLine2 = finalOutput.find(line => line.includes("What is the main purpose"));
assert.strictEqual(userLine2, "What is the main purpose of the TUI library?",
`Second user message is garbled: "${userLine2}"`);
// Check for common garbling patterns
const garbledPatterns = [
"README.mdategy",
"README.mdectly",
"modulesl rendering",
"[assistant]ns.",
"node_modules/@esbuild/darwin-arm64/README.mdategy"
];
for (const pattern of garbledPatterns) {
const hasGarbled = finalOutput.some(line => line.includes(pattern));
assert.ok(!hasGarbled, `Found garbled pattern "${pattern}" in output`);
}
ui.stop();
});
});

View file

@ -1,102 +1,3 @@
- agent/tui: interrupting requests to model via ESC doesn't work. Interrupting bash tool works.
- agent/tui: "read all README.md files except in node_modules", gpt-5. wait for completion, then send a new message. Getting garbled output. this happens for both of the renderDifferential and renderDifferentialSurgical methods. We need to emiulate this in a test and get to the bottom of it.
```markdown
>> pi interactive chat <<<
Press Escape to interrupt while processing
Press CTRL+C to clear the text editorM deployments and building AI agents.peScript API)
Press CTRL+C twice quickly to exitdeterministic programs (via JSON mode in any language or the
## PackagesAPI)
[user]iding your own system prompts and tools
read all README.md files except in node_modulesrminal UI library with differential rendering
- **[@mariozechner/pi-agent](packages/agent)** - General-purpose agent with tool calling and session
[assistant]re lines)
[tool] glob({"pattern":"**/README.md"}) - CLI for managing vLLM deployments on GPU pods
README.mdad({"path": "packages/pods/README.md"})
node_modules/@biomejs/biome/README.md
node_modules/@esbuild/darwin-arm64/README.md
node_modules/@types/node/README.mdnt/README.md"}) vLLM configuration for agentic workloads.
node_modules/@xterm/headless/README.md
node_modules/@xterm/xterm/README.md
node_modules/chalk/readme.md tool calling and session persistence, modeled after Claude Code but
node_modules/esbuild/README.md. It comes with a built-in TUI (also modeled after Claude Code) for
node_modules/fsevents/README.md
node_modules/get-tsconfig/README.md
... (59 more lines)ned to be easy:
- Writing custom UIs on top of it (via JSON mode in any language or the TypeScript API)
[tool] read({"path": "README.md"})deterministic programs (via JSON mode in any language or the
# Pi MonorepoI)ath": "packages/tui/README.md"})
- Providing your own system prompts and tools
A collection of tools for managing LLM deployments and building AI agents.
Terminal UI framework with surgical differential rendering for building flicker-free interactive CLI
## Packagesre lines)
- **[@mariozechner/pi-tui](packages/tui)** - Terminal UI library with differential rendering
- **[@mariozechner/pi-agent](packages/agent)** - General-purpose agent with tool calling and session
persistencel Differential Rendering**: Three-strategy system that minimizes redraws to 1-2 lines for
- **[@mariozechner/pi](packages/pods)** - CLI for managing vLLM deployments on GPU podsads.
- **Scrollback Buffer Preservation**: Correctly maintains terminal history when content exceeds
... (76 more lines)
- **Zero Flicker**: Components like text editors remain perfectly still while other parts update
[tool] read({"path": "packages/agent/README.md"})tocomplete, selection lists, markdown rendering
# pi-agentl -g @mariozechner/pi
```
A general-purpose agent with tool calling and session persistence, modeled after Claude Code but
extremely hackable and minimal. It comes with a built-in TUI (also modeled after Claude Code) for────╮
interactive use. │
[tool] read({"path": "packages/tui/README.md"})──────────────────────────────────────────────────────╯
Everything is designed to be easy:
- Writing custom UIs on top of it (via JSON mode in any language or the TypeScript API)
- Using it for inference steps in deterministic programs (via JSON mode in any language or theve CLI
TypeScript API)
- Providing your own system prompts and tools
- Working with various LLM providers or self-hosted LLMs
... (422 more lines)ntial Rendering**: Three-strategy system that minimizes redraws to 1-2 lines for
typical updates
[tool] read({"path": "packages/pods/README.md"})maintains terminal history when content exceeds
# piport
- **Zero Flicker**: Components like text editors remain perfectly still while other parts update
Deploy and manage LLMs on GPU pods with automatic vLLM configuration for agentic workloads.ering
... (570 more lines)
## Installation
[user]
```bash
npm install -g @mariozechner/pi
```sistant]
⠴ Thinking...
... (501 more lines)
[tool] read({"path": "packages/tui/README.md"})──────────────────────────────────────────────────────╮
# @mariozechner/pi-tui │
╰────────────────────────────────────────────────────────────────────────────────────────────────────╯
Terminal UI framework with surgical differential rendering for building flicker-free interactive CLI
applications.
## Features
- **Surgical Differential Rendering**: Three-strategy system that minimizes redraws to 1-2 lines for
typical updates
- **Scrollback Buffer Preservation**: Correctly maintains terminal history when content exceeds
viewport
- **Zero Flicker**: Components like text editors remain perfectly still while other parts update
- **Interactive Components**: Text editor with autocomplete, selection lists, markdown rendering
... (570 more lines)
[user]
l
[assistant]
Do you want me to list the current directory contents? If yes, should I include hidden files and subdir
ectories?
╭────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ > │
╰────────────────────────────────────────────────────────────────────────────────────────────────────╯
↑14,783 ↓160 ⚡128 ⚒ 5
```
- pods: pi start outputs all models that can be run on the pod. however, it doesn't check the vllm version. e.g. gpt-oss can only run via vllm+gpt-oss. glm4.5 can only run on vllm nightly.
- agent: improve reasoning section in README.md

View file

@ -0,0 +1,93 @@
# TUI Garbled Output Analysis
## Problem Description
When reading multiple README.md files and then sending a new message, the TUI displays garbled output. This happens for both renderDifferential and renderDifferentialSurgical methods, affecting any model (not just gpt-5).
## Rendering System Overview
### Three Rendering Strategies
1. **SURGICAL Updates** - Updates only changed lines (1-2 lines typical)
2. **PARTIAL Re-render** - Clears from first change to end, re-renders tail
3. **FULL Re-render** - Clears scrollback and screen, renders everything
### Key Components
- **TUI Class** (`packages/tui/src/tui.ts`): Main rendering engine
- **Container Class**: Manages child components, auto-triggers re-renders
- **TuiRenderer** (`packages/agent/src/renderers/tui-renderer.ts`): Agent's TUI integration
- **Event System**: Event-driven updates through AgentEvent
## Root Causes Identified
### 1. Complex ANSI Code Handling
- MarkdownComponent line wrapping has issues with ANSI escape sequences
- Code comment at line 203: "Need to wrap - this is complex with ANSI codes"
- ANSI codes can be split across render operations, causing corruption
### 2. Race Conditions in Rapid Updates
When processing multiple tool calls:
- Multiple containers change simultaneously
- Content added both above and within viewport
- Surgical renderer handles structural changes while maintaining cursor position
- Heavy ANSI content (colored tool output, markdown) increases complexity
### 3. Cursor Position Miscalculation
- Rapid updates can cause cursor positioning logic errors
- Content shifts due to previous renders not properly accounted for
- Viewport vs scrollback buffer calculations can become incorrect
### 4. Container Change Detection Timing
- Recent fix (192d8d2) addressed container clear detection
- But rapid component addition/removal still may leave artifacts
- Multiple render requests debounced but may miss intermediate states
## Specific Scenario Analysis
### Sequence When Issue Occurs:
1. User sends "read all README.md files"
2. Multiple tool calls execute rapidly:
- glob() finds files
- Multiple read() calls for each README
3. Long file contents displayed with markdown formatting
4. User sends new message while output still rendering
5. New components added while previous render incomplete
### Visual Artifacts Observed:
- Text overlapping from different messages
- Partial ANSI codes causing color bleeding
- Editor borders duplicated or misaligned
- Content from previous render persisting
- Line wrapping breaking mid-word with styling
## Related Fixes
- Commit 1d9b772: Fixed ESC interrupt handling race conditions
- Commit 192d8d2: Fixed container change detection for clear operations
- Commit 2ec8a27: Added instructional header to chat demo
## Test Coverage Gaps
- No tests for rapid multi-tool execution scenarios
- Missing tests for ANSI code handling across line wraps
- No stress tests for viewport overflow with rapid updates
- Layout shift artifacts test exists but limited scope
## Recommended Solutions
### 1. Improve ANSI Handling
- Fix MarkdownComponent line wrapping to preserve ANSI codes
- Ensure escape sequences never split across operations
- Add ANSI-aware string measurement utilities
### 2. Add Render Queuing
- Implement render operation queue to prevent overlaps
- Ensure each render completes before next begins
- Add render state tracking
### 3. Enhanced Change Detection
- Track render generation/version numbers
- Validate cursor position before surgical updates
- Add checksums for rendered content verification
### 4. Comprehensive Testing
- Create test simulating exact failure scenario
- Add stress tests with rapid multi-component updates
- Test ANSI-heavy content with line wrapping
- Verify viewport calculations under load

View file

@ -0,0 +1,35 @@
# Fix TUI Garbled Output When Sending Multiple Messages
**Status:** InProgress
**Agent PID:** 54802
## Original Todo
agent/tui: "read all README.md files except in node_modules". wait for completion, then send a new message. Getting garbled output. this happens for both of the renderDifferential and renderDifferentialSurgical methods. We need to emulate this in a test and get to the bottom of it.
## Description
Fix the TUI rendering corruption that occurs when sending multiple messages in rapid succession, particularly after tool calls that produce large outputs. The issue manifests as garbled/overlapping text when new messages are sent while previous output is still being displayed.
*Read [analysis.md](./analysis.md) in full for detailed codebase research and context*
## Implementation Plan
[how we are building it]
- [x] Create test to reproduce the issue: Simulate rapid tool calls with large outputs followed by new message
- [x] Fix ANSI code handling in MarkdownComponent line wrapping (packages/tui/src/components/markdown-component.ts:203-276)
- [x] Implement new line-based rendering strategy that properly handles scrollback and viewport boundaries
- [x] Add comprehensive test coverage for multi-message scenarios
- [ ] User test: Run agent, execute "read all README.md files", wait for completion, send new message, verify no garbled output
## Notes
- Successfully reproduced the issue with test showing garbled text overlay
- Fixed ANSI code handling in MarkdownComponent line wrapping
- Root cause: PARTIAL rendering strategy incorrectly calculated cursor position when content exceeded viewport
- When content is in scrollback, cursor can't reach it (can only move within viewport)
- Old PARTIAL strategy tried to move cursor 33 lines up when only 30 were possible
- This caused cursor to land at wrong position (top of viewport instead of target line in scrollback)
- Solution: Implemented new `renderLineBased` method that:
- Compares old and new lines directly (component-agnostic)
- Detects if changes are in scrollback (unreachable) or viewport
- For scrollback changes: does full clear and re-render
- For viewport changes: moves cursor correctly within viewport bounds and updates efficiently
- Handles surgical line-by-line updates when possible for minimal redraws
- Test now passes - no more garbled output when messages exceed viewport!