diff --git a/packages/tui/src/components/markdown-component.ts b/packages/tui/src/components/markdown-component.ts index 37c5fb28..7e5a0fef 100644 --- a/packages/tui/src/components/markdown-component.ts +++ b/packages/tui/src/components/markdown-component.ts @@ -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]; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 8ee690b0..e1a48e00 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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 diff --git a/packages/tui/test/multi-message-garbled.test.ts b/packages/tui/test/multi-message-garbled.test.ts new file mode 100644 index 00000000..27338772 --- /dev/null +++ b/packages/tui/test/multi-message-garbled.test.ts @@ -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(); + }); +}); \ No newline at end of file diff --git a/todos/todos.md b/todos/todos.md index 85af95e4..cf2d6474 100644 --- a/todos/todos.md +++ b/todos/todos.md @@ -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 diff --git a/todos/work/20250811-122302-tui-garbled-output-fix/analysis.md b/todos/work/20250811-122302-tui-garbled-output-fix/analysis.md new file mode 100644 index 00000000..ced0ab81 --- /dev/null +++ b/todos/work/20250811-122302-tui-garbled-output-fix/analysis.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 \ No newline at end of file diff --git a/todos/work/20250811-122302-tui-garbled-output-fix/task.md b/todos/work/20250811-122302-tui-garbled-output-fix/task.md new file mode 100644 index 00000000..87d183d1 --- /dev/null +++ b/todos/work/20250811-122302-tui-garbled-output-fix/task.md @@ -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! \ No newline at end of file