import chalk from "chalk"; import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; import type { Component } from "../tui.js"; import { SelectList } from "./select-list.js"; interface EditorState { lines: string[]; cursorLine: number; cursorCol: number; } interface LayoutLine { text: string; hasCursor: boolean; cursorPos?: number; } export interface TextEditorConfig { // Configuration options for text editor (none currently) } export class 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; // Bracketed paste mode buffering private pasteBuffer: string = ""; private isInPaste: boolean = false; 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 - check if we have room for the space if (layoutLine.text.length < width) { // We have room - 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; } else { // Line is at full width - use reverse video on last character if possible // or just show cursor at the end without adding space if (before.length > 0) { const lastChar = before[before.length - 1]; const cursor = `\x1b[7m${lastChar}\x1b[0m`; displayText = before.slice(0, -1) + cursor; } // visibleLength stays the same } } } // 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 bracketed paste mode // Start of paste: \x1b[200~ // End of paste: \x1b[201~ // Check if we're starting a bracketed paste if (data.includes("\x1b[200~")) { this.isInPaste = true; this.pasteBuffer = ""; // Remove the start marker and keep the rest data = data.replace("\x1b[200~", ""); } // If we're in a paste, buffer the data if (this.isInPaste) { // Append data to buffer first (end marker could be split across chunks) this.pasteBuffer += data; // Check if the accumulated buffer contains the end marker const endIndex = this.pasteBuffer.indexOf("\x1b[201~"); if (endIndex !== -1) { // Extract content before the end marker const pasteContent = this.pasteBuffer.substring(0, endIndex); // Process the complete paste this.handlePaste(pasteContent); // Reset paste state this.isInPaste = false; // Process any remaining data after the end marker const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~ this.pasteBuffer = ""; if (remaining.length > 0) { this.handleInput(remaining); } return; } else { // Still accumulating, wait for more data return; } } // Handle special key combinations first // Ctrl+C - Exit (let parent handle this) if (data.charCodeAt(0) === 3) { 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 +xxx lines] markers with actual paste content for (const [pasteId, pasteContent] of this.pastes) { // Match both old format [paste #N] and new format [paste #N +xxx lines] const markerRegex = new RegExp(`\\[paste #${pasteId}( \\+\\d+ lines)?\\]`, "g"); result = result.replace(markerRegex, 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 +123 lines]" const marker = `[paste #${pasteId} +${pastedLines.length} lines]`; 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(); } } }