Clean up TUI package and refactor component structure

- Remove old TUI implementation and components (LoadingAnimation, MarkdownComponent, TextComponent, TextEditor, WhitespaceComponent)
- Rename components-new to components with new API (Loader, Markdown, Text, Editor, Spacer)
- Move Text and Input components to separate files in src/components/
- Add render caching to Text component (similar to Markdown)
- Add proper ANSI code handling in Text component using stripVTControlCharacters
- Update coding-agent to use new TUI API (requires ProcessTerminal, uses custom Editor subclass for key handling)
- Remove old test files, keep only chat-simple.ts and virtual-terminal.ts
- Update README.md with new minimal API documentation
- Switch from tsc to tsgo for type checking
- Update package dependencies across monorepo
This commit is contained in:
Mario Zechner 2025-11-11 10:32:18 +01:00
parent 1caa3cc1a7
commit 985f955ea0
40 changed files with 998 additions and 4516 deletions

View file

@ -1,6 +1,6 @@
import chalk from "chalk";
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js";
import type { Component } from "../tui.js";
import { SelectList } from "./select-list.js";
interface EditorState {
@ -19,8 +19,7 @@ export interface TextEditorConfig {
// Configuration options for text editor (none currently)
}
export class TextEditor implements Component {
readonly id = getNextComponentId();
export class Editor implements Component {
private state: EditorState = {
lines: [""],
cursorLine: 0,
@ -35,6 +34,14 @@ export class TextEditor implements Component {
private isAutocompleting: boolean = false;
private autocompletePrefix: string = "";
// Paste tracking for large pastes
private pastes: Map<number, string> = new Map();
private pasteCounter: number = 0;
// Bracketed paste mode buffering
private pasteBuffer: string = "";
private isInPaste: boolean = false;
public onSubmit?: (text: string) => void;
public onChange?: (text: string) => void;
public disableSubmit: boolean = false;
@ -53,26 +60,16 @@ export class TextEditor implements Component {
this.autocompleteProvider = provider;
}
render(width: number): ComponentRenderResult {
// Box drawing characters
const topLeft = chalk.gray("╭");
const topRight = chalk.gray("╮");
const bottomLeft = chalk.gray("╰");
const bottomRight = chalk.gray("╯");
render(width: number): string[] {
const horizontal = chalk.gray("─");
const vertical = chalk.gray("│");
// Calculate box width - leave 1 char margin to avoid edge wrapping
const boxWidth = width - 1;
const contentWidth = boxWidth - 4; // Account for "│ " and " │"
// Layout the text
const layoutLines = this.layoutText(contentWidth);
// Layout the text - use full width
const layoutLines = this.layoutText(width);
const result: string[] = [];
// Render top border
result.push(topLeft + horizontal.repeat(boxWidth - 2) + topRight);
result.push(horizontal.repeat(width));
// Render each layout line
for (const layoutLine of layoutLines) {
@ -91,39 +88,89 @@ export class TextEditor implements Component {
displayText = before + cursor + restAfter;
// visibleLength stays the same - we're replacing, not adding
} else {
// Cursor is at the end - add highlighted space
const cursor = "\x1b[7m \x1b[0m";
displayText = before + cursor;
// visibleLength increases by 1 - we're adding a space
visibleLength = layoutLine.text.length + 1;
// Cursor is at the end - check if we have room for the space
if (layoutLine.text.length < width) {
// We have room - add highlighted space
const cursor = "\x1b[7m \x1b[0m";
displayText = before + cursor;
// visibleLength increases by 1 - we're adding a space
visibleLength = layoutLine.text.length + 1;
} else {
// Line is at full width - use reverse video on last character if possible
// or just show cursor at the end without adding space
if (before.length > 0) {
const lastChar = before[before.length - 1];
const cursor = `\x1b[7m${lastChar}\x1b[0m`;
displayText = before.slice(0, -1) + cursor;
}
// visibleLength stays the same
}
}
}
// Calculate padding based on actual visible length
const padding = " ".repeat(Math.max(0, contentWidth - visibleLength));
const padding = " ".repeat(Math.max(0, width - visibleLength));
// Render the line
result.push(`${vertical} ${displayText}${padding} ${vertical}`);
// Render the line (no side borders, just horizontal lines above and below)
result.push(displayText + padding);
}
// Render bottom border
result.push(bottomLeft + horizontal.repeat(boxWidth - 2) + bottomRight);
result.push(horizontal.repeat(width));
// Add autocomplete list if active
if (this.isAutocompleting && this.autocompleteList) {
const autocompleteResult = this.autocompleteList.render(width);
result.push(...autocompleteResult.lines);
result.push(...autocompleteResult);
}
// For interactive components like text editors, always assume changed
// This ensures cursor position updates are always reflected
return {
lines: result,
changed: true,
};
return result;
}
handleInput(data: string): void {
// Handle bracketed paste mode
// Start of paste: \x1b[200~
// End of paste: \x1b[201~
// Check if we're starting a bracketed paste
if (data.includes("\x1b[200~")) {
this.isInPaste = true;
this.pasteBuffer = "";
// Remove the start marker and keep the rest
data = data.replace("\x1b[200~", "");
}
// If we're in a paste, buffer the data
if (this.isInPaste) {
// Append data to buffer first (end marker could be split across chunks)
this.pasteBuffer += data;
// Check if the accumulated buffer contains the end marker
const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
if (endIndex !== -1) {
// Extract content before the end marker
const pasteContent = this.pasteBuffer.substring(0, endIndex);
// Process the complete paste
this.handlePaste(pasteContent);
// Reset paste state
this.isInPaste = false;
// Process any remaining data after the end marker
const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~
this.pasteBuffer = "";
if (remaining.length > 0) {
this.handleInput(remaining);
}
return;
} else {
// Still accumulating, wait for more data
return;
}
}
// Handle special key combinations first
// Ctrl+C - Exit (let parent handle this)
@ -131,13 +178,6 @@ export class TextEditor implements Component {
return;
}
// Handle paste - detect when we get a lot of text at once
const isPaste = data.length > 10 || (data.length > 2 && data.includes("\n"));
if (isPaste) {
this.handlePaste(data);
return;
}
// Handle autocomplete special keys first (but don't block other input)
if (this.isAutocompleting && this.autocompleteList) {
// Escape - cancel autocomplete
@ -152,8 +192,8 @@ export class TextEditor implements Component {
this.autocompleteList.handleInput(data);
}
// If Tab was pressed, apply the selection
if (data === "\t") {
// If Tab or Enter was pressed, apply the selection
if (data === "\t" || data === "\r") {
const selected = this.autocompleteList.getSelectedItem();
if (selected && this.autocompleteProvider) {
const result = this.autocompleteProvider.applyCompletion(
@ -175,11 +215,6 @@ export class TextEditor implements Component {
}
}
return;
}
// If Enter was pressed, cancel autocomplete and let it fall through to submission
else if (data === "\r") {
this.cancelAutocomplete();
// Don't return here - let Enter fall through to normal submission handling
} else {
// For other keys, handle normally within autocomplete
return;
@ -227,15 +262,24 @@ export class TextEditor implements Component {
return;
}
// Plain Enter = submit
const result = this.state.lines.join("\n").trim();
// Get text and substitute paste markers with actual content
let result = this.state.lines.join("\n").trim();
// Reset editor
// Replace all [paste #N +xxx lines] markers with actual paste content
for (const [pasteId, pasteContent] of this.pastes) {
// Match both old format [paste #N] and new format [paste #N +xxx lines]
const markerRegex = new RegExp(`\\[paste #${pasteId}( \\+\\d+ lines)?\\]`, "g");
result = result.replace(markerRegex, pasteContent);
}
// Reset editor and clear pastes
this.state = {
lines: [""],
cursorLine: 0,
cursorCol: 0,
};
this.pastes.clear();
this.pasteCounter = 0;
// Notify that editor is now empty
if (this.onChange) {
@ -289,9 +333,9 @@ export class TextEditor implements Component {
if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {
// Empty editor
layoutLines.push({
text: "> ",
text: "",
hasCursor: true,
cursorPos: 2,
cursorPos: 0,
});
return layoutLines;
}
@ -300,29 +344,27 @@ export class TextEditor implements Component {
for (let i = 0; i < this.state.lines.length; i++) {
const line = this.state.lines[i] || "";
const isCurrentLine = i === this.state.cursorLine;
const prefix = i === 0 ? "> " : " ";
const prefixedLine = prefix + line;
const maxLineLength = contentWidth;
if (prefixedLine.length <= maxLineLength) {
if (line.length <= maxLineLength) {
// Line fits in one layout line
if (isCurrentLine) {
layoutLines.push({
text: prefixedLine,
text: line,
hasCursor: true,
cursorPos: prefix.length + this.state.cursorCol,
cursorPos: this.state.cursorCol,
});
} else {
layoutLines.push({
text: prefixedLine,
text: line,
hasCursor: false,
});
}
} else {
// Line needs wrapping
const chunks = [];
for (let pos = 0; pos < prefixedLine.length; pos += maxLineLength) {
chunks.push(prefixedLine.slice(pos, pos + maxLineLength));
for (let pos = 0; pos < line.length; pos += maxLineLength) {
chunks.push(line.slice(pos, pos + maxLineLength));
}
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
@ -331,8 +373,8 @@ export class TextEditor implements Component {
const chunkStart = chunkIndex * maxLineLength;
const chunkEnd = chunkStart + chunk.length;
const cursorPos = prefix.length + this.state.cursorCol;
const hasCursorInChunk = isCurrentLine && cursorPos >= chunkStart && cursorPos < chunkEnd;
const cursorPos = this.state.cursorCol;
const hasCursorInChunk = isCurrentLine && cursorPos >= chunkStart && cursorPos <= chunkEnd;
if (hasCursorInChunk) {
layoutLines.push({
@ -424,6 +466,22 @@ export class TextEditor implements Component {
// Split into lines
const pastedLines = filteredText.split("\n");
// Check if this is a large paste (> 10 lines)
if (pastedLines.length > 10) {
// Store the paste and insert a marker
this.pasteCounter++;
const pasteId = this.pasteCounter;
this.pastes.set(pasteId, filteredText);
// Insert marker like "[paste #1 +123 lines]"
const marker = `[paste #${pasteId} +${pastedLines.length} lines]`;
for (const char of marker) {
this.insertCharacter(char);
}
return;
}
if (pastedLines.length === 1) {
// Single line - just insert each character
const text = pastedLines[0] || "";

View file

@ -0,0 +1,137 @@
import { stripVTControlCharacters } from "node:util";
import type { Component } from "../tui.js";
/**
* Input component - single-line text input with horizontal scrolling
*/
export class Input implements Component {
private value: string = "";
private cursor: number = 0; // Cursor position in the value
public onSubmit?: (value: string) => void;
getValue(): string {
return this.value;
}
setValue(value: string): void {
this.value = value;
this.cursor = Math.min(this.cursor, value.length);
}
handleInput(data: string): void {
// Handle special keys
if (data === "\r" || data === "\n") {
// Enter - submit
if (this.onSubmit) {
this.onSubmit(this.value);
}
return;
}
if (data === "\x7f" || data === "\x08") {
// Backspace
if (this.cursor > 0) {
this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor);
this.cursor--;
}
return;
}
if (data === "\x1b[D") {
// Left arrow
if (this.cursor > 0) {
this.cursor--;
}
return;
}
if (data === "\x1b[C") {
// Right arrow
if (this.cursor < this.value.length) {
this.cursor++;
}
return;
}
if (data === "\x1b[3~") {
// Delete
if (this.cursor < this.value.length) {
this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1);
}
return;
}
if (data === "\x01") {
// Ctrl+A - beginning of line
this.cursor = 0;
return;
}
if (data === "\x05") {
// Ctrl+E - end of line
this.cursor = this.value.length;
return;
}
// Regular character input
if (data.length === 1 && data >= " " && data <= "~") {
this.value = this.value.slice(0, this.cursor) + data + this.value.slice(this.cursor);
this.cursor++;
}
}
render(width: number): string[] {
// Calculate visible window
const prompt = "> ";
const availableWidth = width - prompt.length;
if (availableWidth <= 0) {
return [prompt];
}
let visibleText = "";
let cursorDisplay = this.cursor;
if (this.value.length < availableWidth) {
// Everything fits (leave room for cursor at end)
visibleText = this.value;
} else {
// Need horizontal scrolling
// Reserve one character for cursor if it's at the end
const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth;
const halfWidth = Math.floor(scrollWidth / 2);
if (this.cursor < halfWidth) {
// Cursor near start
visibleText = this.value.slice(0, scrollWidth);
cursorDisplay = this.cursor;
} else if (this.cursor > this.value.length - halfWidth) {
// Cursor near end
visibleText = this.value.slice(this.value.length - scrollWidth);
cursorDisplay = scrollWidth - (this.value.length - this.cursor);
} else {
// Cursor in middle
const start = this.cursor - halfWidth;
visibleText = this.value.slice(start, start + scrollWidth);
cursorDisplay = halfWidth;
}
}
// Build line with fake cursor
// Insert cursor character at cursor position
const beforeCursor = visibleText.slice(0, cursorDisplay);
const atCursor = visibleText[cursorDisplay] || " "; // Character at cursor, or space if at end
const afterCursor = visibleText.slice(cursorDisplay + 1);
// Use inverse video to show cursor
const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal
const textWithCursor = beforeCursor + cursorChar + afterCursor;
// Calculate visual width (strip ANSI codes to measure actual displayed characters)
const visualLength = stripVTControlCharacters(textWithCursor).length;
const padding = " ".repeat(Math.max(0, availableWidth - visualLength));
const line = prompt + textWithCursor + padding;
return [line];
}
}

View file

@ -1,12 +1,11 @@
import chalk from "chalk";
import type { TUI } from "../tui.js";
import { TextComponent } from "./text-component.js";
import { Text } from "./text.js";
/**
* LoadingAnimation component that updates every 80ms
* Simulates the animation component that causes flicker in single-buffer mode
* Loader component that updates every 80ms with spinning animation
*/
export class LoadingAnimation extends TextComponent {
export class Loader extends Text {
private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
private currentFrame = 0;
private intervalId: NodeJS.Timeout | null = null;
@ -16,7 +15,7 @@ export class LoadingAnimation extends TextComponent {
ui: TUI,
private message: string = "Loading...",
) {
super("", { bottom: 1 });
super("");
this.ui = ui;
this.start();
}

View file

@ -1,22 +1,90 @@
import { stripVTControlCharacters } from "node:util";
import chalk from "chalk";
import { marked, type Token } from "marked";
import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js";
import type { Component } from "../tui.js";
export class MarkdownComponent implements Component {
readonly id = getNextComponentId();
type Color =
| "black"
| "red"
| "green"
| "yellow"
| "blue"
| "magenta"
| "cyan"
| "white"
| "gray"
| "bgBlack"
| "bgRed"
| "bgGreen"
| "bgYellow"
| "bgBlue"
| "bgMagenta"
| "bgCyan"
| "bgWhite"
| "bgGray";
export class Markdown implements Component {
private text: string;
private lines: string[] = [];
private previousLines: string[] = [];
private bgColor?: Color;
private fgColor?: Color;
private customBgRgb?: { r: number; g: number; b: number };
private paddingX: number; // Left/right padding
private paddingY: number; // Top/bottom padding
constructor(text: string = "") {
// Cache for rendered output
private cachedText?: string;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(
text: string = "",
bgColor?: Color,
fgColor?: Color,
customBgRgb?: { r: number; g: number; b: number },
paddingX: number = 1,
paddingY: number = 1,
) {
this.text = text;
this.bgColor = bgColor;
this.fgColor = fgColor;
this.customBgRgb = customBgRgb;
this.paddingX = paddingX;
this.paddingY = paddingY;
}
setText(text: string): void {
this.text = text;
// Invalidate cache when text changes
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
render(width: number): ComponentRenderResult {
setBgColor(bgColor?: Color): void {
this.bgColor = bgColor;
// Invalidate cache when color changes
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
setFgColor(fgColor?: Color): void {
this.fgColor = fgColor;
// Invalidate cache when color changes
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
render(width: number): string[] {
// Check cache
if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
return this.cachedLines;
}
// Calculate available width for content (subtract horizontal padding)
const contentWidth = Math.max(1, width - this.paddingX * 2);
// Parse markdown to HTML-like tokens
const tokens = marked.lexer(this.text);
@ -26,28 +94,79 @@ export class MarkdownComponent implements Component {
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const nextToken = tokens[i + 1];
const tokenLines = this.renderToken(token, width, nextToken?.type);
const tokenLines = this.renderToken(token, contentWidth, nextToken?.type);
renderedLines.push(...tokenLines);
}
// Wrap lines to fit width
// Wrap lines to fit content width
const wrappedLines: string[] = [];
for (const line of renderedLines) {
wrappedLines.push(...this.wrapLine(line, width));
wrappedLines.push(...this.wrapLine(line, contentWidth));
}
this.previousLines = this.lines;
this.lines = wrappedLines;
// Add padding and apply colors
const leftPad = " ".repeat(this.paddingX);
const paddedLines: string[] = [];
// Determine if content changed
const changed =
this.lines.length !== this.previousLines.length ||
this.lines.some((line, i) => line !== this.previousLines[i]);
for (const line of wrappedLines) {
// Calculate visible length (strip ANSI codes)
const visibleLength = stripVTControlCharacters(line).length;
// Right padding to fill to width (accounting for left padding and content)
const rightPadLength = Math.max(0, width - this.paddingX - visibleLength);
const rightPad = " ".repeat(rightPadLength);
return {
lines: this.lines,
changed,
};
// Add left padding, content, and right padding
let paddedLine = leftPad + line + rightPad;
// Apply foreground color if specified
if (this.fgColor) {
paddedLine = (chalk as any)[this.fgColor](paddedLine);
}
// Apply background color if specified
if (this.customBgRgb) {
paddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(paddedLine);
} else if (this.bgColor) {
paddedLine = (chalk as any)[this.bgColor](paddedLine);
}
paddedLines.push(paddedLine);
}
// Add top padding (empty lines)
const emptyLine = " ".repeat(width);
const topPadding: string[] = [];
for (let i = 0; i < this.paddingY; i++) {
let emptyPaddedLine = emptyLine;
if (this.customBgRgb) {
emptyPaddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(emptyPaddedLine);
} else if (this.bgColor) {
emptyPaddedLine = (chalk as any)[this.bgColor](emptyPaddedLine);
}
topPadding.push(emptyPaddedLine);
}
// Add bottom padding (empty lines)
const bottomPadding: string[] = [];
for (let i = 0; i < this.paddingY; i++) {
let emptyPaddedLine = emptyLine;
if (this.customBgRgb) {
emptyPaddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(emptyPaddedLine);
} else if (this.bgColor) {
emptyPaddedLine = (chalk as any)[this.bgColor](emptyPaddedLine);
}
bottomPadding.push(emptyPaddedLine);
}
// Combine top padding, content, and bottom padding
const result = [...topPadding, ...paddedLines, ...bottomPadding];
// Update cache
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result.length > 0 ? result : [""];
}
private renderToken(token: Token, width: number, nextTokenType?: string): string[] {
@ -210,7 +329,7 @@ export class MarkdownComponent implements Component {
}
// If line fits within width, return as-is
const visibleLength = this.getVisibleLength(line);
const visibleLength = stripVTControlCharacters(line).length;
if (visibleLength <= width) {
return [line];
}
@ -274,9 +393,4 @@ export class MarkdownComponent implements Component {
return wrapped.length > 0 ? wrapped : [""];
}
private getVisibleLength(str: string): number {
// Remove ANSI escape codes and count visible characters
return (str || "").replace(/\x1b\[[0-9;]*m/g, "").length;
}
}

View file

@ -1,5 +1,5 @@
import chalk from "chalk";
import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js";
import type { Component } from "../tui.js";
export interface SelectItem {
value: string;
@ -8,7 +8,6 @@ export interface SelectItem {
}
export class SelectList implements Component {
readonly id = getNextComponentId();
private items: SelectItem[] = [];
private filteredItems: SelectItem[] = [];
private selectedIndex: number = 0;
@ -31,13 +30,13 @@ export class SelectList implements Component {
this.selectedIndex = 0;
}
render(width: number): ComponentRenderResult {
render(width: number): string[] {
const lines: string[] = [];
// If no items match filter, show message
if (this.filteredItems.length === 0) {
lines.push(chalk.gray(" No matching commands"));
return { lines, changed: true };
return lines;
}
// Calculate visible range with scrolling
@ -121,7 +120,7 @@ export class SelectList implements Component {
lines.push(scrollInfo);
}
return { lines, changed: true };
return lines;
}
handleInput(keyData: string): void {

View file

@ -0,0 +1,24 @@
import type { Component } from "../tui.js";
/**
* Spacer component that renders empty lines
*/
export class Spacer implements Component {
private lines: number;
constructor(lines: number = 1) {
this.lines = lines;
}
setLines(lines: number): void {
this.lines = lines;
}
render(_width: number): string[] {
const result: string[] = [];
for (let i = 0; i < this.lines; i++) {
result.push("");
}
return result;
}
}

View file

@ -1,105 +0,0 @@
import { type Component, type ComponentRenderResult, getNextComponentId, type Padding } from "../tui.js";
export class TextComponent implements Component {
readonly id = getNextComponentId();
private text: string;
private lastRenderedLines: string[] = [];
private padding: Required<Padding>;
constructor(text: string, padding?: Padding) {
this.text = text;
this.padding = {
top: padding?.top ?? 0,
bottom: padding?.bottom ?? 0,
left: padding?.left ?? 0,
right: padding?.right ?? 0,
};
}
render(width: number): ComponentRenderResult {
// Calculate available width after horizontal padding
const availableWidth = Math.max(1, width - this.padding.left - this.padding.right);
const leftPadding = " ".repeat(this.padding.left);
// First split by newlines to preserve line breaks
const textLines = this.text.split("\n");
const lines: string[] = [];
// Add top padding
for (let i = 0; i < this.padding.top; i++) {
lines.push("");
}
// Process each line for word wrapping
for (const textLine of textLines) {
if (textLine.length === 0) {
// Preserve empty lines with padding
lines.push(leftPadding);
} else {
// Word wrapping with ANSI-aware length calculation
const words = textLine.split(" ");
let currentLine = "";
let currentVisibleLength = 0;
for (const word of words) {
const wordVisibleLength = this.getVisibleLength(word);
const spaceLength = currentLine ? 1 : 0;
if (currentVisibleLength + spaceLength + wordVisibleLength <= availableWidth) {
currentLine += (currentLine ? " " : "") + word;
currentVisibleLength += spaceLength + wordVisibleLength;
} else {
if (currentLine) {
lines.push(leftPadding + currentLine);
}
currentLine = word;
currentVisibleLength = wordVisibleLength;
}
}
if (currentLine) {
lines.push(leftPadding + currentLine);
}
}
}
// Add bottom padding
for (let i = 0; i < this.padding.bottom; i++) {
lines.push("");
}
const newLines = lines.length > 0 ? lines : [""];
// Check if content changed
const changed = !this.arraysEqual(newLines, this.lastRenderedLines);
// Always cache the current rendered lines
this.lastRenderedLines = [...newLines];
return {
lines: newLines,
changed,
};
}
setText(text: string): void {
this.text = text;
}
getText(): string {
return this.text;
}
private arraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
private getVisibleLength(str: string): number {
// Remove ANSI escape codes and count visible characters
return (str || "").replace(/\x1b\[[0-9;]*m/g, "").length;
}
}

View file

@ -0,0 +1,113 @@
import { stripVTControlCharacters } from "node:util";
import type { Component } from "../tui.js";
/**
* Text component - displays multi-line text with word wrapping
*/
export class Text implements Component {
private text: string;
private paddingX: number; // Left/right padding
private paddingY: number; // Top/bottom padding
// Cache for rendered output
private cachedText?: string;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(text: string = "", paddingX: number = 1, paddingY: number = 1) {
this.text = text;
this.paddingX = paddingX;
this.paddingY = paddingY;
}
setText(text: string): void {
this.text = text;
// Invalidate cache when text changes
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
render(width: number): string[] {
// Check cache
if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
return this.cachedLines;
}
// Calculate available width for content (subtract horizontal padding)
const contentWidth = Math.max(1, width - this.paddingX * 2);
if (!this.text) {
const result = [""];
// Update cache
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result;
}
const lines: string[] = [];
const textLines = this.text.split("\n");
for (const line of textLines) {
if (line.length <= contentWidth) {
lines.push(line);
} else {
// Word wrap
const words = line.split(" ");
let currentLine = "";
for (const word of words) {
if (currentLine.length === 0) {
currentLine = word;
} else if (currentLine.length + 1 + word.length <= contentWidth) {
currentLine += " " + word;
} else {
lines.push(currentLine);
currentLine = word;
}
}
if (currentLine.length > 0) {
lines.push(currentLine);
}
}
}
// Add padding to each line
const leftPad = " ".repeat(this.paddingX);
const paddedLines: string[] = [];
for (const line of lines) {
// Calculate visible length (strip ANSI codes)
const visibleLength = stripVTControlCharacters(line).length;
// Right padding to fill to width (accounting for left padding and content)
const rightPadLength = Math.max(0, width - this.paddingX - visibleLength);
const rightPad = " ".repeat(rightPadLength);
paddedLines.push(leftPad + line + rightPad);
}
// Add top padding (empty lines)
const emptyLine = " ".repeat(width);
const topPadding: string[] = [];
for (let i = 0; i < this.paddingY; i++) {
topPadding.push(emptyLine);
}
// Add bottom padding (empty lines)
const bottomPadding: string[] = [];
for (let i = 0; i < this.paddingY; i++) {
bottomPadding.push(emptyLine);
}
// Combine top padding, content, and bottom padding
const result = [...topPadding, ...paddedLines, ...bottomPadding];
// Update cache
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result.length > 0 ? result : [""];
}
}

View file

@ -1,25 +0,0 @@
import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js";
/**
* A simple component that renders blank lines for spacing
*/
export class WhitespaceComponent implements Component {
readonly id = getNextComponentId();
private lines: string[] = [];
private lineCount: number;
private firstRender: boolean = true;
constructor(lineCount: number = 1) {
this.lineCount = Math.max(0, lineCount); // Ensure non-negative
this.lines = new Array(this.lineCount).fill("");
}
render(_width: number): ComponentRenderResult {
const result = {
lines: this.lines,
changed: this.firstRender,
};
this.firstRender = false;
return result;
}
}