mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 04:02:21 +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
|
|
@ -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();
|
||||
55
packages/tui/test/file-browser.ts
Normal file
55
packages/tui/test/file-browser.ts
Normal 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);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue