fix(tui): Container change detection for proper differential rendering

Fixed rendering artifact where duplicate bottom borders appeared when components
dynamically shifted positions (e.g., Ctrl+C in agent clearing status container).

Root cause: Container wasn't reporting as "changed" when cleared (0 children),
causing differential renderer to skip re-rendering that area.

Solution: Container now tracks previousChildCount and reports changed when
child count changes, ensuring proper re-rendering when containers are cleared.

- Added comprehensive test reproducing the layout shift artifact
- Fixed Container to track and report child count changes
- All tests pass including new layout shift artifact test
This commit is contained in:
Mario Zechner 2025-08-11 02:31:49 +02:00
parent 2ec8a27222
commit 192d8d2600
24 changed files with 356 additions and 2910 deletions

View file

@ -1,98 +0,0 @@
#!/usr/bin/env node
import {
CombinedAutocompleteProvider,
Container,
MarkdownComponent,
TextComponent,
TextEditor,
TUI,
} from "../src/index.js";
// Create TUI manager
const ui = new TUI();
ui.configureLogging({
enabled: true,
logLevel: "debug",
logFile: "tui-debug.log",
});
// Create a chat container that will hold messages
const chatContainer = new Container();
const editor = new TextEditor();
// Set up autocomplete with slash commands
const autocompleteProvider = new CombinedAutocompleteProvider(
[
{ name: "clear", description: "Clear chat history" },
{ name: "clear-last", description: "Clear last message" },
{ name: "exit", description: "Exit the application" },
],
process.cwd(),
);
editor.setAutocompleteProvider(autocompleteProvider);
// Add components to UI
ui.addChild(new TextComponent("Differential Rendering TUI"));
ui.addChild(chatContainer);
ui.addChild(editor);
// Set focus to the editor (index 2)
ui.setFocus(editor);
// Test with Claude's multiline text
const testText = `Root level:
- CLAUDE.md
- README.md
- biome.json
- package.json
- package-lock.json
- tsconfig.json
- tui-debug.log
Directories:
- \`data/\` (JSON test files)
- \`dist/\`
- \`docs/\` (markdown documentation)
- \`node_modules/\`
- \`src/\` (TypeScript source files)`;
// Pre-fill the editor with the test text
editor.setText(testText);
// Handle editor submissions
editor.onSubmit = (text: string) => {
text = text.trim();
if (text === "/clear") {
chatContainer.clear();
ui.requestRender();
return;
}
if (text === "/clear-last") {
const count = chatContainer.getChildCount();
if (count > 0) {
chatContainer.removeChildAt(count - 1);
ui.requestRender();
}
return;
}
if (text === "/exit") {
ui.stop();
return;
}
if (text) {
// Create new message component and add to chat container
const message = new MarkdownComponent(text);
chatContainer.addChild(message);
// Manually trigger re-render
ui.requestRender();
}
};
// Start the UI
ui.start();

View file

@ -0,0 +1,55 @@
import { TUI, SelectList } from "../src/index.js";
import { readdirSync, statSync } from "fs";
import { join } from "path";
const ui = new TUI();
ui.start();
let currentPath = process.cwd();
function createFileList(path: string) {
const entries = readdirSync(path).map((entry) => {
const fullPath = join(path, entry);
const isDir = statSync(fullPath).isDirectory();
return {
value: entry,
label: entry,
description: isDir ? "directory" : "file",
};
});
// Add parent directory option
if (path !== "/") {
entries.unshift({
value: "..",
label: "..",
description: "parent directory",
});
}
return entries;
}
function showDirectory(path: string) {
ui.clear();
const entries = createFileList(path);
const fileList = new SelectList(entries, 10);
fileList.onSelect = (item) => {
if (item.value === "..") {
currentPath = join(currentPath, "..");
showDirectory(currentPath);
} else if (item.description === "directory") {
currentPath = join(currentPath, item.value);
showDirectory(currentPath);
} else {
console.log(`Selected file: ${join(currentPath, item.value)}`);
ui.stop();
}
};
ui.addChild(fileList);
ui.setFocus(fileList);
}
showDirectory(currentPath);

View file

@ -0,0 +1,151 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { TextEditor, TextComponent, Container, TUI } from "../src/index.js";
import { VirtualTerminal } from "./virtual-terminal.js";
describe("Layout shift artifacts", () => {
test("clears artifacts when components shift positions dynamically (like agent Ctrl+C)", async () => {
const term = new VirtualTerminal(80, 20);
const ui = new TUI(term);
// Simulate agent's layout: header, chat container, status container, editor
const header = new TextComponent(">> pi interactive chat <<<");
const chatContainer = new Container();
const statusContainer = new Container();
const editor = new TextEditor({ multiline: false });
// Add some chat content
chatContainer.addChild(new TextComponent("[user]"));
chatContainer.addChild(new TextComponent("Hello"));
chatContainer.addChild(new TextComponent("[assistant]"));
chatContainer.addChild(new TextComponent("Hi there!"));
ui.addChild(header);
ui.addChild(chatContainer);
ui.addChild(statusContainer);
ui.addChild(editor);
// Initial render
ui.start();
await new Promise(resolve => process.nextTick(resolve));
await term.flush();
// Capture initial state
const initialViewport = term.getViewport();
// Simulate what happens when Ctrl+C is pressed (like in agent)
statusContainer.clear();
const hint = new TextComponent("Press Ctrl+C again to exit");
statusContainer.addChild(hint);
ui.requestRender();
// Wait for render
await new Promise(resolve => process.nextTick(resolve));
await term.flush();
// Capture state with status message
const withStatusViewport = term.getViewport();
// Simulate the timeout that clears the hint (like agent does after 500ms)
statusContainer.clear();
ui.requestRender();
// Wait for render
await new Promise(resolve => process.nextTick(resolve));
await term.flush();
// Capture final state
const finalViewport = term.getViewport();
// Check for artifacts - look for duplicate bottom borders on consecutive lines
let foundDuplicateBorder = false;
for (let i = 0; i < finalViewport.length - 1; i++) {
const currentLine = finalViewport[i];
const nextLine = finalViewport[i + 1];
// Check if we have duplicate bottom borders (the artifact)
if (currentLine.includes("╰") && currentLine.includes("╯") &&
nextLine.includes("╰") && nextLine.includes("╯")) {
foundDuplicateBorder = true;
}
}
// The test should FAIL if we find duplicate borders (indicating the bug exists)
assert.strictEqual(foundDuplicateBorder, false, "Found duplicate bottom borders - rendering artifact detected!");
// Also check that there's only one bottom border total
const bottomBorderCount = finalViewport.filter((line) =>
line.includes("╰")
).length;
assert.strictEqual(bottomBorderCount, 1, `Expected 1 bottom border, found ${bottomBorderCount}`);
// Verify the editor is back in its original position
const finalEditorStartLine = finalViewport.findIndex((line) =>
line.includes("╭")
);
const initialEditorStartLine = initialViewport.findIndex((line) =>
line.includes("╭")
);
assert.strictEqual(finalEditorStartLine, initialEditorStartLine);
ui.stop();
});
test("handles rapid addition and removal of components", async () => {
const term = new VirtualTerminal(80, 20);
const ui = new TUI(term);
const header = new TextComponent("Header");
const editor = new TextEditor({ multiline: false });
ui.addChild(header);
ui.addChild(editor);
// Initial render
ui.start();
await new Promise(resolve => process.nextTick(resolve));
await term.flush();
// Rapidly add and remove a status message
const status = new TextComponent("Temporary Status");
// Add status
ui.children.splice(1, 0, status);
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await term.flush();
// Remove status immediately
ui.children.splice(1, 1);
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await term.flush();
// Final output check
const finalViewport = term.getViewport();
// Should only have one set of borders for the editor
const topBorderCount = finalViewport.filter((line) =>
line.includes("╭") && line.includes("╮")
).length;
const bottomBorderCount = finalViewport.filter((line) =>
line.includes("╰") && line.includes("╯")
).length;
assert.strictEqual(topBorderCount, 1);
assert.strictEqual(bottomBorderCount, 1);
// Check no duplicate lines
for (let i = 0; i < finalViewport.length - 1; i++) {
const currentLine = finalViewport[i];
const nextLine = finalViewport[i + 1];
// If current line is a bottom border, next line should not be a bottom border
if (currentLine.includes("╰") && currentLine.includes("╯")) {
assert.strictEqual(nextLine.includes("╰") && nextLine.includes("╯"), false);
}
}
ui.stop();
});
});

View file

@ -3,7 +3,7 @@ import { TUI, Container, TextComponent, TextEditor, MarkdownComponent } from "..
/**
* Multi-Component Layout Demo
*
*
* Demonstrates:
* - Complex layout with multiple containers
* - Header, sidebar, main content, and footer areas
@ -65,13 +65,6 @@ ui.addChild(mainContent);
ui.addChild(footer);
ui.setFocus(inputArea);
// Configure debug logging
ui.configureLogging({
enabled: true,
level: "info",
logFile: "tui-debug.log",
});
// Handle Ctrl+C to exit
ui.onGlobalKeyPress = (data: string) => {
if (data === "\x03") {