co-mono/packages/tui/src/components/editor.ts
2025-12-21 22:56:20 +01:00

1316 lines
40 KiB
TypeScript

import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
import {
isAltBackspace,
isAltEnter,
isAltLeft,
isAltRight,
isArrowDown,
isArrowLeft,
isArrowRight,
isArrowUp,
isBackspace,
isCtrlA,
isCtrlC,
isCtrlE,
isCtrlK,
isCtrlLeft,
isCtrlRight,
isCtrlU,
isCtrlW,
isDelete,
isEnd,
isEnter,
isEscape,
isHome,
isShiftEnter,
isTab,
} from "../keys.js";
import type { Component } from "../tui.js";
import { visibleWidth } from "../utils.js";
import { SelectList, type SelectListTheme } from "./select-list.js";
// Grapheme segmenter for proper Unicode iteration (handles emojis, etc.)
const segmenter = new Intl.Segmenter();
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 class Editor implements Component {
private state: EditorState = {
lines: [""],
cursorLine: 0,
cursorCol: 0,
};
private theme: EditorTheme;
// Store last render width for cursor navigation
private lastWidth: number = 80;
// Border color (can be changed dynamically)
public borderColor: (str: string) => string;
// Autocomplete support
private autocompleteProvider?: AutocompleteProvider;
private autocompleteList?: SelectList;
private isAutocompleting: boolean = false;
private autocompletePrefix: string = "";
// Paste tracking for large pastes
private pastes: Map<number, string> = 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.
public onSubmit?: (text: string) => void;
public onChange?: (text: string) => void;
public disableSubmit: boolean = false;
constructor(theme: EditorTheme) {
this.theme = theme;
this.borderColor = theme.borderColor;
}
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 {
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;
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.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0;
if (this.onChange) {
this.onChange(this.getText());
}
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
// Store width for cursor navigation
this.lastWidth = width;
const horizontal = this.borderColor("─");
// 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 lineVisibleWidth = visibleWidth(layoutLine.text);
// 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 (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 + cursor + restAfter;
// lineVisibleWidth stays the same - we're replacing, not adding
} else {
// Cursor is at the end - check if we have room for the space
if (lineVisibleWidth < width) {
// We have room - add highlighted space
const cursor = "\x1b[7m \x1b[0m";
displayText = before + cursor;
// lineVisibleWidth increases by 1 - we're adding a space
lineVisibleWidth = lineVisibleWidth + 1;
} else {
// Line is at full width - use reverse video on last grapheme if possible
// or just show cursor at the end without adding space
const beforeGraphemes = [...segmenter.segment(before)];
if (beforeGraphemes.length > 0) {
const lastGrapheme = beforeGraphemes[beforeGraphemes.length - 1]?.segment || "";
const cursor = `\x1b[7m${lastGrapheme}\x1b[0m`;
// Rebuild 'before' without the last grapheme
const beforeWithoutLast = beforeGraphemes
.slice(0, -1)
.map((g) => g.segment)
.join("");
displayText = beforeWithoutLast + cursor;
}
// lineVisibleWidth stays the same
}
}
}
// Calculate padding based on actual visible width
const padding = " ".repeat(Math.max(0, width - lineVisibleWidth));
// 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 (isCtrlC(data)) {
return;
}
// Handle autocomplete special keys first (but don't block other input)
if (this.isAutocompleting && this.autocompleteList) {
// Escape - cancel autocomplete
if (isEscape(data)) {
this.cancelAutocomplete();
return;
}
// Let the autocomplete list handle navigation and selection
else if (isArrowUp(data) || isArrowDown(data) || isEnter(data) || isTab(data)) {
// Only pass arrow keys to the list, not Enter/Tab (we handle those directly)
if (isArrowUp(data) || isArrowDown(data)) {
this.autocompleteList.handleInput(data);
return;
}
// If Tab was pressed, always apply the selection
if (isTab(data)) {
const selected = this.autocompleteList.getSelectedItem();
if (selected && this.autocompleteProvider) {
const result = this.autocompleteProvider.applyCompletion(
this.state.lines,
this.state.cursorLine,
this.state.cursorCol,
selected,
this.autocompletePrefix,
);
this.state.lines = result.lines;
this.state.cursorLine = result.cursorLine;
this.state.cursorCol = result.cursorCol;
this.cancelAutocomplete();
if (this.onChange) {
this.onChange(this.getText());
}
}
return;
}
// If Enter was pressed on a slash command, apply completion and submit
if (isEnter(data) && this.autocompletePrefix.startsWith("/")) {
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();
// Don't return - fall through to submission logic
}
// If Enter was pressed on a file path, apply completion
else if (isEnter(data)) {
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;
}
}
// 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 (isTab(data) && !this.isAutocompleting) {
this.handleTabCompletion();
return;
}
// Continue with rest of input handling
// Ctrl+K - Delete to end of line
if (isCtrlK(data)) {
this.deleteToEndOfLine();
}
// Ctrl+U - Delete to start of line
else if (isCtrlU(data)) {
this.deleteToStartOfLine();
}
// Ctrl+W - Delete word backwards
else if (isCtrlW(data)) {
this.deleteWordBackwards();
}
// Option/Alt+Backspace - Delete word backwards
else if (isAltBackspace(data)) {
this.deleteWordBackwards();
}
// Ctrl+A - Move to start of line
else if (isCtrlA(data)) {
this.moveToLineStart();
}
// Ctrl+E - Move to end of line
else if (isCtrlE(data)) {
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 (legacy)
data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
isShiftEnter(data) || // Shift+Enter (Kitty protocol, handles lock bits)
isAltEnter(data) || // Alt+Enter (Kitty protocol, handles lock bits)
(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 - submit (handles both legacy \r and Kitty protocol with lock bits)
else if (isEnter(data)) {
// 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] or [paste #N xxx chars] markers with actual paste content
for (const [pasteId, pasteContent] of this.pastes) {
// Match formats: [paste #N], [paste #N +xxx lines], or [paste #N xxx chars]
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
result = result.replace(markerRegex, pasteContent);
}
// Reset editor and clear pastes
this.state = {
lines: [""],
cursorLine: 0,
cursorCol: 0,
};
this.pastes.clear();
this.pasteCounter = 0;
this.historyIndex = -1; // Exit history browsing mode
// Notify that editor is now empty
if (this.onChange) {
this.onChange("");
}
if (this.onSubmit) {
this.onSubmit(result);
}
}
// Backspace
else if (isBackspace(data)) {
this.handleBackspace();
}
// Line navigation shortcuts (Home/End keys)
else if (isHome(data)) {
this.moveToLineStart();
} else if (isEnd(data)) {
this.moveToLineEnd();
}
// Forward delete (Fn+Backspace or Delete key)
else if (isDelete(data)) {
this.handleForwardDelete();
}
// Word navigation (Option/Alt + Arrow or Ctrl + Arrow)
else if (isAltLeft(data) || isCtrlLeft(data)) {
// Word left
this.moveWordBackwards();
} else if (isAltRight(data) || isCtrlRight(data)) {
// Word right
this.moveWordForwards();
}
// Arrow keys
else if (isArrowUp(data)) {
// Up - history navigation or cursor movement
if (this.isEditorEmpty()) {
this.navigateHistory(-1); // Start browsing history
} else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
this.navigateHistory(-1); // Navigate to older history entry
} else {
this.moveCursor(-1, 0); // Cursor movement (within text or history entry)
}
} else if (isArrowDown(data)) {
// Down - history navigation or cursor movement
if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
this.navigateHistory(1); // Navigate to newer history entry or clear
} else {
this.moveCursor(1, 0); // Cursor movement (within text or history entry)
}
} else if (isArrowRight(data)) {
// Right
this.moveCursor(0, 1);
} else if (isArrowLeft(data)) {
// Left
this.moveCursor(0, -1);
}
// Regular characters (printable characters and unicode, but not control characters)
else 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 grapheme-aware chunking
const chunks: { text: string; startIndex: number; endIndex: number }[] = [];
let currentChunk = "";
let currentWidth = 0;
let chunkStartIndex = 0;
let currentIndex = 0;
for (const seg of segmenter.segment(line)) {
const grapheme = seg.segment;
const graphemeWidth = visibleWidth(grapheme);
if (currentWidth + graphemeWidth > contentWidth && currentChunk !== "") {
// Start a new chunk
chunks.push({
text: currentChunk,
startIndex: chunkStartIndex,
endIndex: currentIndex,
});
currentChunk = grapheme;
currentWidth = graphemeWidth;
chunkStartIndex = currentIndex;
} else {
currentChunk += grapheme;
currentWidth += graphemeWidth;
}
currentIndex += grapheme.length;
}
// Push the last chunk
if (currentChunk !== "") {
chunks.push({
text: currentChunk,
startIndex: chunkStartIndex,
endIndex: currentIndex,
});
}
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;
// For non-last chunks, cursor at endIndex belongs to the next chunk
const hasCursorInChunk =
isCurrentLine &&
cursorPos >= chunk.startIndex &&
(isLastChunk ? cursorPos <= chunk.endIndex : cursorPos < chunk.endIndex);
if (hasCursorInChunk) {
layoutLines.push({
text: chunk.text,
hasCursor: true,
cursorPos: cursorPos - chunk.startIndex,
});
} else {
layoutLines.push({
text: chunk.text,
hasCursor: false,
});
}
}
}
}
return layoutLines;
}
getText(): string {
return this.state.lines.join("\n");
}
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.historyIndex = -1; // Exit history browsing mode
this.setTextInternal(text);
}
// All the editor methods from before...
private insertCharacter(char: string): void {
this.historyIndex = -1; // Exit history browsing mode
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();
}
// 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 (textBeforeCursor.trimStart().startsWith("/")) {
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
// 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.charCodeAt(0) >= 32)
.join("");
// Split into lines
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]`;
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 {
this.historyIndex = -1; // Exit history browsing mode
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 {
this.historyIndex = -1; // Exit history browsing mode
if (this.state.cursorCol > 0) {
// 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.state.cursorCol -= graphemeLength;
} 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 or re-trigger autocomplete after backspace
if (this.isAutocompleting) {
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 (textBeforeCursor.trimStart().startsWith("/")) {
this.tryTriggerAutocomplete();
}
// @ file reference context
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
this.tryTriggerAutocomplete();
}
}
}
private moveToLineStart(): void {
this.state.cursorCol = 0;
}
private moveToLineEnd(): void {
const currentLine = this.state.lines[this.state.cursorLine] || "";
this.state.cursorCol = 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) {
// Delete from start of line up to cursor
this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol);
this.state.cursorCol = 0;
} else if (this.state.cursorLine > 0) {
// At start of line - merge with previous line
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());
}
}
private deleteToEndOfLine(): void {
this.historyIndex = -1; // Exit history browsing mode
const currentLine = this.state.lines[this.state.cursorLine] || "";
if (this.state.cursorCol < currentLine.length) {
// 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) {
// 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 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) {
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;
}
} else {
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
const isWhitespace = (char: string): boolean => /\s/.test(char);
const isPunctuation = (char: string): boolean => {
// Treat obvious code punctuation as boundaries
return /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/.test(char);
};
let deleteFrom = this.state.cursorCol;
const lastChar = textBeforeCursor[deleteFrom - 1] ?? "";
// If immediately on whitespace or punctuation, delete that single boundary char
if (isWhitespace(lastChar) || isPunctuation(lastChar)) {
deleteFrom -= 1;
} else {
// Otherwise, delete a run of non-boundary characters (the "word")
while (deleteFrom > 0) {
const ch = textBeforeCursor[deleteFrom - 1] ?? "";
if (isWhitespace(ch) || isPunctuation(ch)) {
break;
}
deleteFrom -= 1;
}
}
this.state.lines[this.state.cursorLine] =
currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol);
this.state.cursorCol = deleteFrom;
}
if (this.onChange) {
this.onChange(this.getText());
}
}
private handleForwardDelete(): void {
this.historyIndex = -1; // Exit history browsing mode
const currentLine = this.state.lines[this.state.cursorLine] || "";
if (this.state.cursorCol < currentLine.length) {
// 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) {
// 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.isAutocompleting) {
this.updateAutocomplete();
} else {
const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
// Slash command context
if (textBeforeCursor.trimStart().startsWith("/")) {
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 grapheme-aware chunking
let currentWidth = 0;
let chunkStartIndex = 0;
let currentIndex = 0;
for (const seg of segmenter.segment(line)) {
const grapheme = seg.segment;
const graphemeWidth = visibleWidth(grapheme);
if (currentWidth + graphemeWidth > width && currentIndex > chunkStartIndex) {
// Start a new chunk
visualLines.push({
logicalLine: i,
startCol: chunkStartIndex,
length: currentIndex - chunkStartIndex,
});
chunkStartIndex = currentIndex;
currentWidth = graphemeWidth;
} else {
currentWidth += graphemeWidth;
}
currentIndex += grapheme.length;
}
// Push the last chunk
if (currentIndex > chunkStartIndex) {
visualLines.push({
logicalLine: i,
startCol: chunkStartIndex,
length: currentIndex - chunkStartIndex,
});
}
}
}
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 {
const width = this.lastWidth;
if (deltaLine !== 0) {
// Build visual line map for navigation
const visualLines = this.buildVisualLineMap(width);
const currentVisualLine = this.findCurrentVisualLine(visualLines);
// Calculate column position within current visual line
const currentVL = visualLines[currentVisualLine];
const visualCol = currentVL ? this.state.cursorCol - currentVL.startCol : 0;
// Move to target visual line
const targetVisualLine = currentVisualLine + deltaLine;
if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
const targetVL = visualLines[targetVisualLine];
if (targetVL) {
this.state.cursorLine = targetVL.logicalLine;
// Try to maintain visual column position, clamped to line length
const targetCol = targetVL.startCol + Math.min(visualCol, targetVL.length);
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
}
}
}
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.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.state.cursorCol = 0;
}
} 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.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.state.cursorCol = prevLine.length;
}
}
}
}
private isWordBoundary(char: string): boolean {
return /\s/.test(char) || /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/.test(char);
}
private moveWordBackwards(): void {
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.state.cursorCol = prevLine.length;
}
return;
}
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
let newCol = this.state.cursorCol;
const lastChar = textBeforeCursor[newCol - 1] ?? "";
// If immediately on whitespace or punctuation, skip that single boundary char
if (this.isWordBoundary(lastChar)) {
newCol -= 1;
}
// Now skip the "word" (non-boundary characters)
while (newCol > 0) {
const ch = textBeforeCursor[newCol - 1] ?? "";
if (this.isWordBoundary(ch)) {
break;
}
newCol -= 1;
}
this.state.cursorCol = newCol;
}
private moveWordForwards(): void {
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.state.cursorCol = 0;
}
return;
}
let newCol = this.state.cursorCol;
const charAtCursor = currentLine[newCol] ?? "";
// If on whitespace or punctuation, skip it
if (this.isWordBoundary(charAtCursor)) {
newCol += 1;
}
// Skip the "word" (non-boundary characters)
while (newCol < currentLine.length) {
const ch = currentLine[newCol] ?? "";
if (this.isWordBoundary(ch)) {
break;
}
newCol += 1;
}
this.state.cursorCol = 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.theme.selectList);
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("/") && !beforeCursor.trimStart().includes(" ")) {
this.handleSlashCommandCompletion();
} else {
this.forceFileAutocomplete();
}
}
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(): 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.theme.selectList);
this.isAutocompleting = true;
} else {
this.cancelAutocomplete();
}
}
private cancelAutocomplete(): void {
this.isAutocompleting = false;
this.autocompleteList = undefined as any;
this.autocompletePrefix = "";
}
public isShowingAutocomplete(): boolean {
return this.isAutocompleting;
}
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;
// Always create new SelectList to ensure update
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
} else {
this.cancelAutocomplete();
}
}
}