diff --git a/packages/tui/src/components-new/editor.ts b/packages/tui/src/components-new/editor.ts new file mode 100644 index 00000000..187cae09 --- /dev/null +++ b/packages/tui/src/components-new/editor.ts @@ -0,0 +1,719 @@ +import chalk from "chalk"; +import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; +import type { Component } from "../tui-new.js"; +import { SelectList } from "./select-list.js"; + +interface EditorState { + lines: string[]; + cursorLine: number; + cursorCol: number; +} + +interface LayoutLine { + text: string; + hasCursor: boolean; + cursorPos?: number; +} + +export interface TextEditorConfig { + // Configuration options for text editor (none currently) +} + +export class Editor implements Component { + private state: EditorState = { + lines: [""], + cursorLine: 0, + cursorCol: 0, + }; + + private config: TextEditorConfig = {}; + + // Autocomplete support + private autocompleteProvider?: AutocompleteProvider; + private autocompleteList?: SelectList; + private isAutocompleting: boolean = false; + private autocompletePrefix: string = ""; + + // Paste tracking for large pastes + private pastes: Map = new Map(); + private pasteCounter: number = 0; + + public onSubmit?: (text: string) => void; + public onChange?: (text: string) => void; + public disableSubmit: boolean = false; + + constructor(config?: TextEditorConfig) { + if (config) { + this.config = { ...this.config, ...config }; + } + } + + configure(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + setAutocompleteProvider(provider: AutocompleteProvider): void { + this.autocompleteProvider = provider; + } + + render(width: number): string[] { + const horizontal = chalk.gray("─"); + + // Layout the text - use full width + const layoutLines = this.layoutText(width); + + const result: string[] = []; + + // Render top border + result.push(horizontal.repeat(width)); + + // Render each layout line + for (const layoutLine of layoutLines) { + let displayText = layoutLine.text; + let visibleLength = layoutLine.text.length; + + // Add cursor if this line has it + if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) { + const before = displayText.slice(0, layoutLine.cursorPos); + const after = displayText.slice(layoutLine.cursorPos); + + if (after.length > 0) { + // Cursor is on a character - replace it with highlighted version + const cursor = `\x1b[7m${after[0]}\x1b[0m`; + const restAfter = after.slice(1); + displayText = before + cursor + restAfter; + // visibleLength stays the same - we're replacing, not adding + } else { + // Cursor is at the end - add highlighted space + const cursor = "\x1b[7m \x1b[0m"; + displayText = before + cursor; + // visibleLength increases by 1 - we're adding a space + visibleLength = layoutLine.text.length + 1; + } + } + + // Calculate padding based on actual visible length + const padding = " ".repeat(Math.max(0, width - visibleLength)); + + // Render the line (no side borders, just horizontal lines above and below) + result.push(displayText + padding); + } + + // Render bottom border + result.push(horizontal.repeat(width)); + + // Add autocomplete list if active + if (this.isAutocompleting && this.autocompleteList) { + const autocompleteResult = this.autocompleteList.render(width); + result.push(...autocompleteResult); + } + + return result; + } + + handleInput(data: string): void { + // Handle special key combinations first + + // Ctrl+C - Exit (let parent handle this) + if (data.charCodeAt(0) === 3) { + return; + } + + // Handle paste - detect when we get a lot of text at once + const isPaste = data.length > 10 || (data.length > 2 && data.includes("\n")); + if (isPaste) { + this.handlePaste(data); + return; + } + + // Handle autocomplete special keys first (but don't block other input) + if (this.isAutocompleting && this.autocompleteList) { + // Escape - cancel autocomplete + if (data === "\x1b") { + this.cancelAutocomplete(); + return; + } + // Let the autocomplete list handle navigation and selection + else if (data === "\x1b[A" || data === "\x1b[B" || data === "\r" || data === "\t") { + // Only pass arrow keys to the list, not Enter/Tab (we handle those directly) + if (data === "\x1b[A" || data === "\x1b[B") { + this.autocompleteList.handleInput(data); + } + + // If Tab or Enter was pressed, apply the selection + if (data === "\t" || data === "\r") { + const selected = this.autocompleteList.getSelectedItem(); + if (selected && this.autocompleteProvider) { + const result = this.autocompleteProvider.applyCompletion( + this.state.lines, + this.state.cursorLine, + this.state.cursorCol, + selected, + this.autocompletePrefix, + ); + + this.state.lines = result.lines; + this.state.cursorLine = result.cursorLine; + this.state.cursorCol = result.cursorCol; + + this.cancelAutocomplete(); + + if (this.onChange) { + this.onChange(this.getText()); + } + } + return; + } else { + // For other keys, handle normally within autocomplete + return; + } + } + // For other keys (like regular typing), DON'T return here + // Let them fall through to normal character handling + } + + // Tab key - context-aware completion (but not when already autocompleting) + if (data === "\t" && !this.isAutocompleting) { + this.handleTabCompletion(); + return; + } + + // Continue with rest of input handling + // Ctrl+K - Delete current line + if (data.charCodeAt(0) === 11) { + this.deleteCurrentLine(); + } + // Ctrl+A - Move to start of line + else if (data.charCodeAt(0) === 1) { + this.moveToLineStart(); + } + // Ctrl+E - Move to end of line + else if (data.charCodeAt(0) === 5) { + this.moveToLineEnd(); + } + // New line shortcuts (but not plain LF/CR which should be submit) + else if ( + (data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers + data === "\x1b\r" || // Option+Enter in some terminals + data === "\x1b[13;2~" || // Shift+Enter in some terminals + (data.length > 1 && data.includes("\x1b") && data.includes("\r")) || + (data === "\n" && data.length === 1) || // Shift+Enter from iTerm2 mapping + data === "\\\r" // Shift+Enter in VS Code terminal + ) { + // Modifier + Enter = new line + this.addNewLine(); + } + // Plain Enter (char code 13 for CR) - only CR submits, LF adds new line + else if (data.charCodeAt(0) === 13 && data.length === 1) { + // If submit is disabled, do nothing + if (this.disableSubmit) { + return; + } + + // Get text and substitute paste markers with actual content + let result = this.state.lines.join("\n").trim(); + + // Replace all [paste #N] markers with actual paste content + for (const [pasteId, pasteContent] of this.pastes) { + const marker = `[paste #${pasteId}]`; + result = result.replace(marker, pasteContent); + } + + // Reset editor and clear pastes + this.state = { + lines: [""], + cursorLine: 0, + cursorCol: 0, + }; + this.pastes.clear(); + this.pasteCounter = 0; + + // Notify that editor is now empty + if (this.onChange) { + this.onChange(""); + } + + if (this.onSubmit) { + this.onSubmit(result); + } + } + // Backspace + else if (data.charCodeAt(0) === 127 || data.charCodeAt(0) === 8) { + this.handleBackspace(); + } + // Line navigation shortcuts (Home/End keys) + else if (data === "\x1b[H" || data === "\x1b[1~" || data === "\x1b[7~") { + // Home key + this.moveToLineStart(); + } else if (data === "\x1b[F" || data === "\x1b[4~" || data === "\x1b[8~") { + // End key + this.moveToLineEnd(); + } + // Forward delete (Fn+Backspace or Delete key) + else if (data === "\x1b[3~") { + // Delete key + this.handleForwardDelete(); + } + // Arrow keys + else if (data === "\x1b[A") { + // Up + this.moveCursor(-1, 0); + } else if (data === "\x1b[B") { + // Down + this.moveCursor(1, 0); + } else if (data === "\x1b[C") { + // Right + this.moveCursor(0, 1); + } else if (data === "\x1b[D") { + // Left + this.moveCursor(0, -1); + } + // Regular characters (printable ASCII) + else if (data.charCodeAt(0) >= 32 && data.charCodeAt(0) <= 126) { + this.insertCharacter(data); + } + } + + private layoutText(contentWidth: number): LayoutLine[] { + const layoutLines: LayoutLine[] = []; + + if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) { + // Empty editor + layoutLines.push({ + text: "", + hasCursor: true, + cursorPos: 0, + }); + return layoutLines; + } + + // Process each logical line + for (let i = 0; i < this.state.lines.length; i++) { + const line = this.state.lines[i] || ""; + const isCurrentLine = i === this.state.cursorLine; + const maxLineLength = contentWidth; + + if (line.length <= maxLineLength) { + // Line fits in one layout line + if (isCurrentLine) { + layoutLines.push({ + text: line, + hasCursor: true, + cursorPos: this.state.cursorCol, + }); + } else { + layoutLines.push({ + text: line, + hasCursor: false, + }); + } + } else { + // Line needs wrapping + const chunks = []; + for (let pos = 0; pos < line.length; pos += maxLineLength) { + chunks.push(line.slice(pos, pos + maxLineLength)); + } + + for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { + const chunk = chunks[chunkIndex]; + if (!chunk) continue; + + const chunkStart = chunkIndex * maxLineLength; + const chunkEnd = chunkStart + chunk.length; + const cursorPos = this.state.cursorCol; + const hasCursorInChunk = isCurrentLine && cursorPos >= chunkStart && cursorPos < chunkEnd; + + if (hasCursorInChunk) { + layoutLines.push({ + text: chunk, + hasCursor: true, + cursorPos: cursorPos - chunkStart, + }); + } else { + layoutLines.push({ + text: chunk, + hasCursor: false, + }); + } + } + } + } + + return layoutLines; + } + + getText(): string { + return this.state.lines.join("\n"); + } + + setText(text: string): void { + // Split text into lines, handling different line endings + const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + + // Ensure at least one empty line + this.state.lines = lines.length === 0 ? [""] : lines; + + // Reset cursor to end of text + this.state.cursorLine = this.state.lines.length - 1; + this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0; + + // Notify of change + if (this.onChange) { + this.onChange(this.getText()); + } + } + + // All the editor methods from before... + private insertCharacter(char: string): void { + const line = this.state.lines[this.state.cursorLine] || ""; + + const before = line.slice(0, this.state.cursorCol); + const after = line.slice(this.state.cursorCol); + + this.state.lines[this.state.cursorLine] = before + char + after; + this.state.cursorCol += char.length; // Fix: increment by the length of the inserted string + + if (this.onChange) { + this.onChange(this.getText()); + } + + // Check if we should trigger or update autocomplete + if (!this.isAutocompleting) { + // Auto-trigger for "/" at the start of a line (slash commands) + if (char === "/" && this.isAtStartOfMessage()) { + this.tryTriggerAutocomplete(); + } + // Also auto-trigger when typing letters in a slash command context + else if (/[a-zA-Z0-9]/.test(char)) { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + // Check if we're in a slash command with a space (i.e., typing arguments) + if (textBeforeCursor.startsWith("/") && textBeforeCursor.includes(" ")) { + this.tryTriggerAutocomplete(); + } + } + } else { + this.updateAutocomplete(); + } + } + + private handlePaste(pastedText: string): void { + // Clean the pasted text + const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + + // Convert tabs to spaces (4 spaces per tab) + const tabExpandedText = cleanText.replace(/\t/g, " "); + + // Filter out non-printable characters except newlines + const filteredText = tabExpandedText + .split("") + .filter((char) => char === "\n" || (char >= " " && char <= "~")) + .join(""); + + // Split into lines + const pastedLines = filteredText.split("\n"); + + // Check if this is a large paste (> 10 lines) + if (pastedLines.length > 10) { + // Store the paste and insert a marker + this.pasteCounter++; + const pasteId = this.pasteCounter; + this.pastes.set(pasteId, filteredText); + + // Insert marker like "[paste #1]" + const marker = `[paste #${pasteId}]`; + for (const char of marker) { + this.insertCharacter(char); + } + + return; + } + + if (pastedLines.length === 1) { + // Single line - just insert each character + const text = pastedLines[0] || ""; + for (const char of text) { + this.insertCharacter(char); + } + + return; + } + + // Multi-line paste - be very careful with array manipulation + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const beforeCursor = currentLine.slice(0, this.state.cursorCol); + const afterCursor = currentLine.slice(this.state.cursorCol); + + // Build the new lines array step by step + const newLines: string[] = []; + + // Add all lines before current line + for (let i = 0; i < this.state.cursorLine; i++) { + newLines.push(this.state.lines[i] || ""); + } + + // Add the first pasted line merged with before cursor text + newLines.push(beforeCursor + (pastedLines[0] || "")); + + // Add all middle pasted lines + for (let i = 1; i < pastedLines.length - 1; i++) { + newLines.push(pastedLines[i] || ""); + } + + // Add the last pasted line with after cursor text + newLines.push((pastedLines[pastedLines.length - 1] || "") + afterCursor); + + // Add all lines after current line + for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) { + newLines.push(this.state.lines[i] || ""); + } + + // Replace the entire lines array + this.state.lines = newLines; + + // Update cursor position to end of pasted content + this.state.cursorLine += pastedLines.length - 1; + this.state.cursorCol = (pastedLines[pastedLines.length - 1] || "").length; + + // Notify of change + if (this.onChange) { + this.onChange(this.getText()); + } + } + + private addNewLine(): void { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + const before = currentLine.slice(0, this.state.cursorCol); + const after = currentLine.slice(this.state.cursorCol); + + // Split current line + this.state.lines[this.state.cursorLine] = before; + this.state.lines.splice(this.state.cursorLine + 1, 0, after); + + // Move cursor to start of new line + this.state.cursorLine++; + this.state.cursorCol = 0; + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + private handleBackspace(): void { + if (this.state.cursorCol > 0) { + // Delete character in current line + const line = this.state.lines[this.state.cursorLine] || ""; + + const before = line.slice(0, this.state.cursorCol - 1); + const after = line.slice(this.state.cursorCol); + + this.state.lines[this.state.cursorLine] = before + after; + this.state.cursorCol--; + } else if (this.state.cursorLine > 0) { + // Merge with previous line + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const previousLine = this.state.lines[this.state.cursorLine - 1] || ""; + + this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; + this.state.lines.splice(this.state.cursorLine, 1); + + this.state.cursorLine--; + this.state.cursorCol = previousLine.length; + } + + if (this.onChange) { + this.onChange(this.getText()); + } + + // Update autocomplete after backspace + if (this.isAutocompleting) { + this.updateAutocomplete(); + } + } + + private moveToLineStart(): void { + this.state.cursorCol = 0; + } + + private moveToLineEnd(): void { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + this.state.cursorCol = currentLine.length; + } + + private handleForwardDelete(): void { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + if (this.state.cursorCol < currentLine.length) { + // Delete character at cursor position (forward delete) + const before = currentLine.slice(0, this.state.cursorCol); + const after = currentLine.slice(this.state.cursorCol + 1); + this.state.lines[this.state.cursorLine] = before + after; + } else if (this.state.cursorLine < this.state.lines.length - 1) { + // At end of line - merge with next line + const nextLine = this.state.lines[this.state.cursorLine + 1] || ""; + this.state.lines[this.state.cursorLine] = currentLine + nextLine; + this.state.lines.splice(this.state.cursorLine + 1, 1); + } + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + private deleteCurrentLine(): void { + if (this.state.lines.length === 1) { + // Only one line - just clear it + this.state.lines[0] = ""; + this.state.cursorCol = 0; + } else { + // Multiple lines - remove current line + this.state.lines.splice(this.state.cursorLine, 1); + + // Adjust cursor position + if (this.state.cursorLine >= this.state.lines.length) { + // Was on last line, move to new last line + this.state.cursorLine = this.state.lines.length - 1; + } + + // Clamp cursor column to new line length + const newLine = this.state.lines[this.state.cursorLine] || ""; + this.state.cursorCol = Math.min(this.state.cursorCol, newLine.length); + } + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + private moveCursor(deltaLine: number, deltaCol: number): void { + if (deltaLine !== 0) { + const newLine = this.state.cursorLine + deltaLine; + if (newLine >= 0 && newLine < this.state.lines.length) { + this.state.cursorLine = newLine; + // Clamp cursor column to new line length + const line = this.state.lines[this.state.cursorLine] || ""; + this.state.cursorCol = Math.min(this.state.cursorCol, line.length); + } + } + + if (deltaCol !== 0) { + // Move column + const newCol = this.state.cursorCol + deltaCol; + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const maxCol = currentLine.length; + this.state.cursorCol = Math.max(0, Math.min(maxCol, newCol)); + } + } + + // Helper method to check if cursor is at start of message (for slash command detection) + private isAtStartOfMessage(): boolean { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const beforeCursor = currentLine.slice(0, this.state.cursorCol); + + // At start if line is empty, only contains whitespace, or is just "/" + return beforeCursor.trim() === "" || beforeCursor.trim() === "/"; + } + + // Autocomplete methods + private tryTriggerAutocomplete(explicitTab: boolean = false): void { + if (!this.autocompleteProvider) return; + + // Check if we should trigger file completion on Tab + if (explicitTab) { + const provider = this.autocompleteProvider as CombinedAutocompleteProvider; + const shouldTrigger = + !provider.shouldTriggerFileCompletion || + provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol); + if (!shouldTrigger) { + return; + } + } + + const suggestions = this.autocompleteProvider.getSuggestions( + this.state.lines, + this.state.cursorLine, + this.state.cursorCol, + ); + + if (suggestions && suggestions.items.length > 0) { + this.autocompletePrefix = suggestions.prefix; + this.autocompleteList = new SelectList(suggestions.items, 5); + this.isAutocompleting = true; + } else { + this.cancelAutocomplete(); + } + } + + private handleTabCompletion(): void { + if (!this.autocompleteProvider) return; + + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const beforeCursor = currentLine.slice(0, this.state.cursorCol); + + // Check if we're in a slash command context + if (beforeCursor.trimStart().startsWith("/")) { + this.handleSlashCommandCompletion(); + } else { + this.forceFileAutocomplete(); + } + } + + private handleSlashCommandCompletion(): void { + // For now, fall back to regular autocomplete (slash commands) + // This can be extended later to handle command-specific argument completion + this.tryTriggerAutocomplete(true); + } + + private forceFileAutocomplete(): void { + if (!this.autocompleteProvider) return; + + // Check if provider has the force method + const provider = this.autocompleteProvider as any; + if (!provider.getForceFileSuggestions) { + this.tryTriggerAutocomplete(true); + return; + } + + const suggestions = provider.getForceFileSuggestions( + this.state.lines, + this.state.cursorLine, + this.state.cursorCol, + ); + + if (suggestions && suggestions.items.length > 0) { + this.autocompletePrefix = suggestions.prefix; + this.autocompleteList = new SelectList(suggestions.items, 5); + this.isAutocompleting = true; + } else { + this.cancelAutocomplete(); + } + } + + private cancelAutocomplete(): void { + this.isAutocompleting = false; + this.autocompleteList = undefined as any; + this.autocompletePrefix = ""; + } + + private updateAutocomplete(): void { + if (!this.isAutocompleting || !this.autocompleteProvider) return; + + const suggestions = this.autocompleteProvider.getSuggestions( + this.state.lines, + this.state.cursorLine, + this.state.cursorCol, + ); + + if (suggestions && suggestions.items.length > 0) { + this.autocompletePrefix = suggestions.prefix; + if (this.autocompleteList) { + // Update the existing list with new items + this.autocompleteList = new SelectList(suggestions.items, 5); + } + } else { + // No more matches, cancel autocomplete + this.cancelAutocomplete(); + } + } +} diff --git a/packages/tui/src/components-new/loader.ts b/packages/tui/src/components-new/loader.ts new file mode 100644 index 00000000..e694e441 --- /dev/null +++ b/packages/tui/src/components-new/loader.ts @@ -0,0 +1,49 @@ +import chalk from "chalk"; +import { Text, type TUI } from "../tui-new.js"; + +/** + * Loader component that updates every 80ms with spinning animation + */ +export class Loader extends Text { + private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + private currentFrame = 0; + private intervalId: NodeJS.Timeout | null = null; + private ui: TUI | null = null; + + constructor( + ui: TUI, + private message: string = "Loading...", + ) { + super(""); + this.ui = ui; + this.start(); + } + + start() { + this.updateDisplay(); + this.intervalId = setInterval(() => { + this.currentFrame = (this.currentFrame + 1) % this.frames.length; + this.updateDisplay(); + }, 80); + } + + stop() { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + setMessage(message: string) { + this.message = message; + this.updateDisplay(); + } + + private updateDisplay() { + const frame = this.frames[this.currentFrame]; + this.setText(`${chalk.cyan(frame)} ${chalk.dim(this.message)}`); + if (this.ui) { + this.ui.requestRender(); + } + } +} diff --git a/packages/tui/src/components-new/markdown.ts b/packages/tui/src/components-new/markdown.ts new file mode 100644 index 00000000..d751dbfe --- /dev/null +++ b/packages/tui/src/components-new/markdown.ts @@ -0,0 +1,357 @@ +import { stripVTControlCharacters } from "node:util"; +import chalk from "chalk"; +import { marked, type Token } from "marked"; +import type { Component } from "../tui-new.js"; + +type Color = + | "black" + | "red" + | "green" + | "yellow" + | "blue" + | "magenta" + | "cyan" + | "white" + | "gray" + | "bgBlack" + | "bgRed" + | "bgGreen" + | "bgYellow" + | "bgBlue" + | "bgMagenta" + | "bgCyan" + | "bgWhite" + | "bgGray"; + +export class Markdown implements Component { + private text: string; + private bgColor?: Color; + private fgColor?: Color; + private customBgRgb?: { r: number; g: number; b: number }; + + // Cache for rendered output + private cachedText?: string; + private cachedWidth?: number; + private cachedLines?: string[]; + + constructor(text: string = "", bgColor?: Color, fgColor?: Color, customBgRgb?: { r: number; g: number; b: number }) { + this.text = text; + this.bgColor = bgColor; + this.fgColor = fgColor; + this.customBgRgb = customBgRgb; + } + + setText(text: string): void { + this.text = text; + // Invalidate cache when text changes + this.cachedText = undefined; + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + setBgColor(bgColor?: Color): void { + this.bgColor = bgColor; + // Invalidate cache when color changes + this.cachedText = undefined; + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + setFgColor(fgColor?: Color): void { + this.fgColor = fgColor; + // Invalidate cache when color changes + this.cachedText = undefined; + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + render(width: number): string[] { + // Check cache + if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) { + return this.cachedLines; + } + + // Parse markdown to HTML-like tokens + const tokens = marked.lexer(this.text); + + // Convert tokens to styled terminal output + const renderedLines: string[] = []; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const nextToken = tokens[i + 1]; + const tokenLines = this.renderToken(token, width, nextToken?.type); + renderedLines.push(...tokenLines); + } + + // Wrap lines to fit width + const wrappedLines: string[] = []; + for (const line of renderedLines) { + wrappedLines.push(...this.wrapLine(line, width)); + } + + // Apply background and foreground colors if specified + let result: string[]; + if (this.bgColor || this.fgColor || this.customBgRgb) { + const coloredLines: string[] = []; + for (const line of wrappedLines) { + // Calculate visible length (strip ANSI codes) + const visibleLength = stripVTControlCharacters(line).length; + const padding = " ".repeat(Math.max(0, width - visibleLength)); + + // Apply colors + let coloredLine = line + padding; + + // Apply foreground color first if specified + if (this.fgColor) { + coloredLine = (chalk as any)[this.fgColor](coloredLine); + } + + // Apply background color if specified + if (this.customBgRgb) { + // Use custom RGB background + coloredLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(coloredLine); + } else if (this.bgColor) { + coloredLine = (chalk as any)[this.bgColor](coloredLine); + } + + coloredLines.push(coloredLine); + } + result = coloredLines.length > 0 ? coloredLines : [""]; + } else { + result = wrappedLines.length > 0 ? wrappedLines : [""]; + } + + // Update cache + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = result; + + return result; + } + + private renderToken(token: Token, width: number, nextTokenType?: string): string[] { + const lines: string[] = []; + + switch (token.type) { + case "heading": { + const headingLevel = token.depth; + const headingPrefix = "#".repeat(headingLevel) + " "; + const headingText = this.renderInlineTokens(token.tokens || []); + if (headingLevel === 1) { + lines.push(chalk.bold.underline.yellow(headingText)); + } else if (headingLevel === 2) { + lines.push(chalk.bold.yellow(headingText)); + } else { + lines.push(chalk.bold(headingPrefix + headingText)); + } + lines.push(""); // Add spacing after headings + break; + } + + case "paragraph": { + const paragraphText = this.renderInlineTokens(token.tokens || []); + lines.push(paragraphText); + // Don't add spacing if next token is space or list + if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") { + lines.push(""); + } + break; + } + + case "code": { + lines.push(chalk.gray("```" + (token.lang || ""))); + // Split code by newlines and style each line + const codeLines = token.text.split("\n"); + for (const codeLine of codeLines) { + lines.push(chalk.dim(" ") + chalk.green(codeLine)); + } + lines.push(chalk.gray("```")); + lines.push(""); // Add spacing after code blocks + break; + } + + case "list": + for (let i = 0; i < token.items.length; i++) { + const item = token.items[i]; + const bullet = token.ordered ? `${i + 1}. ` : "- "; + const itemText = this.renderInlineTokens(item.tokens || []); + + // Check if the item text contains multiple lines (embedded content) + const itemLines = itemText.split("\n").filter((line) => line.trim()); + if (itemLines.length > 1) { + // First line is the list item + lines.push(chalk.cyan(bullet) + itemLines[0]); + // Rest are treated as separate content + for (let j = 1; j < itemLines.length; j++) { + lines.push(""); // Add spacing + lines.push(itemLines[j]); + } + } else { + lines.push(chalk.cyan(bullet) + itemText); + } + } + // Don't add spacing after lists if a space token follows + // (the space token will handle it) + break; + + case "blockquote": { + const quoteText = this.renderInlineTokens(token.tokens || []); + const quoteLines = quoteText.split("\n"); + for (const quoteLine of quoteLines) { + lines.push(chalk.gray("│ ") + chalk.italic(quoteLine)); + } + lines.push(""); // Add spacing after blockquotes + break; + } + + case "hr": + lines.push(chalk.gray("─".repeat(Math.min(width, 80)))); + lines.push(""); // Add spacing after horizontal rules + break; + + case "html": + // Skip HTML for terminal output + break; + + case "space": + // Space tokens represent blank lines in markdown + lines.push(""); + break; + + default: + // Handle any other token types as plain text + if ("text" in token && typeof token.text === "string") { + lines.push(token.text); + } + } + + return lines; + } + + private renderInlineTokens(tokens: Token[]): string { + let result = ""; + + for (const token of tokens) { + switch (token.type) { + case "text": + // Text tokens in list items can have nested tokens for inline formatting + if (token.tokens && token.tokens.length > 0) { + result += this.renderInlineTokens(token.tokens); + } else { + result += token.text; + } + break; + + case "strong": + result += chalk.bold(this.renderInlineTokens(token.tokens || [])); + break; + + case "em": + result += chalk.italic(this.renderInlineTokens(token.tokens || [])); + break; + + case "codespan": + result += chalk.gray("`") + chalk.cyan(token.text) + chalk.gray("`"); + break; + + case "link": { + const linkText = this.renderInlineTokens(token.tokens || []); + result += chalk.underline.blue(linkText) + chalk.gray(` (${token.href})`); + break; + } + + case "br": + result += "\n"; + break; + + case "del": + result += chalk.strikethrough(this.renderInlineTokens(token.tokens || [])); + break; + + default: + // Handle any other inline token types as plain text + if ("text" in token && typeof token.text === "string") { + result += token.text; + } + } + } + + return result; + } + + private wrapLine(line: string, width: number): string[] { + // Handle ANSI escape codes properly when wrapping + const wrapped: string[] = []; + + // Handle undefined or null lines + if (!line) { + return [""]; + } + + // If line fits within width, return as-is + const visibleLength = stripVTControlCharacters(line).length; + if (visibleLength <= width) { + return [line]; + } + + // Track active ANSI codes to preserve them across wrapped lines + const activeAnsiCodes: string[] = []; + let currentLine = ""; + let currentLength = 0; + let i = 0; + + while (i < line.length) { + if (line[i] === "\x1b" && line[i + 1] === "[") { + // ANSI escape sequence - parse and track it + let j = i + 2; + while (j < line.length && line[j] && !/[mGKHJ]/.test(line[j]!)) { + j++; + } + if (j < line.length) { + const ansiCode = line.substring(i, j + 1); + currentLine += ansiCode; + + // Track styling codes (ending with 'm') + if (line[j] === "m") { + // Reset code + if (ansiCode === "\x1b[0m" || ansiCode === "\x1b[m") { + activeAnsiCodes.length = 0; + } else { + // Add to active codes (replacing similar ones) + activeAnsiCodes.push(ansiCode); + } + } + + i = j + 1; + } else { + // Incomplete ANSI sequence at end - don't include it + break; + } + } else { + // Regular character + if (currentLength >= width) { + // Need to wrap - close current line with reset if needed + if (activeAnsiCodes.length > 0) { + wrapped.push(currentLine + "\x1b[0m"); + // Start new line with active codes + currentLine = activeAnsiCodes.join(""); + } else { + wrapped.push(currentLine); + currentLine = ""; + } + currentLength = 0; + } + currentLine += line[i]; + currentLength++; + i++; + } + } + + if (currentLine) { + wrapped.push(currentLine); + } + + return wrapped.length > 0 ? wrapped : [""]; + } +} diff --git a/packages/tui/src/components-new/select-list.ts b/packages/tui/src/components-new/select-list.ts new file mode 100644 index 00000000..1f64947b --- /dev/null +++ b/packages/tui/src/components-new/select-list.ts @@ -0,0 +1,154 @@ +import chalk from "chalk"; +import type { Component } from "../tui-new.js"; + +export interface SelectItem { + value: string; + label: string; + description?: string; +} + +export class SelectList implements Component { + private items: SelectItem[] = []; + private filteredItems: SelectItem[] = []; + private selectedIndex: number = 0; + private filter: string = ""; + private maxVisible: number = 5; + + public onSelect?: (item: SelectItem) => void; + public onCancel?: () => void; + + constructor(items: SelectItem[], maxVisible: number = 5) { + this.items = items; + this.filteredItems = items; + this.maxVisible = maxVisible; + } + + setFilter(filter: string): void { + this.filter = filter; + this.filteredItems = this.items.filter((item) => item.value.toLowerCase().startsWith(filter.toLowerCase())); + // Reset selection when filter changes + this.selectedIndex = 0; + } + + render(width: number): string[] { + const lines: string[] = []; + + // If no items match filter, show message + if (this.filteredItems.length === 0) { + lines.push(chalk.gray(" No matching commands")); + return lines; + } + + // Calculate visible range with scrolling + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible), + ); + const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length); + + // Render visible items + for (let i = startIndex; i < endIndex; i++) { + const item = this.filteredItems[i]; + if (!item) continue; + + const isSelected = i === this.selectedIndex; + + let line = ""; + if (isSelected) { + // Use arrow indicator for selection + const prefix = chalk.blue("→ "); + const displayValue = item.label || item.value; + + if (item.description && width > 40) { + // Calculate how much space we have for value + description + const maxValueLength = Math.min(displayValue.length, 30); + const truncatedValue = displayValue.substring(0, maxValueLength); + const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length)); + + // Calculate remaining space for description + const descriptionStart = prefix.length + truncatedValue.length + spacing.length - 2; // -2 for arrow color codes + const remainingWidth = width - descriptionStart - 2; // -2 for safety + + if (remainingWidth > 10) { + const truncatedDesc = item.description.substring(0, remainingWidth); + line = prefix + chalk.blue(truncatedValue) + chalk.gray(spacing + truncatedDesc); + } else { + // Not enough space for description + const maxWidth = width - 4; // 2 for arrow + space, 2 for safety + line = prefix + chalk.blue(displayValue.substring(0, maxWidth)); + } + } else { + // No description or not enough width + const maxWidth = width - 4; // 2 for arrow + space, 2 for safety + line = prefix + chalk.blue(displayValue.substring(0, maxWidth)); + } + } else { + const displayValue = item.label || item.value; + const prefix = " "; + + if (item.description && width > 40) { + // Calculate how much space we have for value + description + const maxValueLength = Math.min(displayValue.length, 30); + const truncatedValue = displayValue.substring(0, maxValueLength); + const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length)); + + // Calculate remaining space for description + const descriptionStart = prefix.length + truncatedValue.length + spacing.length; + const remainingWidth = width - descriptionStart - 2; // -2 for safety + + if (remainingWidth > 10) { + const truncatedDesc = item.description.substring(0, remainingWidth); + line = prefix + truncatedValue + chalk.gray(spacing + truncatedDesc); + } else { + // Not enough space for description + const maxWidth = width - prefix.length - 2; + line = prefix + displayValue.substring(0, maxWidth); + } + } else { + // No description or not enough width + const maxWidth = width - prefix.length - 2; + line = prefix + displayValue.substring(0, maxWidth); + } + } + + lines.push(line); + } + + // Add scroll indicators if needed + if (startIndex > 0 || endIndex < this.filteredItems.length) { + const scrollInfo = chalk.gray(` (${this.selectedIndex + 1}/${this.filteredItems.length})`); + lines.push(scrollInfo); + } + + return lines; + } + + handleInput(keyData: string): void { + // Up arrow + if (keyData === "\x1b[A") { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + } + // Down arrow + else if (keyData === "\x1b[B") { + this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1); + } + // Enter + else if (keyData === "\r") { + const selectedItem = this.filteredItems[this.selectedIndex]; + if (selectedItem && this.onSelect) { + this.onSelect(selectedItem); + } + } + // Escape + else if (keyData === "\x1b") { + if (this.onCancel) { + this.onCancel(); + } + } + } + + getSelectedItem(): SelectItem | null { + const item = this.filteredItems[this.selectedIndex]; + return item || null; + } +} diff --git a/packages/tui/src/components-new/spacer.ts b/packages/tui/src/components-new/spacer.ts new file mode 100644 index 00000000..0673ef62 --- /dev/null +++ b/packages/tui/src/components-new/spacer.ts @@ -0,0 +1,24 @@ +import type { Component } from "../tui-new.js"; + +/** + * Spacer component that renders empty lines + */ +export class Spacer implements Component { + private lines: number; + + constructor(lines: number = 1) { + this.lines = lines; + } + + setLines(lines: number): void { + this.lines = lines; + } + + render(_width: number): string[] { + const result: string[] = []; + for (let i = 0; i < this.lines; i++) { + result.push(""); + } + return result; + } +} diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 9fe21d73..fa13fab1 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -14,6 +14,18 @@ export interface Terminal { // Get terminal dimensions get columns(): number; get rows(): number; + + // Cursor positioning (relative to current position) + moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines + + // Cursor visibility + hideCursor(): void; // Hide the cursor + showCursor(): void; // Show the cursor + + // Clear operations + clearLine(): void; // Clear current line + clearFromCursor(): void; // Clear from cursor to end of screen + clearScreen(): void; // Clear entire screen and move cursor to (0,0) } /** @@ -69,4 +81,35 @@ export class ProcessTerminal implements Terminal { get rows(): number { return process.stdout.rows || 24; } + + moveBy(lines: number): void { + if (lines > 0) { + // Move down + process.stdout.write(`\x1b[${lines}B`); + } else if (lines < 0) { + // Move up + process.stdout.write(`\x1b[${-lines}A`); + } + // lines === 0: no movement + } + + hideCursor(): void { + process.stdout.write("\x1b[?25l"); + } + + showCursor(): void { + process.stdout.write("\x1b[?25h"); + } + + clearLine(): void { + process.stdout.write("\x1b[K"); + } + + clearFromCursor(): void { + process.stdout.write("\x1b[J"); + } + + clearScreen(): void { + process.stdout.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1) + } } diff --git a/packages/tui/src/tui-new.ts b/packages/tui/src/tui-new.ts new file mode 100644 index 00000000..d1fbf58a --- /dev/null +++ b/packages/tui/src/tui-new.ts @@ -0,0 +1,411 @@ +/** + * Minimal TUI implementation with differential rendering + */ + +import { stripVTControlCharacters } from "node:util"; +import type { Terminal } from "./terminal.js"; + +/** + * Component interface - all components must implement this + */ +export interface Component { + /** + * Render the component to lines for the given viewport width + * @param width - Current viewport width + * @returns Array of strings, each representing a line + */ + render(width: number): string[]; + + /** + * Optional handler for keyboard input when component has focus + */ + handleInput?(data: string): void; +} + +/** + * Container - a component that contains other components + */ +export class Container implements Component { + children: Component[] = []; + + addChild(component: Component): void { + this.children.push(component); + } + + removeChild(component: Component): void { + const index = this.children.indexOf(component); + if (index !== -1) { + this.children.splice(index, 1); + } + } + + clear(): void { + this.children = []; + } + + render(width: number): string[] { + const lines: string[] = []; + for (const child of this.children) { + lines.push(...child.render(width)); + } + return lines; + } +} + +/** + * Text component - displays multi-line text with word wrapping + */ +export class Text implements Component { + constructor(private text: string = "") {} + + setText(text: string): void { + this.text = text; + } + + render(width: number): string[] { + if (!this.text) { + return [""]; + } + + const lines: string[] = []; + const textLines = this.text.split("\n"); + + for (const line of textLines) { + if (line.length <= width) { + lines.push(line); + } else { + // Word wrap + const words = line.split(" "); + let currentLine = ""; + + for (const word of words) { + if (currentLine.length === 0) { + currentLine = word; + } else if (currentLine.length + 1 + word.length <= width) { + currentLine += " " + word; + } else { + lines.push(currentLine); + currentLine = word; + } + } + + if (currentLine.length > 0) { + lines.push(currentLine); + } + } + } + + return lines.length > 0 ? lines : [""]; + } +} + +/** + * Input component - single-line text input with horizontal scrolling + */ +export class Input implements Component { + private value: string = ""; + private cursor: number = 0; // Cursor position in the value + public onSubmit?: (value: string) => void; + + getValue(): string { + return this.value; + } + + setValue(value: string): void { + this.value = value; + this.cursor = Math.min(this.cursor, value.length); + } + + handleInput(data: string): void { + // Handle special keys + if (data === "\r" || data === "\n") { + // Enter - submit + if (this.onSubmit) { + this.onSubmit(this.value); + } + return; + } + + if (data === "\x7f" || data === "\x08") { + // Backspace + if (this.cursor > 0) { + this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor); + this.cursor--; + } + return; + } + + if (data === "\x1b[D") { + // Left arrow + if (this.cursor > 0) { + this.cursor--; + } + return; + } + + if (data === "\x1b[C") { + // Right arrow + if (this.cursor < this.value.length) { + this.cursor++; + } + return; + } + + if (data === "\x1b[3~") { + // Delete + if (this.cursor < this.value.length) { + this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1); + } + return; + } + + if (data === "\x01") { + // Ctrl+A - beginning of line + this.cursor = 0; + return; + } + + if (data === "\x05") { + // Ctrl+E - end of line + this.cursor = this.value.length; + return; + } + + // Regular character input + if (data.length === 1 && data >= " " && data <= "~") { + this.value = this.value.slice(0, this.cursor) + data + this.value.slice(this.cursor); + this.cursor++; + } + } + + render(width: number): string[] { + // Calculate visible window + const prompt = "> "; + const availableWidth = width - prompt.length; + + if (availableWidth <= 0) { + return [prompt]; + } + + let visibleText = ""; + let cursorDisplay = this.cursor; + + if (this.value.length < availableWidth) { + // Everything fits (leave room for cursor at end) + visibleText = this.value; + } else { + // Need horizontal scrolling + // Reserve one character for cursor if it's at the end + const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth; + const halfWidth = Math.floor(scrollWidth / 2); + + if (this.cursor < halfWidth) { + // Cursor near start + visibleText = this.value.slice(0, scrollWidth); + cursorDisplay = this.cursor; + } else if (this.cursor > this.value.length - halfWidth) { + // Cursor near end + visibleText = this.value.slice(this.value.length - scrollWidth); + cursorDisplay = scrollWidth - (this.value.length - this.cursor); + } else { + // Cursor in middle + const start = this.cursor - halfWidth; + visibleText = this.value.slice(start, start + scrollWidth); + cursorDisplay = halfWidth; + } + } + + // Build line with fake cursor + // Insert cursor character at cursor position + const beforeCursor = visibleText.slice(0, cursorDisplay); + const atCursor = visibleText[cursorDisplay] || " "; // Character at cursor, or space if at end + const afterCursor = visibleText.slice(cursorDisplay + 1); + + // Use inverse video to show cursor + const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal + const textWithCursor = beforeCursor + cursorChar + afterCursor; + + // Calculate visual width (strip ANSI codes to measure actual displayed characters) + const visualLength = stripVTControlCharacters(textWithCursor).length; + const padding = " ".repeat(Math.max(0, availableWidth - visualLength)); + const line = prompt + textWithCursor + padding; + + return [line]; + } +} + +/** + * TUI - Main class for managing terminal UI with differential rendering + */ +export class TUI extends Container { + private terminal: Terminal; + private previousLines: string[] = []; + private previousWidth = 0; + private focusedComponent: Component | null = null; + private renderRequested = false; + private cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line) + + constructor(terminal: Terminal) { + super(); + this.terminal = terminal; + } + + setFocus(component: Component | null): void { + this.focusedComponent = component; + } + + start(): void { + this.terminal.start( + (data) => this.handleInput(data), + () => this.requestRender(), + ); + this.terminal.hideCursor(); + this.requestRender(); + } + + stop(): void { + this.terminal.showCursor(); + this.terminal.stop(); + } + + requestRender(): void { + if (this.renderRequested) return; + this.renderRequested = true; + process.nextTick(() => { + this.renderRequested = false; + this.doRender(); + }); + } + + private handleInput(data: string): void { + // Exit on Ctrl+C + if (data === "\x03") { + this.stop(); + process.exit(0); + } + + // Pass input to focused component + if (this.focusedComponent?.handleInput) { + this.focusedComponent.handleInput(data); + this.requestRender(); + } + } + + private doRender(): void { + const width = this.terminal.columns; + const height = this.terminal.rows; + + // Render all components to get new lines + const newLines = this.render(width); + + // Width changed - need full re-render + const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width; + + // First render - just output everything without clearing + if (this.previousLines.length === 0) { + let buffer = "\x1b[?2026h"; // Begin synchronized output + for (let i = 0; i < newLines.length; i++) { + if (i > 0) buffer += "\r\n"; + buffer += newLines[i]; + } + buffer += "\x1b[?2026l"; // End synchronized output + this.terminal.write(buffer); + // After rendering N lines, cursor is at end of last line (line N-1) + this.cursorRow = newLines.length - 1; + this.previousLines = newLines; + this.previousWidth = width; + return; + } + + // Width changed - full re-render + if (widthChanged) { + let buffer = "\x1b[?2026h"; // Begin synchronized output + buffer += "\x1b[2J\x1b[H"; // Clear screen and home + for (let i = 0; i < newLines.length; i++) { + if (i > 0) buffer += "\r\n"; + buffer += newLines[i]; + } + buffer += "\x1b[?2026l"; // End synchronized output + this.terminal.write(buffer); + this.cursorRow = newLines.length - 1; + this.previousLines = newLines; + this.previousWidth = width; + return; + } + + // Find first and last changed lines + let firstChanged = -1; + let lastChanged = -1; + + const maxLines = Math.max(newLines.length, this.previousLines.length); + for (let i = 0; i < maxLines; i++) { + const oldLine = i < this.previousLines.length ? this.previousLines[i] : ""; + const newLine = i < newLines.length ? newLines[i] : ""; + + if (oldLine !== newLine) { + if (firstChanged === -1) { + firstChanged = i; + } + lastChanged = i; + } + } + + // No changes + if (firstChanged === -1) { + return; + } + + // Check if firstChanged is outside the viewport + // cursorRow is the line where cursor is (0-indexed) + // Viewport shows lines from (cursorRow - height + 1) to cursorRow + // If firstChanged < viewportTop, we need full re-render + const viewportTop = this.cursorRow - height + 1; + if (firstChanged < viewportTop) { + // First change is above viewport - need full re-render + let buffer = "\x1b[?2026h"; // Begin synchronized output + buffer += "\x1b[2J\x1b[H"; // Clear screen and home + for (let i = 0; i < newLines.length; i++) { + if (i > 0) buffer += "\r\n"; + buffer += newLines[i]; + } + buffer += "\x1b[?2026l"; // End synchronized output + this.terminal.write(buffer); + this.cursorRow = newLines.length - 1; + this.previousLines = newLines; + this.previousWidth = width; + return; + } + + // Render from first changed line to end + // Build buffer with all updates wrapped in synchronized output + let buffer = "\x1b[?2026h"; // Begin synchronized output + + // Move cursor to first changed line + const lineDiff = firstChanged - this.cursorRow; + if (lineDiff > 0) { + buffer += `\x1b[${lineDiff}B`; // Move down + } else if (lineDiff < 0) { + buffer += `\x1b[${-lineDiff}A`; // Move up + } + + buffer += "\r"; // Move to column 0 + buffer += "\x1b[J"; // Clear from cursor to end of screen + + // Render from first changed line to end + for (let i = firstChanged; i < newLines.length; i++) { + if (i > firstChanged) buffer += "\r\n"; + buffer += newLines[i]; + } + + buffer += "\x1b[?2026l"; // End synchronized output + + // Write entire buffer at once + this.terminal.write(buffer); + + // Cursor is now at end of last line + this.cursorRow = newLines.length - 1; + + this.previousLines = newLines; + this.previousWidth = width; + } +} diff --git a/packages/tui/test/chat-simple.ts b/packages/tui/test/chat-simple.ts new file mode 100644 index 00000000..74f079d1 --- /dev/null +++ b/packages/tui/test/chat-simple.ts @@ -0,0 +1,145 @@ +/** + * Simple chat interface demo using tui-new.ts + */ + +import { CombinedAutocompleteProvider } from "../src/autocomplete.js"; +import { Editor } from "../src/components-new/editor.js"; +import { Loader } from "../src/components-new/loader.js"; +import { Markdown } from "../src/components-new/markdown.js"; +import { Spacer } from "../src/components-new/spacer.js"; +import { ProcessTerminal } from "../src/terminal.js"; +import { Text, TUI } from "../src/tui-new.js"; + +// Create terminal +const terminal = new ProcessTerminal(); + +// Create TUI +const tui = new TUI(terminal); + +// Create chat container with some initial messages +tui.addChild(new Text("Welcome to Simple Chat!")); +tui.addChild(new Text("Type your messages below. Type '/' for commands. Press Ctrl+C to exit.\n")); + +// Create editor with autocomplete +const editor = new Editor(); + +// Set up autocomplete provider with slash commands and file completion +const autocompleteProvider = new CombinedAutocompleteProvider( + [ + { name: "delete", description: "Delete the last message" }, + { name: "clear", description: "Clear all messages" }, + ], + process.cwd(), +); +editor.setAutocompleteProvider(autocompleteProvider); + +tui.addChild(editor); + +// Focus the editor +tui.setFocus(editor); + +// Track if we're waiting for bot response +let isResponding = false; + +// Handle message submission +editor.onSubmit = (value: string) => { + // Prevent submission if already responding + if (isResponding) { + return; + } + + const trimmed = value.trim(); + + // Handle slash commands + if (trimmed === "/delete") { + const children = tui.children; + // Remove component before editor (if there are any besides the initial text) + if (children.length > 3) { + // children[0] = "Welcome to Simple Chat!" + // children[1] = "Type your messages below..." + // children[2...n-1] = messages + // children[n] = editor + children.splice(children.length - 2, 1); + } + tui.requestRender(); + return; + } + + if (trimmed === "/clear") { + const children = tui.children; + // Remove all messages but keep the welcome text and editor + children.splice(2, children.length - 3); + tui.requestRender(); + return; + } + + if (trimmed) { + // Mark as responding and disable submit + isResponding = true; + editor.disableSubmit = true; + + // Add user message with custom gray background (similar to Claude.ai) + const userMessage = new Markdown(value, undefined, undefined, { r: 52, g: 53, b: 65 }); + + // Insert before the editor (which is last) + const children = tui.children; + children.splice(children.length - 1, 0, userMessage); + + // Add spacer after user message + children.splice(children.length - 1, 0, new Spacer()); + + // Add loader + const loader = new Loader(tui, "Thinking..."); + children.splice(children.length - 1, 0, loader); + + // Add spacer after loader + const loaderSpacer = new Spacer(); + children.splice(children.length - 1, 0, loaderSpacer); + + tui.requestRender(); + + // Simulate a 1 second delay + setTimeout(() => { + // Remove loader and its spacer + const loaderIndex = children.indexOf(loader); + if (loaderIndex !== -1) { + children.splice(loaderIndex, 1); + loader.stop(); + } + const loaderSpacerIndex = children.indexOf(loaderSpacer); + if (loaderSpacerIndex !== -1) { + children.splice(loaderSpacerIndex, 1); + } + + // Simulate a response + const responses = [ + "That's interesting! Tell me more.", + "I see what you mean.", + "Fascinating perspective!", + "Could you elaborate on that?", + "That makes sense to me.", + "I hadn't thought of it that way.", + "Great point!", + "Thanks for sharing that.", + ]; + const randomResponse = responses[Math.floor(Math.random() * responses.length)]; + + // Add assistant message with no background (transparent) + const botMessage = new Markdown(randomResponse); + children.splice(children.length - 1, 0, botMessage); + + // Add spacer after assistant message + children.splice(children.length - 1, 0, new Spacer()); + + // Re-enable submit + isResponding = false; + editor.disableSubmit = false; + + // Request render + tui.requestRender(); + }, 1000); + } +}; + +// Start the TUI +tui.start(); diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index 0417e63b..74ccbbfc 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -52,6 +52,37 @@ export class VirtualTerminal implements Terminal { return this._rows; } + moveBy(lines: number): void { + if (lines > 0) { + // Move down + this.xterm.write(`\x1b[${lines}B`); + } else if (lines < 0) { + // Move up + this.xterm.write(`\x1b[${-lines}A`); + } + // lines === 0: no movement + } + + hideCursor(): void { + this.xterm.write("\x1b[?25l"); + } + + showCursor(): void { + this.xterm.write("\x1b[?25h"); + } + + clearLine(): void { + this.xterm.write("\x1b[K"); + } + + clearFromCursor(): void { + this.xterm.write("\x1b[J"); + } + + clearScreen(): void { + this.xterm.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1) + } + // Test-specific methods not in Terminal interface /**