mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 05:02:14 +00:00
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:
parent
1d9b77298c
commit
6e40c5d761
6 changed files with 485 additions and 106 deletions
|
|
@ -215,30 +215,51 @@ export class MarkdownComponent implements Component {
|
||||||
return [line];
|
return [line];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Need to wrap - this is complex with ANSI codes
|
// Track active ANSI codes to preserve them across wrapped lines
|
||||||
// For now, use a simple approach that may break styling at wrap points
|
const activeAnsiCodes: string[] = [];
|
||||||
let currentLine = "";
|
let currentLine = "";
|
||||||
let currentLength = 0;
|
let currentLength = 0;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
||||||
while (i < line.length) {
|
while (i < line.length) {
|
||||||
if (line[i] === "\x1b" && line[i + 1] === "[") {
|
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;
|
let j = i + 2;
|
||||||
while (j < line.length && line[j] && !/[mGKHJ]/.test(line[j]!)) {
|
while (j < line.length && line[j] && !/[mGKHJ]/.test(line[j]!)) {
|
||||||
j++;
|
j++;
|
||||||
}
|
}
|
||||||
if (j < line.length) {
|
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;
|
i = j + 1;
|
||||||
} else {
|
} else {
|
||||||
|
// Incomplete ANSI sequence at end - don't include it
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Regular character
|
// Regular character
|
||||||
if (currentLength >= width) {
|
if (currentLength >= width) {
|
||||||
wrapped.push(currentLine);
|
// Need to wrap - close current line with reset if needed
|
||||||
currentLine = "";
|
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;
|
currentLength = 0;
|
||||||
}
|
}
|
||||||
currentLine += line[i];
|
currentLine += line[i];
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,8 @@ export class TUI extends Container {
|
||||||
} else {
|
} else {
|
||||||
// this.executeDifferentialRender(currentRenderCommands, termHeight);
|
// this.executeDifferentialRender(currentRenderCommands, termHeight);
|
||||||
// this.renderDifferential(currentRenderCommands, termHeight);
|
// this.renderDifferential(currentRenderCommands, termHeight);
|
||||||
this.renderDifferentialSurgical(currentRenderCommands, termHeight);
|
// this.renderDifferentialSurgical(currentRenderCommands, termHeight);
|
||||||
|
this.renderLineBased(currentRenderCommands, termHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save for next render
|
// Save for next render
|
||||||
|
|
@ -322,6 +323,148 @@ export class TUI extends Container {
|
||||||
this.totalLinesRedrawn += lines.length;
|
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 {
|
private renderDifferentialSurgical(currentCommands: RenderCommand[], termHeight: number): void {
|
||||||
const viewportHeight = termHeight - 1; // Leave one line for cursor
|
const viewportHeight = termHeight - 1; // Leave one line for cursor
|
||||||
|
|
||||||
|
|
|
||||||
186
packages/tui/test/multi-message-garbled.test.ts
Normal file
186
packages/tui/test/multi-message-garbled.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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.
|
- 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
|
- agent: improve reasoning section in README.md
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
35
todos/work/20250811-122302-tui-garbled-output-fix/task.md
Normal file
35
todos/work/20250811-122302-tui-garbled-output-fix/task.md
Normal 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!
|
||||||
Loading…
Add table
Add a link
Reference in a new issue