mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-18 18:03:44 +00:00
tui-double-buffer: Implement smart differential rendering with terminal abstraction
- Create Terminal interface abstracting stdin/stdout operations for dependency injection - Implement ProcessTerminal for production use with process.stdin/stdout - Implement VirtualTerminal using @xterm/headless for accurate terminal emulation in tests - Fix TypeScript imports for @xterm/headless module - Move all component files to src/components/ directory for better organization - Add comprehensive test suite with async/await patterns for proper render timing - Fix critical TUI differential rendering bug when components grow in height - Issue: Old content wasn't properly cleared when component line count increased - Solution: Clear each old line individually before redrawing, ensure cursor at line start - Add test verifying terminal content preservation and text editor growth behavior - Update tsconfig.json to include test files in type checking - Add benchmark test comparing single vs double buffer performance The implementation successfully reduces flicker by only updating changed lines rather than clearing entire sections. Both TUI implementations maintain the same interface for backward compatibility.
This commit is contained in:
parent
923a9e58ab
commit
afa807b200
19 changed files with 1591 additions and 344 deletions
51
packages/tui/src/components/loading-animation.ts
Normal file
51
packages/tui/src/components/loading-animation.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import chalk from "chalk";
|
||||
import type { TUI } from "../tui.js";
|
||||
import { TextComponent } from "./text-component.js";
|
||||
|
||||
/**
|
||||
* LoadingAnimation component that updates every 80ms
|
||||
* Simulates the animation component that causes flicker in single-buffer mode
|
||||
*/
|
||||
export class LoadingAnimation extends TextComponent {
|
||||
private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
private currentFrame = 0;
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
private ui: TUI | null = null;
|
||||
|
||||
constructor(
|
||||
ui: TUI,
|
||||
private message: string = "Loading...",
|
||||
) {
|
||||
super("", { bottom: 1 });
|
||||
this.ui = ui;
|
||||
this.start();
|
||||
}
|
||||
|
||||
start() {
|
||||
this.updateDisplay();
|
||||
this.intervalId = setInterval(() => {
|
||||
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
|
||||
this.updateDisplay();
|
||||
}, 80);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
setMessage(message: string) {
|
||||
this.message = message;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private updateDisplay() {
|
||||
const frame = this.frames[this.currentFrame];
|
||||
this.setText(`${chalk.cyan(frame)} ${chalk.dim(this.message)}`);
|
||||
if (this.ui) {
|
||||
this.ui.requestRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
261
packages/tui/src/components/markdown-component.ts
Normal file
261
packages/tui/src/components/markdown-component.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import chalk from "chalk";
|
||||
import { marked, type Token } from "marked";
|
||||
import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js";
|
||||
|
||||
export class MarkdownComponent implements Component {
|
||||
readonly id = getNextComponentId();
|
||||
private text: string;
|
||||
private lines: string[] = [];
|
||||
private previousLines: string[] = [];
|
||||
|
||||
constructor(text: string = "") {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
setText(text: string): void {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
render(width: number): ComponentRenderResult {
|
||||
// Parse markdown to HTML-like tokens
|
||||
const tokens = marked.lexer(this.text);
|
||||
|
||||
// Convert tokens to styled terminal output
|
||||
const renderedLines: string[] = [];
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
const nextToken = tokens[i + 1];
|
||||
const tokenLines = this.renderToken(token, width, nextToken?.type);
|
||||
renderedLines.push(...tokenLines);
|
||||
}
|
||||
|
||||
// Wrap lines to fit width
|
||||
const wrappedLines: string[] = [];
|
||||
for (const line of renderedLines) {
|
||||
wrappedLines.push(...this.wrapLine(line, width));
|
||||
}
|
||||
|
||||
this.previousLines = this.lines;
|
||||
this.lines = wrappedLines;
|
||||
|
||||
// Determine if content changed
|
||||
const changed =
|
||||
this.lines.length !== this.previousLines.length ||
|
||||
this.lines.some((line, i) => line !== this.previousLines[i]);
|
||||
|
||||
return {
|
||||
lines: this.lines,
|
||||
changed,
|
||||
};
|
||||
}
|
||||
|
||||
private renderToken(token: Token, width: number, nextTokenType?: string): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
switch (token.type) {
|
||||
case "heading": {
|
||||
const headingLevel = token.depth;
|
||||
const headingPrefix = "#".repeat(headingLevel) + " ";
|
||||
const headingText = this.renderInlineTokens(token.tokens || []);
|
||||
if (headingLevel === 1) {
|
||||
lines.push(chalk.bold.underline.yellow(headingText));
|
||||
} else if (headingLevel === 2) {
|
||||
lines.push(chalk.bold.yellow(headingText));
|
||||
} else {
|
||||
lines.push(chalk.bold(headingPrefix + headingText));
|
||||
}
|
||||
lines.push(""); // Add spacing after headings
|
||||
break;
|
||||
}
|
||||
|
||||
case "paragraph": {
|
||||
const paragraphText = this.renderInlineTokens(token.tokens || []);
|
||||
lines.push(paragraphText);
|
||||
// Don't add spacing if next token is space or list
|
||||
if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") {
|
||||
lines.push("");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "code": {
|
||||
lines.push(chalk.gray("```" + (token.lang || "")));
|
||||
// Split code by newlines and style each line
|
||||
const codeLines = token.text.split("\n");
|
||||
for (const codeLine of codeLines) {
|
||||
lines.push(chalk.dim(" ") + chalk.green(codeLine));
|
||||
}
|
||||
lines.push(chalk.gray("```"));
|
||||
lines.push(""); // Add spacing after code blocks
|
||||
break;
|
||||
}
|
||||
|
||||
case "list":
|
||||
for (let i = 0; i < token.items.length; i++) {
|
||||
const item = token.items[i];
|
||||
const bullet = token.ordered ? `${i + 1}. ` : "- ";
|
||||
const itemText = this.renderInlineTokens(item.tokens || []);
|
||||
|
||||
// Check if the item text contains multiple lines (embedded content)
|
||||
const itemLines = itemText.split("\n").filter((line) => line.trim());
|
||||
if (itemLines.length > 1) {
|
||||
// First line is the list item
|
||||
lines.push(chalk.cyan(bullet) + itemLines[0]);
|
||||
// Rest are treated as separate content
|
||||
for (let j = 1; j < itemLines.length; j++) {
|
||||
lines.push(""); // Add spacing
|
||||
lines.push(itemLines[j]);
|
||||
}
|
||||
} else {
|
||||
lines.push(chalk.cyan(bullet) + itemText);
|
||||
}
|
||||
}
|
||||
// Don't add spacing after lists if a space token follows
|
||||
// (the space token will handle it)
|
||||
break;
|
||||
|
||||
case "blockquote": {
|
||||
const quoteText = this.renderInlineTokens(token.tokens || []);
|
||||
const quoteLines = quoteText.split("\n");
|
||||
for (const quoteLine of quoteLines) {
|
||||
lines.push(chalk.gray("│ ") + chalk.italic(quoteLine));
|
||||
}
|
||||
lines.push(""); // Add spacing after blockquotes
|
||||
break;
|
||||
}
|
||||
|
||||
case "hr":
|
||||
lines.push(chalk.gray("─".repeat(Math.min(width, 80))));
|
||||
lines.push(""); // Add spacing after horizontal rules
|
||||
break;
|
||||
|
||||
case "html":
|
||||
// Skip HTML for terminal output
|
||||
break;
|
||||
|
||||
case "space":
|
||||
// Space tokens represent blank lines in markdown
|
||||
lines.push("");
|
||||
break;
|
||||
|
||||
default:
|
||||
// Handle any other token types as plain text
|
||||
if ("text" in token && typeof token.text === "string") {
|
||||
lines.push(token.text);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderInlineTokens(tokens: Token[]): string {
|
||||
let result = "";
|
||||
|
||||
for (const token of tokens) {
|
||||
switch (token.type) {
|
||||
case "text":
|
||||
// Text tokens in list items can have nested tokens for inline formatting
|
||||
if (token.tokens && token.tokens.length > 0) {
|
||||
result += this.renderInlineTokens(token.tokens);
|
||||
} else {
|
||||
result += token.text;
|
||||
}
|
||||
break;
|
||||
|
||||
case "strong":
|
||||
result += chalk.bold(this.renderInlineTokens(token.tokens || []));
|
||||
break;
|
||||
|
||||
case "em":
|
||||
result += chalk.italic(this.renderInlineTokens(token.tokens || []));
|
||||
break;
|
||||
|
||||
case "codespan":
|
||||
result += chalk.gray("`") + chalk.cyan(token.text) + chalk.gray("`");
|
||||
break;
|
||||
|
||||
case "link": {
|
||||
const linkText = this.renderInlineTokens(token.tokens || []);
|
||||
result += chalk.underline.blue(linkText) + chalk.gray(` (${token.href})`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "br":
|
||||
result += "\n";
|
||||
break;
|
||||
|
||||
case "del":
|
||||
result += chalk.strikethrough(this.renderInlineTokens(token.tokens || []));
|
||||
break;
|
||||
|
||||
default:
|
||||
// Handle any other inline token types as plain text
|
||||
if ("text" in token && typeof token.text === "string") {
|
||||
result += token.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private wrapLine(line: string, width: number): string[] {
|
||||
// Handle ANSI escape codes properly when wrapping
|
||||
const wrapped: string[] = [];
|
||||
|
||||
// Handle undefined or null lines
|
||||
if (!line) {
|
||||
return [""];
|
||||
}
|
||||
|
||||
// If line fits within width, return as-is
|
||||
const visibleLength = this.getVisibleLength(line);
|
||||
if (visibleLength <= width) {
|
||||
return [line];
|
||||
}
|
||||
|
||||
// Need to wrap - this is complex with ANSI codes
|
||||
// For now, use a simple approach that may break styling at wrap points
|
||||
let currentLine = "";
|
||||
let currentLength = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < line.length) {
|
||||
if (line[i] === "\x1b" && line[i + 1] === "[") {
|
||||
// ANSI escape sequence - include it without counting length
|
||||
let j = i + 2;
|
||||
while (j < line.length && line[j] && !/[mGKHJ]/.test(line[j]!)) {
|
||||
j++;
|
||||
}
|
||||
if (j < line.length) {
|
||||
currentLine += line.substring(i, j + 1);
|
||||
i = j + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Regular character
|
||||
if (currentLength >= width) {
|
||||
wrapped.push(currentLine);
|
||||
currentLine = "";
|
||||
currentLength = 0;
|
||||
}
|
||||
currentLine += line[i];
|
||||
currentLength++;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
wrapped.push(currentLine);
|
||||
}
|
||||
|
||||
return wrapped.length > 0 ? wrapped : [""];
|
||||
}
|
||||
|
||||
private getVisibleLength(str: string): number {
|
||||
// Remove ANSI escape codes and count visible characters
|
||||
return (str || "").replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||
}
|
||||
}
|
||||
155
packages/tui/src/components/select-list.ts
Normal file
155
packages/tui/src/components/select-list.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import chalk from "chalk";
|
||||
import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js";
|
||||
|
||||
export interface SelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class SelectList implements Component {
|
||||
readonly id = getNextComponentId();
|
||||
private items: SelectItem[] = [];
|
||||
private filteredItems: SelectItem[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
private filter: string = "";
|
||||
private maxVisible: number = 5;
|
||||
|
||||
public onSelect?: (item: SelectItem) => void;
|
||||
public onCancel?: () => void;
|
||||
|
||||
constructor(items: SelectItem[], maxVisible: number = 5) {
|
||||
this.items = items;
|
||||
this.filteredItems = items;
|
||||
this.maxVisible = maxVisible;
|
||||
}
|
||||
|
||||
setFilter(filter: string): void {
|
||||
this.filter = filter;
|
||||
this.filteredItems = this.items.filter((item) => item.value.toLowerCase().startsWith(filter.toLowerCase()));
|
||||
// Reset selection when filter changes
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
render(width: number): ComponentRenderResult {
|
||||
const lines: string[] = [];
|
||||
|
||||
// If no items match filter, show message
|
||||
if (this.filteredItems.length === 0) {
|
||||
lines.push(chalk.gray(" No matching commands"));
|
||||
return { lines, changed: true };
|
||||
}
|
||||
|
||||
// Calculate visible range with scrolling
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
|
||||
);
|
||||
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
|
||||
|
||||
// Render visible items
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const item = this.filteredItems[i];
|
||||
if (!item) continue;
|
||||
|
||||
const isSelected = i === this.selectedIndex;
|
||||
|
||||
let line = "";
|
||||
if (isSelected) {
|
||||
// Use arrow indicator for selection
|
||||
const prefix = chalk.blue("→ ");
|
||||
const displayValue = item.label || item.value;
|
||||
|
||||
if (item.description && width > 40) {
|
||||
// Calculate how much space we have for value + description
|
||||
const maxValueLength = Math.min(displayValue.length, 30);
|
||||
const truncatedValue = displayValue.substring(0, maxValueLength);
|
||||
const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length));
|
||||
|
||||
// Calculate remaining space for description
|
||||
const descriptionStart = prefix.length + truncatedValue.length + spacing.length - 2; // -2 for arrow color codes
|
||||
const remainingWidth = width - descriptionStart - 2; // -2 for safety
|
||||
|
||||
if (remainingWidth > 10) {
|
||||
const truncatedDesc = item.description.substring(0, remainingWidth);
|
||||
line = prefix + chalk.blue(truncatedValue) + chalk.gray(spacing + truncatedDesc);
|
||||
} else {
|
||||
// Not enough space for description
|
||||
const maxWidth = width - 4; // 2 for arrow + space, 2 for safety
|
||||
line = prefix + chalk.blue(displayValue.substring(0, maxWidth));
|
||||
}
|
||||
} else {
|
||||
// No description or not enough width
|
||||
const maxWidth = width - 4; // 2 for arrow + space, 2 for safety
|
||||
line = prefix + chalk.blue(displayValue.substring(0, maxWidth));
|
||||
}
|
||||
} else {
|
||||
const displayValue = item.label || item.value;
|
||||
const prefix = " ";
|
||||
|
||||
if (item.description && width > 40) {
|
||||
// Calculate how much space we have for value + description
|
||||
const maxValueLength = Math.min(displayValue.length, 30);
|
||||
const truncatedValue = displayValue.substring(0, maxValueLength);
|
||||
const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length));
|
||||
|
||||
// Calculate remaining space for description
|
||||
const descriptionStart = prefix.length + truncatedValue.length + spacing.length;
|
||||
const remainingWidth = width - descriptionStart - 2; // -2 for safety
|
||||
|
||||
if (remainingWidth > 10) {
|
||||
const truncatedDesc = item.description.substring(0, remainingWidth);
|
||||
line = prefix + truncatedValue + chalk.gray(spacing + truncatedDesc);
|
||||
} else {
|
||||
// Not enough space for description
|
||||
const maxWidth = width - prefix.length - 2;
|
||||
line = prefix + displayValue.substring(0, maxWidth);
|
||||
}
|
||||
} else {
|
||||
// No description or not enough width
|
||||
const maxWidth = width - prefix.length - 2;
|
||||
line = prefix + displayValue.substring(0, maxWidth);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
// Add scroll indicators if needed
|
||||
if (startIndex > 0 || endIndex < this.filteredItems.length) {
|
||||
const scrollInfo = chalk.gray(` (${this.selectedIndex + 1}/${this.filteredItems.length})`);
|
||||
lines.push(scrollInfo);
|
||||
}
|
||||
|
||||
return { lines, changed: true };
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
// Up arrow
|
||||
if (keyData === "\x1b[A") {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
}
|
||||
// Down arrow
|
||||
else if (keyData === "\x1b[B") {
|
||||
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
|
||||
}
|
||||
// Enter
|
||||
else if (keyData === "\r") {
|
||||
const selectedItem = this.filteredItems[this.selectedIndex];
|
||||
if (selectedItem && this.onSelect) {
|
||||
this.onSelect(selectedItem);
|
||||
}
|
||||
}
|
||||
// Escape
|
||||
else if (keyData === "\x1b") {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedItem(): SelectItem | null {
|
||||
const item = this.filteredItems[this.selectedIndex];
|
||||
return item || null;
|
||||
}
|
||||
}
|
||||
105
packages/tui/src/components/text-component.ts
Normal file
105
packages/tui/src/components/text-component.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { type Component, type ComponentRenderResult, getNextComponentId, type Padding } from "../tui.js";
|
||||
|
||||
export class TextComponent implements Component {
|
||||
readonly id = getNextComponentId();
|
||||
private text: string;
|
||||
private lastRenderedLines: string[] = [];
|
||||
private padding: Required<Padding>;
|
||||
|
||||
constructor(text: string, padding?: Padding) {
|
||||
this.text = text;
|
||||
this.padding = {
|
||||
top: padding?.top ?? 0,
|
||||
bottom: padding?.bottom ?? 0,
|
||||
left: padding?.left ?? 0,
|
||||
right: padding?.right ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
render(width: number): ComponentRenderResult {
|
||||
// Calculate available width after horizontal padding
|
||||
const availableWidth = Math.max(1, width - this.padding.left - this.padding.right);
|
||||
const leftPadding = " ".repeat(this.padding.left);
|
||||
|
||||
// First split by newlines to preserve line breaks
|
||||
const textLines = this.text.split("\n");
|
||||
const lines: string[] = [];
|
||||
|
||||
// Add top padding
|
||||
for (let i = 0; i < this.padding.top; i++) {
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Process each line for word wrapping
|
||||
for (const textLine of textLines) {
|
||||
if (textLine.length === 0) {
|
||||
// Preserve empty lines with padding
|
||||
lines.push(leftPadding);
|
||||
} else {
|
||||
// Word wrapping with ANSI-aware length calculation
|
||||
const words = textLine.split(" ");
|
||||
let currentLine = "";
|
||||
let currentVisibleLength = 0;
|
||||
|
||||
for (const word of words) {
|
||||
const wordVisibleLength = this.getVisibleLength(word);
|
||||
const spaceLength = currentLine ? 1 : 0;
|
||||
|
||||
if (currentVisibleLength + spaceLength + wordVisibleLength <= availableWidth) {
|
||||
currentLine += (currentLine ? " " : "") + word;
|
||||
currentVisibleLength += spaceLength + wordVisibleLength;
|
||||
} else {
|
||||
if (currentLine) {
|
||||
lines.push(leftPadding + currentLine);
|
||||
}
|
||||
currentLine = word;
|
||||
currentVisibleLength = wordVisibleLength;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(leftPadding + currentLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add bottom padding
|
||||
for (let i = 0; i < this.padding.bottom; i++) {
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
const newLines = lines.length > 0 ? lines : [""];
|
||||
|
||||
// Check if content changed
|
||||
const changed = !this.arraysEqual(newLines, this.lastRenderedLines);
|
||||
|
||||
// Always cache the current rendered lines
|
||||
this.lastRenderedLines = [...newLines];
|
||||
|
||||
return {
|
||||
lines: newLines,
|
||||
changed,
|
||||
};
|
||||
}
|
||||
|
||||
setText(text: string): void {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
getText(): string {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
private arraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private getVisibleLength(str: string): number {
|
||||
// Remove ANSI escape codes and count visible characters
|
||||
return (str || "").replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||
}
|
||||
}
|
||||
803
packages/tui/src/components/text-editor.ts
Normal file
803
packages/tui/src/components/text-editor.ts
Normal file
|
|
@ -0,0 +1,803 @@
|
|||
import chalk from "chalk";
|
||||
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js";
|
||||
import { SelectList } from "./select-list.js";
|
||||
|
||||
interface EditorState {
|
||||
lines: string[];
|
||||
cursorLine: number;
|
||||
cursorCol: number;
|
||||
}
|
||||
|
||||
interface LayoutLine {
|
||||
text: string;
|
||||
hasCursor: boolean;
|
||||
cursorPos?: number;
|
||||
}
|
||||
|
||||
export interface TextEditorConfig {
|
||||
// Configuration options for text editor (none currently)
|
||||
}
|
||||
|
||||
export class TextEditor implements Component {
|
||||
readonly id = getNextComponentId();
|
||||
private state: EditorState = {
|
||||
lines: [""],
|
||||
cursorLine: 0,
|
||||
cursorCol: 0,
|
||||
};
|
||||
|
||||
private config: TextEditorConfig = {};
|
||||
|
||||
// Autocomplete support
|
||||
private autocompleteProvider?: AutocompleteProvider;
|
||||
private autocompleteList?: SelectList;
|
||||
private isAutocompleting: boolean = false;
|
||||
private autocompletePrefix: string = "";
|
||||
|
||||
public onSubmit?: (text: string) => void;
|
||||
public onChange?: (text: string) => void;
|
||||
public disableSubmit: boolean = false;
|
||||
|
||||
constructor(config?: TextEditorConfig) {
|
||||
if (config) {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
logger.componentLifecycle("TextEditor", "created", { config: this.config });
|
||||
}
|
||||
|
||||
configure(config: Partial<TextEditorConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
logger.info("TextEditor", "Configuration updated", { config: this.config });
|
||||
}
|
||||
|
||||
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
||||
this.autocompleteProvider = provider;
|
||||
}
|
||||
|
||||
render(width: number): ComponentRenderResult {
|
||||
// Box drawing characters
|
||||
const topLeft = chalk.gray("╭");
|
||||
const topRight = chalk.gray("╮");
|
||||
const bottomLeft = chalk.gray("╰");
|
||||
const bottomRight = chalk.gray("╯");
|
||||
const horizontal = chalk.gray("─");
|
||||
const vertical = chalk.gray("│");
|
||||
|
||||
// Calculate box width - leave 1 char margin to avoid edge wrapping
|
||||
const boxWidth = width - 1;
|
||||
const contentWidth = boxWidth - 4; // Account for "│ " and " │"
|
||||
|
||||
// Layout the text
|
||||
const layoutLines = this.layoutText(contentWidth);
|
||||
|
||||
const result: string[] = [];
|
||||
|
||||
// Render top border
|
||||
result.push(topLeft + horizontal.repeat(boxWidth - 2) + topRight);
|
||||
|
||||
// Render each layout line
|
||||
for (const layoutLine of layoutLines) {
|
||||
let displayText = layoutLine.text;
|
||||
let visibleLength = layoutLine.text.length;
|
||||
|
||||
// Add cursor if this line has it
|
||||
if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
|
||||
const before = displayText.slice(0, layoutLine.cursorPos);
|
||||
const after = displayText.slice(layoutLine.cursorPos);
|
||||
|
||||
if (after.length > 0) {
|
||||
// Cursor is on a character - replace it with highlighted version
|
||||
const cursor = `\x1b[7m${after[0]}\x1b[0m`;
|
||||
const restAfter = after.slice(1);
|
||||
displayText = before + cursor + restAfter;
|
||||
// visibleLength stays the same - we're replacing, not adding
|
||||
} else {
|
||||
// Cursor is at the end - add highlighted space
|
||||
const cursor = "\x1b[7m \x1b[0m";
|
||||
displayText = before + cursor;
|
||||
// visibleLength increases by 1 - we're adding a space
|
||||
visibleLength = layoutLine.text.length + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate padding based on actual visible length
|
||||
const padding = " ".repeat(Math.max(0, contentWidth - visibleLength));
|
||||
|
||||
// Render the line
|
||||
result.push(`${vertical} ${displayText}${padding} ${vertical}`);
|
||||
}
|
||||
|
||||
// Render bottom border
|
||||
result.push(bottomLeft + horizontal.repeat(boxWidth - 2) + bottomRight);
|
||||
|
||||
// Add autocomplete list if active
|
||||
if (this.isAutocompleting && this.autocompleteList) {
|
||||
const autocompleteResult = this.autocompleteList.render(width);
|
||||
result.push(...autocompleteResult.lines);
|
||||
}
|
||||
|
||||
// For interactive components like text editors, always assume changed
|
||||
// This ensures cursor position updates are always reflected
|
||||
return {
|
||||
lines: result,
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
logger.keyInput("TextEditor", data);
|
||||
logger.debug("TextEditor", "Current state before input", {
|
||||
lines: this.state.lines,
|
||||
cursorLine: this.state.cursorLine,
|
||||
cursorCol: this.state.cursorCol,
|
||||
});
|
||||
|
||||
// Handle special key combinations first
|
||||
|
||||
// Ctrl+C - Exit (let parent handle this)
|
||||
if (data.charCodeAt(0) === 3) {
|
||||
logger.debug("TextEditor", "Ctrl+C received, returning to parent");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle paste - detect when we get a lot of text at once
|
||||
const isPaste = data.length > 10 || (data.length > 2 && data.includes("\n"));
|
||||
logger.debug("TextEditor", "Paste detection", {
|
||||
dataLength: data.length,
|
||||
includesNewline: data.includes("\n"),
|
||||
includesTabs: data.includes("\t"),
|
||||
tabCount: (data.match(/\t/g) || []).length,
|
||||
isPaste,
|
||||
data: JSON.stringify(data),
|
||||
charCodes: Array.from(data).map((c) => c.charCodeAt(0)),
|
||||
});
|
||||
|
||||
if (isPaste) {
|
||||
logger.info("TextEditor", "Handling as paste");
|
||||
this.handlePaste(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle autocomplete special keys first (but don't block other input)
|
||||
if (this.isAutocompleting && this.autocompleteList) {
|
||||
logger.debug("TextEditor", "Autocomplete active, handling input", {
|
||||
data,
|
||||
charCode: data.charCodeAt(0),
|
||||
isEscape: data === "\x1b",
|
||||
isArrowOrEnter: data === "\x1b[A" || data === "\x1b[B" || data === "\r",
|
||||
});
|
||||
|
||||
// Escape - cancel autocomplete
|
||||
if (data === "\x1b") {
|
||||
this.cancelAutocomplete();
|
||||
return;
|
||||
}
|
||||
// Let the autocomplete list handle navigation and selection
|
||||
else if (data === "\x1b[A" || data === "\x1b[B" || data === "\r" || data === "\t") {
|
||||
// Only pass arrow keys to the list, not Enter/Tab (we handle those directly)
|
||||
if (data === "\x1b[A" || data === "\x1b[B") {
|
||||
this.autocompleteList.handleInput(data);
|
||||
}
|
||||
|
||||
// If Tab was pressed, apply the selection
|
||||
if (data === "\t") {
|
||||
const selected = this.autocompleteList.getSelectedItem();
|
||||
if (selected && this.autocompleteProvider) {
|
||||
const result = this.autocompleteProvider.applyCompletion(
|
||||
this.state.lines,
|
||||
this.state.cursorLine,
|
||||
this.state.cursorCol,
|
||||
selected,
|
||||
this.autocompletePrefix,
|
||||
);
|
||||
|
||||
this.state.lines = result.lines;
|
||||
this.state.cursorLine = result.cursorLine;
|
||||
this.state.cursorCol = result.cursorCol;
|
||||
|
||||
this.cancelAutocomplete();
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// If Enter was pressed, cancel autocomplete and let it fall through to submission
|
||||
else if (data === "\r") {
|
||||
this.cancelAutocomplete();
|
||||
// Don't return here - let Enter fall through to normal submission handling
|
||||
} else {
|
||||
// For other keys, handle normally within autocomplete
|
||||
return;
|
||||
}
|
||||
}
|
||||
// For other keys (like regular typing), DON'T return here
|
||||
// Let them fall through to normal character handling
|
||||
logger.debug("TextEditor", "Autocomplete active but falling through to normal handling");
|
||||
}
|
||||
|
||||
// Tab key - context-aware completion (but not when already autocompleting)
|
||||
if (data === "\t" && !this.isAutocompleting) {
|
||||
logger.debug("TextEditor", "Tab key pressed, determining context", {
|
||||
isAutocompleting: this.isAutocompleting,
|
||||
hasProvider: !!this.autocompleteProvider,
|
||||
});
|
||||
this.handleTabCompletion();
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue with rest of input handling
|
||||
// Ctrl+K - Delete current line
|
||||
if (data.charCodeAt(0) === 11) {
|
||||
this.deleteCurrentLine();
|
||||
}
|
||||
// Ctrl+A - Move to start of line
|
||||
else if (data.charCodeAt(0) === 1) {
|
||||
this.moveToLineStart();
|
||||
}
|
||||
// Ctrl+E - Move to end of line
|
||||
else if (data.charCodeAt(0) === 5) {
|
||||
this.moveToLineEnd();
|
||||
}
|
||||
// New line shortcuts (but not plain LF/CR which should be submit)
|
||||
else if (
|
||||
(data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
|
||||
data === "\x1b\r" || // Option+Enter in some terminals
|
||||
data === "\x1b[13;2~" || // Shift+Enter in some terminals
|
||||
(data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
|
||||
(data === "\n" && data.length === 1) || // Shift+Enter from iTerm2 mapping
|
||||
data === "\\\r" // Shift+Enter in VS Code terminal
|
||||
) {
|
||||
// Modifier + Enter = new line
|
||||
this.addNewLine();
|
||||
}
|
||||
// Plain Enter (char code 13 for CR) - only CR submits, LF adds new line
|
||||
else if (data.charCodeAt(0) === 13 && data.length === 1) {
|
||||
// If submit is disabled, do nothing
|
||||
if (this.disableSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Plain Enter = submit
|
||||
const result = this.state.lines.join("\n").trim();
|
||||
logger.info("TextEditor", "Submit triggered", {
|
||||
result,
|
||||
rawResult: JSON.stringify(this.state.lines.join("\n")),
|
||||
lines: this.state.lines,
|
||||
resultLines: result.split("\n"),
|
||||
});
|
||||
|
||||
// Reset editor
|
||||
this.state = {
|
||||
lines: [""],
|
||||
cursorLine: 0,
|
||||
cursorCol: 0,
|
||||
};
|
||||
|
||||
// Notify that editor is now empty
|
||||
if (this.onChange) {
|
||||
this.onChange("");
|
||||
}
|
||||
|
||||
if (this.onSubmit) {
|
||||
logger.info("TextEditor", "Calling onSubmit callback", { result });
|
||||
this.onSubmit(result);
|
||||
} else {
|
||||
logger.warn("TextEditor", "No onSubmit callback set");
|
||||
}
|
||||
}
|
||||
// Backspace
|
||||
else if (data.charCodeAt(0) === 127 || data.charCodeAt(0) === 8) {
|
||||
this.handleBackspace();
|
||||
}
|
||||
// Line navigation shortcuts (Home/End keys)
|
||||
else if (data === "\x1b[H" || data === "\x1b[1~" || data === "\x1b[7~") {
|
||||
// Home key
|
||||
this.moveToLineStart();
|
||||
} else if (data === "\x1b[F" || data === "\x1b[4~" || data === "\x1b[8~") {
|
||||
// End key
|
||||
this.moveToLineEnd();
|
||||
}
|
||||
// Forward delete (Fn+Backspace or Delete key)
|
||||
else if (data === "\x1b[3~") {
|
||||
// Delete key
|
||||
this.handleForwardDelete();
|
||||
}
|
||||
// Arrow keys
|
||||
else if (data === "\x1b[A") {
|
||||
// Up
|
||||
this.moveCursor(-1, 0);
|
||||
} else if (data === "\x1b[B") {
|
||||
// Down
|
||||
this.moveCursor(1, 0);
|
||||
} else if (data === "\x1b[C") {
|
||||
// Right
|
||||
this.moveCursor(0, 1);
|
||||
} else if (data === "\x1b[D") {
|
||||
// Left
|
||||
this.moveCursor(0, -1);
|
||||
}
|
||||
// Regular characters (printable ASCII)
|
||||
else if (data.charCodeAt(0) >= 32 && data.charCodeAt(0) <= 126) {
|
||||
logger.debug("TextEditor", "Inserting character", { char: data, charCode: data.charCodeAt(0) });
|
||||
this.insertCharacter(data);
|
||||
} else {
|
||||
logger.warn("TextEditor", "Unhandled input", {
|
||||
data,
|
||||
charCodes: Array.from(data).map((c) => c.charCodeAt(0)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private layoutText(contentWidth: number): LayoutLine[] {
|
||||
const layoutLines: LayoutLine[] = [];
|
||||
|
||||
if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {
|
||||
// Empty editor
|
||||
layoutLines.push({
|
||||
text: "> ",
|
||||
hasCursor: true,
|
||||
cursorPos: 2,
|
||||
});
|
||||
return layoutLines;
|
||||
}
|
||||
|
||||
// Process each logical line
|
||||
for (let i = 0; i < this.state.lines.length; i++) {
|
||||
const line = this.state.lines[i] || "";
|
||||
const isCurrentLine = i === this.state.cursorLine;
|
||||
const prefix = i === 0 ? "> " : " ";
|
||||
const prefixedLine = prefix + line;
|
||||
const maxLineLength = contentWidth;
|
||||
|
||||
if (prefixedLine.length <= maxLineLength) {
|
||||
// Line fits in one layout line
|
||||
if (isCurrentLine) {
|
||||
layoutLines.push({
|
||||
text: prefixedLine,
|
||||
hasCursor: true,
|
||||
cursorPos: prefix.length + this.state.cursorCol,
|
||||
});
|
||||
} else {
|
||||
layoutLines.push({
|
||||
text: prefixedLine,
|
||||
hasCursor: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Line needs wrapping
|
||||
const chunks = [];
|
||||
for (let pos = 0; pos < prefixedLine.length; pos += maxLineLength) {
|
||||
chunks.push(prefixedLine.slice(pos, pos + maxLineLength));
|
||||
}
|
||||
|
||||
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
||||
const chunk = chunks[chunkIndex];
|
||||
if (!chunk) continue;
|
||||
|
||||
const chunkStart = chunkIndex * maxLineLength;
|
||||
const chunkEnd = chunkStart + chunk.length;
|
||||
const cursorPos = prefix.length + this.state.cursorCol;
|
||||
const hasCursorInChunk = isCurrentLine && cursorPos >= chunkStart && cursorPos < chunkEnd;
|
||||
|
||||
if (hasCursorInChunk) {
|
||||
layoutLines.push({
|
||||
text: chunk,
|
||||
hasCursor: true,
|
||||
cursorPos: cursorPos - chunkStart,
|
||||
});
|
||||
} else {
|
||||
layoutLines.push({
|
||||
text: chunk,
|
||||
hasCursor: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return layoutLines;
|
||||
}
|
||||
|
||||
getText(): string {
|
||||
return this.state.lines.join("\n");
|
||||
}
|
||||
|
||||
setText(text: string): void {
|
||||
// Split text into lines, handling different line endings
|
||||
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
|
||||
// Ensure at least one empty line
|
||||
this.state.lines = lines.length === 0 ? [""] : lines;
|
||||
|
||||
// Reset cursor to end of text
|
||||
this.state.cursorLine = this.state.lines.length - 1;
|
||||
this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0;
|
||||
|
||||
// Notify of change
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
|
||||
// All the editor methods from before...
|
||||
private insertCharacter(char: string): void {
|
||||
const line = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
const before = line.slice(0, this.state.cursorCol);
|
||||
const after = line.slice(this.state.cursorCol);
|
||||
|
||||
this.state.lines[this.state.cursorLine] = before + char + after;
|
||||
this.state.cursorCol += char.length; // Fix: increment by the length of the inserted string
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
|
||||
// Check if we should trigger or update autocomplete
|
||||
if (!this.isAutocompleting) {
|
||||
// Auto-trigger for "/" at the start of a line (slash commands)
|
||||
if (char === "/" && this.isAtStartOfMessage()) {
|
||||
this.tryTriggerAutocomplete();
|
||||
}
|
||||
// Also auto-trigger when typing letters in a slash command context
|
||||
else if (/[a-zA-Z0-9]/.test(char)) {
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
// Check if we're in a slash command with a space (i.e., typing arguments)
|
||||
if (textBeforeCursor.startsWith("/") && textBeforeCursor.includes(" ")) {
|
||||
this.tryTriggerAutocomplete();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.updateAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
private handlePaste(pastedText: string): void {
|
||||
logger.debug("TextEditor", "Processing paste", {
|
||||
pastedText: JSON.stringify(pastedText),
|
||||
hasTab: pastedText.includes("\t"),
|
||||
tabCount: (pastedText.match(/\t/g) || []).length,
|
||||
});
|
||||
|
||||
// Clean the pasted text
|
||||
const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
|
||||
// Convert tabs to spaces (4 spaces per tab)
|
||||
const tabExpandedText = cleanText.replace(/\t/g, " ");
|
||||
|
||||
// Filter out non-printable characters except newlines
|
||||
const filteredText = tabExpandedText
|
||||
.split("")
|
||||
.filter((char) => char === "\n" || (char >= " " && char <= "~"))
|
||||
.join("");
|
||||
|
||||
// Split into lines
|
||||
const pastedLines = filteredText.split("\n");
|
||||
|
||||
if (pastedLines.length === 1) {
|
||||
// Single line - just insert each character
|
||||
const text = pastedLines[0] || "";
|
||||
for (const char of text) {
|
||||
this.insertCharacter(char);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-line paste - be very careful with array manipulation
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
const afterCursor = currentLine.slice(this.state.cursorCol);
|
||||
|
||||
// Build the new lines array step by step
|
||||
const newLines: string[] = [];
|
||||
|
||||
// Add all lines before current line
|
||||
for (let i = 0; i < this.state.cursorLine; i++) {
|
||||
newLines.push(this.state.lines[i] || "");
|
||||
}
|
||||
|
||||
// Add the first pasted line merged with before cursor text
|
||||
newLines.push(beforeCursor + (pastedLines[0] || ""));
|
||||
|
||||
// Add all middle pasted lines
|
||||
for (let i = 1; i < pastedLines.length - 1; i++) {
|
||||
newLines.push(pastedLines[i] || "");
|
||||
}
|
||||
|
||||
// Add the last pasted line with after cursor text
|
||||
newLines.push((pastedLines[pastedLines.length - 1] || "") + afterCursor);
|
||||
|
||||
// Add all lines after current line
|
||||
for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) {
|
||||
newLines.push(this.state.lines[i] || "");
|
||||
}
|
||||
|
||||
// Replace the entire lines array
|
||||
this.state.lines = newLines;
|
||||
|
||||
// Update cursor position to end of pasted content
|
||||
this.state.cursorLine += pastedLines.length - 1;
|
||||
this.state.cursorCol = (pastedLines[pastedLines.length - 1] || "").length;
|
||||
|
||||
// Notify of change
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
|
||||
private addNewLine(): void {
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
const before = currentLine.slice(0, this.state.cursorCol);
|
||||
const after = currentLine.slice(this.state.cursorCol);
|
||||
|
||||
// Split current line
|
||||
this.state.lines[this.state.cursorLine] = before;
|
||||
this.state.lines.splice(this.state.cursorLine + 1, 0, after);
|
||||
|
||||
// Move cursor to start of new line
|
||||
this.state.cursorLine++;
|
||||
this.state.cursorCol = 0;
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
|
||||
private handleBackspace(): void {
|
||||
if (this.state.cursorCol > 0) {
|
||||
// Delete character in current line
|
||||
const line = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
const before = line.slice(0, this.state.cursorCol - 1);
|
||||
const after = line.slice(this.state.cursorCol);
|
||||
|
||||
this.state.lines[this.state.cursorLine] = before + after;
|
||||
this.state.cursorCol--;
|
||||
} else if (this.state.cursorLine > 0) {
|
||||
// Merge with previous line
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
||||
|
||||
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
|
||||
this.state.lines.splice(this.state.cursorLine, 1);
|
||||
|
||||
this.state.cursorLine--;
|
||||
this.state.cursorCol = previousLine.length;
|
||||
}
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
|
||||
// Update autocomplete after backspace
|
||||
if (this.isAutocompleting) {
|
||||
this.updateAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
private moveToLineStart(): void {
|
||||
this.state.cursorCol = 0;
|
||||
}
|
||||
|
||||
private moveToLineEnd(): void {
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
this.state.cursorCol = currentLine.length;
|
||||
}
|
||||
|
||||
private handleForwardDelete(): void {
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
if (this.state.cursorCol < currentLine.length) {
|
||||
// Delete character at cursor position (forward delete)
|
||||
const before = currentLine.slice(0, this.state.cursorCol);
|
||||
const after = currentLine.slice(this.state.cursorCol + 1);
|
||||
this.state.lines[this.state.cursorLine] = before + after;
|
||||
} else if (this.state.cursorLine < this.state.lines.length - 1) {
|
||||
// At end of line - merge with next line
|
||||
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
||||
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
|
||||
this.state.lines.splice(this.state.cursorLine + 1, 1);
|
||||
}
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
|
||||
private deleteCurrentLine(): void {
|
||||
if (this.state.lines.length === 1) {
|
||||
// Only one line - just clear it
|
||||
this.state.lines[0] = "";
|
||||
this.state.cursorCol = 0;
|
||||
} else {
|
||||
// Multiple lines - remove current line
|
||||
this.state.lines.splice(this.state.cursorLine, 1);
|
||||
|
||||
// Adjust cursor position
|
||||
if (this.state.cursorLine >= this.state.lines.length) {
|
||||
// Was on last line, move to new last line
|
||||
this.state.cursorLine = this.state.lines.length - 1;
|
||||
}
|
||||
|
||||
// Clamp cursor column to new line length
|
||||
const newLine = this.state.lines[this.state.cursorLine] || "";
|
||||
this.state.cursorCol = Math.min(this.state.cursorCol, newLine.length);
|
||||
}
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
|
||||
private moveCursor(deltaLine: number, deltaCol: number): void {
|
||||
if (deltaLine !== 0) {
|
||||
const newLine = this.state.cursorLine + deltaLine;
|
||||
if (newLine >= 0 && newLine < this.state.lines.length) {
|
||||
this.state.cursorLine = newLine;
|
||||
// Clamp cursor column to new line length
|
||||
const line = this.state.lines[this.state.cursorLine] || "";
|
||||
this.state.cursorCol = Math.min(this.state.cursorCol, line.length);
|
||||
}
|
||||
}
|
||||
|
||||
if (deltaCol !== 0) {
|
||||
// Move column
|
||||
const newCol = this.state.cursorCol + deltaCol;
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const maxCol = currentLine.length;
|
||||
this.state.cursorCol = Math.max(0, Math.min(maxCol, newCol));
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to check if cursor is at start of message (for slash command detection)
|
||||
private isAtStartOfMessage(): boolean {
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
|
||||
// At start if line is empty, only contains whitespace, or is just "/"
|
||||
return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
|
||||
}
|
||||
|
||||
// Autocomplete methods
|
||||
private tryTriggerAutocomplete(explicitTab: boolean = false): void {
|
||||
logger.debug("TextEditor", "tryTriggerAutocomplete called", {
|
||||
explicitTab,
|
||||
hasProvider: !!this.autocompleteProvider,
|
||||
});
|
||||
|
||||
if (!this.autocompleteProvider) return;
|
||||
|
||||
// Check if we should trigger file completion on Tab
|
||||
if (explicitTab) {
|
||||
const provider = this.autocompleteProvider as CombinedAutocompleteProvider;
|
||||
const shouldTrigger =
|
||||
!provider.shouldTriggerFileCompletion ||
|
||||
provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
|
||||
|
||||
logger.debug("TextEditor", "Tab file completion check", {
|
||||
hasShouldTriggerMethod: !!provider.shouldTriggerFileCompletion,
|
||||
shouldTrigger,
|
||||
lines: this.state.lines,
|
||||
cursorLine: this.state.cursorLine,
|
||||
cursorCol: this.state.cursorCol,
|
||||
});
|
||||
|
||||
if (!shouldTrigger) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const suggestions = this.autocompleteProvider.getSuggestions(
|
||||
this.state.lines,
|
||||
this.state.cursorLine,
|
||||
this.state.cursorCol,
|
||||
);
|
||||
|
||||
logger.debug("TextEditor", "Autocomplete suggestions", {
|
||||
hasSuggestions: !!suggestions,
|
||||
itemCount: suggestions?.items.length || 0,
|
||||
prefix: suggestions?.prefix,
|
||||
});
|
||||
|
||||
if (suggestions && suggestions.items.length > 0) {
|
||||
this.autocompletePrefix = suggestions.prefix;
|
||||
this.autocompleteList = new SelectList(suggestions.items, 5);
|
||||
this.isAutocompleting = true;
|
||||
} else {
|
||||
this.cancelAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
private handleTabCompletion(): void {
|
||||
if (!this.autocompleteProvider) return;
|
||||
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
|
||||
// Check if we're in a slash command context
|
||||
if (beforeCursor.trimStart().startsWith("/")) {
|
||||
logger.debug("TextEditor", "Tab in slash command context", { beforeCursor });
|
||||
this.handleSlashCommandCompletion();
|
||||
} else {
|
||||
logger.debug("TextEditor", "Tab in file completion context", { beforeCursor });
|
||||
this.forceFileAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
private handleSlashCommandCompletion(): void {
|
||||
// For now, fall back to regular autocomplete (slash commands)
|
||||
// This can be extended later to handle command-specific argument completion
|
||||
logger.debug("TextEditor", "Handling slash command completion");
|
||||
this.tryTriggerAutocomplete(true);
|
||||
}
|
||||
|
||||
private forceFileAutocomplete(): void {
|
||||
logger.debug("TextEditor", "forceFileAutocomplete called", {
|
||||
hasProvider: !!this.autocompleteProvider,
|
||||
});
|
||||
|
||||
if (!this.autocompleteProvider) return;
|
||||
|
||||
// Check if provider has the force method
|
||||
const provider = this.autocompleteProvider as any;
|
||||
if (!provider.getForceFileSuggestions) {
|
||||
logger.debug("TextEditor", "Provider doesn't support forced file completion, falling back to regular");
|
||||
this.tryTriggerAutocomplete(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestions = provider.getForceFileSuggestions(
|
||||
this.state.lines,
|
||||
this.state.cursorLine,
|
||||
this.state.cursorCol,
|
||||
);
|
||||
|
||||
logger.debug("TextEditor", "Forced file autocomplete suggestions", {
|
||||
hasSuggestions: !!suggestions,
|
||||
itemCount: suggestions?.items.length || 0,
|
||||
prefix: suggestions?.prefix,
|
||||
});
|
||||
|
||||
if (suggestions && suggestions.items.length > 0) {
|
||||
this.autocompletePrefix = suggestions.prefix;
|
||||
this.autocompleteList = new SelectList(suggestions.items, 5);
|
||||
this.isAutocompleting = true;
|
||||
} else {
|
||||
this.cancelAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
private cancelAutocomplete(): void {
|
||||
this.isAutocompleting = false;
|
||||
this.autocompleteList = undefined as any;
|
||||
this.autocompletePrefix = "";
|
||||
}
|
||||
|
||||
private updateAutocomplete(): void {
|
||||
if (!this.isAutocompleting || !this.autocompleteProvider) return;
|
||||
|
||||
const suggestions = this.autocompleteProvider.getSuggestions(
|
||||
this.state.lines,
|
||||
this.state.cursorLine,
|
||||
this.state.cursorCol,
|
||||
);
|
||||
|
||||
if (suggestions && suggestions.items.length > 0) {
|
||||
this.autocompletePrefix = suggestions.prefix;
|
||||
if (this.autocompleteList) {
|
||||
// Update the existing list with new items
|
||||
this.autocompleteList = new SelectList(suggestions.items, 5);
|
||||
}
|
||||
} else {
|
||||
// No more matches, cancel autocomplete
|
||||
this.cancelAutocomplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
25
packages/tui/src/components/whitespace-component.ts
Normal file
25
packages/tui/src/components/whitespace-component.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js";
|
||||
|
||||
/**
|
||||
* A simple component that renders blank lines for spacing
|
||||
*/
|
||||
export class WhitespaceComponent implements Component {
|
||||
readonly id = getNextComponentId();
|
||||
private lines: string[] = [];
|
||||
private lineCount: number;
|
||||
private firstRender: boolean = true;
|
||||
|
||||
constructor(lineCount: number = 1) {
|
||||
this.lineCount = Math.max(0, lineCount); // Ensure non-negative
|
||||
this.lines = new Array(this.lineCount).fill("");
|
||||
}
|
||||
|
||||
render(_width: number): ComponentRenderResult {
|
||||
const result = {
|
||||
lines: this.lines,
|
||||
changed: this.firstRender,
|
||||
};
|
||||
this.firstRender = false;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue