import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; import { getEditorKeybindings } from "../keybindings.js"; import { matchesKey } from "../keys.js"; import { KillRing } from "../kill-ring.js"; import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.js"; import { UndoStack } from "../undo-stack.js"; import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js"; import { SelectList, type SelectListTheme } from "./select-list.js"; const segmenter = getSegmenter(); /** * Represents a chunk of text for word-wrap layout. * Tracks both the text content and its position in the original line. */ export interface TextChunk { text: string; startIndex: number; endIndex: number; } /** * Split a line into word-wrapped chunks. * Wraps at word boundaries when possible, falling back to character-level * wrapping for words longer than the available width. * * @param line - The text line to wrap * @param maxWidth - Maximum visible width per chunk * @returns Array of chunks with text and position information */ export function wordWrapLine(line: string, maxWidth: number): TextChunk[] { if (!line || maxWidth <= 0) { return [{ text: "", startIndex: 0, endIndex: 0 }]; } const lineWidth = visibleWidth(line); if (lineWidth <= maxWidth) { return [{ text: line, startIndex: 0, endIndex: line.length }]; } const chunks: TextChunk[] = []; const segments = [...segmenter.segment(line)]; let currentWidth = 0; let chunkStart = 0; // Wrap opportunity: the position after the last whitespace before a non-whitespace // grapheme, i.e. where a line break is allowed. let wrapOppIndex = -1; let wrapOppWidth = 0; for (let i = 0; i < segments.length; i++) { const seg = segments[i]!; const grapheme = seg.segment; const gWidth = visibleWidth(grapheme); const charIndex = seg.index; const isWs = isWhitespaceChar(grapheme); // Overflow check before advancing. if (currentWidth + gWidth > maxWidth) { if (wrapOppIndex >= 0) { // Backtrack to last wrap opportunity. chunks.push({ text: line.slice(chunkStart, wrapOppIndex), startIndex: chunkStart, endIndex: wrapOppIndex }); chunkStart = wrapOppIndex; currentWidth -= wrapOppWidth; } else if (chunkStart < charIndex) { // No wrap opportunity: force-break at current position. chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex }); chunkStart = charIndex; currentWidth = 0; } wrapOppIndex = -1; } // Advance. currentWidth += gWidth; // Record wrap opportunity: whitespace followed by non-whitespace. // Multiple spaces join (no break between them); the break point is // after the last space before the next word. const next = segments[i + 1]; if (isWs && next && !isWhitespaceChar(next.segment)) { wrapOppIndex = next.index; wrapOppWidth = currentWidth; } } // Push final chunk. chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length }); return chunks; } // Kitty CSI-u sequences for printable keys, including optional shifted/base codepoints. const KITTY_CSI_U_REGEX = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/; const KITTY_MOD_SHIFT = 1; const KITTY_MOD_ALT = 2; const KITTY_MOD_CTRL = 4; // Decode a printable CSI-u sequence, preferring the shifted key when present. function decodeKittyPrintable(data: string): string | undefined { const match = data.match(KITTY_CSI_U_REGEX); if (!match) return undefined; // CSI-u groups: [:[:]];u const codepoint = Number.parseInt(match[1] ?? "", 10); if (!Number.isFinite(codepoint)) return undefined; const shiftedKey = match[2] && match[2].length > 0 ? Number.parseInt(match[2], 10) : undefined; const modValue = match[4] ? Number.parseInt(match[4], 10) : 1; // Modifiers are 1-indexed in CSI-u; normalize to our bitmask. const modifier = Number.isFinite(modValue) ? modValue - 1 : 0; // Ignore CSI-u sequences used for Alt/Ctrl shortcuts. if (modifier & (KITTY_MOD_ALT | KITTY_MOD_CTRL)) return undefined; // Prefer the shifted keycode when Shift is held. let effectiveCodepoint = codepoint; if (modifier & KITTY_MOD_SHIFT && typeof shiftedKey === "number") { effectiveCodepoint = shiftedKey; } // Drop control characters or invalid codepoints. if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32) return undefined; try { return String.fromCodePoint(effectiveCodepoint); } catch { return undefined; } } interface EditorState { lines: string[]; cursorLine: number; cursorCol: number; } interface LayoutLine { text: string; hasCursor: boolean; cursorPos?: number; } export interface EditorTheme { borderColor: (str: string) => string; selectList: SelectListTheme; } export interface EditorOptions { paddingX?: number; autocompleteMaxVisible?: number; } export class Editor implements Component, Focusable { private state: EditorState = { lines: [""], cursorLine: 0, cursorCol: 0, }; /** Focusable interface - set by TUI when focus changes */ focused: boolean = false; protected tui: TUI; private theme: EditorTheme; private paddingX: number = 0; // Store last render width for cursor navigation private lastWidth: number = 80; // Vertical scrolling support private scrollOffset: number = 0; // Border color (can be changed dynamically) public borderColor: (str: string) => string; // Autocomplete support private autocompleteProvider?: AutocompleteProvider; private autocompleteList?: SelectList; private autocompleteState: "regular" | "force" | null = null; private autocompletePrefix: string = ""; private autocompleteMaxVisible: number = 5; // 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; // Prompt history for up/down navigation private history: string[] = []; private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc. // Kill ring for Emacs-style kill/yank operations private killRing = new KillRing(); private lastAction: "kill" | "yank" | "type-word" | null = null; // Character jump mode private jumpMode: "forward" | "backward" | null = null; // Preferred visual column for vertical cursor movement (sticky column) private preferredVisualCol: number | null = null; // Undo support private undoStack = new UndoStack(); public onSubmit?: (text: string) => void; public onChange?: (text: string) => void; public disableSubmit: boolean = false; constructor(tui: TUI, theme: EditorTheme, options: EditorOptions = {}) { this.tui = tui; this.theme = theme; this.borderColor = theme.borderColor; const paddingX = options.paddingX ?? 0; this.paddingX = Number.isFinite(paddingX) ? Math.max(0, Math.floor(paddingX)) : 0; const maxVisible = options.autocompleteMaxVisible ?? 5; this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5; } getPaddingX(): number { return this.paddingX; } setPaddingX(padding: number): void { const newPadding = Number.isFinite(padding) ? Math.max(0, Math.floor(padding)) : 0; if (this.paddingX !== newPadding) { this.paddingX = newPadding; this.tui.requestRender(); } } getAutocompleteMaxVisible(): number { return this.autocompleteMaxVisible; } setAutocompleteMaxVisible(maxVisible: number): void { const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5; if (this.autocompleteMaxVisible !== newMaxVisible) { this.autocompleteMaxVisible = newMaxVisible; this.tui.requestRender(); } } setAutocompleteProvider(provider: AutocompleteProvider): void { this.autocompleteProvider = provider; } /** * Add a prompt to history for up/down arrow navigation. * Called after successful submission. */ addToHistory(text: string): void { const trimmed = text.trim(); if (!trimmed) return; // Don't add consecutive duplicates if (this.history.length > 0 && this.history[0] === trimmed) return; this.history.unshift(trimmed); // Limit history size if (this.history.length > 100) { this.history.pop(); } } private isEditorEmpty(): boolean { return this.state.lines.length === 1 && this.state.lines[0] === ""; } private isOnFirstVisualLine(): boolean { const visualLines = this.buildVisualLineMap(this.lastWidth); const currentVisualLine = this.findCurrentVisualLine(visualLines); return currentVisualLine === 0; } private isOnLastVisualLine(): boolean { const visualLines = this.buildVisualLineMap(this.lastWidth); const currentVisualLine = this.findCurrentVisualLine(visualLines); return currentVisualLine === visualLines.length - 1; } private navigateHistory(direction: 1 | -1): void { this.lastAction = null; if (this.history.length === 0) return; const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases if (newIndex < -1 || newIndex >= this.history.length) return; // Capture state when first entering history browsing mode if (this.historyIndex === -1 && newIndex >= 0) { this.pushUndoSnapshot(); } this.historyIndex = newIndex; if (this.historyIndex === -1) { // Returned to "current" state - clear editor this.setTextInternal(""); } else { this.setTextInternal(this.history[this.historyIndex] || ""); } } /** Internal setText that doesn't reset history state - used by navigateHistory */ private setTextInternal(text: string): void { const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); this.state.lines = lines.length === 0 ? [""] : lines; this.state.cursorLine = this.state.lines.length - 1; this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0); // Reset scroll - render() will adjust to show cursor this.scrollOffset = 0; if (this.onChange) { this.onChange(this.getText()); } } invalidate(): void { // No cached state to invalidate currently } render(width: number): string[] { const maxPadding = Math.max(0, Math.floor((width - 1) / 2)); const paddingX = Math.min(this.paddingX, maxPadding); const contentWidth = Math.max(1, width - paddingX * 2); // Layout width: with padding the cursor can overflow into it, // without padding we reserve 1 column for the cursor. const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1)); // Store for cursor navigation (must match wrapping width) this.lastWidth = layoutWidth; const horizontal = this.borderColor("─"); // Layout the text const layoutLines = this.layoutText(layoutWidth); // Calculate max visible lines: 30% of terminal height, minimum 5 lines const terminalRows = this.tui.terminal.rows; const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3)); // Find the cursor line index in layoutLines let cursorLineIndex = layoutLines.findIndex((line) => line.hasCursor); if (cursorLineIndex === -1) cursorLineIndex = 0; // Adjust scroll offset to keep cursor visible if (cursorLineIndex < this.scrollOffset) { this.scrollOffset = cursorLineIndex; } else if (cursorLineIndex >= this.scrollOffset + maxVisibleLines) { this.scrollOffset = cursorLineIndex - maxVisibleLines + 1; } // Clamp scroll offset to valid range const maxScrollOffset = Math.max(0, layoutLines.length - maxVisibleLines); this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScrollOffset)); // Get visible lines slice const visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines); const result: string[] = []; const leftPadding = " ".repeat(paddingX); const rightPadding = leftPadding; // Render top border (with scroll indicator if scrolled down) if (this.scrollOffset > 0) { const indicator = `─── ↑ ${this.scrollOffset} more `; const remaining = width - visibleWidth(indicator); result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))); } else { result.push(horizontal.repeat(width)); } // Render each visible layout line // Emit hardware cursor marker only when focused and not showing autocomplete const emitCursorMarker = this.focused && !this.autocompleteState; for (const layoutLine of visibleLines) { let displayText = layoutLine.text; let lineVisibleWidth = visibleWidth(layoutLine.text); let cursorInPadding = false; // 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); // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning) const marker = emitCursorMarker ? CURSOR_MARKER : ""; if (after.length > 0) { // Cursor is on a character (grapheme) - replace it with highlighted version // Get the first grapheme from 'after' const afterGraphemes = [...segmenter.segment(after)]; const firstGrapheme = afterGraphemes[0]?.segment || ""; const restAfter = after.slice(firstGrapheme.length); const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`; displayText = before + marker + cursor + restAfter; // lineVisibleWidth 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 + marker + cursor; lineVisibleWidth = lineVisibleWidth + 1; // If cursor overflows content width into the padding, flag it if (lineVisibleWidth > contentWidth && paddingX > 0) { cursorInPadding = true; } } } // Calculate padding based on actual visible width const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth)); const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding; // Render the line (no side borders, just horizontal lines above and below) result.push(`${leftPadding}${displayText}${padding}${lineRightPadding}`); } // Render bottom border (with scroll indicator if more content below) const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length); if (linesBelow > 0) { const indicator = `─── ↓ ${linesBelow} more `; const remaining = width - visibleWidth(indicator); result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))); } else { result.push(horizontal.repeat(width)); } // Add autocomplete list if active if (this.autocompleteState && this.autocompleteList) { const autocompleteResult = this.autocompleteList.render(contentWidth); for (const line of autocompleteResult) { const lineWidth = visibleWidth(line); const linePadding = " ".repeat(Math.max(0, contentWidth - lineWidth)); result.push(`${leftPadding}${line}${linePadding}${rightPadding}`); } } return result; } handleInput(data: string): void { const kb = getEditorKeybindings(); // Handle character jump mode (awaiting next character to jump to) if (this.jumpMode !== null) { // Cancel if the hotkey is pressed again if (kb.matches(data, "jumpForward") || kb.matches(data, "jumpBackward")) { this.jumpMode = null; return; } if (data.charCodeAt(0) >= 32) { // Printable character - perform the jump const direction = this.jumpMode; this.jumpMode = null; this.jumpToChar(data, direction); return; } // Control character - cancel and fall through to normal handling this.jumpMode = null; } // Handle bracketed paste mode if (data.includes("\x1b[200~")) { this.isInPaste = true; this.pasteBuffer = ""; data = data.replace("\x1b[200~", ""); } if (this.isInPaste) { this.pasteBuffer += data; const endIndex = this.pasteBuffer.indexOf("\x1b[201~"); if (endIndex !== -1) { const pasteContent = this.pasteBuffer.substring(0, endIndex); if (pasteContent.length > 0) { this.handlePaste(pasteContent); } this.isInPaste = false; const remaining = this.pasteBuffer.substring(endIndex + 6); this.pasteBuffer = ""; if (remaining.length > 0) { this.handleInput(remaining); } return; } return; } // Ctrl+C - let parent handle (exit/clear) if (kb.matches(data, "copy")) { return; } // Undo if (kb.matches(data, "undo")) { this.undo(); return; } // Handle autocomplete mode if (this.autocompleteState && this.autocompleteList) { if (kb.matches(data, "selectCancel")) { this.cancelAutocomplete(); return; } if (kb.matches(data, "selectUp") || kb.matches(data, "selectDown")) { this.autocompleteList.handleInput(data); return; } if (kb.matches(data, "tab")) { const selected = this.autocompleteList.getSelectedItem(); if (selected && this.autocompleteProvider) { this.pushUndoSnapshot(); this.lastAction = null; 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.setCursorCol(result.cursorCol); this.cancelAutocomplete(); if (this.onChange) this.onChange(this.getText()); } return; } if (kb.matches(data, "selectConfirm")) { const selected = this.autocompleteList.getSelectedItem(); if (selected && this.autocompleteProvider) { this.pushUndoSnapshot(); this.lastAction = null; 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.setCursorCol(result.cursorCol); if (this.autocompletePrefix.startsWith("/")) { this.cancelAutocomplete(); // Fall through to submit } else { this.cancelAutocomplete(); if (this.onChange) this.onChange(this.getText()); return; } } } } // Tab - trigger completion if (kb.matches(data, "tab") && !this.autocompleteState) { this.handleTabCompletion(); return; } // Deletion actions if (kb.matches(data, "deleteToLineEnd")) { this.deleteToEndOfLine(); return; } if (kb.matches(data, "deleteToLineStart")) { this.deleteToStartOfLine(); return; } if (kb.matches(data, "deleteWordBackward")) { this.deleteWordBackwards(); return; } if (kb.matches(data, "deleteWordForward")) { this.deleteWordForward(); return; } if (kb.matches(data, "deleteCharBackward") || matchesKey(data, "shift+backspace")) { this.handleBackspace(); return; } if (kb.matches(data, "deleteCharForward") || matchesKey(data, "shift+delete")) { this.handleForwardDelete(); return; } // Kill ring actions if (kb.matches(data, "yank")) { this.yank(); return; } if (kb.matches(data, "yankPop")) { this.yankPop(); return; } // Cursor movement actions if (kb.matches(data, "cursorLineStart")) { this.moveToLineStart(); return; } if (kb.matches(data, "cursorLineEnd")) { this.moveToLineEnd(); return; } if (kb.matches(data, "cursorWordLeft")) { this.moveWordBackwards(); return; } if (kb.matches(data, "cursorWordRight")) { this.moveWordForwards(); return; } // New line if ( kb.matches(data, "newLine") || (data.charCodeAt(0) === 10 && data.length > 1) || data === "\x1b\r" || data === "\x1b[13;2~" || (data.length > 1 && data.includes("\x1b") && data.includes("\r")) || (data === "\n" && data.length === 1) ) { if (this.shouldSubmitOnBackslashEnter(data, kb)) { this.handleBackspace(); this.submitValue(); return; } this.addNewLine(); return; } // Submit (Enter) if (kb.matches(data, "submit")) { if (this.disableSubmit) return; // Workaround for terminals without Shift+Enter support: // If char before cursor is \, delete it and insert newline instead of submitting. const currentLine = this.state.lines[this.state.cursorLine] || ""; if (this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\") { this.handleBackspace(); this.addNewLine(); return; } this.submitValue(); return; } // Arrow key navigation (with history support) if (kb.matches(data, "cursorUp")) { if (this.isEditorEmpty()) { this.navigateHistory(-1); } else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) { this.navigateHistory(-1); } else if (this.isOnFirstVisualLine()) { // Already at top - jump to start of line this.moveToLineStart(); } else { this.moveCursor(-1, 0); } return; } if (kb.matches(data, "cursorDown")) { if (this.historyIndex > -1 && this.isOnLastVisualLine()) { this.navigateHistory(1); } else if (this.isOnLastVisualLine()) { // Already at bottom - jump to end of line this.moveToLineEnd(); } else { this.moveCursor(1, 0); } return; } if (kb.matches(data, "cursorRight")) { this.moveCursor(0, 1); return; } if (kb.matches(data, "cursorLeft")) { this.moveCursor(0, -1); return; } // Page up/down - scroll by page and move cursor if (kb.matches(data, "pageUp")) { this.pageScroll(-1); return; } if (kb.matches(data, "pageDown")) { this.pageScroll(1); return; } // Character jump mode triggers if (kb.matches(data, "jumpForward")) { this.jumpMode = "forward"; return; } if (kb.matches(data, "jumpBackward")) { this.jumpMode = "backward"; return; } // Shift+Space - insert regular space if (matchesKey(data, "shift+space")) { this.insertCharacter(" "); return; } const kittyPrintable = decodeKittyPrintable(data); if (kittyPrintable !== undefined) { this.insertCharacter(kittyPrintable); return; } // Regular characters if (data.charCodeAt(0) >= 32) { 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 lineVisibleWidth = visibleWidth(line); if (lineVisibleWidth <= contentWidth) { // 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 - use word-aware wrapping const chunks = wordWrapLine(line, contentWidth); for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const chunk = chunks[chunkIndex]; if (!chunk) continue; const cursorPos = this.state.cursorCol; const isLastChunk = chunkIndex === chunks.length - 1; // Determine if cursor is in this chunk // For word-wrapped chunks, we need to handle the case where // cursor might be in trimmed whitespace at end of chunk let hasCursorInChunk = false; let adjustedCursorPos = 0; if (isCurrentLine) { if (isLastChunk) { // Last chunk: cursor belongs here if >= startIndex hasCursorInChunk = cursorPos >= chunk.startIndex; adjustedCursorPos = cursorPos - chunk.startIndex; } else { // Non-last chunk: cursor belongs here if in range [startIndex, endIndex) // But we need to handle the visual position in the trimmed text hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex; if (hasCursorInChunk) { adjustedCursorPos = cursorPos - chunk.startIndex; // Clamp to text length (in case cursor was in trimmed whitespace) if (adjustedCursorPos > chunk.text.length) { adjustedCursorPos = chunk.text.length; } } } } if (hasCursorInChunk) { layoutLines.push({ text: chunk.text, hasCursor: true, cursorPos: adjustedCursorPos, }); } else { layoutLines.push({ text: chunk.text, hasCursor: false, }); } } } } return layoutLines; } getText(): string { return this.state.lines.join("\n"); } /** * Get text with paste markers expanded to their actual content. * Use this when you need the full content (e.g., for external editor). */ getExpandedText(): string { let result = this.state.lines.join("\n"); for (const [pasteId, pasteContent] of this.pastes) { const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g"); result = result.replace(markerRegex, pasteContent); } return result; } getLines(): string[] { return [...this.state.lines]; } getCursor(): { line: number; col: number } { return { line: this.state.cursorLine, col: this.state.cursorCol }; } setText(text: string): void { this.lastAction = null; this.historyIndex = -1; // Exit history browsing mode // Push undo snapshot if content differs (makes programmatic changes undoable) if (this.getText() !== text) { this.pushUndoSnapshot(); } this.setTextInternal(text); } /** * Insert text at the current cursor position. * Used for programmatic insertion (e.g., clipboard image markers). * This is atomic for undo - single undo restores entire pre-insert state. */ insertTextAtCursor(text: string): void { if (!text) return; this.pushUndoSnapshot(); this.lastAction = null; this.historyIndex = -1; this.insertTextAtCursorInternal(text); } /** * Internal text insertion at cursor. Handles single and multi-line text. * Does not push undo snapshots or trigger autocomplete - caller is responsible. * Normalizes line endings and calls onChange once at the end. */ private insertTextAtCursorInternal(text: string): void { if (!text) return; // Normalize line endings const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const insertedLines = normalized.split("\n"); const currentLine = this.state.lines[this.state.cursorLine] || ""; const beforeCursor = currentLine.slice(0, this.state.cursorCol); const afterCursor = currentLine.slice(this.state.cursorCol); if (insertedLines.length === 1) { // Single line - insert at cursor position this.state.lines[this.state.cursorLine] = beforeCursor + normalized + afterCursor; this.setCursorCol(this.state.cursorCol + normalized.length); } else { // Multi-line insertion this.state.lines = [ // All lines before current line ...this.state.lines.slice(0, this.state.cursorLine), // The first inserted line merged with text before cursor beforeCursor + insertedLines[0], // All middle inserted lines ...insertedLines.slice(1, -1), // The last inserted line with text after cursor insertedLines[insertedLines.length - 1] + afterCursor, // All lines after current line ...this.state.lines.slice(this.state.cursorLine + 1), ]; this.state.cursorLine += insertedLines.length - 1; this.setCursorCol((insertedLines[insertedLines.length - 1] || "").length); } if (this.onChange) { this.onChange(this.getText()); } } // All the editor methods from before... private insertCharacter(char: string, skipUndoCoalescing?: boolean): void { this.historyIndex = -1; // Exit history browsing mode // Undo coalescing (fish-style): // - Consecutive word chars coalesce into one undo unit // - Space captures state before itself (so undo removes space+following word together) // - Each space is separately undoable // Skip coalescing when called from atomic operations (e.g., handlePaste) if (!skipUndoCoalescing) { if (isWhitespaceChar(char) || this.lastAction !== "type-word") { this.pushUndoSnapshot(); } this.lastAction = "type-word"; } 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.setCursorCol(this.state.cursorCol + char.length); if (this.onChange) { this.onChange(this.getText()); } // Check if we should trigger or update autocomplete if (!this.autocompleteState) { // Auto-trigger for "/" at the start of a line (slash commands) if (char === "/" && this.isAtStartOfMessage()) { this.tryTriggerAutocomplete(); } // Auto-trigger for "@" file reference (fuzzy search) else if (char === "@") { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); // Only trigger if @ is after whitespace or at start of line const charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2]; if (textBeforeCursor.length === 1 || charBeforeAt === " " || charBeforeAt === "\t") { 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 or without space for arguments) if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } // Check if we're in an @ file reference context else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) { this.tryTriggerAutocomplete(); } } } else { this.updateAutocomplete(); } } private handlePaste(pastedText: string): void { this.historyIndex = -1; // Exit history browsing mode this.lastAction = null; this.pushUndoSnapshot(); // 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 let filteredText = tabExpandedText .split("") .filter((char) => char === "\n" || char.charCodeAt(0) >= 32) .join(""); // If pasting a file path (starts with /, ~, or .) and the character before // the cursor is a word character, prepend a space for better readability if (/^[/~.]/.test(filteredText)) { const currentLine = this.state.lines[this.state.cursorLine] || ""; const charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : ""; if (charBeforeCursor && /\w/.test(charBeforeCursor)) { filteredText = ` ${filteredText}`; } } // Split into lines to check for large paste const pastedLines = filteredText.split("\n"); // Check if this is a large paste (> 10 lines or > 1000 characters) const totalChars = filteredText.length; if (pastedLines.length > 10 || totalChars > 1000) { // 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]" or "[paste #1 1234 chars]" const marker = pastedLines.length > 10 ? `[paste #${pasteId} +${pastedLines.length} lines]` : `[paste #${pasteId} ${totalChars} chars]`; this.insertTextAtCursorInternal(marker); return; } if (pastedLines.length === 1) { // Single line - insert character by character to trigger autocomplete for (const char of filteredText) { this.insertCharacter(char, true); } return; } // Multi-line paste - use direct state manipulation this.insertTextAtCursorInternal(filteredText); } private addNewLine(): void { this.historyIndex = -1; // Exit history browsing mode this.lastAction = null; this.pushUndoSnapshot(); 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.setCursorCol(0); if (this.onChange) { this.onChange(this.getText()); } } private shouldSubmitOnBackslashEnter(data: string, kb: ReturnType): boolean { if (this.disableSubmit) return false; if (!matchesKey(data, "enter")) return false; const submitKeys = kb.getKeys("submit"); const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return"); if (!hasShiftEnter) return false; const currentLine = this.state.lines[this.state.cursorLine] || ""; return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\"; } private submitValue(): void { let result = this.state.lines.join("\n").trim(); for (const [pasteId, pasteContent] of this.pastes) { const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g"); result = result.replace(markerRegex, pasteContent); } this.state = { lines: [""], cursorLine: 0, cursorCol: 0 }; this.pastes.clear(); this.pasteCounter = 0; this.historyIndex = -1; this.scrollOffset = 0; this.undoStack.clear(); this.lastAction = null; if (this.onChange) this.onChange(""); if (this.onSubmit) this.onSubmit(result); } private handleBackspace(): void { this.historyIndex = -1; // Exit history browsing mode this.lastAction = null; if (this.state.cursorCol > 0) { this.pushUndoSnapshot(); // Delete grapheme before cursor (handles emojis, combining characters, etc.) const line = this.state.lines[this.state.cursorLine] || ""; const beforeCursor = line.slice(0, this.state.cursorCol); // Find the last grapheme in the text before cursor const graphemes = [...segmenter.segment(beforeCursor)]; const lastGrapheme = graphemes[graphemes.length - 1]; const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; const before = line.slice(0, this.state.cursorCol - graphemeLength); const after = line.slice(this.state.cursorCol); this.state.lines[this.state.cursorLine] = before + after; this.setCursorCol(this.state.cursorCol - graphemeLength); } else if (this.state.cursorLine > 0) { this.pushUndoSnapshot(); // 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.setCursorCol(previousLine.length); } if (this.onChange) { this.onChange(this.getText()); } // Update or re-trigger autocomplete after backspace if (this.autocompleteState) { this.updateAutocomplete(); } else { // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); // Slash command context if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } // @ file reference context else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) { this.tryTriggerAutocomplete(); } } } /** * Set cursor column and clear preferredVisualCol. * Use this for all non-vertical cursor movements to reset sticky column behavior. */ private setCursorCol(col: number): void { this.state.cursorCol = col; this.preferredVisualCol = null; } /** * Move cursor to a target visual line, applying sticky column logic. * Shared by moveCursor() and pageScroll(). */ private moveToVisualLine( visualLines: Array<{ logicalLine: number; startCol: number; length: number }>, currentVisualLine: number, targetVisualLine: number, ): void { const currentVL = visualLines[currentVisualLine]; const targetVL = visualLines[targetVisualLine]; if (currentVL && targetVL) { const currentVisualCol = this.state.cursorCol - currentVL.startCol; // For non-last segments, clamp to length-1 to stay within the segment const isLastSourceSegment = currentVisualLine === visualLines.length - 1 || visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine; const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1); const isLastTargetSegment = targetVisualLine === visualLines.length - 1 || visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine; const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1); const moveToVisualCol = this.computeVerticalMoveColumn( currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol, ); // Set cursor position this.state.cursorLine = targetVL.logicalLine; const targetCol = targetVL.startCol + moveToVisualCol; const logicalLine = this.state.lines[targetVL.logicalLine] || ""; this.state.cursorCol = Math.min(targetCol, logicalLine.length); } } /** * Compute the target visual column for vertical cursor movement. * Implements the sticky column decision table: * * | P | S | T | U | Scenario | Set Preferred | Move To | * |---|---|---|---| ---------------------------------------------------- |---------------|-------------| * | 0 | * | 0 | - | Start nav, target fits | null | current | * | 0 | * | 1 | - | Start nav, target shorter | current | target end | * | 1 | 0 | 0 | 0 | Clamped, target fits preferred | null | preferred | * | 1 | 0 | 0 | 1 | Clamped, target longer but still can't fit preferred | keep | target end | * | 1 | 0 | 1 | - | Clamped, target even shorter | keep | target end | * | 1 | 1 | 0 | - | Rewrapped, target fits current | null | current | * | 1 | 1 | 1 | - | Rewrapped, target shorter than current | current | target end | * * Where: * - P = preferred col is set * - S = cursor in middle of source line (not clamped to end) * - T = target line shorter than current visual col * - U = target line shorter than preferred col */ private computeVerticalMoveColumn( currentVisualCol: number, sourceMaxVisualCol: number, targetMaxVisualCol: number, ): number { const hasPreferred = this.preferredVisualCol !== null; // P const cursorInMiddle = currentVisualCol < sourceMaxVisualCol; // S const targetTooShort = targetMaxVisualCol < currentVisualCol; // T if (!hasPreferred || cursorInMiddle) { if (targetTooShort) { // Cases 2 and 7 this.preferredVisualCol = currentVisualCol; return targetMaxVisualCol; } // Cases 1 and 6 this.preferredVisualCol = null; return currentVisualCol; } const targetCantFitPreferred = targetMaxVisualCol < this.preferredVisualCol!; // U if (targetTooShort || targetCantFitPreferred) { // Cases 4 and 5 return targetMaxVisualCol; } // Case 3 const result = this.preferredVisualCol!; this.preferredVisualCol = null; return result; } private moveToLineStart(): void { this.lastAction = null; this.setCursorCol(0); } private moveToLineEnd(): void { this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; this.setCursorCol(currentLine.length); } private deleteToStartOfLine(): void { this.historyIndex = -1; // Exit history browsing mode const currentLine = this.state.lines[this.state.cursorLine] || ""; if (this.state.cursorCol > 0) { this.pushUndoSnapshot(); // Calculate text to be deleted and save to kill ring (backward deletion = prepend) const deletedText = currentLine.slice(0, this.state.cursorCol); this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" }); this.lastAction = "kill"; // Delete from start of line up to cursor this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol); this.setCursorCol(0); } else if (this.state.cursorLine > 0) { this.pushUndoSnapshot(); // At start of line - merge with previous line, treating newline as deleted text this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" }); this.lastAction = "kill"; 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.setCursorCol(previousLine.length); } if (this.onChange) { this.onChange(this.getText()); } } private deleteToEndOfLine(): void { this.historyIndex = -1; // Exit history browsing mode const currentLine = this.state.lines[this.state.cursorLine] || ""; if (this.state.cursorCol < currentLine.length) { this.pushUndoSnapshot(); // Calculate text to be deleted and save to kill ring (forward deletion = append) const deletedText = currentLine.slice(this.state.cursorCol); this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" }); this.lastAction = "kill"; // Delete from cursor to end of line this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol); } else if (this.state.cursorLine < this.state.lines.length - 1) { this.pushUndoSnapshot(); // At end of line - merge with next line, treating newline as deleted text this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" }); this.lastAction = "kill"; 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 deleteWordBackwards(): void { this.historyIndex = -1; // Exit history browsing mode const currentLine = this.state.lines[this.state.cursorLine] || ""; // If at start of line, behave like backspace at column 0 (merge with previous line) if (this.state.cursorCol === 0) { if (this.state.cursorLine > 0) { this.pushUndoSnapshot(); // Treat newline as deleted text (backward deletion = prepend) this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" }); this.lastAction = "kill"; 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.setCursorCol(previousLine.length); } } else { this.pushUndoSnapshot(); // Save lastAction before cursor movement (moveWordBackwards resets it) const wasKill = this.lastAction === "kill"; const oldCursorCol = this.state.cursorCol; this.moveWordBackwards(); const deleteFrom = this.state.cursorCol; this.setCursorCol(oldCursorCol); const deletedText = currentLine.slice(deleteFrom, this.state.cursorCol); this.killRing.push(deletedText, { prepend: true, accumulate: wasKill }); this.lastAction = "kill"; this.state.lines[this.state.cursorLine] = currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol); this.setCursorCol(deleteFrom); } if (this.onChange) { this.onChange(this.getText()); } } private deleteWordForward(): void { this.historyIndex = -1; // Exit history browsing mode const currentLine = this.state.lines[this.state.cursorLine] || ""; // If at end of line, merge with next line (delete the newline) if (this.state.cursorCol >= currentLine.length) { if (this.state.cursorLine < this.state.lines.length - 1) { this.pushUndoSnapshot(); // Treat newline as deleted text (forward deletion = append) this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" }); this.lastAction = "kill"; 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); } } else { this.pushUndoSnapshot(); // Save lastAction before cursor movement (moveWordForwards resets it) const wasKill = this.lastAction === "kill"; const oldCursorCol = this.state.cursorCol; this.moveWordForwards(); const deleteTo = this.state.cursorCol; this.setCursorCol(oldCursorCol); const deletedText = currentLine.slice(this.state.cursorCol, deleteTo); this.killRing.push(deletedText, { prepend: false, accumulate: wasKill }); this.lastAction = "kill"; this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol) + currentLine.slice(deleteTo); } if (this.onChange) { this.onChange(this.getText()); } } private handleForwardDelete(): void { this.historyIndex = -1; // Exit history browsing mode this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; if (this.state.cursorCol < currentLine.length) { this.pushUndoSnapshot(); // Delete grapheme at cursor position (handles emojis, combining characters, etc.) const afterCursor = currentLine.slice(this.state.cursorCol); // Find the first grapheme at cursor const graphemes = [...segmenter.segment(afterCursor)]; const firstGrapheme = graphemes[0]; const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; const before = currentLine.slice(0, this.state.cursorCol); const after = currentLine.slice(this.state.cursorCol + graphemeLength); this.state.lines[this.state.cursorLine] = before + after; } else if (this.state.cursorLine < this.state.lines.length - 1) { this.pushUndoSnapshot(); // 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()); } // Update or re-trigger autocomplete after forward delete if (this.autocompleteState) { this.updateAutocomplete(); } else { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); // Slash command context if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } // @ file reference context else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) { this.tryTriggerAutocomplete(); } } } /** * Build a mapping from visual lines to logical positions. * Returns an array where each element represents a visual line with: * - logicalLine: index into this.state.lines * - startCol: starting column in the logical line * - length: length of this visual line segment */ private buildVisualLineMap(width: number): Array<{ logicalLine: number; startCol: number; length: number }> { const visualLines: Array<{ logicalLine: number; startCol: number; length: number }> = []; for (let i = 0; i < this.state.lines.length; i++) { const line = this.state.lines[i] || ""; const lineVisWidth = visibleWidth(line); if (line.length === 0) { // Empty line still takes one visual line visualLines.push({ logicalLine: i, startCol: 0, length: 0 }); } else if (lineVisWidth <= width) { visualLines.push({ logicalLine: i, startCol: 0, length: line.length }); } else { // Line needs wrapping - use word-aware wrapping const chunks = wordWrapLine(line, width); for (const chunk of chunks) { visualLines.push({ logicalLine: i, startCol: chunk.startIndex, length: chunk.endIndex - chunk.startIndex, }); } } } return visualLines; } /** * Find the visual line index for the current cursor position. */ private findCurrentVisualLine( visualLines: Array<{ logicalLine: number; startCol: number; length: number }>, ): number { for (let i = 0; i < visualLines.length; i++) { const vl = visualLines[i]; if (!vl) continue; if (vl.logicalLine === this.state.cursorLine) { const colInSegment = this.state.cursorCol - vl.startCol; // Cursor is in this segment if it's within range // For the last segment of a logical line, cursor can be at length (end position) const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine; if (colInSegment >= 0 && (colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))) { return i; } } } // Fallback: return last visual line return visualLines.length - 1; } private moveCursor(deltaLine: number, deltaCol: number): void { this.lastAction = null; const visualLines = this.buildVisualLineMap(this.lastWidth); const currentVisualLine = this.findCurrentVisualLine(visualLines); if (deltaLine !== 0) { const targetVisualLine = currentVisualLine + deltaLine; if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) { this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine); } } if (deltaCol !== 0) { const currentLine = this.state.lines[this.state.cursorLine] || ""; if (deltaCol > 0) { // Moving right - move by one grapheme (handles emojis, combining characters, etc.) if (this.state.cursorCol < currentLine.length) { const afterCursor = currentLine.slice(this.state.cursorCol); const graphemes = [...segmenter.segment(afterCursor)]; const firstGrapheme = graphemes[0]; this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1)); } else if (this.state.cursorLine < this.state.lines.length - 1) { // Wrap to start of next logical line this.state.cursorLine++; this.setCursorCol(0); } else { // At end of last line - can't move, but set preferredVisualCol for up/down navigation const currentVL = visualLines[currentVisualLine]; if (currentVL) { this.preferredVisualCol = this.state.cursorCol - currentVL.startCol; } } } else { // Moving left - move by one grapheme (handles emojis, combining characters, etc.) if (this.state.cursorCol > 0) { const beforeCursor = currentLine.slice(0, this.state.cursorCol); const graphemes = [...segmenter.segment(beforeCursor)]; const lastGrapheme = graphemes[graphemes.length - 1]; this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1)); } else if (this.state.cursorLine > 0) { // Wrap to end of previous logical line this.state.cursorLine--; const prevLine = this.state.lines[this.state.cursorLine] || ""; this.setCursorCol(prevLine.length); } } } } /** * Scroll by a page (direction: -1 for up, 1 for down). * Moves cursor by the page size while keeping it in bounds. */ private pageScroll(direction: -1 | 1): void { this.lastAction = null; const terminalRows = this.tui.terminal.rows; const pageSize = Math.max(5, Math.floor(terminalRows * 0.3)); const visualLines = this.buildVisualLineMap(this.lastWidth); const currentVisualLine = this.findCurrentVisualLine(visualLines); const targetVisualLine = Math.max(0, Math.min(visualLines.length - 1, currentVisualLine + direction * pageSize)); this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine); } private moveWordBackwards(): void { this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; // If at start of line, move to end of previous line if (this.state.cursorCol === 0) { if (this.state.cursorLine > 0) { this.state.cursorLine--; const prevLine = this.state.lines[this.state.cursorLine] || ""; this.setCursorCol(prevLine.length); } return; } const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); const graphemes = [...segmenter.segment(textBeforeCursor)]; let newCol = this.state.cursorCol; // Skip trailing whitespace while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) { newCol -= graphemes.pop()?.segment.length || 0; } if (graphemes.length > 0) { const lastGrapheme = graphemes[graphemes.length - 1]?.segment || ""; if (isPunctuationChar(lastGrapheme)) { // Skip punctuation run while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) { newCol -= graphemes.pop()?.segment.length || 0; } } else { // Skip word run while ( graphemes.length > 0 && !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") && !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") ) { newCol -= graphemes.pop()?.segment.length || 0; } } } this.setCursorCol(newCol); } /** * Yank (paste) the most recent kill ring entry at cursor position. */ private yank(): void { if (this.killRing.length === 0) return; this.pushUndoSnapshot(); const text = this.killRing.peek()!; this.insertYankedText(text); this.lastAction = "yank"; } /** * Cycle through kill ring (only works immediately after yank or yank-pop). * Replaces the last yanked text with the previous entry in the ring. */ private yankPop(): void { // Only works if we just yanked and have more than one entry if (this.lastAction !== "yank" || this.killRing.length <= 1) return; this.pushUndoSnapshot(); // Delete the previously yanked text (still at end of ring before rotation) this.deleteYankedText(); // Rotate the ring: move end to front this.killRing.rotate(); // Insert the new most recent entry (now at end after rotation) const text = this.killRing.peek()!; this.insertYankedText(text); this.lastAction = "yank"; } /** * Insert text at cursor position (used by yank operations). */ private insertYankedText(text: string): void { this.historyIndex = -1; // Exit history browsing mode const lines = text.split("\n"); if (lines.length === 1) { // Single line - insert at cursor const currentLine = this.state.lines[this.state.cursorLine] || ""; const before = currentLine.slice(0, this.state.cursorCol); const after = currentLine.slice(this.state.cursorCol); this.state.lines[this.state.cursorLine] = before + text + after; this.setCursorCol(this.state.cursorCol + text.length); } else { // Multi-line insert const currentLine = this.state.lines[this.state.cursorLine] || ""; const before = currentLine.slice(0, this.state.cursorCol); const after = currentLine.slice(this.state.cursorCol); // First line merges with text before cursor this.state.lines[this.state.cursorLine] = before + (lines[0] || ""); // Insert middle lines for (let i = 1; i < lines.length - 1; i++) { this.state.lines.splice(this.state.cursorLine + i, 0, lines[i] || ""); } // Last line merges with text after cursor const lastLineIndex = this.state.cursorLine + lines.length - 1; this.state.lines.splice(lastLineIndex, 0, (lines[lines.length - 1] || "") + after); // Update cursor position this.state.cursorLine = lastLineIndex; this.setCursorCol((lines[lines.length - 1] || "").length); } if (this.onChange) { this.onChange(this.getText()); } } /** * Delete the previously yanked text (used by yank-pop). * The yanked text is derived from killRing[end] since it hasn't been rotated yet. */ private deleteYankedText(): void { const yankedText = this.killRing.peek(); if (!yankedText) return; const yankLines = yankedText.split("\n"); if (yankLines.length === 1) { // Single line - delete backward from cursor const currentLine = this.state.lines[this.state.cursorLine] || ""; const deleteLen = yankedText.length; const before = currentLine.slice(0, this.state.cursorCol - deleteLen); const after = currentLine.slice(this.state.cursorCol); this.state.lines[this.state.cursorLine] = before + after; this.setCursorCol(this.state.cursorCol - deleteLen); } else { // Multi-line delete - cursor is at end of last yanked line const startLine = this.state.cursorLine - (yankLines.length - 1); const startCol = (this.state.lines[startLine] || "").length - (yankLines[0] || "").length; // Get text after cursor on current line const afterCursor = (this.state.lines[this.state.cursorLine] || "").slice(this.state.cursorCol); // Get text before yank start position const beforeYank = (this.state.lines[startLine] || "").slice(0, startCol); // Remove all lines from startLine to cursorLine and replace with merged line this.state.lines.splice(startLine, yankLines.length, beforeYank + afterCursor); // Update cursor this.state.cursorLine = startLine; this.setCursorCol(startCol); } if (this.onChange) { this.onChange(this.getText()); } } private pushUndoSnapshot(): void { this.undoStack.push(this.state); } private undo(): void { this.historyIndex = -1; // Exit history browsing mode const snapshot = this.undoStack.pop(); if (!snapshot) return; Object.assign(this.state, snapshot); this.lastAction = null; this.preferredVisualCol = null; if (this.onChange) { this.onChange(this.getText()); } } /** * Jump to the first occurrence of a character in the specified direction. * Multi-line search. Case-sensitive. Skips the current cursor position. */ private jumpToChar(char: string, direction: "forward" | "backward"): void { this.lastAction = null; const isForward = direction === "forward"; const lines = this.state.lines; const end = isForward ? lines.length : -1; const step = isForward ? 1 : -1; for (let lineIdx = this.state.cursorLine; lineIdx !== end; lineIdx += step) { const line = lines[lineIdx] || ""; const isCurrentLine = lineIdx === this.state.cursorLine; // Current line: start after/before cursor; other lines: search full line const searchFrom = isCurrentLine ? isForward ? this.state.cursorCol + 1 : this.state.cursorCol - 1 : undefined; const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom); if (idx !== -1) { this.state.cursorLine = lineIdx; this.setCursorCol(idx); return; } } // No match found - cursor stays in place } private moveWordForwards(): void { this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; // If at end of line, move to start of next line if (this.state.cursorCol >= currentLine.length) { if (this.state.cursorLine < this.state.lines.length - 1) { this.state.cursorLine++; this.setCursorCol(0); } return; } const textAfterCursor = currentLine.slice(this.state.cursorCol); const segments = segmenter.segment(textAfterCursor); const iterator = segments[Symbol.iterator](); let next = iterator.next(); let newCol = this.state.cursorCol; // Skip leading whitespace while (!next.done && isWhitespaceChar(next.value.segment)) { newCol += next.value.segment.length; next = iterator.next(); } if (!next.done) { const firstGrapheme = next.value.segment; if (isPunctuationChar(firstGrapheme)) { // Skip punctuation run while (!next.done && isPunctuationChar(next.value.segment)) { newCol += next.value.segment.length; next = iterator.next(); } } else { // Skip word run while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) { newCol += next.value.segment.length; next = iterator.next(); } } } this.setCursorCol(newCol); } // Slash menu only allowed on the first line of the editor private isSlashMenuAllowed(): boolean { return this.state.cursorLine === 0; } // Helper method to check if cursor is at start of message (for slash command detection) private isAtStartOfMessage(): boolean { if (!this.isSlashMenuAllowed()) return false; const currentLine = this.state.lines[this.state.cursorLine] || ""; const beforeCursor = currentLine.slice(0, this.state.cursorCol); return beforeCursor.trim() === "" || beforeCursor.trim() === "/"; } private isInSlashCommandContext(textBeforeCursor: string): boolean { return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/"); } // 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, this.autocompleteMaxVisible, this.theme.selectList); this.autocompleteState = "regular"; } 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 (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) { this.handleSlashCommandCompletion(); } else { this.forceFileAutocomplete(true); } } private handleSlashCommandCompletion(): void { this.tryTriggerAutocomplete(true); } /* https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883 17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19 536643416/job/55932288317 havea look at .gi */ private forceFileAutocomplete(explicitTab: boolean = false): void { if (!this.autocompleteProvider) return; // Check if provider supports force file suggestions via runtime check const provider = this.autocompleteProvider as { getForceFileSuggestions?: CombinedAutocompleteProvider["getForceFileSuggestions"]; }; if (typeof provider.getForceFileSuggestions !== "function") { this.tryTriggerAutocomplete(true); return; } const suggestions = provider.getForceFileSuggestions( this.state.lines, this.state.cursorLine, this.state.cursorCol, ); if (suggestions && suggestions.items.length > 0) { // If there's exactly one suggestion, apply it immediately if (explicitTab && suggestions.items.length === 1) { const item = suggestions.items[0]!; this.pushUndoSnapshot(); this.lastAction = null; const result = this.autocompleteProvider.applyCompletion( this.state.lines, this.state.cursorLine, this.state.cursorCol, item, suggestions.prefix, ); this.state.lines = result.lines; this.state.cursorLine = result.cursorLine; this.setCursorCol(result.cursorCol); if (this.onChange) this.onChange(this.getText()); return; } this.autocompletePrefix = suggestions.prefix; this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList); this.autocompleteState = "force"; } else { this.cancelAutocomplete(); } } private cancelAutocomplete(): void { this.autocompleteState = null; this.autocompleteList = undefined; this.autocompletePrefix = ""; } public isShowingAutocomplete(): boolean { return this.autocompleteState !== null; } private updateAutocomplete(): void { if (!this.autocompleteState || !this.autocompleteProvider) return; if (this.autocompleteState === "force") { this.forceFileAutocomplete(); 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; // Always create new SelectList to ensure update this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList); } else { this.cancelAutocomplete(); } } }