co-mono/packages/tui
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
..
src fix(tui): Container change detection for proper differential rendering 2025-08-11 02:31:49 +02:00
test fix(tui): Container change detection for proper differential rendering 2025-08-11 02:31:49 +02:00
package-lock.json tui: Fix differential rendering to preserve scrollback buffer 2025-08-11 00:57:59 +02:00
package.json tui: Fix differential rendering to preserve scrollback buffer 2025-08-11 00:57:59 +02:00
README.md fix(tui): Container change detection for proper differential rendering 2025-08-11 02:31:49 +02:00
tsconfig.build.json Initial monorepo setup with npm workspaces and dual TypeScript configuration 2025-08-09 17:18:38 +02:00

@mariozechner/pi-tui

Terminal UI framework with surgical differential rendering for building flicker-free interactive CLI applications.

Features

  • Surgical Differential Rendering: Three-strategy system that minimizes redraws to 1-2 lines for typical updates
  • Scrollback Buffer Preservation: Correctly maintains terminal history when content exceeds viewport
  • Zero Flicker: Components like text editors remain perfectly still while other parts update
  • Interactive Components: Text editor with autocomplete, selection lists, markdown rendering
  • Composable Architecture: Container-based component system with automatic lifecycle management

Quick Start

import { TUI, Container, TextComponent, TextEditor } from "@mariozechner/pi-tui";

// Create TUI manager
const ui = new TUI();

// Create components
const header = new TextComponent("🚀 My TUI App");
const chatContainer = new Container();
const editor = new TextEditor();

// Add components to UI
ui.addChild(header);
ui.addChild(chatContainer);
ui.addChild(editor);

// Set focus to the editor
ui.setFocus(editor);

// Handle editor submissions
editor.onSubmit = (text: string) => {
	if (text.trim()) {
		const message = new TextComponent(`💬 ${text}`);
		chatContainer.addChild(message);
		// Note: Container automatically calls requestRender when children change
	}
};

// Start the UI
ui.start();

Core Components

TUI

Main TUI manager with surgical differential rendering that handles input and component lifecycle.

Key Features:

  • Three rendering strategies: Automatically selects optimal approach
    • Surgical: Updates only changed lines (1-2 lines typical)
    • Partial: Re-renders from first change when structure shifts
    • Full: Complete re-render when changes are above viewport
  • Performance metrics: Built-in tracking via getLinesRedrawn() and getAverageLinesRedrawn()
  • Terminal abstraction: Works with any Terminal interface implementation

Methods:

  • addChild(component) - Add a component
  • removeChild(component) - Remove a component
  • setFocus(component) - Set keyboard focus
  • start() / stop() - Lifecycle management
  • requestRender() - Queue re-render (automatically debounced)
  • configureLogging(config) - Enable debug logging

Container

Component that manages child components. Automatically triggers re-renders when children change.

const container = new Container();
container.addChild(new TextComponent("Child 1"));
container.removeChild(component);
container.clear();

TextEditor

Interactive multiline text editor with autocomplete support.

const editor = new TextEditor();
editor.setText("Initial text");
editor.onSubmit = (text) => console.log("Submitted:", text);
editor.setAutocompleteProvider(provider);

Key Bindings:

  • Enter - Submit text
  • Shift+Enter - New line
  • Tab - Autocomplete
  • Ctrl+K - Delete line
  • Ctrl+A/E - Start/end of line
  • Arrow keys, Backspace, Delete work as expected

TextComponent

Simple text display with automatic word wrapping.

const text = new TextComponent("Hello World", { top: 1, bottom: 1 });
text.setText("Updated text");

MarkdownComponent

Renders markdown content with syntax highlighting and proper formatting.

Constructor:

new MarkdownComponent(text?: string)

Methods:

  • setText(text) - Update markdown content
  • render(width) - Render parsed markdown

Features:

  • Headings: Styled with colors and formatting
  • Code blocks: Syntax highlighting with gray background
  • Lists: Bullet points (•) and numbered lists
  • Emphasis: Bold and italic text
  • Links: Underlined with URL display
  • Blockquotes: Styled with left border
  • Inline code: Highlighted with background
  • Horizontal rules: Terminal-width separator lines
  • Differential rendering for performance

SelectList

Interactive selection component for choosing from options.

Constructor:

new SelectList(items: SelectItem[], maxVisible?: number)

interface SelectItem {
	value: string;
	label: string;
	description?: string;
}

Properties:

  • onSelect?: (item: SelectItem) => void - Called when item is selected
  • onCancel?: () => void - Called when selection is cancelled

