mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 18:03:50 +00:00
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:
parent
1caa3cc1a7
commit
985f955ea0
40 changed files with 998 additions and 4516 deletions
|
|
@ -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] || "";
|
||||
137
packages/tui/src/components/input.ts
Normal file
137
packages/tui/src/components/input.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
24
packages/tui/src/components/spacer.ts
Normal file
24
packages/tui/src/components/spacer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
113
packages/tui/src/components/text.ts
Normal file
113
packages/tui/src/components/text.ts
Normal 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 : [""];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue