Initial monorepo setup with npm workspaces and dual TypeScript configuration

- Set up npm workspaces for three packages: pi-tui, pi-agent, and pi (pods)
- Implemented dual TypeScript configuration:
  - Root tsconfig.json with path mappings for development and type checking
  - Package-specific tsconfig.build.json for clean production builds
- Configured lockstep versioning with sync script for inter-package dependencies
- Added comprehensive documentation for development and publishing workflows
- All packages at version 0.5.0 ready for npm publishing
This commit is contained in:
Mario Zechner 2025-08-09 17:18:38 +02:00
commit a74c5da112
63 changed files with 14558 additions and 0 deletions

View file

@ -0,0 +1,549 @@
import { readdirSync, statSync } from "fs";
import mimeTypes from "mime-types";
import { homedir } from "os";
import { basename, dirname, extname, join } from "path";
import { logger } from "./logger.js";
function isAttachableFile(filePath: string): boolean {
const mimeType = mimeTypes.lookup(filePath);
// Check file extension for common text files that might be misidentified
const textExtensions = [
".txt",
".md",
".markdown",
".js",
".ts",
".tsx",
".jsx",
".py",
".java",
".c",
".cpp",
".h",
".hpp",
".cs",
".php",
".rb",
".go",
".rs",
".swift",
".kt",
".scala",
".sh",
".bash",
".zsh",
".fish",
".html",
".htm",
".css",
".scss",
".sass",
".less",
".xml",
".json",
".yaml",
".yml",
".toml",
".ini",
".cfg",
".conf",
".log",
".sql",
".r",
".R",
".m",
".pl",
".lua",
".vim",
".dockerfile",
".makefile",
".cmake",
".gradle",
".maven",
".properties",
".env",
];
const ext = extname(filePath).toLowerCase();
if (textExtensions.includes(ext)) return true;
if (!mimeType) return false;
if (mimeType.startsWith("image/")) return true;
if (mimeType.startsWith("text/")) return true;
// Special cases for common text files that might not be detected as text/
const commonTextTypes = [
"application/json",
"application/javascript",
"application/typescript",
"application/xml",
"application/yaml",
"application/x-yaml",
];
return commonTextTypes.includes(mimeType);
}
export interface AutocompleteItem {
value: string;
label: string;
description?: string;
}
export interface SlashCommand {
name: string;
description?: string;
// Function to get argument completions for this command
// Returns null if no argument completion is available
getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;
}
export interface AutocompleteProvider {
// Get autocomplete suggestions for current text/cursor position
// Returns null if no suggestions available
getSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
): {
items: AutocompleteItem[];
prefix: string; // What we're matching against (e.g., "/" or "src/")
} | null;
// Apply the selected item
// Returns the new text and cursor position
applyCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
): {
lines: string[];
cursorLine: number;
cursorCol: number;
};
}
// Combined provider that handles both slash commands and file paths
export class CombinedAutocompleteProvider implements AutocompleteProvider {
private commands: (SlashCommand | AutocompleteItem)[];
private basePath: string;
constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string = process.cwd()) {
this.commands = commands;
this.basePath = basePath;
}
getSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
): { items: AutocompleteItem[]; prefix: string } | null {
logger.debug("CombinedAutocompleteProvider", "getSuggestions called", {
lines,
cursorLine,
cursorCol,
});
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
// Check for slash commands
if (textBeforeCursor.startsWith("/")) {
const spaceIndex = textBeforeCursor.indexOf(" ");
if (spaceIndex === -1) {
// No space yet - complete command names
const prefix = textBeforeCursor.slice(1); // Remove the "/"
const filtered = this.commands
.filter((cmd) => {
const name = "name" in cmd ? cmd.name : cmd.value; // Check if SlashCommand or AutocompleteItem
return name?.toLowerCase().startsWith(prefix.toLowerCase());
})
.map((cmd) => ({
value: "name" in cmd ? cmd.name : cmd.value,
label: "name" in cmd ? cmd.name : cmd.label,
...(cmd.description && { description: cmd.description }),
}));
if (filtered.length === 0) return null;
return {
items: filtered,
prefix: textBeforeCursor,
};
} else {
// Space found - complete command arguments
const commandName = textBeforeCursor.slice(1, spaceIndex); // Command without "/"
const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space
const command = this.commands.find((cmd) => {
const name = "name" in cmd ? cmd.name : cmd.value;
return name === commandName;
});
if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
return null; // No argument completion for this command
}
const argumentSuggestions = command.getArgumentCompletions(argumentText);
if (!argumentSuggestions || argumentSuggestions.length === 0) {
return null;
}
return {
items: argumentSuggestions,
prefix: argumentText,
};
}
}
// Check for file paths - triggered by Tab or if we detect a path pattern
const pathMatch = this.extractPathPrefix(textBeforeCursor, false);
logger.debug("CombinedAutocompleteProvider", "Path match check", {
textBeforeCursor,
pathMatch,
});
if (pathMatch !== null) {
const suggestions = this.getFileSuggestions(pathMatch);
if (suggestions.length === 0) return null;
return {
items: suggestions,
prefix: pathMatch,
};
}
return null;
}
applyCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
): { lines: string[]; cursorLine: number; cursorCol: number } {
const currentLine = lines[cursorLine] || "";
const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
const afterCursor = currentLine.slice(cursorCol);
// Check if we're completing a slash command (prefix starts with "/")
if (prefix.startsWith("/")) {
// This is a command name completion
const newLine = beforePrefix + "/" + item.value + " " + afterCursor;
const newLines = [...lines];
newLines[cursorLine] = newLine;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + item.value.length + 2, // +2 for "/" and space
};
}
// Check if we're completing a file attachment (prefix starts with "@")
if (prefix.startsWith("@")) {
// This is a file attachment completion
const newLine = beforePrefix + item.value + " " + afterCursor;
const newLines = [...lines];
newLines[cursorLine] = newLine;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + item.value.length + 1, // +1 for space
};
}
// Check if we're in a slash command context (beforePrefix contains "/command ")
const textBeforeCursor = currentLine.slice(0, cursorCol);
if (textBeforeCursor.includes("/") && textBeforeCursor.includes(" ")) {
// This is likely a command argument completion
const newLine = beforePrefix + item.value + afterCursor;
const newLines = [...lines];
newLines[cursorLine] = newLine;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + item.value.length,
};
}
// For file paths, complete the path
const newLine = beforePrefix + item.value + afterCursor;
const newLines = [...lines];
newLines[cursorLine] = newLine;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + item.value.length,
};
}
// Extract a path-like prefix from the text before cursor
private extractPathPrefix(text: string, forceExtract: boolean = false): string | null {
// Check for @ file attachment syntax first
const atMatch = text.match(/@([^\s]*)$/);
if (atMatch) {
return atMatch[0]; // Return the full @path pattern
}
// Match paths - including those ending with /, ~/, or any word at end for forced extraction
// This regex captures:
// - Paths starting from beginning of line or after space/quote/equals
// - Optional ./ or ../ or ~/ prefix (including the trailing slash for ~/)
// - The path itself (can include / in the middle)
// - For forced extraction, capture any word at the end
const matches = text.match(/(?:^|[\s"'=])((?:~\/|\.{0,2}\/?)?(?:[^\s"'=]*\/?)*[^\s"'=]*)$/);
if (!matches) {
// If forced extraction and no matches, return empty string to trigger from current dir
return forceExtract ? "" : null;
}
const pathPrefix = matches[1] || "";
// For forced extraction (Tab key), always return something
if (forceExtract) {
return pathPrefix;
}
// For natural triggers, return if it looks like a path, ends with /, starts with ~/, .
// Only return empty string if the text looks like it's starting a path context
if (pathPrefix.includes("/") || pathPrefix.startsWith(".") || pathPrefix.startsWith("~/")) {
return pathPrefix;
}
// Return empty string only if we're at the beginning of the line or after a space
// (not after quotes or other delimiters that don't suggest file paths)
if (pathPrefix === "" && (text === "" || text.endsWith(" "))) {
return pathPrefix;
}
return null;
}
// Expand home directory (~/) to actual home path
private expandHomePath(path: string): string {
if (path.startsWith("~/")) {
const expandedPath = join(homedir(), path.slice(2));
// Preserve trailing slash if original path had one
return path.endsWith("/") && !expandedPath.endsWith("/") ? expandedPath + "/" : expandedPath;
} else if (path === "~") {
return homedir();
}
return path;
}
// Get file/directory suggestions for a given path prefix
private getFileSuggestions(prefix: string): AutocompleteItem[] {
logger.debug("CombinedAutocompleteProvider", "getFileSuggestions called", {
prefix,
basePath: this.basePath,
});
try {
let searchDir: string;
let searchPrefix: string;
let expandedPrefix = prefix;
let isAtPrefix = false;
// Handle @ file attachment prefix
if (prefix.startsWith("@")) {
isAtPrefix = true;
expandedPrefix = prefix.slice(1); // Remove the @
}
// Handle home directory expansion
if (expandedPrefix.startsWith("~")) {
expandedPrefix = this.expandHomePath(expandedPrefix);
}
if (
expandedPrefix === "" ||
expandedPrefix === "./" ||
expandedPrefix === "../" ||
expandedPrefix === "~" ||
expandedPrefix === "~/" ||
prefix === "@"
) {
// Complete from specified position
if (prefix.startsWith("~")) {
searchDir = expandedPrefix;
} else {
searchDir = join(this.basePath, expandedPrefix);
}
searchPrefix = "";
} else if (expandedPrefix.endsWith("/")) {
// If prefix ends with /, show contents of that directory
if (prefix.startsWith("~") || (isAtPrefix && expandedPrefix.startsWith("/"))) {
searchDir = expandedPrefix;
} else {
searchDir = join(this.basePath, expandedPrefix);
}
searchPrefix = "";
} else {
// Split into directory and file prefix
const dir = dirname(expandedPrefix);
const file = basename(expandedPrefix);
if (prefix.startsWith("~") || (isAtPrefix && expandedPrefix.startsWith("/"))) {
searchDir = dir;
} else {
searchDir = join(this.basePath, dir);
}
searchPrefix = file;
}
logger.debug("CombinedAutocompleteProvider", "Searching directory", {
searchDir,
searchPrefix,
});
const entries = readdirSync(searchDir);
const suggestions: AutocompleteItem[] = [];
for (const entry of entries) {
if (!entry.toLowerCase().startsWith(searchPrefix.toLowerCase())) {
continue;
}
const fullPath = join(searchDir, entry);
const isDirectory = statSync(fullPath).isDirectory();
// For @ prefix, filter to only show directories and attachable files
if (isAtPrefix && !isDirectory && !isAttachableFile(fullPath)) {
continue;
}
let relativePath: string;
// Handle @ prefix path construction
if (isAtPrefix) {
const pathWithoutAt = expandedPrefix;
if (pathWithoutAt.endsWith("/")) {
relativePath = "@" + pathWithoutAt + entry;
} else if (pathWithoutAt.includes("/")) {
if (pathWithoutAt.startsWith("~/")) {
const homeRelativeDir = pathWithoutAt.slice(2); // Remove ~/
const dir = dirname(homeRelativeDir);
relativePath = "@~/" + (dir === "." ? entry : join(dir, entry));
} else {
relativePath = "@" + join(dirname(pathWithoutAt), entry);
}
} else {
if (pathWithoutAt.startsWith("~")) {
relativePath = "@~/" + entry;
} else {
relativePath = "@" + entry;
}
}
} else if (prefix.endsWith("/")) {
// If prefix ends with /, append entry to the prefix
relativePath = prefix + entry;
} else if (prefix.includes("/")) {
// Preserve ~/ format for home directory paths
if (prefix.startsWith("~/")) {
const homeRelativeDir = prefix.slice(2); // Remove ~/
const dir = dirname(homeRelativeDir);
relativePath = "~/" + (dir === "." ? entry : join(dir, entry));
} else {
relativePath = join(dirname(prefix), entry);
}
} else {
// For standalone entries, preserve ~/ if original prefix was ~/
if (prefix.startsWith("~")) {
relativePath = "~/" + entry;
} else {
relativePath = entry;
}
}
suggestions.push({
value: isDirectory ? relativePath + "/" : relativePath,
label: entry,
description: isDirectory ? "directory" : "file",
});
}
// Sort directories first, then alphabetically
suggestions.sort((a, b) => {
const aIsDir = a.description === "directory";
const bIsDir = b.description === "directory";
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
return a.label.localeCompare(b.label);
});
logger.debug("CombinedAutocompleteProvider", "Returning suggestions", {
count: suggestions.length,
firstFew: suggestions.slice(0, 3).map((s) => s.label),
});
return suggestions.slice(0, 10); // Limit to 10 suggestions
} catch (e) {
// Directory doesn't exist or not accessible
logger.error("CombinedAutocompleteProvider", "Error reading directory", {
error: e instanceof Error ? e.message : String(e),
});
return [];
}
}
// Force file completion (called on Tab key) - always returns suggestions
getForceFileSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
): { items: AutocompleteItem[]; prefix: string } | null {
logger.debug("CombinedAutocompleteProvider", "getForceFileSuggestions called", {
lines,
cursorLine,
cursorCol,
});
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
// Don't trigger if we're in a slash command
if (textBeforeCursor.startsWith("/") && !textBeforeCursor.includes(" ")) {
return null;
}
// Force extract path prefix - this will always return something
const pathMatch = this.extractPathPrefix(textBeforeCursor, true);
logger.debug("CombinedAutocompleteProvider", "Forced path match", {
textBeforeCursor,
pathMatch,
});
if (pathMatch !== null) {
const suggestions = this.getFileSuggestions(pathMatch);
if (suggestions.length === 0) return null;
return {
items: suggestions,
prefix: pathMatch,
};
}
return null;
}
// Check if we should trigger file completion (called on Tab key)
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
// Don't trigger if we're in a slash command
if (textBeforeCursor.startsWith("/") && !textBeforeCursor.includes(" ")) {
return false;
}
return true;
}
}

29
packages/tui/src/index.ts Normal file
View file

@ -0,0 +1,29 @@
// Core TUI interfaces and classes
// Autocomplete support
export {
type AutocompleteItem,
type AutocompleteProvider,
CombinedAutocompleteProvider,
type SlashCommand,
} from "./autocomplete.js";
// Logger for debugging
export { type LoggerConfig, logger } from "./logger.js";
// Markdown component
export { MarkdownComponent } from "./markdown-component.js";
// Select list component
export { type SelectItem, SelectList } from "./select-list.js";
// Text component
export { TextComponent } from "./text-component.js";
// Text editor component
export { TextEditor, type TextEditorConfig } from "./text-editor.js";
export {
type Component,
type ComponentRenderResult,
Container,
type ContainerRenderResult,
type Padding,
TUI,
} from "./tui.js";
// Whitespace component
export { WhitespaceComponent } from "./whitespace-component.js";

View file

@ -0,0 +1,95 @@
import { appendFileSync, writeFileSync } from "fs";
import { join } from "path";
export interface LoggerConfig {
enabled: boolean;
logFile: string;
logLevel: "debug" | "info" | "warn" | "error";
}
class Logger {
private config: LoggerConfig = {
enabled: false,
logFile: join(process.cwd(), "tui-debug.log"),
logLevel: "debug",
};
configure(config: Partial<LoggerConfig>): void {
this.config = { ...this.config, ...config };
if (this.config.enabled) {
// Clear log file on startup
try {
writeFileSync(this.config.logFile, `=== TUI Debug Log Started ${new Date().toISOString()} ===\n`);
} catch (error) {
// Silently fail if we can't write to log file
}
}
}
private shouldLog(level: string): boolean {
if (!this.config.enabled) return false;
const levels = ["debug", "info", "warn", "error"];
const currentLevel = levels.indexOf(this.config.logLevel);
const messageLevel = levels.indexOf(level);
return messageLevel >= currentLevel;
}
private log(level: string, component: string, message: string, data?: any): void {
if (!this.shouldLog(level)) return;
try {
const timestamp = new Date().toISOString();
const dataStr = data ? ` | Data: ${JSON.stringify(data)}` : "";
const logLine = `[${timestamp}] ${level.toUpperCase()} [${component}] ${message}${dataStr}\n`;
appendFileSync(this.config.logFile, logLine);
} catch (error) {
// Silently fail if we can't write to log file
}
}
debug(component: string, message: string, data?: any): void {
this.log("debug", component, message, data);
}
info(component: string, message: string, data?: any): void {
this.log("info", component, message, data);
}
warn(component: string, message: string, data?: any): void {
this.log("warn", component, message, data);
}
error(component: string, message: string, data?: any): void {
this.log("error", component, message, data);
}
// Specific TUI logging methods
keyInput(component: string, keyData: string): void {
this.debug(component, "Key input received", {
keyData,
charCodes: Array.from(keyData).map((c) => c.charCodeAt(0)),
});
}
render(component: string, renderResult: any): void {
this.debug(component, "Render result", renderResult);
}
focus(component: string, focused: boolean): void {
this.info(component, `Focus ${focused ? "gained" : "lost"}`);
}
componentLifecycle(component: string, action: string, details?: any): void {
this.info(component, `Component ${action}`, details);
}
stateChange(component: string, property: string, oldValue: any, newValue: any): void {
this.debug(component, `State change: ${property}`, { oldValue, newValue });
}
}
export const logger = new Logger();