Methods:

  • setFilter(filter) - Filter items by value
  • getSelectedItem() - Get currently selected item
  • handleInput(keyData) - Handle keyboard navigation
  • render(width) - Render the selection list

Features:

  • Keyboard navigation (arrow keys, Enter)
  • Search/filter functionality
  • Scrolling for long lists
  • Custom option rendering with descriptions
  • Visual selection indicator (→)
  • Scroll position indicator

Autocomplete System

Comprehensive autocomplete system supporting slash commands and file paths.

AutocompleteProvider Interface

interface AutocompleteProvider {
	getSuggestions(
		lines: string[],
		cursorLine: number,
		cursorCol: number,
	): {
		items: AutocompleteItem[];
		prefix: string;
	} | null;

	applyCompletion(
		lines: string[],
		cursorLine: number,
		cursorCol: number,
		item: AutocompleteItem,
		prefix: string,
	): {
		lines: string[];
		cursorLine: number;
		cursorCol: number;
	};
}

interface AutocompleteItem {
	value: string;
	label: string;
	description?: string;
}

CombinedAutocompleteProvider

Built-in provider supporting slash commands and file completion.

Constructor:

new CombinedAutocompleteProvider(
	commands: (SlashCommand | AutocompleteItem)[] = [],
	basePath: string = process.cwd()
)

interface SlashCommand {
	name: string;
	description?: string;
	getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;
}

Features:

Slash Commands:

  • Type / to trigger command completion
  • Auto-completion for command names
  • Argument completion for commands that support it
  • Space after command name for argument input

File Completion:

  • Tab key triggers file completion
  • @ prefix for file attachments
  • Home directory expansion (~/)
  • Relative and absolute path support
  • Directory-first sorting
  • Filters to attachable files for @ prefix

Path Patterns:

  • ./ and ../ - Relative paths
  • ~/ - Home directory
  • @path - File attachment syntax
  • Tab completion from any context

Methods:

  • getSuggestions() - Get completions for current context
  • getForceFileSuggestions() - Force file completion (Tab key)
  • shouldTriggerFileCompletion() - Check if file completion should trigger
  • applyCompletion() - Apply selected completion

Surgical Differential Rendering

The TUI uses a three-strategy rendering system that minimizes redraws to only what's necessary:

Rendering Strategies

  1. Surgical Updates (most common)

    • When: Only content changes, same line counts, all changes in viewport
    • Action: Updates only specific changed lines (typically 1-2 lines)
    • Example: Loading spinner animation, updating status text
  2. Partial Re-render

    • When: Line count changes or structural changes within viewport
    • Action: Clears from first change to end of screen, re-renders tail
    • Example: Adding new messages to a chat, expanding text editor
  3. Full Re-render

    • When: Changes occur above the viewport (in scrollback buffer)
    • Action: Clears scrollback and screen, renders everything fresh
    • Example: Content exceeds viewport and early components change

How Components Participate

Components implement the simple Component interface:

interface ComponentRenderResult {
  lines: string[];      // The lines to display
  changed: boolean;     // Whether content changed since last render
}

interface Component {
  readonly id: number;  // Unique ID for tracking
  render(width: number): ComponentRenderResult;
  handleInput?(keyData: string): void;
}

The TUI tracks component IDs and line positions to determine the optimal strategy automatically.

Performance Metrics

Monitor rendering efficiency:

const ui = new TUI();
// After some rendering...
console.log(`Total lines redrawn: ${ui.getLinesRedrawn()}`);
console.log(`Average per render: ${ui.getAverageLinesRedrawn()}`);

Typical performance: 1-2 lines redrawn for animations, 0 for static content.

Examples

Run the example applications in the test/ directory:

# Chat application with slash commands and autocomplete
npx tsx test/chat-app.ts

# File browser with navigation
npx tsx test/file-browser.ts

# Multi-component layout demo
npx tsx test/multi-layout.ts

# Performance benchmark with animation
npx tsx test/bench.ts

Example Descriptions

  • chat-app.ts - Chat interface with slash commands (/clear, /help, /attach) and autocomplete
  • file-browser.ts - Interactive file browser with directory navigation
  • multi-layout.ts - Complex layout with header, sidebar, main content, and footer
  • bench.ts - Performance test with animation showing surgical rendering efficiency

Interfaces and Types

Core Types

interface ComponentRenderResult {
	lines: string[];
	changed: boolean;
}

interface ContainerRenderResult extends ComponentRenderResult {
	keepLines: number;
}

interface Component {
	render(width: number): ComponentRenderResult;
	handleInput?(keyData: string): void;
}

interface Padding {
	top?: number;
	bottom?: number;
	left?: number;
	right?: number;
}

Autocomplete Types

interface AutocompleteItem {
	value: string;
	label: string;
	description?: string;
}

interface SlashCommand {
	name: string;
	description?: string;
	getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;
}

interface AutocompleteProvider {
	getSuggestions(
		lines: string[],
		cursorLine: number,
		cursorCol: number,
	): {
		items: AutocompleteItem[];
		prefix: string;
	} | null;

	applyCompletion(
		lines: string[],
		cursorLine: number,
		cursorCol: number,
		item: AutocompleteItem,
		prefix: string,
	): {
		lines: string[];
		cursorLine: number;
		cursorCol: number;
	};
}

Selection Types

interface SelectItem {
	value: string;
	label: string;
	description?: string;
}

Testing

Running Tests

# Run all tests
npm test

# Run specific test file
npm test -- test/tui-rendering.test.ts

# Run tests matching a pattern
npm test -- --test-name-pattern="preserves existing"

Test Infrastructure

The TUI uses a VirtualTerminal for testing that provides accurate terminal emulation via @xterm/headless:

import { VirtualTerminal } from "./test/virtual-terminal.js";
import { TUI, TextComponent } from "../src/index.js";

test("my TUI test", async () => {
  const terminal = new VirtualTerminal(80, 24);
  const ui = new TUI(terminal);
  ui.start();

  ui.addChild(new TextComponent("Hello"));

  // Wait for render
  await new Promise(resolve => process.nextTick(resolve));

  // Get rendered output
  const viewport = await terminal.flushAndGetViewport();
  assert.strictEqual(viewport[0], "Hello");

  ui.stop();
});

Writing a New Test

  1. Create test file in test/ directory with .test.ts extension
  2. Use VirtualTerminal for accurate terminal emulation
  3. Key testing patterns:
import { test, describe } from "node:test";
import assert from "node:assert";
import { VirtualTerminal } from "./virtual-terminal.js";
import { TUI, Container, TextComponent } from "../src/index.js";

describe("My Feature", () => {
  test("should handle dynamic content", async () => {
    const terminal = new VirtualTerminal(80, 24);
    const ui = new TUI(terminal);
    ui.start();

    // Setup components
    const container = new Container();
    ui.addChild(container);

    // Initial render
    await new Promise(resolve => process.nextTick(resolve));
    await terminal.flush();

    // Check viewport (visible content)
    let viewport = terminal.getViewport();
    assert.strictEqual(viewport.length, 24);

    // Check scrollback buffer (all content including history)
    let scrollBuffer = terminal.getScrollBuffer();

    // Simulate user input
    terminal.sendInput("Hello");

    // Wait for processing
    await new Promise(resolve => process.nextTick(resolve));
    await terminal.flush();

    // Verify changes
    viewport = terminal.getViewport();
    // ... assertions

    ui.stop();
  });
});

VirtualTerminal API

  • new VirtualTerminal(columns, rows) - Create terminal with dimensions
  • write(data) - Write ANSI sequences to terminal
  • sendInput(data) - Simulate keyboard input
  • flush() - Wait for all writes to complete
  • getViewport() - Get visible lines (what user sees)
  • getScrollBuffer() - Get all lines including scrollback
  • flushAndGetViewport() - Convenience method
  • getCursorPosition() - Get cursor row/column
  • resize(columns, rows) - Resize terminal

Testing Best Practices

  1. Always flush after renders: Terminal writes are async

    await new Promise(resolve => process.nextTick(resolve));
    await terminal.flush();
    
  2. Test both viewport and scrollback: Ensure content preservation

    const viewport = terminal.getViewport();     // Visible content
    const scrollBuffer = terminal.getScrollBuffer(); // All content
    
  3. Use exact string matching: Don't trim() - whitespace matters

    assert.strictEqual(viewport[0], "Expected text"); // Good
    assert.strictEqual(viewport[0].trim(), "Expected"); // Bad
    
  4. Test rendering strategies: Verify surgical vs partial vs full

    const beforeLines = ui.getLinesRedrawn();
    // Make change...
    const afterLines = ui.getLinesRedrawn();
    assert.strictEqual(afterLines - beforeLines, 1); // Only 1 line changed
    

Performance Testing

Use test/bench.ts as a template for performance testing:

npx tsx test/bench.ts

Monitor real-time performance metrics:

  • Render count and timing
  • Lines redrawn per render
  • Visual verification of flicker-free updates

Development

# Install dependencies (from monorepo root)
npm install

# Run type checking
npm run check

# Run tests
npm test

Debugging: Enable logging to see detailed component behavior:

ui.configureLogging({
	enabled: true,
	level: "debug", // "error" | "warn" | "info" | "debug"
	logFile: "tui-debug.log",
});