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