View file

@ -0,0 +1,260 @@
import chalk from "chalk";
import { marked, type Token } from "marked";
import type { Component, ComponentRenderResult } from "./tui.js";
export class MarkdownComponent implements Component {
private text: string;
private lines: string[] = [];
private previousLines: string[] = [];
constructor(text: string = "") {
this.text = text;
}
setText(text: string): void {
this.text = text;
}
render(width: number): ComponentRenderResult {
// Parse markdown to HTML-like tokens
const tokens = marked.lexer(this.text);
// Convert tokens to styled terminal output
const renderedLines: string[] = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const nextToken = tokens[i + 1];
const tokenLines = this.renderToken(token, width, nextToken?.type);
renderedLines.push(...tokenLines);
}
// Wrap lines to fit width
const wrappedLines: string[] = [];
for (const line of renderedLines) {
wrappedLines.push(...this.wrapLine(line, width));
}
this.previousLines = this.lines;
this.lines = wrappedLines;
// Determine if content changed
const changed =
this.lines.length !== this.previousLines.length ||
this.lines.some((line, i) => line !== this.previousLines[i]);
return {
lines: this.lines,
changed,
};
}
private renderToken(token: Token, width: number, nextTokenType?: string): string[] {
const lines: string[] = [];
switch (token.type) {
case "heading": {
const headingLevel = token.depth;
const headingPrefix = "#".repeat(headingLevel) + " ";
const headingText = this.renderInlineTokens(token.tokens || []);
if (headingLevel === 1) {
lines.push(chalk.bold.underline.yellow(headingText));
} else if (headingLevel === 2) {
lines.push(chalk.bold.yellow(headingText));
} else {
lines.push(chalk.bold(headingPrefix + headingText));
}
lines.push(""); // Add spacing after headings
break;
}
case "paragraph": {
const paragraphText = this.renderInlineTokens(token.tokens || []);
lines.push(paragraphText);
// Don't add spacing if next token is space or list
if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") {
lines.push("");
}
break;
}
case "code": {
lines.push(chalk.gray("```" + (token.lang || "")));
// Split code by newlines and style each line
const codeLines = token.text.split("\n");
for (const codeLine of codeLines) {
lines.push(chalk.dim(" ") + chalk.green(codeLine));
}
lines.push(chalk.gray("```"));
lines.push(""); // Add spacing after code blocks
break;
}
case "list":
for (let i = 0; i < token.items.length; i++) {
const item = token.items[i];
const bullet = token.ordered ? `${i + 1}. ` : "- ";
const itemText = this.renderInlineTokens(item.tokens || []);
// Check if the item text contains multiple lines (embedded content)
const itemLines = itemText.split("\n").filter((line) => line.trim());
if (itemLines.length > 1) {
// First line is the list item
lines.push(chalk.cyan(bullet) + itemLines[0]);
// Rest are treated as separate content
for (let j = 1; j < itemLines.length; j++) {
lines.push(""); // Add spacing
lines.push(itemLines[j]);
}
} else {
lines.push(chalk.cyan(bullet) + itemText);
}
}
// Don't add spacing after lists if a space token follows
// (the space token will handle it)
break;
case "blockquote": {
const quoteText = this.renderInlineTokens(token.tokens || []);
const quoteLines = quoteText.split("\n");
for (const quoteLine of quoteLines) {
lines.push(chalk.gray("│ ") + chalk.italic(quoteLine));
}
lines.push(""); // Add spacing after blockquotes
break;
}
case "hr":
lines.push(chalk.gray("─".repeat(Math.min(width, 80))));
lines.push(""); // Add spacing after horizontal rules
break;
case "html":
// Skip HTML for terminal output
break;
case "space":
// Space tokens represent blank lines in markdown
lines.push("");
break;
default:
// Handle any other token types as plain text
if ("text" in token && typeof token.text === "string") {
lines.push(token.text);
}
}
return lines;
}
private renderInlineTokens(tokens: Token[]): string {
let result = "";
for (const token of tokens) {
switch (token.type) {
case "text":
// Text tokens in list items can have nested tokens for inline formatting
if (token.tokens && token.tokens.length > 0) {
result += this.renderInlineTokens(token.tokens);
} else {
result += token.text;
}
break;
case "strong":
result += chalk.bold(this.renderInlineTokens(token.tokens || []));
break;
case "em":
result += chalk.italic(this.renderInlineTokens(token.tokens || []));
break;
case "codespan":
result += chalk.gray("`") + chalk.cyan(token.text) + chalk.gray("`");
break;
case "link": {
const linkText = this.renderInlineTokens(token.tokens || []);
result += chalk.underline.blue(linkText) + chalk.gray(` (${token.href})`);
break;
}
case "br":
result += "\n";
break;
case "del":
result += chalk.strikethrough(this.renderInlineTokens(token.tokens || []));
break;
default:
// Handle any other inline token types as plain text
if ("text" in token && typeof token.text === "string") {
result += token.text;
}
}
}
return result;
}
private wrapLine(line: string, width: number): string[] {
// Handle ANSI escape codes properly when wrapping
const wrapped: string[] = [];
// Handle undefined or null lines
if (!line) {
return [""];
}
// If line fits within width, return as-is
const visibleLength = this.getVisibleLength(line);
if (visibleLength <= width) {
return [line];
}
// Need to wrap - this is complex with ANSI codes
// For now, use a simple approach that may break styling at wrap points
let currentLine = "";
let currentLength = 0;
let i = 0;
while (i < line.length) {
if (line[i] === "\x1b" && line[i + 1] === "[") {
// ANSI escape sequence - include it without counting length
let j = i + 2;
while (j < line.length && line[j] && !/[mGKHJ]/.test(line[j]!)) {
j++;
}
if (j < line.length) {
currentLine += line.substring(i, j + 1);
i = j + 1;
} else {
break;
}
} else {
// Regular character
if (currentLength >= width) {
wrapped.push(currentLine);
currentLine = "";
currentLength = 0;
}
currentLine += line[i];
currentLength++;
i++;
}
}
if (currentLine) {
wrapped.push(currentLine);
}
return wrapped.length > 0 ? wrapped : [""];
}
private getVisibleLength(str: string): number {
// Remove ANSI escape codes and count visible characters
return (str || "").replace(/\x1b\[[0-9;]*m/g, "").length;
}
}

