mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 07:03:25 +00:00
docs(tui): Update README with surgical differential rendering documentation
- Add surgical differential rendering as the main feature - Document the three rendering strategies (surgical, partial, full) - Add performance metrics documentation - Simplify component examples to be more concise - Add comprehensive testing section with VirtualTerminal API - Include testing best practices and performance testing guidance - Remove duplicate TextEditor documentation section
This commit is contained in:
parent
386f90fc36
commit
12dfcfad23
1 changed files with 234 additions and 137 deletions
|
|
@ -1,13 +1,14 @@
|
||||||
# @mariozechner/pi-tui
|
# @mariozechner/pi-tui
|
||||||
|
|
||||||
Terminal UI framework with differential rendering for building interactive CLI applications.
|
Terminal UI framework with surgical differential rendering for building flicker-free interactive CLI applications.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Differential Rendering**: Only re-renders content that has changed for optimal performance
|
- **Surgical Differential Rendering**: Three-strategy system that minimizes redraws to 1-2 lines for typical updates
|
||||||
- **Interactive Components**: Text editor, autocomplete, selection lists, and markdown rendering
|
- **Scrollback Buffer Preservation**: Correctly maintains terminal history when content exceeds viewport
|
||||||
- **Composable Architecture**: Container-based component system with proper lifecycle management
|
- **Zero Flicker**: Components like text editors remain perfectly still while other parts update
|
||||||
- **Text Editor Autocomplete System**: File completion and slash commands with provider interface
|
- **Interactive Components**: Text editor with autocomplete, selection lists, markdown rendering
|
||||||
|
- **Composable Architecture**: Container-based component system with automatic lifecycle management
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
|
@ -35,7 +36,7 @@ editor.onSubmit = (text: string) => {
|
||||||
if (text.trim()) {
|
if (text.trim()) {
|
||||||
const message = new TextComponent(`💬 ${text}`);
|
const message = new TextComponent(`💬 ${text}`);
|
||||||
chatContainer.addChild(message);
|
chatContainer.addChild(message);
|
||||||
ui.requestRender();
|
// Note: Container automatically calls requestRender when children change
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -47,135 +48,63 @@ ui.start();
|
||||||
|
|
||||||
### TUI
|
### TUI
|
||||||
|
|
||||||
Main TUI manager that handles rendering, input, and component coordination.
|
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:**
|
**Methods:**
|
||||||
|
- `addChild(component)` - Add a component
|
||||||
- `addChild(component)` - Add a component to the TUI
|
- `removeChild(component)` - Remove a component
|
||||||
- `removeChild(component)` - Remove a component from the TUI
|
- `setFocus(component)` - Set keyboard focus
|
||||||
- `setFocus(component)` - Set which component receives keyboard input
|
- `start()` / `stop()` - Lifecycle management
|
||||||
- `start()` - Start the TUI (enables raw mode)
|
- `requestRender()` - Queue re-render (automatically debounced)
|
||||||
- `stop()` - Stop the TUI (disables raw mode)
|
- `configureLogging(config)` - Enable debug logging
|
||||||
- `requestRender()` - Request a re-render on next tick
|
|
||||||
- `configureLogging(config)` - Configure debug logging
|
|
||||||
- `cleanupSentinels()` - Remove placeholder components after removal operations
|
|
||||||
- `findComponent(component)` - Check if a component exists in the hierarchy (private)
|
|
||||||
- `findInContainer(container, component)` - Search for component in container (private)
|
|
||||||
|
|
||||||
### Container
|
### Container
|
||||||
|
|
||||||
Component that manages child components with differential rendering.
|
Component that manages child components. Automatically triggers re-renders when children change.
|
||||||
|
|
||||||
**Constructor:**
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
new Container(parentTui?: TUI | undefined)
|
const container = new Container();
|
||||||
|
container.addChild(new TextComponent("Child 1"));
|
||||||
|
container.removeChild(component);
|
||||||
|
container.clear();
|
||||||
```
|
```
|
||||||
|
|
||||||
**Methods:**
|
|
||||||
|
|
||||||
- `addChild(component)` - Add a child component
|
|
||||||
- `removeChild(component)` - Remove a child component
|
|
||||||
- `getChild(index)` - Get a specific child component
|
|
||||||
- `getChildCount()` - Get the number of child components
|
|
||||||
- `clear()` - Remove all child components
|
|
||||||
- `setParentTui(tui)` - Set the parent TUI reference
|
|
||||||
- `cleanupSentinels()` - Clean up removed component placeholders
|
|
||||||
- `render(width)` - Render all child components (returns ContainerRenderResult)
|
|
||||||
|
|
||||||
### TextEditor
|
### TextEditor
|
||||||
|
|
||||||
Interactive multiline text editor with cursor support and comprehensive keyboard shortcuts.
|
Interactive multiline text editor with autocomplete support.
|
||||||
|
|
||||||
**Constructor:**
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
new TextEditor(config?: TextEditorConfig)
|
const editor = new TextEditor();
|
||||||
|
editor.setText("Initial text");
|
||||||
|
editor.onSubmit = (text) => console.log("Submitted:", text);
|
||||||
|
editor.setAutocompleteProvider(provider);
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configuration:**
|
**Key Bindings:**
|
||||||
|
- `Enter` - Submit text
|
||||||
```typescript
|
- `Shift+Enter` - New line
|
||||||
interface TextEditorConfig {
|
- `Tab` - Autocomplete
|
||||||
// Configuration options for text editor
|
- `Ctrl+K` - Delete line
|
||||||
}
|
- `Ctrl+A/E` - Start/end of line
|
||||||
|
- Arrow keys, Backspace, Delete work as expected
|
||||||
editor.configure(config: Partial<TextEditorConfig>)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Properties:**
|
|
||||||
|
|
||||||
- `onSubmit?: (text: string) => void` - Callback when user presses Enter
|
|
||||||
- `onChange?: (text: string) => void` - Callback when text content changes
|
|
||||||
|
|
||||||
**Methods:**
|
|
||||||
|
|
||||||
- `getText()` - Get current text content
|
|
||||||
- `setText(text)` - Set text content and move cursor to end
|
|
||||||
- `setAutocompleteProvider(provider)` - Set autocomplete provider for Tab completion
|
|
||||||
- `render(width)` - Render the editor with current state
|
|
||||||
- `handleInput(data)` - Process keyboard input
|
|
||||||
|
|
||||||
**Keyboard Shortcuts:**
|
|
||||||
|
|
||||||
**Navigation:**
|
|
||||||
|
|
||||||
- `Arrow Keys` - Move cursor
|
|
||||||
- `Home` / `Ctrl+A` - Move to start of line
|
|
||||||
- `End` / `Ctrl+E` - Move to end of line
|
|
||||||
|
|
||||||
**Editing:**
|
|
||||||
|
|
||||||
- `Backspace` - Delete character before cursor
|
|
||||||
- `Delete` / `Fn+Backspace` - Delete character at cursor
|
|
||||||
- `Ctrl+K` - Delete current line
|
|
||||||
- `Enter` - Submit text (calls onSubmit)
|
|
||||||
- `Shift+Enter` / `Option+Enter` - Add new line
|
|
||||||
- `Tab` - Trigger autocomplete
|
|
||||||
|
|
||||||
**Autocomplete (when active):**
|
|
||||||
|
|
||||||
- `Tab` - Apply selected completion
|
|
||||||
- `Arrow Up/Down` - Navigate suggestions
|
|
||||||
- `Escape` - Cancel autocomplete
|
|
||||||
- `Enter` - Cancel autocomplete and submit
|
|
||||||
|
|
||||||
**Paste Detection:**
|
|
||||||
|
|
||||||
- Automatically handles multi-line paste
|
|
||||||
- Converts tabs to 4 spaces
|
|
||||||
- Filters non-printable characters
|
|
||||||
|
|
||||||
### TextComponent
|
### TextComponent
|
||||||
|
|
||||||
Simple text component with automatic text wrapping and differential rendering.
|
Simple text display with automatic word wrapping.
|
||||||
|
|
||||||
**Constructor:**
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
new TextComponent(text: string, padding?: Padding)
|
const text = new TextComponent("Hello World", { top: 1, bottom: 1 });
|
||||||
|
text.setText("Updated text");
|
||||||
interface Padding {
|
|
||||||
top?: number;
|
|
||||||
bottom?: number;
|
|
||||||
left?: number;
|
|
||||||
right?: number;
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Methods:**
|
|
||||||
|
|
||||||
- `setText(text)` - Update the text content
|
|
||||||
- `getText()` - Get current text content
|
|
||||||
- `render(width)` - Render with word wrapping
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
- Automatic text wrapping to fit terminal width
|
|
||||||
- Configurable padding on all sides
|
|
||||||
- Preserves line breaks in source text
|
|
||||||
- Uses differential rendering to avoid unnecessary updates
|
|
||||||
|
|
||||||
### MarkdownComponent
|
### MarkdownComponent
|
||||||
|
|
||||||
Renders markdown content with syntax highlighting and proper formatting.
|
Renders markdown content with syntax highlighting and proper formatting.
|
||||||
|
|
@ -328,24 +257,58 @@ interface SlashCommand {
|
||||||
- `shouldTriggerFileCompletion()` - Check if file completion should trigger
|
- `shouldTriggerFileCompletion()` - Check if file completion should trigger
|
||||||
- `applyCompletion()` - Apply selected completion
|
- `applyCompletion()` - Apply selected completion
|
||||||
|
|
||||||
## Differential Rendering
|
## Surgical Differential Rendering
|
||||||
|
|
||||||
The core concept: components return `{lines: string[], changed: boolean, keepLines?: number}`:
|
The TUI uses a three-strategy rendering system that minimizes redraws to only what's necessary:
|
||||||
|
|
||||||
- `lines`: All lines the component should display
|
### Rendering Strategies
|
||||||
- `changed`: Whether the component has changed since last render
|
|
||||||
- `keepLines`: (Containers only) How many lines from the beginning are unchanged
|
|
||||||
|
|
||||||
**How it works:**
|
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
|
||||||
|
|
||||||
1. TUI calculates total unchanged lines from top (`keepLines`)
|
2. **Partial Re-render**
|
||||||
2. Moves cursor up by `(totalLines - keepLines)` positions
|
- When: Line count changes or structural changes within viewport
|
||||||
3. Clears from cursor position down with `\x1b[0J`
|
- Action: Clears from first change to end of screen, re-renders tail
|
||||||
4. Prints only the changing lines: `result.lines.slice(keepLines)`
|
- Example: Adding new messages to a chat, expanding text editor
|
||||||
|
|
||||||
This approach minimizes screen updates and provides smooth performance even with large amounts of text.
|
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
|
||||||
|
|
||||||
**Important:** Don't add extra cursor positioning after printing - it interferes with terminal scrolling and causes rendering artifacts.
|
### How Components Participate
|
||||||
|
|
||||||
|
Components implement the simple `Component` interface:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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.
|
||||||
|
|
||||||
## Advanced Examples
|
## Advanced Examples
|
||||||
|
|
||||||
|
|
@ -618,6 +581,149 @@ interface SelectItem {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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
|
||||||
|
```typescript
|
||||||
|
await new Promise(resolve => process.nextTick(resolve));
|
||||||
|
await terminal.flush();
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Test both viewport and scrollback**: Ensure content preservation
|
||||||
|
```typescript
|
||||||
|
const viewport = terminal.getViewport(); // Visible content
|
||||||
|
const scrollBuffer = terminal.getScrollBuffer(); // All content
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Use exact string matching**: Don't trim() - whitespace matters
|
||||||
|
```typescript
|
||||||
|
assert.strictEqual(viewport[0], "Expected text"); // Good
|
||||||
|
assert.strictEqual(viewport[0].trim(), "Expected"); // Bad
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Test rendering strategies**: Verify surgical vs partial vs full
|
||||||
|
```typescript
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -629,18 +735,11 @@ npm run build
|
||||||
|
|
||||||
# Run type checking
|
# Run type checking
|
||||||
npm run check
|
npm run check
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
**Testing:**
|
|
||||||
Create a test file and run it with tsx:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From packages/tui directory
|
|
||||||
npx tsx test/demo.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Special input keywords for simulation: "TAB", "ENTER", "SPACE", "ESC"
|
|
||||||
|
|
||||||
**Debugging:**
|
**Debugging:**
|
||||||
Enable logging to see detailed component behavior:
|
Enable logging to see detailed component behavior:
|
||||||
|
|
||||||
|
|
@ -651,5 +750,3 @@ ui.configureLogging({
|
||||||
logFile: "tui-debug.log",
|
logFile: "tui-debug.log",
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Check the log file to debug rendering issues, input handling, and component lifecycle.
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue