mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-15 23:01:34 +00:00
- Copy all pi-mono source into apps/companion-os/ - Update Dockerfile to COPY pre-built binary instead of downloading from GitHub Releases - Update deploy-staging.yml to build pi from source (bun compile) before Docker build - Add apps/companion-os/** to path triggers - No more cross-repo dispatch needed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
409 lines
12 KiB
TypeScript
409 lines
12 KiB
TypeScript
import assert from "node:assert";
|
|
import { describe, it } from "node:test";
|
|
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
|
import { type Component, TUI } from "../src/tui.js";
|
|
import { VirtualTerminal } from "./virtual-terminal.js";
|
|
|
|
class TestComponent implements Component {
|
|
lines: string[] = [];
|
|
render(_width: number): string[] {
|
|
return this.lines;
|
|
}
|
|
invalidate(): void {}
|
|
}
|
|
|
|
function getCellItalic(
|
|
terminal: VirtualTerminal,
|
|
row: number,
|
|
col: number,
|
|
): number {
|
|
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
|
|
const buffer = xterm.buffer.active;
|
|
const line = buffer.getLine(buffer.viewportY + row);
|
|
assert.ok(line, `Missing buffer line at row ${row}`);
|
|
const cell = line.getCell(col);
|
|
assert.ok(cell, `Missing cell at row ${row} col ${col}`);
|
|
return cell.isItalic();
|
|
}
|
|
|
|
describe("TUI resize handling", () => {
|
|
it("triggers full re-render when terminal height changes", async () => {
|
|
const terminal = new VirtualTerminal(40, 10);
|
|
const tui = new TUI(terminal);
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
component.lines = ["Line 0", "Line 1", "Line 2"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
const initialRedraws = tui.fullRedraws;
|
|
|
|
// Resize height
|
|
terminal.resize(40, 15);
|
|
await terminal.flush();
|
|
|
|
// Should have triggered a full redraw
|
|
assert.ok(
|
|
tui.fullRedraws > initialRedraws,
|
|
"Height change should trigger full redraw",
|
|
);
|
|
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(
|
|
viewport[0]?.includes("Line 0"),
|
|
"Content preserved after height change",
|
|
);
|
|
|
|
tui.stop();
|
|
});
|
|
|
|
it("triggers full re-render when terminal width changes", async () => {
|
|
const terminal = new VirtualTerminal(40, 10);
|
|
const tui = new TUI(terminal);
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
component.lines = ["Line 0", "Line 1", "Line 2"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
const initialRedraws = tui.fullRedraws;
|
|
|
|
// Resize width
|
|
terminal.resize(60, 10);
|
|
await terminal.flush();
|
|
|
|
// Should have triggered a full redraw
|
|
assert.ok(
|
|
tui.fullRedraws > initialRedraws,
|
|
"Width change should trigger full redraw",
|
|
);
|
|
|
|
tui.stop();
|
|
});
|
|
});
|
|
|
|
describe("TUI content shrinkage", () => {
|
|
it("clears empty rows when content shrinks significantly", async () => {
|
|
const terminal = new VirtualTerminal(40, 10);
|
|
const tui = new TUI(terminal);
|
|
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
// Start with many lines
|
|
component.lines = [
|
|
"Line 0",
|
|
"Line 1",
|
|
"Line 2",
|
|
"Line 3",
|
|
"Line 4",
|
|
"Line 5",
|
|
];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
const initialRedraws = tui.fullRedraws;
|
|
|
|
// Shrink to fewer lines
|
|
component.lines = ["Line 0", "Line 1"];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
// Should have triggered a full redraw to clear empty rows
|
|
assert.ok(
|
|
tui.fullRedraws > initialRedraws,
|
|
"Content shrinkage should trigger full redraw",
|
|
);
|
|
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(viewport[0]?.includes("Line 0"), "First line preserved");
|
|
assert.ok(viewport[1]?.includes("Line 1"), "Second line preserved");
|
|
// Lines below should be empty (cleared)
|
|
assert.strictEqual(viewport[2]?.trim(), "", "Line 2 should be cleared");
|
|
assert.strictEqual(viewport[3]?.trim(), "", "Line 3 should be cleared");
|
|
|
|
tui.stop();
|
|
});
|
|
|
|
it("handles shrink to single line", async () => {
|
|
const terminal = new VirtualTerminal(40, 10);
|
|
const tui = new TUI(terminal);
|
|
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
// Shrink to single line
|
|
component.lines = ["Only line"];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(viewport[0]?.includes("Only line"), "Single line rendered");
|
|
assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared");
|
|
|
|
tui.stop();
|
|
});
|
|
|
|
it("handles shrink to empty", async () => {
|
|
const terminal = new VirtualTerminal(40, 10);
|
|
const tui = new TUI(terminal);
|
|
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
component.lines = ["Line 0", "Line 1", "Line 2"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
// Shrink to empty
|
|
component.lines = [];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
const viewport = terminal.getViewport();
|
|
// All lines should be empty
|
|
assert.strictEqual(viewport[0]?.trim(), "", "Line 0 should be cleared");
|
|
assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared");
|
|
|
|
tui.stop();
|
|
});
|
|
});
|
|
|
|
describe("TUI differential rendering", () => {
|
|
it("tracks cursor correctly when content shrinks with unchanged remaining lines", async () => {
|
|
const terminal = new VirtualTerminal(40, 10);
|
|
const tui = new TUI(terminal);
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
// Initial render: 5 identical lines
|
|
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
// Shrink to 3 lines, all identical to before (no content changes in remaining lines)
|
|
component.lines = ["Line 0", "Line 1", "Line 2"];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
// cursorRow should be 2 (last line of new content)
|
|
// Verify by doing another render with a change on line 1
|
|
component.lines = ["Line 0", "CHANGED", "Line 2"];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
const viewport = terminal.getViewport();
|
|
// Line 1 should show "CHANGED", proving cursor tracking was correct
|
|
assert.ok(
|
|
viewport[1]?.includes("CHANGED"),
|
|
`Expected "CHANGED" on line 1, got: ${viewport[1]}`,
|
|
);
|
|
|
|
tui.stop();
|
|
});
|
|
|
|
it("renders correctly when only a middle line changes (spinner case)", async () => {
|
|
const terminal = new VirtualTerminal(40, 10);
|
|
const tui = new TUI(terminal);
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
// Initial render
|
|
component.lines = ["Header", "Working...", "Footer"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
// Simulate spinner animation - only middle line changes
|
|
const spinnerFrames = ["|", "/", "-", "\\"];
|
|
for (const frame of spinnerFrames) {
|
|
component.lines = ["Header", `Working ${frame}`, "Footer"];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(
|
|
viewport[0]?.includes("Header"),
|
|
`Header preserved: ${viewport[0]}`,
|
|
);
|
|
assert.ok(
|
|
viewport[1]?.includes(`Working ${frame}`),
|
|
`Spinner updated: ${viewport[1]}`,
|
|
);
|
|
assert.ok(
|
|
viewport[2]?.includes("Footer"),
|
|
`Footer preserved: ${viewport[2]}`,
|
|
);
|
|
}
|
|
|
|
tui.stop();
|
|
});
|
|
|
|
it("resets styles after each rendered line", async () => {
|
|
const terminal = new VirtualTerminal(20, 6);
|
|
const tui = new TUI(terminal);
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
component.lines = ["\x1b[3mItalic", "Plain"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
|
|
tui.stop();
|
|
});
|
|
|
|
it("renders correctly when first line changes but rest stays same", async () => {
|
|
const terminal = new VirtualTerminal(40, 10);
|
|
const tui = new TUI(terminal);
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
// Change only first line
|
|
component.lines = ["CHANGED", "Line 1", "Line 2", "Line 3"];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(
|
|
viewport[0]?.includes("CHANGED"),
|
|
`First line changed: ${viewport[0]}`,
|
|
);
|
|
assert.ok(
|
|
viewport[1]?.includes("Line 1"),
|
|
`Line 1 preserved: ${viewport[1]}`,
|
|
);
|
|
assert.ok(
|
|
viewport[2]?.includes("Line 2"),
|
|
`Line 2 preserved: ${viewport[2]}`,
|
|
);
|
|
assert.ok(
|
|
viewport[3]?.includes("Line 3"),
|
|
`Line 3 preserved: ${viewport[3]}`,
|
|
);
|
|
|
|
tui.stop();
|
|
});
|
|
|
|
it("renders correctly when last line changes but rest stays same", async () => {
|
|
const terminal = new VirtualTerminal(40, 10);
|
|
const tui = new TUI(terminal);
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
// Change only last line
|
|
component.lines = ["Line 0", "Line 1", "Line 2", "CHANGED"];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(
|
|
viewport[0]?.includes("Line 0"),
|
|
`Line 0 preserved: ${viewport[0]}`,
|
|
);
|
|
assert.ok(
|
|
viewport[1]?.includes("Line 1"),
|
|
`Line 1 preserved: ${viewport[1]}`,
|
|
);
|
|
assert.ok(
|
|
viewport[2]?.includes("Line 2"),
|
|
`Line 2 preserved: ${viewport[2]}`,
|
|
);
|
|
assert.ok(
|
|
viewport[3]?.includes("CHANGED"),
|
|
`Last line changed: ${viewport[3]}`,
|
|
);
|
|
|
|
tui.stop();
|
|
});
|
|
|
|
it("renders correctly when multiple non-adjacent lines change", async () => {
|
|
const terminal = new VirtualTerminal(40, 10);
|
|
const tui = new TUI(terminal);
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
// Change lines 1 and 3, keep 0, 2, 4 the same
|
|
component.lines = ["Line 0", "CHANGED 1", "Line 2", "CHANGED 3", "Line 4"];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(
|
|
viewport[0]?.includes("Line 0"),
|
|
`Line 0 preserved: ${viewport[0]}`,
|
|
);
|
|
assert.ok(
|
|
viewport[1]?.includes("CHANGED 1"),
|
|
`Line 1 changed: ${viewport[1]}`,
|
|
);
|
|
assert.ok(
|
|
viewport[2]?.includes("Line 2"),
|
|
`Line 2 preserved: ${viewport[2]}`,
|
|
);
|
|
assert.ok(
|
|
viewport[3]?.includes("CHANGED 3"),
|
|
`Line 3 changed: ${viewport[3]}`,
|
|
);
|
|
assert.ok(
|
|
viewport[4]?.includes("Line 4"),
|
|
`Line 4 preserved: ${viewport[4]}`,
|
|
);
|
|
|
|
tui.stop();
|
|
});
|
|
|
|
it("handles transition from content to empty and back to content", async () => {
|
|
const terminal = new VirtualTerminal(40, 10);
|
|
const tui = new TUI(terminal);
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
// Start with content
|
|
component.lines = ["Line 0", "Line 1", "Line 2"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
let viewport = terminal.getViewport();
|
|
assert.ok(viewport[0]?.includes("Line 0"), "Initial content rendered");
|
|
|
|
// Clear to empty
|
|
component.lines = [];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
// Add content back - this should work correctly even after empty state
|
|
component.lines = ["New Line 0", "New Line 1"];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
viewport = terminal.getViewport();
|
|
assert.ok(
|
|
viewport[0]?.includes("New Line 0"),
|
|
`New content rendered: ${viewport[0]}`,
|
|
);
|
|
assert.ok(
|
|
viewport[1]?.includes("New Line 1"),
|
|
`New content line 1: ${viewport[1]}`,
|
|
);
|
|
|
|
tui.stop();
|
|
});
|
|
});
|