View file

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

View file

@ -0,0 +1,104 @@
import type { Component, ComponentRenderResult, Padding } from "./tui.js";
export class TextComponent implements Component {
private text: string;
private lastRenderedLines: string[] = [];
private padding: Required<Padding>;
constructor(text: string, padding?: Padding) {
this.text = text;
this.padding = {
top: padding?.top ?? 0,
bottom: padding?.bottom ?? 0,
left: padding?.left ?? 0,
right: padding?.right ?? 0,
};
}
render(width: number): ComponentRenderResult {
// Calculate available width after horizontal padding
const availableWidth = Math.max(1, width - this.padding.left - this.padding.right);
const leftPadding = " ".repeat(this.padding.left);
// First split by newlines to preserve line breaks
const textLines = this.text.split("\n");
const lines: string[] = [];
// Add top padding
for (let i = 0; i < this.padding.top; i++) {
lines.push("");
}
// Process each line for word wrapping
for (const textLine of textLines) {
if (textLine.length === 0) {
// Preserve empty lines with padding
lines.push(leftPadding);
} else {
// Word wrapping with ANSI-aware length calculation
const words = textLine.split(" ");
let currentLine = "";
let currentVisibleLength = 0;
for (const word of words) {
const wordVisibleLength = this.getVisibleLength(word);
const spaceLength = currentLine ? 1 : 0;
if (currentVisibleLength + spaceLength + wordVisibleLength <= availableWidth) {
currentLine += (currentLine ? " " : "") + word;
currentVisibleLength += spaceLength + wordVisibleLength;
} else {
if (currentLine) {
lines.push(leftPadding + currentLine);
}
currentLine = word;
currentVisibleLength = wordVisibleLength;
}
}
if (currentLine) {
lines.push(leftPadding + currentLine);
}
}
}
// Add bottom padding
for (let i = 0; i < this.padding.bottom; i++) {
lines.push("");
}
const newLines = lines.length > 0 ? lines : [""];
// Check if content changed
const changed = !this.arraysEqual(newLines, this.lastRenderedLines);
// Always cache the current rendered lines
this.lastRenderedLines = [...newLines];
return {
lines: newLines,
changed,
};
}
setText(text: string): void {
this.text = text;
}
getText(): string {
return this.text;
}
private arraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
private getVisibleLength(str: string): number {
// Remove ANSI escape codes and count visible characters
return (str || "").replace(/\x1b\[[0-9;]*m/g, "").length;
}
}

View file

@ -0,0 +1,802 @@
import chalk from "chalk";
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "./autocomplete.js";
import { logger } from "./logger.js";
import { SelectList } from "./select-list.js";
import type { Component, ComponentRenderResult } from "./tui.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 {
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 some margin)
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();
}
}
}

