mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 04:00:10 +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,154 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import chalk from "chalk";
|
||||
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
|
||||
import type { Component } from "../tui-new.js";
|
||||
import type { Component } from "../tui.js";
|
||||
import { SelectList } from "./select-list.js";
|
||||
|
||||
interface EditorState {
|
||||
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,5 +1,6 @@
|
|||
import chalk from "chalk";
|
||||
import { Text, type TUI } from "../tui-new.js";
|
||||
import type { TUI } from "../tui.js";
|
||||
import { Text } from "./text.js";
|
||||
|
||||
/**
|
||||
* Loader component that updates every 80ms with spinning animation
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,282 +0,0 @@
|
|||
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];
|
||||
}
|
||||
|
||||
// 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 : [""];
|
||||
}
|
||||
|
||||
private getVisibleLength(str: string): number {
|
||||
// Remove ANSI escape codes and count visible characters
|
||||
return (str || "").replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { stripVTControlCharacters } from "node:util";
|
||||
import chalk from "chalk";
|
||||
import { marked, type Token } from "marked";
|
||||
import type { Component } from "../tui-new.js";
|
||||
import type { Component } from "../tui.js";
|
||||
|
||||
type Color =
|
||||
| "black"
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Component } from "../tui-new.js";
|
||||
import type { Component } from "../tui.js";
|
||||
|
||||
/**
|
||||
* Spacer component that renders empty lines
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,714 +0,0 @@
|
|||
import chalk from "chalk";
|
||||
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.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 };
|
||||
}
|
||||
}
|
||||
|
||||
configure(config: Partial<TextEditorConfig>): void {
|
||||
this.config = { ...this.config, ...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 {
|
||||
// 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 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
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Plain Enter = submit
|
||||
const result = this.state.lines.join("\n").trim();
|
||||
|
||||
// Reset editor
|
||||
this.state = {
|
||||
lines: [""],
|
||||
cursorLine: 0,
|
||||
cursorCol: 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: 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 {
|
||||
// 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 {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,25 +7,14 @@ export {
|
|||
CombinedAutocompleteProvider,
|
||||
type SlashCommand,
|
||||
} from "./autocomplete.js";
|
||||
// Loading animation component
|
||||
export { LoadingAnimation } from "./components/loading-animation.js";
|
||||
// Markdown component
|
||||
export { MarkdownComponent } from "./components/markdown-component.js";
|
||||
// Select list component
|
||||
// Components
|
||||
export { Editor, type TextEditorConfig } from "./components/editor.js";
|
||||
export { Input } from "./components/input.js";
|
||||
export { Loader } from "./components/loader.js";
|
||||
export { Markdown } from "./components/markdown.js";
|
||||
export { type SelectItem, SelectList } from "./components/select-list.js";
|
||||
// Text component
|
||||
export { TextComponent } from "./components/text-component.js";
|
||||
// Text editor component
|
||||
export { TextEditor, type TextEditorConfig } from "./components/text-editor.js";
|
||||
// Whitespace component
|
||||
export { WhitespaceComponent } from "./components/whitespace-component.js";
|
||||
export { Spacer } from "./components/spacer.js";
|
||||
export { Text } from "./components/text.js";
|
||||
// Terminal interface and implementations
|
||||
export { ProcessTerminal, type Terminal } from "./terminal.js";
|
||||
export {
|
||||
type Component,
|
||||
type ComponentRenderResult,
|
||||
Container,
|
||||
getNextComponentId,
|
||||
type Padding,
|
||||
TUI,
|
||||
} from "./tui.js";
|
||||
export { Component, Container, TUI } from "./tui.js";
|
||||
|
|
|
|||
|
|
@ -1,450 +0,0 @@
|
|||
/**
|
||||
* 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 {
|
||||
private paddingX: number; // Left/right padding
|
||||
private paddingY: number; // Top/bottom padding
|
||||
|
||||
constructor(
|
||||
private text: string = "",
|
||||
paddingX: number = 1,
|
||||
paddingY: number = 1,
|
||||
) {
|
||||
this.paddingX = paddingX;
|
||||
this.paddingY = paddingY;
|
||||
}
|
||||
|
||||
setText(text: string): void {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
// Calculate available width for content (subtract horizontal padding)
|
||||
const contentWidth = Math.max(1, width - this.paddingX * 2);
|
||||
|
||||
if (!this.text) {
|
||||
return [""];
|
||||
}
|
||||
|
||||
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) {
|
||||
const rightPadLength = Math.max(0, width - this.paddingX - line.length);
|
||||
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];
|
||||
|
||||
return result.length > 0 ? result : [""];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,483 +1,228 @@
|
|||
import process from "process";
|
||||
import { ProcessTerminal, type Terminal } from "./terminal.js";
|
||||
|
||||
/**
|
||||
* Result of rendering a component
|
||||
* Minimal TUI implementation with differential rendering
|
||||
*/
|
||||
export interface ComponentRenderResult {
|
||||
lines: string[];
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
import type { Terminal } from "./terminal.js";
|
||||
|
||||
/**
|
||||
* Component interface
|
||||
* Component interface - all components must implement this
|
||||
*/
|
||||
export interface Component {
|
||||
readonly id: number;
|
||||
render(width: number): ComponentRenderResult;
|
||||
handleInput?(keyData: string): void;
|
||||
}
|
||||
/**
|
||||
* 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[];
|
||||
|
||||
// Global component ID counter
|
||||
let nextComponentId = 1;
|
||||
|
||||
// Helper to get next component ID
|
||||
export function getNextComponentId(): number {
|
||||
return nextComponentId++;
|
||||
}
|
||||
|
||||
// Padding type for components
|
||||
export interface Padding {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
/**
|
||||
* Optional handler for keyboard input when component has focus
|
||||
*/
|
||||
handleInput?(data: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for managing child components
|
||||
* Container - a component that contains other components
|
||||
*/
|
||||
export class Container implements Component {
|
||||
readonly id: number;
|
||||
public children: (Component | Container)[] = [];
|
||||
private tui?: TUI;
|
||||
private previousChildCount: number = 0;
|
||||
children: Component[] = [];
|
||||
|
||||
constructor() {
|
||||
this.id = getNextComponentId();
|
||||
}
|
||||
|
||||
setTui(tui: TUI | undefined): void {
|
||||
this.tui = tui;
|
||||
for (const child of this.children) {
|
||||
if (child instanceof Container) {
|
||||
child.setTui(tui);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addChild(component: Component | Container): void {
|
||||
addChild(component: Component): void {
|
||||
this.children.push(component);
|
||||
if (component instanceof Container) {
|
||||
component.setTui(this.tui);
|
||||
}
|
||||
this.tui?.requestRender();
|
||||
}
|
||||
|
||||
removeChild(component: Component | Container): void {
|
||||
removeChild(component: Component): void {
|
||||
const index = this.children.indexOf(component);
|
||||
if (index >= 0) {
|
||||
if (index !== -1) {
|
||||
this.children.splice(index, 1);
|
||||
if (component instanceof Container) {
|
||||
component.setTui(undefined);
|
||||
}
|
||||
this.tui?.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
removeChildAt(index: number): void {
|
||||
if (index >= 0 && index < this.children.length) {
|
||||
const component = this.children[index];
|
||||
this.children.splice(index, 1);
|
||||
if (component instanceof Container) {
|
||||
component.setTui(undefined);
|
||||
}
|
||||
this.tui?.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const child of this.children) {
|
||||
if (child instanceof Container) {
|
||||
child.setTui(undefined);
|
||||
}
|
||||
}
|
||||
this.children = [];
|
||||
this.tui?.requestRender();
|
||||
}
|
||||
|
||||
getChild(index: number): (Component | Container) | undefined {
|
||||
return this.children[index];
|
||||
}
|
||||
|
||||
getChildCount(): number {
|
||||
return this.children.length;
|
||||
}
|
||||
|
||||
render(width: number): ComponentRenderResult {
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
let changed = false;
|
||||
|
||||
// Check if the number of children changed (important for detecting clears)
|
||||
if (this.children.length !== this.previousChildCount) {
|
||||
changed = true;
|
||||
this.previousChildCount = this.children.length;
|
||||
}
|
||||
|
||||
for (const child of this.children) {
|
||||
const result = child.render(width);
|
||||
lines.push(...result.lines);
|
||||
if (result.changed) {
|
||||
changed = true;
|
||||
}
|
||||
lines.push(...child.render(width));
|
||||
}
|
||||
|
||||
return { lines, changed };
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render command for tracking component output
|
||||
*/
|
||||
interface RenderCommand {
|
||||
id: number;
|
||||
lines: string[];
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* TUI - Smart differential rendering TUI implementation.
|
||||
* TUI - Main class for managing terminal UI with differential rendering
|
||||
*/
|
||||
export class TUI extends Container {
|
||||
private focusedComponent: Component | null = null;
|
||||
private needsRender = false;
|
||||
private isFirstRender = true;
|
||||
private isStarted = false;
|
||||
public onGlobalKeyPress?: (data: string) => boolean;
|
||||
private terminal: Terminal;
|
||||
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: Used in renderToScreen method on lines 260 and 276
|
||||
private previousRenderCommands: RenderCommand[] = [];
|
||||
private previousLines: string[] = []; // What we rendered last time
|
||||
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)
|
||||
|
||||
// Performance metrics
|
||||
private totalLinesRedrawn = 0;
|
||||
private renderCount = 0;
|
||||
public getLinesRedrawn(): number {
|
||||
return this.totalLinesRedrawn;
|
||||
}
|
||||
public getAverageLinesRedrawn(): number {
|
||||
return this.renderCount > 0 ? this.totalLinesRedrawn / this.renderCount : 0;
|
||||
}
|
||||
|
||||
constructor(terminal?: Terminal) {
|
||||
constructor(terminal: Terminal) {
|
||||
super();
|
||||
this.setTui(this);
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
this.handleKeypress = this.handleKeypress.bind(this);
|
||||
|
||||
// Use provided terminal or default to ProcessTerminal
|
||||
this.terminal = terminal || new ProcessTerminal();
|
||||
this.terminal = terminal;
|
||||
}
|
||||
|
||||
setFocus(component: Component): void {
|
||||
if (this.findComponent(component)) {
|
||||
this.focusedComponent = component;
|
||||
}
|
||||
}
|
||||
|
||||
private findComponent(component: Component): boolean {
|
||||
if (this.children.includes(component)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const child of this.children) {
|
||||
if (child instanceof Container) {
|
||||
if (this.findInContainer(child, component)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private findInContainer(container: Container, component: Component): boolean {
|
||||
const childCount = container.getChildCount();
|
||||
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
const child = container.getChild(i);
|
||||
if (child === component) {
|
||||
return true;
|
||||
}
|
||||
if (child instanceof Container) {
|
||||
if (this.findInContainer(child, component)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
requestRender(): void {
|
||||
if (!this.isStarted) return;
|
||||
|
||||
// Only queue a render if we haven't already
|
||||
if (!this.needsRender) {
|
||||
this.needsRender = true;
|
||||
process.nextTick(() => {
|
||||
if (this.needsRender) {
|
||||
this.renderToScreen();
|
||||
this.needsRender = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
setFocus(component: Component | null): void {
|
||||
this.focusedComponent = component;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.isStarted = true;
|
||||
|
||||
// Hide cursor
|
||||
this.terminal.write("\x1b[?25l");
|
||||
|
||||
// Start terminal with handlers
|
||||
try {
|
||||
this.terminal.start(this.handleKeypress, this.handleResize);
|
||||
} catch (error) {
|
||||
console.error("Error starting terminal:", error);
|
||||
}
|
||||
|
||||
// Trigger initial render if we have components
|
||||
if (this.children.length > 0) {
|
||||
this.requestRender();
|
||||
}
|
||||
this.terminal.start(
|
||||
(data) => this.handleInput(data),
|
||||
() => this.requestRender(),
|
||||
);
|
||||
this.terminal.hideCursor();
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Show cursor
|
||||
this.terminal.write("\x1b[?25h");
|
||||
|
||||
// Stop terminal
|
||||
this.terminal.showCursor();
|
||||
this.terminal.stop();
|
||||
|
||||
this.isStarted = false;
|
||||
}
|
||||
|
||||
private renderToScreen(resize = false): void {
|
||||
const termWidth = this.terminal.columns;
|
||||
const termHeight = this.terminal.rows;
|
||||
|
||||
if (resize) {
|
||||
this.isFirstRender = true;
|
||||
this.previousRenderCommands = [];
|
||||
this.previousLines = [];
|
||||
}
|
||||
|
||||
// Collect all render commands
|
||||
const currentRenderCommands: RenderCommand[] = [];
|
||||
this.collectRenderCommands(this, termWidth, currentRenderCommands);
|
||||
|
||||
if (this.isFirstRender) {
|
||||
this.renderInitial(currentRenderCommands);
|
||||
this.isFirstRender = false;
|
||||
} else {
|
||||
this.renderLineBased(currentRenderCommands, termHeight);
|
||||
}
|
||||
|
||||
// Save for next render
|
||||
this.previousRenderCommands = currentRenderCommands;
|
||||
this.renderCount++;
|
||||
requestRender(): void {
|
||||
if (this.renderRequested) return;
|
||||
this.renderRequested = true;
|
||||
process.nextTick(() => {
|
||||
this.renderRequested = false;
|
||||
this.doRender();
|
||||
});
|
||||
}
|
||||
|
||||
private collectRenderCommands(container: Container, width: number, commands: RenderCommand[]): void {
|
||||
const childCount = container.getChildCount();
|
||||
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
const child = container.getChild(i);
|
||||
if (!child) continue;
|
||||
|
||||
const result = child.render(width);
|
||||
commands.push({
|
||||
id: child.id,
|
||||
lines: result.lines,
|
||||
changed: result.changed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderInitial(commands: RenderCommand[]): void {
|
||||
let output = "";
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const command of commands) {
|
||||
lines.push(...command.lines);
|
||||
}
|
||||
|
||||
// Output all lines
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (i > 0) output += "\r\n";
|
||||
output += lines[i];
|
||||
}
|
||||
|
||||
// Add final newline to position cursor below content
|
||||
if (lines.length > 0) output += "\r\n";
|
||||
|
||||
this.terminal.write(output);
|
||||
|
||||
// Save what we rendered
|
||||
this.previousLines = lines;
|
||||
this.totalLinesRedrawn += lines.length;
|
||||
}
|
||||
|
||||
private renderLineBased(currentCommands: RenderCommand[], termHeight: number): void {
|
||||
const viewportHeight = termHeight - 1; // Leave one line for cursor
|
||||
|
||||
// Build the new lines array
|
||||
const newLines: string[] = [];
|
||||
for (const command of currentCommands) {
|
||||
newLines.push(...command.lines);
|
||||
}
|
||||
|
||||
const totalNewLines = newLines.length;
|
||||
const totalOldLines = this.previousLines.length;
|
||||
|
||||
// Find first changed line by comparing old and new
|
||||
let firstChangedLine = -1;
|
||||
const minLines = Math.min(totalOldLines, totalNewLines);
|
||||
|
||||
for (let i = 0; i < minLines; i++) {
|
||||
if (this.previousLines[i] !== newLines[i]) {
|
||||
firstChangedLine = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If all common lines are the same, check if we have different lengths
|
||||
if (firstChangedLine === -1 && totalOldLines !== totalNewLines) {
|
||||
firstChangedLine = minLines;
|
||||
}
|
||||
|
||||
// No changes at all
|
||||
if (firstChangedLine === -1) {
|
||||
this.previousLines = newLines;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate viewport boundaries
|
||||
const oldViewportStart = Math.max(0, totalOldLines - viewportHeight);
|
||||
const cursorPosition = totalOldLines; // Cursor is one line below last content
|
||||
|
||||
let output = "";
|
||||
let linesRedrawn = 0;
|
||||
|
||||
// Check if change is in scrollback (unreachable by cursor)
|
||||
if (firstChangedLine < oldViewportStart) {
|
||||
// Must do full clear and re-render
|
||||
output = "\x1b[3J\x1b[H"; // Clear scrollback and screen, home cursor
|
||||
|
||||
for (let i = 0; i < newLines.length; i++) {
|
||||
if (i > 0) output += "\r\n";
|
||||
output += newLines[i];
|
||||
}
|
||||
|
||||
if (newLines.length > 0) output += "\r\n";
|
||||
linesRedrawn = newLines.length;
|
||||
} else {
|
||||
// Change is in viewport - we can reach it with cursor movements
|
||||
// Calculate viewport position of the change
|
||||
const viewportChangePosition = firstChangedLine - oldViewportStart;
|
||||
|
||||
// Move cursor to the change position
|
||||
const linesToMoveUp = cursorPosition - oldViewportStart - viewportChangePosition;
|
||||
if (linesToMoveUp > 0) {
|
||||
output += `\x1b[${linesToMoveUp}A`;
|
||||
}
|
||||
|
||||
// Now do surgical updates or partial clear based on what's more efficient
|
||||
let currentLine = firstChangedLine;
|
||||
const currentViewportLine = viewportChangePosition;
|
||||
|
||||
// If we have significant structural changes, just clear and re-render from here
|
||||
const hasSignificantChanges = totalNewLines !== totalOldLines || totalNewLines - firstChangedLine > 10; // Arbitrary threshold
|
||||
|
||||
if (hasSignificantChanges) {
|
||||
// Clear from cursor to end of screen and render all remaining lines
|
||||
output += "\r\x1b[0J";
|
||||
|
||||
for (let i = firstChangedLine; i < newLines.length; i++) {
|
||||
if (i > firstChangedLine) output += "\r\n";
|
||||
output += newLines[i];
|
||||
linesRedrawn++;
|
||||
}
|
||||
|
||||
if (newLines.length > firstChangedLine) output += "\r\n";
|
||||
} else {
|
||||
// Do surgical line-by-line updates
|
||||
for (let i = firstChangedLine; i < minLines; i++) {
|
||||
if (this.previousLines[i] !== newLines[i]) {
|
||||
// Move to this line if needed
|
||||
const moveLines = i - currentLine;
|
||||
if (moveLines > 0) {
|
||||
output += `\x1b[${moveLines}B`;
|
||||
}
|
||||
|
||||
// Clear and rewrite the line
|
||||
output += "\r\x1b[2K" + newLines[i];
|
||||
currentLine = i;
|
||||
linesRedrawn++;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle added/removed lines at the end
|
||||
if (totalNewLines > totalOldLines) {
|
||||
// Move to end of old content and add new lines
|
||||
const moveToEnd = totalOldLines - 1 - currentLine;
|
||||
if (moveToEnd > 0) {
|
||||
output += `\x1b[${moveToEnd}B`;
|
||||
}
|
||||
output += "\r\n";
|
||||
|
||||
for (let i = totalOldLines; i < totalNewLines; i++) {
|
||||
if (i > totalOldLines) output += "\r\n";
|
||||
output += newLines[i];
|
||||
linesRedrawn++;
|
||||
}
|
||||
output += "\r\n";
|
||||
} else if (totalNewLines < totalOldLines) {
|
||||
// Move to end of new content and clear rest
|
||||
const moveToEnd = totalNewLines - 1 - currentLine;
|
||||
if (moveToEnd > 0) {
|
||||
output += `\x1b[${moveToEnd}B`;
|
||||
} else if (moveToEnd < 0) {
|
||||
output += `\x1b[${-moveToEnd}A`;
|
||||
}
|
||||
output += "\r\n\x1b[0J";
|
||||
} else {
|
||||
// Same length, just position cursor at end
|
||||
const moveToEnd = totalNewLines - 1 - currentLine;
|
||||
if (moveToEnd > 0) {
|
||||
output += `\x1b[${moveToEnd}B`;
|
||||
} else if (moveToEnd < 0) {
|
||||
output += `\x1b[${-moveToEnd}A`;
|
||||
}
|
||||
output += "\r\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.terminal.write(output);
|
||||
this.previousLines = newLines;
|
||||
this.totalLinesRedrawn += linesRedrawn;
|
||||
}
|
||||
|
||||
private handleResize(): void {
|
||||
// Clear screen and reset
|
||||
this.terminal.write("\x1b[2J\x1b[H\x1b[?25l");
|
||||
this.renderToScreen(true);
|
||||
}
|
||||
|
||||
private handleKeypress(data: string): void {
|
||||
if (this.onGlobalKeyPress) {
|
||||
const shouldForward = this.onGlobalKeyPress(data);
|
||||
if (!shouldForward) {
|
||||
this.requestRender();
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue