tui-double-buffer: Implement smart differential rendering with terminal abstraction

- Create Terminal interface abstracting stdin/stdout operations for dependency injection
- Implement ProcessTerminal for production use with process.stdin/stdout
- Implement VirtualTerminal using @xterm/headless for accurate terminal emulation in tests
- Fix TypeScript imports for @xterm/headless module
- Move all component files to src/components/ directory for better organization
- Add comprehensive test suite with async/await patterns for proper render timing
- Fix critical TUI differential rendering bug when components grow in height
  - Issue: Old content wasn't properly cleared when component line count increased
  - Solution: Clear each old line individually before redrawing, ensure cursor at line start
- Add test verifying terminal content preservation and text editor growth behavior
- Update tsconfig.json to include test files in type checking
- Add benchmark test comparing single vs double buffer performance

The implementation successfully reduces flicker by only updating changed lines
rather than clearing entire sections. Both TUI implementations maintain the
same interface for backward compatibility.
This commit is contained in:
Mario Zechner 2025-08-10 22:33:03 +02:00
parent 923a9e58ab
commit afa807b200
19 changed files with 1591 additions and 344 deletions

View file

@ -0,0 +1,51 @@
import chalk from "chalk";
import type { TUI } from "../tui.js";
import { TextComponent } from "./text-component.js";
/**
* LoadingAnimation component that updates every 80ms
* Simulates the animation component that causes flicker in single-buffer mode
*/
export class LoadingAnimation extends TextComponent {
private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
private currentFrame = 0;
private intervalId: NodeJS.Timeout | null = null;
private ui: TUI | null = null;
constructor(
ui: TUI,
private message: string = "Loading...",
) {
super("", { bottom: 1 });
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,261 @@
import chalk from "chalk";
import { marked, type Token } from "marked";
import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js";
export class MarkdownComponent implements Component {
readonly id = getNextComponentId();
private text: string;
private lines: string[] = [];
private previousLines: string[] = [];
constructor(text: string = "") {
this.text = text;
}
setText(text: string): void {
this.text = text;
}
render(width: number): ComponentRenderResult {
// 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));
}
this.previousLines = this.lines;
this.lines = wrappedLines;
// Determine if content changed
const changed =
this.lines.length !== this.previousLines.length ||
this.lines.some((line, i) => line !== this.previousLines[i]);
return {
lines: this.lines,
changed,
};
}
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 = this.getVisibleLength(line);
if (visibleLength <= width) {
return [line];
}
// Need to wrap - this is complex with ANSI codes
// For now, use a simple approach that may break styling at wrap points
let currentLine = "";
let currentLength = 0;
let i = 0;
while (i < line.length) {
if (line[i] === "\x1b" && line[i + 1] === "[") {
// ANSI escape sequence - include it without counting length
let j = i + 2;
while (j < line.length && line[j] && !/[mGKHJ]/.test(line[j]!)) {
j++;
}
if (j < line.length) {
currentLine += line.substring(i, j + 1);
i = j + 1;
} else {
break;
}
} else {
// Regular character
if (currentLength >= width) {
wrapped.push(currentLine);
currentLine = "";
currentLength = 0;
}
currentLine += line[i];
currentLength++;
i++;
}
}
if (currentLine) {
wrapped.push(currentLine);
}
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

@ -0,0 +1,155 @@
import chalk from "chalk";
import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js";
export interface SelectItem {
value: string;
label: string;
description?: string;
}
export class SelectList implements Component {
readonly id = getNextComponentId();
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): ComponentRenderResult {
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 };
}
// 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, changed: true };
}
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,105 @@
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,803 @@
import chalk from "chalk";
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
import { logger } from "../logger.js";
import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.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 TextEditor implements Component {
readonly id = getNextComponentId();
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 = "";
public onSubmit?: (text: string) => void;
public onChange?: (text: string) => void;
public disableSubmit: boolean = false;
constructor(config?: TextEditorConfig) {
if (config) {
this.config = { ...this.config, ...config };
}
logger.componentLifecycle("TextEditor", "created", { config: this.config });
}
configure(config: Partial<TextEditorConfig>): void {
this.config = { ...this.config, ...config };
logger.info("TextEditor", "Configuration updated", { config: this.config });
}
setAutocompleteProvider(provider: AutocompleteProvider): void {
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("╯");
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);
const result: string[] = [];
// Render top border
result.push(topLeft + horizontal.repeat(boxWidth - 2) + topRight);
// 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, contentWidth - visibleLength));
// Render the line
result.push(`${vertical} ${displayText}${padding} ${vertical}`);
}
// Render bottom border
result.push(bottomLeft + horizontal.repeat(boxWidth - 2) + bottomRight);
// Add autocomplete list if active
if (this.isAutocompleting && this.autocompleteList) {
const autocompleteResult = this.autocompleteList.render(width);
result.push(...autocompleteResult.lines);
}
// For interactive components like text editors, always assume changed
// This ensures cursor position updates are always reflected
return {
lines: result,
changed: true,
};
}
handleInput(data: string): void {
logger.keyInput("TextEditor", data);
logger.debug("TextEditor", "Current state before input", {
lines: this.state.lines,
cursorLine: this.state.cursorLine,
cursorCol: this.state.cursorCol,
});
// Handle special key combinations first
// Ctrl+C - Exit (let parent handle this)
if (data.charCodeAt(0) === 3) {
logger.debug("TextEditor", "Ctrl+C received, returning to parent");
return;
}
// Handle paste - detect when we get a lot of text at once
const isPaste = data.length > 10 || (data.length > 2 && data.includes("\n"));
logger.debug("TextEditor", "Paste detection", {
dataLength: data.length,
includesNewline: data.includes("\n"),
includesTabs: data.includes("\t"),
tabCount: (data.match(/\t/g) || []).length,
isPaste,
data: JSON.stringify(data),
charCodes: Array.from(data).map((c) => c.charCodeAt(0)),
});
if (isPaste) {
logger.info("TextEditor", "Handling as paste");
this.handlePaste(data);
return;
}
// Handle autocomplete special keys first (but don't block other input)
if (this.isAutocompleting && this.autocompleteList) {
logger.debug("TextEditor", "Autocomplete active, handling input", {
data,
charCode: data.charCodeAt(0),
isEscape: data === "\x1b",
isArrowOrEnter: data === "\x1b[A" || data === "\x1b[B" || data === "\r",
});
// 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 was pressed, apply the selection
if (data === "\t") {
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;
}
// 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;
}
}
// For other keys (like regular typing), DON'T return here
// Let them fall through to normal character handling
logger.debug("TextEditor", "Autocomplete active but falling through to normal handling");
}
// Tab key - context-aware completion (but not when already autocompleting)
if (data === "\t" && !this.isAutocompleting) {
logger.debug("TextEditor", "Tab key pressed, determining context", {
isAutocompleting: this.isAutocompleting,
hasProvider: !!this.autocompleteProvider,
});
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;
}
// Plain Enter = submit
const result = this.state.lines.join("\n").trim();
logger.info("TextEditor", "Submit triggered", {
result,
rawResult: JSON.stringify(this.state.lines.join("\n")),
lines: this.state.lines,
resultLines: result.split("\n"),
});
// Reset editor
this.state = {
lines: [""],
cursorLine: 0,
cursorCol: 0,
};
// Notify that editor is now empty
if (this.onChange) {
this.onChange("");
}
if (this.onSubmit) {
logger.info("TextEditor", "Calling onSubmit callback", { result });
this.onSubmit(result);
} else {
logger.warn("TextEditor", "No onSubmit callback set");
}
}
// 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) {
logger.debug("TextEditor", "Inserting character", { char: data, charCode: data.charCodeAt(0) });
this.insertCharacter(data);
} else {
logger.warn("TextEditor", "Unhandled input", {
data,
charCodes: Array.from(data).map((c) => c.charCodeAt(0)),
});
}
}
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: 2,
});
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 prefix = i === 0 ? "> " : " ";
const prefixedLine = prefix + line;
const maxLineLength = contentWidth;
if (prefixedLine.length <= maxLineLength) {
// Line fits in one layout line
if (isCurrentLine) {
layoutLines.push({
text: prefixedLine,
hasCursor: true,
cursorPos: prefix.length + this.state.cursorCol,
});
} else {
layoutLines.push({
text: prefixedLine,
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 chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
const chunk = chunks[chunkIndex];
if (!chunk) continue;
const chunkStart = chunkIndex * maxLineLength;
const chunkEnd = chunkStart + chunk.length;
const cursorPos = prefix.length + 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 {
logger.debug("TextEditor", "Processing paste", {
pastedText: JSON.stringify(pastedText),
hasTab: pastedText.includes("\t"),
tabCount: (pastedText.match(/\t/g) || []).length,
});
// 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");
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 {
logger.debug("TextEditor", "tryTriggerAutocomplete called", {
explicitTab,
hasProvider: !!this.autocompleteProvider,
});
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);
logger.debug("TextEditor", "Tab file completion check", {
hasShouldTriggerMethod: !!provider.shouldTriggerFileCompletion,
shouldTrigger,
lines: this.state.lines,
cursorLine: this.state.cursorLine,
cursorCol: this.state.cursorCol,
});
if (!shouldTrigger) {
return;
}
}
const suggestions = this.autocompleteProvider.getSuggestions(
this.state.lines,
this.state.cursorLine,
this.state.cursorCol,
);
logger.debug("TextEditor", "Autocomplete suggestions", {
hasSuggestions: !!suggestions,
itemCount: suggestions?.items.length || 0,
prefix: suggestions?.prefix,
});
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("/")) {
logger.debug("TextEditor", "Tab in slash command context", { beforeCursor });
this.handleSlashCommandCompletion();
} else {
logger.debug("TextEditor", "Tab in file completion context", { beforeCursor });
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
logger.debug("TextEditor", "Handling slash command completion");
this.tryTriggerAutocomplete(true);
}
private forceFileAutocomplete(): void {
logger.debug("TextEditor", "forceFileAutocomplete called", {
hasProvider: !!this.autocompleteProvider,
});
if (!this.autocompleteProvider) return;
// Check if provider has the force method
const provider = this.autocompleteProvider as any;
if (!provider.getForceFileSuggestions) {
logger.debug("TextEditor", "Provider doesn't support forced file completion, falling back to regular");
this.tryTriggerAutocomplete(true);
return;
}
const suggestions = provider.getForceFileSuggestions(
this.state.lines,
this.state.cursorLine,
this.state.cursorCol,
);
logger.debug("TextEditor", "Forced file autocomplete suggestions", {
hasSuggestions: !!suggestions,
itemCount: suggestions?.items.length || 0,
prefix: suggestions?.prefix,
});
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,25 @@
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;
}
}