473
packages/tui/src/tui.ts Normal file
View file

@ -0,0 +1,473 @@
import { writeSync } from "fs";
import process from "process";
import { logger } from "./logger.js";
export interface Padding {
top?: number;
bottom?: number;
left?: number;
right?: number;
}
export interface ComponentRenderResult {
lines: string[];
changed: boolean;
}
export interface ContainerRenderResult extends ComponentRenderResult {
keepLines: number;
}
export interface Component {
render(width: number): ComponentRenderResult;
handleInput?(keyData: string): void;
}
// Sentinel component used to mark removed components - triggers cascade rendering
class SentinelComponent implements Component {
render(): ComponentRenderResult {
return {
lines: [],
changed: true, // Always trigger cascade
};
}
}
// Base Container class that manages child components
export class Container {
protected children: Element[] = [];
protected lines: string[] = [];
protected parentTui: TUI | undefined; // Reference to parent TUI for triggering re-renders
constructor(parentTui?: TUI | undefined) {
this.parentTui = parentTui;
}
setParentTui(tui: TUI | undefined): void {
this.parentTui = tui;
}
addChild(component: Element): void {
this.children.push(component);
// Set parent TUI reference for nested containers
if (component instanceof Container && this.parentTui) {
component.setParentTui(this.parentTui);
}
if (this.parentTui) {
this.parentTui.requestRender();
}
}
removeChild(component: Element): void {
const index = this.children.indexOf(component);
if (index >= 0) {
// Replace with sentinel instead of splicing to maintain array structure
this.children[index] = new SentinelComponent();
// Keep the childTotalLines entry - sentinel will update it to 0
// Clear parent TUI reference for nested containers
if (component instanceof Container) {
component.setParentTui(undefined);
}
// Use normal render - sentinel will trigger cascade naturally
if (this.parentTui) {
this.parentTui.requestRender();
}
} else {
for (const child of this.children) {
if (child instanceof Container) {
child.removeChild(component);
}
}
}
}
removeChildAt(index: number): void {
if (index >= 0 && index < this.children.length) {
const component = this.children[index];
// Replace with sentinel instead of splicing to maintain array structure
this.children[index] = new SentinelComponent();
// Clear parent TUI reference for nested containers
if (component instanceof Container) {
component.setParentTui(undefined);
}
// Use normal render - sentinel will trigger cascade naturally
if (this.parentTui) {
this.parentTui.requestRender();
}
}
}
render(width: number): ContainerRenderResult {
let keepLines = 0;
let changed = false;
const newLines: string[] = [];
for (let i = 0; i < this.children.length; i++) {
const child = this.children[i];
if (!child) continue;
if (child instanceof Container) {
const result = child.render(width);
newLines.push(...result.lines);
if (!changed && !result.changed) {
keepLines += result.lines.length;
} else {
if (!changed) {
// First change - use the child's keepLines
changed = true;
keepLines += result.keepLines;
}
// After first change, don't add any more keepLines
}
} else {
const result = child.render(width);
newLines.push(...result.lines);
if (!changed && !result.changed) {
keepLines += result.lines.length;
} else {
if (!changed) {
// First change for a non-container component
changed = true;
}
// After first change, don't add any more keepLines
}
}
}
this.lines = newLines;
return {
lines: this.lines,
changed,
keepLines,
};
}
// Get child for external manipulation
// Get child at index
// Note: This may return a SentinelComponent if a child was removed but not yet cleaned up
getChild(index: number): Element | undefined {
return this.children[index];
}
// Get number of children
// Note: This count includes sentinel components until they are cleaned up after the next render pass
getChildCount(): number {
return this.children.length;
}
// Clear all children from the container
clear(): void {
// Clear parent TUI references for nested containers
for (const child of this.children) {
if (child instanceof Container) {
child.setParentTui(undefined);
}
}
// Clear the children array
this.children = [];
// Request render if we have a parent TUI
if (this.parentTui) {
this.parentTui.requestRender();
}
}
// Clean up sentinel components
cleanupSentinels(): void {
const originalCount = this.children.length;
const validChildren: Element[] = [];
let sentinelCount = 0;
for (const child of this.children) {
if (child && !(child instanceof SentinelComponent)) {
validChildren.push(child);
// Recursively clean up nested containers
if (child instanceof Container) {
child.cleanupSentinels();
}
} else if (child instanceof SentinelComponent) {
sentinelCount++;
}
}
this.children = validChildren;
if (sentinelCount > 0) {
logger.debug("Container", "Cleaned up sentinels", {
originalCount,
newCount: this.children.length,
sentinelsRemoved: sentinelCount,
});
}
}
}
type Element = Component | Container;
export class TUI extends Container {
private focusedComponent: Component | null = null;
private needsRender: boolean = false;
private wasRaw: boolean = false;
private totalLines: number = 0;
private isFirstRender: boolean = true;
private isStarted: boolean = false;
public onGlobalKeyPress?: (data: string) => boolean;
constructor() {
super(); // No parent TUI for root
this.handleResize = this.handleResize.bind(this);
this.handleKeypress = this.handleKeypress.bind(this);
logger.componentLifecycle("TUI", "created");
}
configureLogging(config: Parameters<typeof logger.configure>[0]): void {
logger.configure(config);
logger.info("TUI", "Logging configured", config);
}
override addChild(component: Element): void {
// Set parent TUI reference for containers
if (component instanceof Container) {
component.setParentTui(this);
}
super.addChild(component);
// Only auto-render if TUI has been started
if (this.isStarted) {
this.requestRender();
}
}
override removeChild(component: Element): void {
super.removeChild(component);
this.requestRender();
}
setFocus(component: Component): void {
// Check if component exists anywhere in the hierarchy
if (this.findComponent(component)) {
this.focusedComponent = component;
}
}
private findComponent(component: Component): boolean {
// Check direct children
if (this.children.includes(component)) {
return true;
}
// Recursively search in containers
for (const comp of this.children) {
if (comp instanceof Container) {
if (this.findInContainer(comp, component)) {
return true;
}
}
}
return false;
}
private findInContainer(container: Container, component: Component): boolean {
const childCount = container.getChildCount();
// Check direct children
for (let i = 0; i < childCount; i++) {
const child = container.getChild(i);
if (child === component) {
return true;
}
}
// Recursively search in nested containers
for (let i = 0; i < childCount; i++) {
const child = container.getChild(i);
if (child instanceof Container) {
if (this.findInContainer(child, component)) {
return true;
}
}
}
return false;
}
requestRender(): void {
if (!this.isStarted) return;
this.needsRender = true;
// Batch renders on next tick
process.nextTick(() => {
if (this.needsRender) {
this.renderToScreen();
this.needsRender = false;
}
});
}
start(): void {
// Set started flag
this.isStarted = true;
// Hide the terminal cursor
process.stdout.write("\x1b[?25l");
// Set up raw mode for key capture
try {
this.wasRaw = process.stdin.isRaw || false;
if (process.stdin.setRawMode) {
process.stdin.setRawMode(true);
}
process.stdin.setEncoding("utf8");
process.stdin.resume();
// Listen for events
process.stdout.on("resize", this.handleResize);
process.stdin.on("data", this.handleKeypress);
} catch (error) {
console.error("Error setting up raw mode:", error);
}
// Initial render
this.renderToScreen();
}
stop(): void {
// Show the terminal cursor again
process.stdout.write("\x1b[?25h");
process.stdin.removeListener("data", this.handleKeypress);
process.stdout.removeListener("resize", this.handleResize);
if (process.stdin.setRawMode) {
process.stdin.setRawMode(this.wasRaw);
}
}
private renderToScreen(resize: boolean = false): void {
const termWidth = process.stdout.columns || 80;
logger.debug("TUI", "Starting render cycle", {
termWidth,
componentCount: this.children.length,
isFirstRender: this.isFirstRender,
});
const result = this.render(termWidth);
if (resize) {
this.totalLines = result.lines.length;
result.keepLines = 0;
this.isFirstRender = true;
}
logger.debug("TUI", "Render result", {
totalLines: result.lines.length,
keepLines: result.keepLines,
changed: result.changed,
previousTotalLines: this.totalLines,
});
if (!result.changed) {
// Nothing changed - skip render
return;
}
// Handle cursor positioning
if (this.isFirstRender) {
// First render: just append to current terminal position
this.isFirstRender = false;
// Output all lines normally on first render
for (const line of result.lines) {
console.log(line);
}
} else {
// Move cursor up to start of changing content and clear down
const linesToMoveUp = this.totalLines - result.keepLines;
let output = "";
logger.debug("TUI", "Cursor movement", {
linesToMoveUp,
totalLines: this.totalLines,
keepLines: result.keepLines,
changingLineCount: result.lines.length - result.keepLines,
});
if (linesToMoveUp > 0) {
output += `\x1b[${linesToMoveUp}A\x1b[0J`;
}
// Build the output string for all changing lines
const changingLines = result.lines.slice(result.keepLines);
logger.debug("TUI", "Output details", {
linesToMoveUp,
changingLinesCount: changingLines.length,
keepLines: result.keepLines,
totalLines: result.lines.length,
previousTotalLines: this.totalLines,
});
for (const line of changingLines) {
output += `${line}\n`;
}
// Write everything at once - use synchronous write to prevent race conditions
writeSync(process.stdout.fd, output);
}
this.totalLines = result.lines.length;
// Clean up sentinels after rendering
this.cleanupSentinels();
}
private handleResize(): void {
// Clear screen, hide cursor, and reset color
process.stdout.write("\u001Bc\x1b[?25l\u001B[3J");
// Terminal size changed - force re-render all
this.renderToScreen(true);
}
private handleKeypress(data: string): void {
logger.keyInput("TUI", data);
// Don't handle Ctrl+C here - let the global key handler deal with it
// if (data.charCodeAt(0) === 3) {
// logger.info("TUI", "Ctrl+C received");
// return; // Don't process this key further
// }
// Call global key handler if set
if (this.onGlobalKeyPress) {
const shouldForward = this.onGlobalKeyPress(data);
if (!shouldForward) {
// Global handler consumed the key, don't forward to focused component
this.requestRender();
return;
}
}
// Send input to focused component
if (this.focusedComponent?.handleInput) {
logger.debug("TUI", "Forwarding input to focused component", {
componentType: this.focusedComponent.constructor.name,
});
this.focusedComponent.handleInput(data);
// Trigger re-render after input
this.requestRender();
} else {
logger.warn("TUI", "No focused component to handle input", {
focusedComponent: this.focusedComponent?.constructor.name || "none",
hasHandleInput: this.focusedComponent?.handleInput ? "yes" : "no",
});
}
}
}

View file

@ -0,0 +1,24 @@
import type { Component, ComponentRenderResult } from "./tui.js";
/**
* A simple component that renders blank lines for spacing
*/
export class WhitespaceComponent implements Component {
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;
}
}