co-mono/packages/tui/test/layout-shift-artifacts.test.ts
Mario Zechner 192d8d2600 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
2025-08-11 02:31:49 +02:00

151 lines
No EOL
4.9 KiB
TypeScript

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