Add minimal TUI rewrite with differential rendering

- New TUI implementation with 3-strategy differential rendering
- Synchronized output (CSI 2026) for flicker-free updates
- New components: Editor, Markdown, Loader, SelectList, Spacer
- Editor: file autocomplete, slash commands, large paste markers
- Markdown: RGB background colors, caching
- Terminal: cursor movement, visibility, clear operations
- Chat demo with color-coded messages
This commit is contained in:
Mario Zechner 2025-11-10 23:55:21 +01:00
parent 904fc909c9
commit 97c730c874
9 changed files with 1933 additions and 0 deletions

View file

@ -0,0 +1,719 @@
import chalk from "chalk";
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
import type { Component } from "../tui-new.js";
import { SelectList } from "./select-list.js";
interface EditorState {
lines: string[];
cursorLine: number;
cursorCol: number;
}
interface LayoutLine {
text: string;
hasCursor: boolean;
cursorPos?: number;
}
export interface TextEditorConfig {
// Configuration options for text editor (none currently)
}
export class Editor implements Component {
private state: EditorState = {
lines: [""],
cursorLine: 0,
cursorCol: 0,
};
private config: TextEditorConfig = {};
// Autocomplete support
private autocompleteProvider?: AutocompleteProvider;
private autocompleteList?: SelectList;
private isAutocompleting: boolean = false;
private autocompletePrefix: string = "";
// Paste tracking for large pastes
private pastes: Map<number, string> = new Map();
private pasteCounter: number = 0;
public onSubmit?: (text: string) => void;
public onChange?: (text: string) => void;
public disableSubmit: boolean = false;
constructor(config?: TextEditorConfig) {
if (config) {
this.config = { ...this.config, ...config };
}
}
configure(config: Partial<TextEditorConfig>): void {
this.config = { ...this.config, ...config };
}
setAutocompleteProvider(provider: AutocompleteProvider): void {
this.autocompleteProvider = provider;
}
render(width: number): string[] {
const horizontal = chalk.gray("─");
// Layout the text - use full width
const layoutLines = this.layoutText(width);
const result: string[] = [];
// Render top border
result.push(horizontal.repeat(width));
// Render each layout line
for (const layoutLine of layoutLines) {
let displayText = layoutLine.text;
let visibleLength = layoutLine.text.length;
// Add cursor if this line has it
if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
const before = displayText.slice(0, layoutLine.cursorPos);
const after = displayText.slice(layoutLine.cursorPos);
if (after.length > 0) {
// Cursor is on a character - replace it with highlighted version
const cursor = `\x1b[7m${after[0]}\x1b[0m`;
const restAfter = after.slice(1);
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;
}
}
// Calculate padding based on actual visible length
const padding = " ".repeat(Math.max(0, width - visibleLength));
// Render the line (no side borders, just horizontal lines above and below)
result.push(displayText + padding);
}
// Render bottom border
result.push(horizontal.repeat(width));
// Add autocomplete list if active
if (this.isAutocompleting && this.autocompleteList) {
const autocompleteResult = this.autocompleteList.render(width);
result.push(...autocompleteResult);
}
return result;
}
handleInput(data: string): void {
// Handle special key combinations first
// Ctrl+C - Exit (let parent handle this)
if (data.charCodeAt(0) === 3) {
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
if (data === "\x1b") {
this.cancelAutocomplete();
return;
}
// Let the autocomplete list handle navigation and selection
else if (data === "\x1b[A" || data === "\x1b[B" || data === "\r" || data === "\t") {
// Only pass arrow keys to the list, not Enter/Tab (we handle those directly)
if (data === "\x1b[A" || data === "\x1b[B") {
this.autocompleteList.handleInput(data);
}
// 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(
this.state.lines,
this.state.cursorLine,
this.state.cursorCol,
selected,
this.autocompletePrefix,
);
this.state.lines = result.lines;
this.state.cursorLine = result.cursorLine;
this.state.cursorCol = result.cursorCol;
this.cancelAutocomplete();
if (this.onChange) {
this.onChange(this.getText());
}
}
return;
} else {
// For other keys, handle normally within autocomplete
return;
}
}
// For other keys (like regular typing), DON'T return here
// Let them fall through to normal character handling
}
// Tab key - context-aware completion (but not when already autocompleting)
if (data === "\t" && !this.isAutocompleting) {
this.handleTabCompletion();
return;
}
// Continue with rest of input handling
// Ctrl+K - Delete current line
if (data.charCodeAt(0) === 11) {
this.deleteCurrentLine();
}
// Ctrl+A - Move to start of line
else if (data.charCodeAt(0) === 1) {
this.moveToLineStart();
}
// Ctrl+E - Move to end of line
else if (data.charCodeAt(0) === 5) {
this.moveToLineEnd();
}
// New line shortcuts (but not plain LF/CR which should be submit)
else if (
(data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
data === "\x1b\r" || // Option+Enter in some terminals
data === "\x1b[13;2~" || // Shift+Enter in some terminals
(data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
(data === "\n" && data.length === 1) || // Shift+Enter from iTerm2 mapping
data === "\\\r" // Shift+Enter in VS Code terminal
) {
// Modifier + Enter = new line
this.addNewLine();
}
// Plain Enter (char code 13 for CR) - only CR submits, LF adds new line
else if (data.charCodeAt(0) === 13 && data.length === 1) {
// If submit is disabled, do nothing
if (this.disableSubmit) {
return;
}
// Get text and substitute paste markers with actual content
let result = this.state.lines.join("\n").trim();
// Replace all [paste #N] markers with actual paste content
for (const [pasteId, pasteContent] of this.pastes) {
const marker = `[paste #${pasteId}]`;
result = result.replace(marker, 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) {
this.onChange("");
}
if (this.onSubmit) {
this.onSubmit(result);
}
}
// Backspace
else if (data.charCodeAt(0) === 127 || data.charCodeAt(0) === 8) {
this.handleBackspace();
}
// Line navigation shortcuts (Home/End keys)
else if (data === "\x1b[H" || data === "\x1b[1~" || data === "\x1b[7~") {
// Home key
this.moveToLineStart();
} else if (data === "\x1b[F" || data === "\x1b[4~" || data === "\x1b[8~") {
// End key
this.moveToLineEnd();
}
// Forward delete (Fn+Backspace or Delete key)
else if (data === "\x1b[3~") {
// Delete key
this.handleForwardDelete();
}
// Arrow keys
else if (data === "\x1b[A") {
// Up
this.moveCursor(-1, 0);
} else if (data === "\x1b[B") {
// Down
this.moveCursor(1, 0);
} else if (data === "\x1b[C") {
// Right
this.moveCursor(0, 1);
} else if (data === "\x1b[D") {
// Left
this.moveCursor(0, -1);
}
// Regular characters (printable ASCII)
else if (data.charCodeAt(0) >= 32 && data.charCodeAt(0) <= 126) {
this.insertCharacter(data);
}
}
private layoutText(contentWidth: number): LayoutLine[] {
const layoutLines: LayoutLine[] = [];
if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {
// Empty editor
layoutLines.push({
text: "",
hasCursor: true,
cursorPos: 0,
});
return layoutLines;
}
// Process each logical line
for (let i = 0; i < this.state.lines.length; i++) {
const line = this.state.lines[i] || "";
const isCurrentLine = i === this.state.cursorLine;
const maxLineLength = contentWidth;
if (line.length <= maxLineLength) {
// Line fits in one layout line
if (isCurrentLine) {
layoutLines.push({
text: line,
hasCursor: true,
cursorPos: this.state.cursorCol,
});
} else {
layoutLines.push({
text: line,
hasCursor: false,
});
}
} else {
// Line needs wrapping
const chunks = [];
for (let pos = 0; pos < line.length; pos += maxLineLength) {
chunks.push(line.slice(pos, pos + maxLineLength));
}
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
const chunk = chunks[chunkIndex];
if (!chunk) continue;
const chunkStart = chunkIndex * maxLineLength;
const chunkEnd = chunkStart + chunk.length;
const cursorPos = this.state.cursorCol;
const hasCursorInChunk = isCurrentLine && cursorPos >= chunkStart && cursorPos < chunkEnd;
if (hasCursorInChunk) {
layoutLines.push({
text: chunk,
hasCursor: true,
cursorPos: cursorPos - chunkStart,
});
} else {
layoutLines.push({
text: chunk,
hasCursor: false,
});
}
}
}
}
return layoutLines;
}
getText(): string {
return this.state.lines.join("\n");
}
setText(text: string): void {
// Split text into lines, handling different line endings
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
// Ensure at least one empty line
this.state.lines = lines.length === 0 ? [""] : lines;
// Reset cursor to end of text
this.state.cursorLine = this.state.lines.length - 1;
this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0;
// Notify of change
if (this.onChange) {
this.onChange(this.getText());
}
}
// All the editor methods from before...
private insertCharacter(char: string): void {
const line = this.state.lines[this.state.cursorLine] || "";
const before = line.slice(0, this.state.cursorCol);
const after = line.slice(this.state.cursorCol);
this.state.lines[this.state.cursorLine] = before + char + after;
this.state.cursorCol += char.length; // Fix: increment by the length of the inserted string
if (this.onChange) {
this.onChange(this.getText());
}
// Check if we should trigger or update autocomplete
if (!this.isAutocompleting) {
// Auto-trigger for "/" at the start of a line (slash commands)
if (char === "/" && this.isAtStartOfMessage()) {
this.tryTriggerAutocomplete();
}
// Also auto-trigger when typing letters in a slash command context
else if (/[a-zA-Z0-9]/.test(char)) {
const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
// Check if we're in a slash command with a space (i.e., typing arguments)
if (textBeforeCursor.startsWith("/") && textBeforeCursor.includes(" ")) {
this.tryTriggerAutocomplete();
}
}
} else {
this.updateAutocomplete();
}
}
private handlePaste(pastedText: string): void {
// Clean the pasted text
const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Convert tabs to spaces (4 spaces per tab)
const tabExpandedText = cleanText.replace(/\t/g, " ");
// Filter out non-printable characters except newlines
const filteredText = tabExpandedText
.split("")
.filter((char) => char === "\n" || (char >= " " && char <= "~"))
.join("");
// 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]"
const marker = `[paste #${pasteId}]`;
for (const char of marker) {
this.insertCharacter(char);
}
return;
}
if (pastedLines.length === 1) {
// Single line - just insert each character
const text = pastedLines[0] || "";
for (const char of text) {
this.insertCharacter(char);
}
return;
}
// Multi-line paste - be very careful with array manipulation
const currentLine = this.state.lines[this.state.cursorLine] || "";
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
const afterCursor = currentLine.slice(this.state.cursorCol);
// Build the new lines array step by step
const newLines: string[] = [];
// Add all lines before current line
for (let i = 0; i < this.state.cursorLine; i++) {
newLines.push(this.state.lines[i] || "");
}
// Add the first pasted line merged with before cursor text
newLines.push(beforeCursor + (pastedLines[0] || ""));
// Add all middle pasted lines
for (let i = 1; i < pastedLines.length - 1; i++) {
newLines.push(pastedLines[i] || "");
}
// Add the last pasted line with after cursor text
newLines.push((pastedLines[pastedLines.length - 1] || "") + afterCursor);
// Add all lines after current line
for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) {
newLines.push(this.state.lines[i] || "");
}
// Replace the entire lines array
this.state.lines = newLines;
// Update cursor position to end of pasted content
this.state.cursorLine += pastedLines.length - 1;
this.state.cursorCol = (pastedLines[pastedLines.length - 1] || "").length;
// Notify of change
if (this.onChange) {
this.onChange(this.getText());
}
}
private addNewLine(): void {
const currentLine = this.state.lines[this.state.cursorLine] || "";
const before = currentLine.slice(0, this.state.cursorCol);
const after = currentLine.slice(this.state.cursorCol);
// Split current line
this.state.lines[this.state.cursorLine] = before;
this.state.lines.splice(this.state.cursorLine + 1, 0, after);
// Move cursor to start of new line
this.state.cursorLine++;
this.state.cursorCol = 0;
if (this.onChange) {
this.onChange(this.getText());
}
}
private handleBackspace(): void {
if (this.state.cursorCol > 0) {
// Delete character in current line
const line = this.state.lines[this.state.cursorLine] || "";
const before = line.slice(0, this.state.cursorCol - 1);
const after = line.slice(this.state.cursorCol);
this.state.lines[this.state.cursorLine] = before + after;
this.state.cursorCol--;
} else if (this.state.cursorLine > 0) {
// Merge with previous line
const currentLine = this.state.lines[this.state.cursorLine] || "";
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
this.state.lines.splice(this.state.cursorLine, 1);
this.state.cursorLine--;
this.state.cursorCol = previousLine.length;
}
if (this.onChange) {
this.onChange(this.getText());
}
// Update autocomplete after backspace
if (this.isAutocompleting) {
this.updateAutocomplete();
}
}
private moveToLineStart(): void {
this.state.cursorCol = 0;
}
private moveToLineEnd(): void {
const currentLine = this.state.lines[this.state.cursorLine] || "";
this.state.cursorCol = currentLine.length;
}
private handleForwardDelete(): void {
const currentLine = this.state.lines[this.state.cursorLine] || "";
if (this.state.cursorCol < currentLine.length) {
// Delete character at cursor position (forward delete)
const before = currentLine.slice(0, this.state.cursorCol);
const after = currentLine.slice(this.state.cursorCol + 1);
this.state.lines[this.state.cursorLine] = before + after;
} else if (this.state.cursorLine < this.state.lines.length - 1) {
// At end of line - merge with next line
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
this.state.lines.splice(this.state.cursorLine + 1, 1);
}
if (this.onChange) {
this.onChange(this.getText());
}
}
private deleteCurrentLine(): void {
if (this.state.lines.length === 1) {
// Only one line - just clear it
this.state.lines[0] = "";
this.state.cursorCol = 0;
} else {
// Multiple lines - remove current line
this.state.lines.splice(this.state.cursorLine, 1);
// Adjust cursor position
if (this.state.cursorLine >= this.state.lines.length) {
// Was on last line, move to new last line
this.state.cursorLine = this.state.lines.length - 1;
}
// Clamp cursor column to new line length
const newLine = this.state.lines[this.state.cursorLine] || "";
this.state.cursorCol = Math.min(this.state.cursorCol, newLine.length);
}
if (this.onChange) {
this.onChange(this.getText());
}
}
private moveCursor(deltaLine: number, deltaCol: number): void {
if (deltaLine !== 0) {
const newLine = this.state.cursorLine + deltaLine;
if (newLine >= 0 && newLine < this.state.lines.length) {
this.state.cursorLine = newLine;
// Clamp cursor column to new line length
const line = this.state.lines[this.state.cursorLine] || "";
this.state.cursorCol = Math.min(this.state.cursorCol, line.length);
}
}
if (deltaCol !== 0) {
// Move column
const newCol = this.state.cursorCol + deltaCol;
const currentLine = this.state.lines[this.state.cursorLine] || "";
const maxCol = currentLine.length;
this.state.cursorCol = Math.max(0, Math.min(maxCol, newCol));
}
}
// Helper method to check if cursor is at start of message (for slash command detection)
private isAtStartOfMessage(): boolean {
const currentLine = this.state.lines[this.state.cursorLine] || "";
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
// At start if line is empty, only contains whitespace, or is just "/"
return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
}
// Autocomplete methods
private tryTriggerAutocomplete(explicitTab: boolean = false): void {
if (!this.autocompleteProvider) return;
// Check if we should trigger file completion on Tab
if (explicitTab) {
const provider = this.autocompleteProvider as CombinedAutocompleteProvider;
const shouldTrigger =
!provider.shouldTriggerFileCompletion ||
provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
if (!shouldTrigger) {
return;
}
}
const suggestions = this.autocompleteProvider.getSuggestions(
this.state.lines,
this.state.cursorLine,
this.state.cursorCol,
);
if (suggestions && suggestions.items.length > 0) {
this.autocompletePrefix = suggestions.prefix;
this.autocompleteList = new SelectList(suggestions.items, 5);
this.isAutocompleting = true;
} else {
this.cancelAutocomplete();
}
}
private handleTabCompletion(): void {
if (!this.autocompleteProvider) return;
const currentLine = this.state.lines[this.state.cursorLine] || "";
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
// Check if we're in a slash command context
if (beforeCursor.trimStart().startsWith("/")) {
this.handleSlashCommandCompletion();
} else {
this.forceFileAutocomplete();
}
}
private handleSlashCommandCompletion(): void {
// For now, fall back to regular autocomplete (slash commands)
// This can be extended later to handle command-specific argument completion
this.tryTriggerAutocomplete(true);
}
private forceFileAutocomplete(): void {
if (!this.autocompleteProvider) return;
// Check if provider has the force method
const provider = this.autocompleteProvider as any;
if (!provider.getForceFileSuggestions) {
this.tryTriggerAutocomplete(true);
return;
}
const suggestions = provider.getForceFileSuggestions(
this.state.lines,
this.state.cursorLine,
this.state.cursorCol,
);
if (suggestions && suggestions.items.length > 0) {
this.autocompletePrefix = suggestions.prefix;
this.autocompleteList = new SelectList(suggestions.items, 5);
this.isAutocompleting = true;
} else {
this.cancelAutocomplete();
}
}
private cancelAutocomplete(): void {
this.isAutocompleting = false;
this.autocompleteList = undefined as any;
this.autocompletePrefix = "";
}
private updateAutocomplete(): void {
if (!this.isAutocompleting || !this.autocompleteProvider) return;
const suggestions = this.autocompleteProvider.getSuggestions(
this.state.lines,
this.state.cursorLine,
this.state.cursorCol,
);
if (suggestions && suggestions.items.length > 0) {
this.autocompletePrefix = suggestions.prefix;
if (this.autocompleteList) {
// Update the existing list with new items
this.autocompleteList = new SelectList(suggestions.items, 5);
}
} else {
// No more matches, cancel autocomplete
this.cancelAutocomplete();
}
}
}

View file

@ -0,0 +1,49 @@
import chalk from "chalk";
import { Text, type TUI } from "../tui-new.js";
/**
* Loader component that updates every 80ms with spinning animation
*/
export class Loader extends Text {
private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
private currentFrame = 0;
private intervalId: NodeJS.Timeout | null = null;
private ui: TUI | null = null;
constructor(
ui: TUI,
private message: string = "Loading...",
) {
super("");
this.ui = ui;
this.start();
}
start() {
this.updateDisplay();
this.intervalId = setInterval(() => {
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
this.updateDisplay();
}, 80);
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
setMessage(message: string) {
this.message = message;
this.updateDisplay();
}
private updateDisplay() {
const frame = this.frames[this.currentFrame];
this.setText(`${chalk.cyan(frame)} ${chalk.dim(this.message)}`);
if (this.ui) {
this.ui.requestRender();
}
}
}

View file

@ -0,0 +1,357 @@
import { stripVTControlCharacters } from "node:util";
import chalk from "chalk";
import { marked, type Token } from "marked";
import type { Component } from "../tui-new.js";
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 bgColor?: Color;
private fgColor?: Color;
private customBgRgb?: { r: number; g: number; b: number };
// 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 }) {
this.text = text;
this.bgColor = bgColor;
this.fgColor = fgColor;
this.customBgRgb = customBgRgb;
}
setText(text: string): void {
this.text = text;
// Invalidate cache when text changes
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
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;
}
// Parse markdown to HTML-like tokens
const tokens = marked.lexer(this.text);
// Convert tokens to styled terminal output
const renderedLines: string[] = [];
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);
renderedLines.push(...tokenLines);
}
// Wrap lines to fit width
const wrappedLines: string[] = [];
for (const line of renderedLines) {
wrappedLines.push(...this.wrapLine(line, width));
}
// Apply background and foreground colors if specified
let result: string[];
if (this.bgColor || this.fgColor || this.customBgRgb) {
const coloredLines: string[] = [];
for (const line of wrappedLines) {
// Calculate visible length (strip ANSI codes)
const visibleLength = stripVTControlCharacters(line).length;
const padding = " ".repeat(Math.max(0, width - visibleLength));
// Apply colors
let coloredLine = line + padding;
// Apply foreground color first if specified
if (this.fgColor) {
coloredLine = (chalk as any)[this.fgColor](coloredLine);
}
// Apply background color if specified
if (this.customBgRgb) {
// Use custom RGB background
coloredLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(coloredLine);
} else if (this.bgColor) {
coloredLine = (chalk as any)[this.bgColor](coloredLine);
}
coloredLines.push(coloredLine);
}
result = coloredLines.length > 0 ? coloredLines : [""];
} else {
result = wrappedLines.length > 0 ? wrappedLines : [""];
}
// Update cache
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result;
}
private renderToken(token: Token, width: number, nextTokenType?: string): string[] {
const lines: string[] = [];
switch (token.type) {
case "heading": {
const headingLevel = token.depth;
const headingPrefix = "#".repeat(headingLevel) + " ";
const headingText = this.renderInlineTokens(token.tokens || []);
if (headingLevel === 1) {
lines.push(chalk.bold.underline.yellow(headingText));
} else if (headingLevel === 2) {
lines.push(chalk.bold.yellow(headingText));
} else {
lines.push(chalk.bold(headingPrefix + headingText));
}
lines.push(""); // Add spacing after headings
break;
}
case "paragraph": {
const paragraphText = this.renderInlineTokens(token.tokens || []);
lines.push(paragraphText);
// Don't add spacing if next token is space or list
if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") {
lines.push("");
}
break;
}
case "code": {
lines.push(chalk.gray("```" + (token.lang || "")));
// Split code by newlines and style each line
const codeLines = token.text.split("\n");
for (const codeLine of codeLines) {
lines.push(chalk.dim(" ") + chalk.green(codeLine));
}
lines.push(chalk.gray("```"));
lines.push(""); // Add spacing after code blocks
break;
}
case "list":
for (let i = 0; i < token.items.length; i++) {
const item = token.items[i];
const bullet = token.ordered ? `${i + 1}. ` : "- ";
const itemText = this.renderInlineTokens(item.tokens || []);
// Check if the item text contains multiple lines (embedded content)
const itemLines = itemText.split("\n").filter((line) => line.trim());
if (itemLines.length > 1) {
// First line is the list item
lines.push(chalk.cyan(bullet) + itemLines[0]);
// Rest are treated as separate content
for (let j = 1; j < itemLines.length; j++) {
lines.push(""); // Add spacing
lines.push(itemLines[j]);
}
} else {
lines.push(chalk.cyan(bullet) + itemText);
}
}
// Don't add spacing after lists if a space token follows
// (the space token will handle it)
break;
case "blockquote": {
const quoteText = this.renderInlineTokens(token.tokens || []);
const quoteLines = quoteText.split("\n");
for (const quoteLine of quoteLines) {
lines.push(chalk.gray("│ ") + chalk.italic(quoteLine));
}
lines.push(""); // Add spacing after blockquotes
break;
}
case "hr":
lines.push(chalk.gray("─".repeat(Math.min(width, 80))));
lines.push(""); // Add spacing after horizontal rules
break;
case "html":
// Skip HTML for terminal output
break;
case "space":
// Space tokens represent blank lines in markdown
lines.push("");
break;
default:
// Handle any other token types as plain text
if ("text" in token && typeof token.text === "string") {
lines.push(token.text);
}
}
return lines;
}
private renderInlineTokens(tokens: Token[]): string {
let result = "";
for (const token of tokens) {
switch (token.type) {
case "text":
// Text tokens in list items can have nested tokens for inline formatting
if (token.tokens && token.tokens.length > 0) {
result += this.renderInlineTokens(token.tokens);
} else {
result += token.text;
}
break;
case "strong":
result += chalk.bold(this.renderInlineTokens(token.tokens || []));
break;
case "em":
result += chalk.italic(this.renderInlineTokens(token.tokens || []));
break;
case "codespan":
result += chalk.gray("`") + chalk.cyan(token.text) + chalk.gray("`");
break;
case "link": {
const linkText = this.renderInlineTokens(token.tokens || []);
result += chalk.underline.blue(linkText) + chalk.gray(` (${token.href})`);
break;
}
case "br":
result += "\n";
break;
case "del":
result += chalk.strikethrough(this.renderInlineTokens(token.tokens || []));
break;
default:
// Handle any other inline token types as plain text
if ("text" in token && typeof token.text === "string") {
result += token.text;
}
}
}
return result;
}
private wrapLine(line: string, width: number): string[] {
// Handle ANSI escape codes properly when wrapping
const wrapped: string[] = [];
// Handle undefined or null lines
if (!line) {
return [""];
}
// If line fits within width, return as-is
const visibleLength = stripVTControlCharacters(line).length;
if (visibleLength <= width) {
return [line];
}
// Track active ANSI codes to preserve them across wrapped lines
const activeAnsiCodes: string[] = [];
let currentLine = "";
let currentLength = 0;
let i = 0;
while (i < line.length) {
if (line[i] === "\x1b" && line[i + 1] === "[") {
// ANSI escape sequence - parse and track it
let j = i + 2;
while (j < line.length && line[j] && !/[mGKHJ]/.test(line[j]!)) {
j++;
}
if (j < line.length) {
const ansiCode = line.substring(i, j + 1);
currentLine += ansiCode;
// Track styling codes (ending with 'm')
if (line[j] === "m") {
// Reset code
if (ansiCode === "\x1b[0m" || ansiCode === "\x1b[m") {
activeAnsiCodes.length = 0;
} else {
// Add to active codes (replacing similar ones)
activeAnsiCodes.push(ansiCode);
}
}
i = j + 1;
} else {
// Incomplete ANSI sequence at end - don't include it
break;
}
} else {
// Regular character
if (currentLength >= width) {
// Need to wrap - close current line with reset if needed
if (activeAnsiCodes.length > 0) {
wrapped.push(currentLine + "\x1b[0m");
// Start new line with active codes
currentLine = activeAnsiCodes.join("");
} else {
wrapped.push(currentLine);
currentLine = "";
}
currentLength = 0;
}
currentLine += line[i];
currentLength++;
i++;
}
}
if (currentLine) {
wrapped.push(currentLine);
}
return wrapped.length > 0 ? wrapped : [""];
}
}

View file

@ -0,0 +1,154 @@
import chalk from "chalk";
import type { Component } from "../tui-new.js";
export interface SelectItem {
value: string;
label: string;
description?: string;
}
export class SelectList implements Component {
private items: SelectItem[] = [];
private filteredItems: SelectItem[] = [];
private selectedIndex: number = 0;
private filter: string = "";
private maxVisible: number = 5;
public onSelect?: (item: SelectItem) => void;
public onCancel?: () => void;
constructor(items: SelectItem[], maxVisible: number = 5) {
this.items = items;
this.filteredItems = items;
this.maxVisible = maxVisible;
}
setFilter(filter: string): void {
this.filter = filter;
this.filteredItems = this.items.filter((item) => item.value.toLowerCase().startsWith(filter.toLowerCase()));
// Reset selection when filter changes
this.selectedIndex = 0;
}
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;
}
// Calculate visible range with scrolling
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
);
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
// Render visible items
for (let i = startIndex; i < endIndex; i++) {
const item = this.filteredItems[i];
if (!item) continue;
const isSelected = i === this.selectedIndex;
let line = "";
if (isSelected) {
// Use arrow indicator for selection
const prefix = chalk.blue("→ ");
const displayValue = item.label || item.value;
if (item.description && width > 40) {
// Calculate how much space we have for value + description
const maxValueLength = Math.min(displayValue.length, 30);
const truncatedValue = displayValue.substring(0, maxValueLength);
const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length));
// Calculate remaining space for description
const descriptionStart = prefix.length + truncatedValue.length + spacing.length - 2; // -2 for arrow color codes
const remainingWidth = width - descriptionStart - 2; // -2 for safety
if (remainingWidth > 10) {
const truncatedDesc = item.description.substring(0, remainingWidth);
line = prefix + chalk.blue(truncatedValue) + chalk.gray(spacing + truncatedDesc);
} else {
// Not enough space for description
const maxWidth = width - 4; // 2 for arrow + space, 2 for safety
line = prefix + chalk.blue(displayValue.substring(0, maxWidth));
}
} else {
// No description or not enough width
const maxWidth = width - 4; // 2 for arrow + space, 2 for safety
line = prefix + chalk.blue(displayValue.substring(0, maxWidth));
}
} else {
const displayValue = item.label || item.value;
const prefix = " ";
if (item.description && width > 40) {
// Calculate how much space we have for value + description
const maxValueLength = Math.min(displayValue.length, 30);
const truncatedValue = displayValue.substring(0, maxValueLength);
const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length));
// Calculate remaining space for description
const descriptionStart = prefix.length + truncatedValue.length + spacing.length;
const remainingWidth = width - descriptionStart - 2; // -2 for safety
if (remainingWidth > 10) {
const truncatedDesc = item.description.substring(0, remainingWidth);
line = prefix + truncatedValue + chalk.gray(spacing + truncatedDesc);
} else {
// Not enough space for description
const maxWidth = width - prefix.length - 2;
line = prefix + displayValue.substring(0, maxWidth);
}
} else {
// No description or not enough width
const maxWidth = width - prefix.length - 2;
line = prefix + displayValue.substring(0, maxWidth);
}
}
lines.push(line);
}
// Add scroll indicators if needed
if (startIndex > 0 || endIndex < this.filteredItems.length) {
const scrollInfo = chalk.gray(` (${this.selectedIndex + 1}/${this.filteredItems.length})`);
lines.push(scrollInfo);
}
return lines;
}
handleInput(keyData: string): void {
// Up arrow
if (keyData === "\x1b[A") {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
}
// Down arrow
else if (keyData === "\x1b[B") {
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
}
// Enter
else if (keyData === "\r") {
const selectedItem = this.filteredItems[this.selectedIndex];
if (selectedItem && this.onSelect) {
this.onSelect(selectedItem);
}
}
// Escape
else if (keyData === "\x1b") {
if (this.onCancel) {
this.onCancel();
}
}
}
getSelectedItem(): SelectItem | null {
const item = this.filteredItems[this.selectedIndex];
return item || null;
}
}

View file

@ -0,0 +1,24 @@
import type { Component } from "../tui-new.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

@ -14,6 +14,18 @@ export interface Terminal {
// Get terminal dimensions
get columns(): number;
get rows(): number;
// Cursor positioning (relative to current position)
moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
// Cursor visibility
hideCursor(): void; // Hide the cursor
showCursor(): void; // Show the cursor
// Clear operations
clearLine(): void; // Clear current line
clearFromCursor(): void; // Clear from cursor to end of screen
clearScreen(): void; // Clear entire screen and move cursor to (0,0)
}
/**
@ -69,4 +81,35 @@ export class ProcessTerminal implements Terminal {
get rows(): number {
return process.stdout.rows || 24;
}
moveBy(lines: number): void {
if (lines > 0) {
// Move down
process.stdout.write(`\x1b[${lines}B`);
} else if (lines < 0) {
// Move up
process.stdout.write(`\x1b[${-lines}A`);
}
// lines === 0: no movement
}
hideCursor(): void {
process.stdout.write("\x1b[?25l");
}
showCursor(): void {
process.stdout.write("\x1b[?25h");
}
clearLine(): void {
process.stdout.write("\x1b[K");
}
clearFromCursor(): void {
process.stdout.write("\x1b[J");
}
clearScreen(): void {
process.stdout.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
}
}

411
packages/tui/src/tui-new.ts Normal file
View file

@ -0,0 +1,411 @@
/**
* Minimal TUI implementation with differential rendering
*/
import { stripVTControlCharacters } from "node:util";
import type { Terminal } from "./terminal.js";
/**
* Component interface - all components must implement this
*/
export interface Component {
/**
* Render the component to lines for the given viewport width
* @param width - Current viewport width
* @returns Array of strings, each representing a line
*/
render(width: number): string[];
/**
* Optional handler for keyboard input when component has focus
*/
handleInput?(data: string): void;
}
/**
* Container - a component that contains other components
*/
export class Container implements Component {
children: Component[] = [];
addChild(component: Component): void {
this.children.push(component);
}
removeChild(component: Component): void {
const index = this.children.indexOf(component);
if (index !== -1) {
this.children.splice(index, 1);
}
}
clear(): void {
this.children = [];
}
render(width: number): string[] {
const lines: string[] = [];
for (const child of this.children) {
lines.push(...child.render(width));
}
return lines;
}
}
/**
* Text component - displays multi-line text with word wrapping
*/
export class Text implements Component {
constructor(private text: string = "") {}
setText(text: string): void {
this.text = text;
}
render(width: number): string[] {
if (!this.text) {
return [""];
}
const lines: string[] = [];
const textLines = this.text.split("\n");
for (const line of textLines) {
if (line.length <= width) {
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 <= width) {
currentLine += " " + word;
} else {
lines.push(currentLine);
currentLine = word;
}
}
if (currentLine.length > 0) {
lines.push(currentLine);
}
}
}
return lines.length > 0 ? lines : [""];
}
}
/**
* 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];
}
}
/**
* TUI - Main class for managing terminal UI with differential rendering
*/
export class TUI extends Container {
private terminal: Terminal;
private previousLines: string[] = [];
private previousWidth = 0;
private focusedComponent: Component | null = null;
private renderRequested = false;
private cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line)
constructor(terminal: Terminal) {
super();
this.terminal = terminal;
}
setFocus(component: Component | null): void {
this.focusedComponent = component;
}
start(): void {
this.terminal.start(
(data) => this.handleInput(data),
() => this.requestRender(),
);
this.terminal.hideCursor();
this.requestRender();
}
stop(): void {
this.terminal.showCursor();
this.terminal.stop();
}
requestRender(): void {
if (this.renderRequested) return;
this.renderRequested = true;
process.nextTick(() => {
this.renderRequested = false;
this.doRender();
});
}
private handleInput(data: string): void {
// Exit on Ctrl+C
if (data === "\x03") {
this.stop();
process.exit(0);
}
// Pass input to focused component
if (this.focusedComponent?.handleInput) {
this.focusedComponent.handleInput(data);
this.requestRender();
}
}
private doRender(): void {
const width = this.terminal.columns;
const height = this.terminal.rows;
// Render all components to get new lines
const newLines = this.render(width);
// Width changed - need full re-render
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
// First render - just output everything without clearing
if (this.previousLines.length === 0) {
let buffer = "\x1b[?2026h"; // Begin synchronized output
for (let i = 0; i < newLines.length; i++) {
if (i > 0) buffer += "\r\n";
buffer += newLines[i];
}
buffer += "\x1b[?2026l"; // End synchronized output
this.terminal.write(buffer);
// After rendering N lines, cursor is at end of last line (line N-1)
this.cursorRow = newLines.length - 1;
this.previousLines = newLines;
this.previousWidth = width;
return;
}
// Width changed - full re-render
if (widthChanged) {
let buffer = "\x1b[?2026h"; // Begin synchronized output
buffer += "\x1b[2J\x1b[H"; // Clear screen and home
for (let i = 0; i < newLines.length; i++) {
if (i > 0) buffer += "\r\n";
buffer += newLines[i];
}
buffer += "\x1b[?2026l"; // End synchronized output
this.terminal.write(buffer);
this.cursorRow = newLines.length - 1;
this.previousLines = newLines;
this.previousWidth = width;
return;
}
// Find first and last changed lines
let firstChanged = -1;
let lastChanged = -1;
const maxLines = Math.max(newLines.length, this.previousLines.length);
for (let i = 0; i < maxLines; i++) {
const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
const newLine = i < newLines.length ? newLines[i] : "";
if (oldLine !== newLine) {
if (firstChanged === -1) {
firstChanged = i;
}
lastChanged = i;
}
}
// No changes
if (firstChanged === -1) {
return;
}
// Check if firstChanged is outside the viewport
// cursorRow is the line where cursor is (0-indexed)
// Viewport shows lines from (cursorRow - height + 1) to cursorRow
// If firstChanged < viewportTop, we need full re-render
const viewportTop = this.cursorRow - height + 1;
if (firstChanged < viewportTop) {
// First change is above viewport - need full re-render
let buffer = "\x1b[?2026h"; // Begin synchronized output
buffer += "\x1b[2J\x1b[H"; // Clear screen and home
for (let i = 0; i < newLines.length; i++) {
if (i > 0) buffer += "\r\n";
buffer += newLines[i];
}
buffer += "\x1b[?2026l"; // End synchronized output
this.terminal.write(buffer);
this.cursorRow = newLines.length - 1;
this.previousLines = newLines;
this.previousWidth = width;
return;
}
// Render from first changed line to end
// Build buffer with all updates wrapped in synchronized output
let buffer = "\x1b[?2026h"; // Begin synchronized output
// Move cursor to first changed line
const lineDiff = firstChanged - this.cursorRow;
if (lineDiff > 0) {
buffer += `\x1b[${lineDiff}B`; // Move down
} else if (lineDiff < 0) {
buffer += `\x1b[${-lineDiff}A`; // Move up
}
buffer += "\r"; // Move to column 0
buffer += "\x1b[J"; // Clear from cursor to end of screen
// Render from first changed line to end
for (let i = firstChanged; i < newLines.length; i++) {
if (i > firstChanged) buffer += "\r\n";
buffer += newLines[i];
}
buffer += "\x1b[?2026l"; // End synchronized output
// Write entire buffer at once
this.terminal.write(buffer);
// Cursor is now at end of last line
this.cursorRow = newLines.length - 1;
this.previousLines = newLines;
this.previousWidth = width;
}
}