mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 13:05:03 +00:00
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:
parent
2ec8a27222
commit
192d8d2600
24 changed files with 356 additions and 2910 deletions
151
packages/tui/test/layout-shift-artifacts.test.ts
Normal file
151
packages/tui/test/layout-shift-artifacts.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue