@mariozechner/pi-tui 0.45.7 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/components/editor.d.ts +12 -3
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +92 -10
- package/dist/components/editor.js.map +1 -1
- package/dist/components/input.d.ts +4 -2
- package/dist/components/input.d.ts.map +1 -1
- package/dist/components/input.js +6 -1
- package/dist/components/input.js.map +1 -1
- package/dist/components/select-list.d.ts.map +1 -1
- package/dist/components/select-list.js +6 -4
- package/dist/components/select-list.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/keybindings.d.ts +1 -1
- package/dist/keybindings.d.ts.map +1 -1
- package/dist/keybindings.js +4 -0
- package/dist/keybindings.js.map +1 -1
- package/dist/keys.d.ts +16 -1
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +316 -48
- package/dist/keys.js.map +1 -1
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +3 -1
- package/dist/terminal.js.map +1 -1
- package/dist/tui.d.ts +32 -0
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +91 -6
- package/dist/tui.js.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +15 -0
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ const tui = new TUI(terminal);
|
|
|
27
27
|
// Add components
|
|
28
28
|
tui.addChild(new Text("Welcome to my app!"));
|
|
29
29
|
|
|
30
|
-
const editor = new Editor(editorTheme);
|
|
30
|
+
const editor = new Editor(tui, editorTheme);
|
|
31
31
|
editor.onSubmit = (text) => {
|
|
32
32
|
console.log("Submitted:", text);
|
|
33
33
|
tui.addChild(new Text(`You said: ${text}`));
|
|
@@ -212,7 +212,7 @@ input.getValue();
|
|
|
212
212
|
|
|
213
213
|
### Editor
|
|
214
214
|
|
|
215
|
-
Multi-line text editor with autocomplete, file completion, and
|
|
215
|
+
Multi-line text editor with autocomplete, file completion, paste handling, and vertical scrolling when content exceeds terminal height.
|
|
216
216
|
|
|
217
217
|
```typescript
|
|
218
218
|
interface EditorTheme {
|
|
@@ -220,7 +220,7 @@ interface EditorTheme {
|
|
|
220
220
|
selectList: SelectListTheme;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
const editor = new Editor(theme);
|
|
223
|
+
const editor = new Editor(tui, theme); // tui is required for height-aware scrolling
|
|
224
224
|
editor.onSubmit = (text) => console.log(text);
|
|
225
225
|
editor.onChange = (text) => console.log("Changed:", text);
|
|
226
226
|
editor.disableSubmit = true; // Disable submit temporarily
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import type { AutocompleteProvider } from "../autocomplete.js";
|
|
2
|
-
import type
|
|
2
|
+
import { type Component, type Focusable, type TUI } from "../tui.js";
|
|
3
3
|
import { type SelectListTheme } from "./select-list.js";
|
|
4
4
|
export interface EditorTheme {
|
|
5
5
|
borderColor: (str: string) => string;
|
|
6
6
|
selectList: SelectListTheme;
|
|
7
7
|
}
|
|
8
|
-
export declare class Editor implements Component {
|
|
8
|
+
export declare class Editor implements Component, Focusable {
|
|
9
9
|
private state;
|
|
10
|
+
/** Focusable interface - set by TUI when focus changes */
|
|
11
|
+
focused: boolean;
|
|
12
|
+
protected tui: TUI;
|
|
10
13
|
private theme;
|
|
11
14
|
private lastWidth;
|
|
15
|
+
private scrollOffset;
|
|
12
16
|
borderColor: (str: string) => string;
|
|
13
17
|
private autocompleteProvider?;
|
|
14
18
|
private autocompleteList?;
|
|
@@ -24,7 +28,7 @@ export declare class Editor implements Component {
|
|
|
24
28
|
onSubmit?: (text: string) => void;
|
|
25
29
|
onChange?: (text: string) => void;
|
|
26
30
|
disableSubmit: boolean;
|
|
27
|
-
constructor(theme: EditorTheme);
|
|
31
|
+
constructor(tui: TUI, theme: EditorTheme);
|
|
28
32
|
setAutocompleteProvider(provider: AutocompleteProvider): void;
|
|
29
33
|
/**
|
|
30
34
|
* Add a prompt to history for up/down arrow navigation.
|
|
@@ -81,6 +85,11 @@ export declare class Editor implements Component {
|
|
|
81
85
|
*/
|
|
82
86
|
private findCurrentVisualLine;
|
|
83
87
|
private moveCursor;
|
|
88
|
+
/**
|
|
89
|
+
* Scroll by a page (direction: -1 for up, 1 for down).
|
|
90
|
+
* Moves cursor by the page size while keeping it in bounds.
|
|
91
|
+
*/
|
|
92
|
+
private pageScroll;
|
|
84
93
|
private moveWordBackwards;
|
|
85
94
|
private moveWordForwards;
|
|
86
95
|
private isAtStartOfMessage;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"editor.d.ts","sourceRoot":"","sources":["../../src/components/editor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAgC,MAAM,oBAAoB,CAAC;AAG7F,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAE3C,OAAO,EAAc,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAoMpE,MAAM,WAAW,WAAW;IAC3B,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;IACrC,UAAU,EAAE,eAAe,CAAC;CAC5B;AAED,qBAAa,MAAO,YAAW,SAAS;IACvC,OAAO,CAAC,KAAK,CAIX;IAEF,OAAO,CAAC,KAAK,CAAc;IAG3B,OAAO,CAAC,SAAS,CAAc;IAGxB,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;IAG5C,OAAO,CAAC,oBAAoB,CAAC,CAAuB;IACpD,OAAO,CAAC,gBAAgB,CAAC,CAAa;IACtC,OAAO,CAAC,gBAAgB,CAAkB;IAC1C,OAAO,CAAC,kBAAkB,CAAc;IAGxC,OAAO,CAAC,MAAM,CAAkC;IAChD,OAAO,CAAC,YAAY,CAAa;IAGjC,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,SAAS,CAAkB;IACnC,OAAO,CAAC,iBAAiB,CAAkB;IAG3C,OAAO,CAAC,OAAO,CAAgB;IAC/B,OAAO,CAAC,YAAY,CAAc;IAE3B,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,aAAa,EAAE,OAAO,CAAS;IAEtC,YAAY,KAAK,EAAE,WAAW,EAG7B;IAED,uBAAuB,CAAC,QAAQ,EAAE,oBAAoB,GAAG,IAAI,CAE5D;IAED;;;OAGG;IACH,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAU/B;IAED,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,mBAAmB;IAM3B,OAAO,CAAC,kBAAkB;IAM1B,OAAO,CAAC,eAAe;IAgBvB,kFAAkF;IAClF,OAAO,CAAC,eAAe;IAWvB,UAAU,IAAI,IAAI,CAEjB;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CA6E9B;IAED,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAgO9B;IAED,OAAO,CAAC,UAAU;IAwFlB,OAAO,IAAI,MAAM,CAEhB;IAED;;;OAGG;IACH,eAAe,IAAI,MAAM,CAOxB;IAED,QAAQ,IAAI,MAAM,EAAE,CAEnB;IAED,SAAS,IAAI;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAEzC;IAED,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAG1B;IAED;;;OAGG;IACH,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAIrC;IAGD,OAAO,CAAC,eAAe;IAiDvB,OAAO,CAAC,WAAW;IAoGnB,OAAO,CAAC,UAAU;IAqBlB,OAAO,CAAC,eAAe;IAoDvB,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,aAAa;IAKrB,OAAO,CAAC,mBAAmB;IAuB3B,OAAO,CAAC,iBAAiB;IAoBzB,OAAO,CAAC,mBAAmB;IA8B3B,OAAO,CAAC,mBAAmB;IA6C3B;;;;;;OAMG;IACH,OAAO,CAAC,kBAAkB;IA2B1B;;OAEG;IACH,OAAO,CAAC,qBAAqB;IAqB7B,OAAO,CAAC,UAAU;IA2DlB,OAAO,CAAC,iBAAiB;IA4CzB,OAAO,CAAC,gBAAgB;IA0CxB,OAAO,CAAC,kBAAkB;IAS1B,OAAO,CAAC,sBAAsB;IA6B9B,OAAO,CAAC,mBAAmB;IAc3B,OAAO,CAAC,4BAA4B;IASpC,OAAO,CAAC,qBAAqB;IA2B7B,OAAO,CAAC,kBAAkB;IAMnB,qBAAqB,IAAI,OAAO,CAEtC;IAED,OAAO,CAAC,kBAAkB;CAiB1B","sourcesContent":["import type { AutocompleteProvider, CombinedAutocompleteProvider } from \"../autocomplete.js\";\nimport { getEditorKeybindings } from \"../keybindings.js\";\nimport { matchesKey } from \"../keys.js\";\nimport type { Component } from \"../tui.js\";\nimport { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from \"../utils.js\";\nimport { SelectList, type SelectListTheme } from \"./select-list.js\";\n\nconst segmenter = getSegmenter();\n\n/**\n * Represents a chunk of text for word-wrap layout.\n * Tracks both the text content and its position in the original line.\n */\ninterface TextChunk {\n\ttext: string;\n\tstartIndex: number;\n\tendIndex: number;\n}\n\n/**\n * Split a line into word-wrapped chunks.\n * Wraps at word boundaries when possible, falling back to character-level\n * wrapping for words longer than the available width.\n *\n * @param line - The text line to wrap\n * @param maxWidth - Maximum visible width per chunk\n * @returns Array of chunks with text and position information\n */\nfunction wordWrapLine(line: string, maxWidth: number): TextChunk[] {\n\tif (!line || maxWidth <= 0) {\n\t\treturn [{ text: \"\", startIndex: 0, endIndex: 0 }];\n\t}\n\n\tconst lineWidth = visibleWidth(line);\n\tif (lineWidth <= maxWidth) {\n\t\treturn [{ text: line, startIndex: 0, endIndex: line.length }];\n\t}\n\n\tconst chunks: TextChunk[] = [];\n\n\t// Split into tokens (words and whitespace runs)\n\tconst tokens: { text: string; startIndex: number; endIndex: number; isWhitespace: boolean }[] = [];\n\tlet currentToken = \"\";\n\tlet tokenStart = 0;\n\tlet inWhitespace = false;\n\tlet charIndex = 0;\n\n\tfor (const seg of segmenter.segment(line)) {\n\t\tconst grapheme = seg.segment;\n\t\tconst graphemeIsWhitespace = isWhitespaceChar(grapheme);\n\n\t\tif (currentToken === \"\") {\n\t\t\tinWhitespace = graphemeIsWhitespace;\n\t\t\ttokenStart = charIndex;\n\t\t} else if (graphemeIsWhitespace !== inWhitespace) {\n\t\t\t// Token type changed - save current token\n\t\t\ttokens.push({\n\t\t\t\ttext: currentToken,\n\t\t\t\tstartIndex: tokenStart,\n\t\t\t\tendIndex: charIndex,\n\t\t\t\tisWhitespace: inWhitespace,\n\t\t\t});\n\t\t\tcurrentToken = \"\";\n\t\t\ttokenStart = charIndex;\n\t\t\tinWhitespace = graphemeIsWhitespace;\n\t\t}\n\n\t\tcurrentToken += grapheme;\n\t\tcharIndex += grapheme.length;\n\t}\n\n\t// Push final token\n\tif (currentToken) {\n\t\ttokens.push({\n\t\t\ttext: currentToken,\n\t\t\tstartIndex: tokenStart,\n\t\t\tendIndex: charIndex,\n\t\t\tisWhitespace: inWhitespace,\n\t\t});\n\t}\n\n\t// Build chunks using word wrapping\n\tlet currentChunk = \"\";\n\tlet currentWidth = 0;\n\tlet chunkStartIndex = 0;\n\tlet atLineStart = true; // Track if we're at the start of a line (for skipping whitespace)\n\n\tfor (const token of tokens) {\n\t\tconst tokenWidth = visibleWidth(token.text);\n\n\t\t// Skip leading whitespace at line start\n\t\tif (atLineStart && token.isWhitespace) {\n\t\t\tchunkStartIndex = token.endIndex;\n\t\t\tcontinue;\n\t\t}\n\t\tatLineStart = false;\n\n\t\t// If this single token is wider than maxWidth, we need to break it\n\t\tif (tokenWidth > maxWidth) {\n\t\t\t// First, push any accumulated chunk\n\t\t\tif (currentChunk) {\n\t\t\t\tchunks.push({\n\t\t\t\t\ttext: currentChunk,\n\t\t\t\t\tstartIndex: chunkStartIndex,\n\t\t\t\t\tendIndex: token.startIndex,\n\t\t\t\t});\n\t\t\t\tcurrentChunk = \"\";\n\t\t\t\tcurrentWidth = 0;\n\t\t\t\tchunkStartIndex = token.startIndex;\n\t\t\t}\n\n\t\t\t// Break the long token by grapheme\n\t\t\tlet tokenChunk = \"\";\n\t\t\tlet tokenChunkWidth = 0;\n\t\t\tlet tokenChunkStart = token.startIndex;\n\t\t\tlet tokenCharIndex = token.startIndex;\n\n\t\t\tfor (const seg of segmenter.segment(token.text)) {\n\t\t\t\tconst grapheme = seg.segment;\n\t\t\t\tconst graphemeWidth = visibleWidth(grapheme);\n\n\t\t\t\tif (tokenChunkWidth + graphemeWidth > maxWidth && tokenChunk) {\n\t\t\t\t\tchunks.push({\n\t\t\t\t\t\ttext: tokenChunk,\n\t\t\t\t\t\tstartIndex: tokenChunkStart,\n\t\t\t\t\t\tendIndex: tokenCharIndex,\n\t\t\t\t\t});\n\t\t\t\t\ttokenChunk = grapheme;\n\t\t\t\t\ttokenChunkWidth = graphemeWidth;\n\t\t\t\t\ttokenChunkStart = tokenCharIndex;\n\t\t\t\t} else {\n\t\t\t\t\ttokenChunk += grapheme;\n\t\t\t\t\ttokenChunkWidth += graphemeWidth;\n\t\t\t\t}\n\t\t\t\ttokenCharIndex += grapheme.length;\n\t\t\t}\n\n\t\t\t// Keep remainder as start of next chunk\n\t\t\tif (tokenChunk) {\n\t\t\t\tcurrentChunk = tokenChunk;\n\t\t\t\tcurrentWidth = tokenChunkWidth;\n\t\t\t\tchunkStartIndex = tokenChunkStart;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Check if adding this token would exceed width\n\t\tif (currentWidth + tokenWidth > maxWidth) {\n\t\t\t// Push current chunk (trimming trailing whitespace for display)\n\t\t\tconst trimmedChunk = currentChunk.trimEnd();\n\t\t\tif (trimmedChunk || chunks.length === 0) {\n\t\t\t\tchunks.push({\n\t\t\t\t\ttext: trimmedChunk,\n\t\t\t\t\tstartIndex: chunkStartIndex,\n\t\t\t\t\tendIndex: chunkStartIndex + currentChunk.length,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\t// Start new line - skip leading whitespace\n\t\t\tatLineStart = true;\n\t\t\tif (token.isWhitespace) {\n\t\t\t\tcurrentChunk = \"\";\n\t\t\t\tcurrentWidth = 0;\n\t\t\t\tchunkStartIndex = token.endIndex;\n\t\t\t} else {\n\t\t\t\tcurrentChunk = token.text;\n\t\t\t\tcurrentWidth = tokenWidth;\n\t\t\t\tchunkStartIndex = token.startIndex;\n\t\t\t\tatLineStart = false;\n\t\t\t}\n\t\t} else {\n\t\t\t// Add token to current chunk\n\t\t\tcurrentChunk += token.text;\n\t\t\tcurrentWidth += tokenWidth;\n\t\t}\n\t}\n\n\t// Push final chunk\n\tif (currentChunk) {\n\t\tchunks.push({\n\t\t\ttext: currentChunk,\n\t\t\tstartIndex: chunkStartIndex,\n\t\t\tendIndex: line.length,\n\t\t});\n\t}\n\n\treturn chunks.length > 0 ? chunks : [{ text: \"\", startIndex: 0, endIndex: 0 }];\n}\n\ninterface EditorState {\n\tlines: string[];\n\tcursorLine: number;\n\tcursorCol: number;\n}\n\ninterface LayoutLine {\n\ttext: string;\n\thasCursor: boolean;\n\tcursorPos?: number;\n}\n\nexport interface EditorTheme {\n\tborderColor: (str: string) => string;\n\tselectList: SelectListTheme;\n}\n\nexport class Editor implements Component {\n\tprivate state: EditorState = {\n\t\tlines: [\"\"],\n\t\tcursorLine: 0,\n\t\tcursorCol: 0,\n\t};\n\n\tprivate theme: EditorTheme;\n\n\t// Store last render width for cursor navigation\n\tprivate lastWidth: number = 80;\n\n\t// Border color (can be changed dynamically)\n\tpublic borderColor: (str: string) => string;\n\n\t// Autocomplete support\n\tprivate autocompleteProvider?: AutocompleteProvider;\n\tprivate autocompleteList?: SelectList;\n\tprivate isAutocompleting: boolean = false;\n\tprivate autocompletePrefix: string = \"\";\n\n\t// Paste tracking for large pastes\n\tprivate pastes: Map<number, string> = new Map();\n\tprivate pasteCounter: number = 0;\n\n\t// Bracketed paste mode buffering\n\tprivate pasteBuffer: string = \"\";\n\tprivate isInPaste: boolean = false;\n\tprivate pendingShiftEnter: boolean = false;\n\n\t// Prompt history for up/down navigation\n\tprivate history: string[] = [];\n\tprivate historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.\n\n\tpublic onSubmit?: (text: string) => void;\n\tpublic onChange?: (text: string) => void;\n\tpublic disableSubmit: boolean = false;\n\n\tconstructor(theme: EditorTheme) {\n\t\tthis.theme = theme;\n\t\tthis.borderColor = theme.borderColor;\n\t}\n\n\tsetAutocompleteProvider(provider: AutocompleteProvider): void {\n\t\tthis.autocompleteProvider = provider;\n\t}\n\n\t/**\n\t * Add a prompt to history for up/down arrow navigation.\n\t * Called after successful submission.\n\t */\n\taddToHistory(text: string): void {\n\t\tconst trimmed = text.trim();\n\t\tif (!trimmed) return;\n\t\t// Don't add consecutive duplicates\n\t\tif (this.history.length > 0 && this.history[0] === trimmed) return;\n\t\tthis.history.unshift(trimmed);\n\t\t// Limit history size\n\t\tif (this.history.length > 100) {\n\t\t\tthis.history.pop();\n\t\t}\n\t}\n\n\tprivate isEditorEmpty(): boolean {\n\t\treturn this.state.lines.length === 1 && this.state.lines[0] === \"\";\n\t}\n\n\tprivate isOnFirstVisualLine(): boolean {\n\t\tconst visualLines = this.buildVisualLineMap(this.lastWidth);\n\t\tconst currentVisualLine = this.findCurrentVisualLine(visualLines);\n\t\treturn currentVisualLine === 0;\n\t}\n\n\tprivate isOnLastVisualLine(): boolean {\n\t\tconst visualLines = this.buildVisualLineMap(this.lastWidth);\n\t\tconst currentVisualLine = this.findCurrentVisualLine(visualLines);\n\t\treturn currentVisualLine === visualLines.length - 1;\n\t}\n\n\tprivate navigateHistory(direction: 1 | -1): void {\n\t\tif (this.history.length === 0) return;\n\n\t\tconst newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases\n\t\tif (newIndex < -1 || newIndex >= this.history.length) return;\n\n\t\tthis.historyIndex = newIndex;\n\n\t\tif (this.historyIndex === -1) {\n\t\t\t// Returned to \"current\" state - clear editor\n\t\t\tthis.setTextInternal(\"\");\n\t\t} else {\n\t\t\tthis.setTextInternal(this.history[this.historyIndex] || \"\");\n\t\t}\n\t}\n\n\t/** Internal setText that doesn't reset history state - used by navigateHistory */\n\tprivate setTextInternal(text: string): void {\n\t\tconst lines = text.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\").split(\"\\n\");\n\t\tthis.state.lines = lines.length === 0 ? [\"\"] : lines;\n\t\tthis.state.cursorLine = this.state.lines.length - 1;\n\t\tthis.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0;\n\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\t}\n\n\tinvalidate(): void {\n\t\t// No cached state to invalidate currently\n\t}\n\n\trender(width: number): string[] {\n\t\t// Store width for cursor navigation\n\t\tthis.lastWidth = width;\n\n\t\tconst horizontal = this.borderColor(\"─\");\n\n\t\t// Layout the text - use full width\n\t\tconst layoutLines = this.layoutText(width);\n\n\t\tconst result: string[] = [];\n\n\t\t// Render top border\n\t\tresult.push(horizontal.repeat(width));\n\n\t\t// Render each layout line\n\t\tfor (const layoutLine of layoutLines) {\n\t\t\tlet displayText = layoutLine.text;\n\t\t\tlet lineVisibleWidth = visibleWidth(layoutLine.text);\n\n\t\t\t// Add cursor if this line has it\n\t\t\tif (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {\n\t\t\t\tconst before = displayText.slice(0, layoutLine.cursorPos);\n\t\t\t\tconst after = displayText.slice(layoutLine.cursorPos);\n\n\t\t\t\tif (after.length > 0) {\n\t\t\t\t\t// Cursor is on a character (grapheme) - replace it with highlighted version\n\t\t\t\t\t// Get the first grapheme from 'after'\n\t\t\t\t\tconst afterGraphemes = [...segmenter.segment(after)];\n\t\t\t\t\tconst firstGrapheme = afterGraphemes[0]?.segment || \"\";\n\t\t\t\t\tconst restAfter = after.slice(firstGrapheme.length);\n\t\t\t\t\tconst cursor = `\\x1b[7m${firstGrapheme}\\x1b[0m`;\n\t\t\t\t\tdisplayText = before + cursor + restAfter;\n\t\t\t\t\t// lineVisibleWidth stays the same - we're replacing, not adding\n\t\t\t\t} else {\n\t\t\t\t\t// Cursor is at the end - check if we have room for the space\n\t\t\t\t\tif (lineVisibleWidth < width) {\n\t\t\t\t\t\t// We have room - add highlighted space\n\t\t\t\t\t\tconst cursor = \"\\x1b[7m \\x1b[0m\";\n\t\t\t\t\t\tdisplayText = before + cursor;\n\t\t\t\t\t\t// lineVisibleWidth increases by 1 - we're adding a space\n\t\t\t\t\t\tlineVisibleWidth = lineVisibleWidth + 1;\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Line is at full width - use reverse video on last grapheme if possible\n\t\t\t\t\t\t// or just show cursor at the end without adding space\n\t\t\t\t\t\tconst beforeGraphemes = [...segmenter.segment(before)];\n\t\t\t\t\t\tif (beforeGraphemes.length > 0) {\n\t\t\t\t\t\t\tconst lastGrapheme = beforeGraphemes[beforeGraphemes.length - 1]?.segment || \"\";\n\t\t\t\t\t\t\tconst cursor = `\\x1b[7m${lastGrapheme}\\x1b[0m`;\n\t\t\t\t\t\t\t// Rebuild 'before' without the last grapheme\n\t\t\t\t\t\t\tconst beforeWithoutLast = beforeGraphemes\n\t\t\t\t\t\t\t\t.slice(0, -1)\n\t\t\t\t\t\t\t\t.map((g) => g.segment)\n\t\t\t\t\t\t\t\t.join(\"\");\n\t\t\t\t\t\t\tdisplayText = beforeWithoutLast + cursor;\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// lineVisibleWidth stays the same\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Calculate padding based on actual visible width\n\t\t\tconst padding = \" \".repeat(Math.max(0, width - lineVisibleWidth));\n\n\t\t\t// Render the line (no side borders, just horizontal lines above and below)\n\t\t\tresult.push(displayText + padding);\n\t\t}\n\n\t\t// Render bottom border\n\t\tresult.push(horizontal.repeat(width));\n\n\t\t// Add autocomplete list if active\n\t\tif (this.isAutocompleting && this.autocompleteList) {\n\t\t\tconst autocompleteResult = this.autocompleteList.render(width);\n\t\t\tresult.push(...autocompleteResult);\n\t\t}\n\n\t\treturn result;\n\t}\n\n\thandleInput(data: string): void {\n\t\tconst kb = getEditorKeybindings();\n\n\t\t// Handle bracketed paste mode\n\t\tif (data.includes(\"\\x1b[200~\")) {\n\t\t\tthis.isInPaste = true;\n\t\t\tthis.pasteBuffer = \"\";\n\t\t\tdata = data.replace(\"\\x1b[200~\", \"\");\n\t\t}\n\n\t\tif (this.isInPaste) {\n\t\t\tthis.pasteBuffer += data;\n\t\t\tconst endIndex = this.pasteBuffer.indexOf(\"\\x1b[201~\");\n\t\t\tif (endIndex !== -1) {\n\t\t\t\tconst pasteContent = this.pasteBuffer.substring(0, endIndex);\n\t\t\t\tif (pasteContent.length > 0) {\n\t\t\t\t\tthis.handlePaste(pasteContent);\n\t\t\t\t}\n\t\t\t\tthis.isInPaste = false;\n\t\t\t\tconst remaining = this.pasteBuffer.substring(endIndex + 6);\n\t\t\t\tthis.pasteBuffer = \"\";\n\t\t\t\tif (remaining.length > 0) {\n\t\t\t\t\tthis.handleInput(remaining);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tif (this.pendingShiftEnter) {\n\t\t\tif (data === \"\\r\") {\n\t\t\t\tthis.pendingShiftEnter = false;\n\t\t\t\tthis.addNewLine();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.pendingShiftEnter = false;\n\t\t\tthis.insertCharacter(\"\\\\\");\n\t\t}\n\n\t\tif (data === \"\\\\\") {\n\t\t\tthis.pendingShiftEnter = true;\n\t\t\treturn;\n\t\t}\n\n\t\t// Ctrl+C - let parent handle (exit/clear)\n\t\tif (kb.matches(data, \"copy\")) {\n\t\t\treturn;\n\t\t}\n\n\t\t// Handle autocomplete mode\n\t\tif (this.isAutocompleting && this.autocompleteList) {\n\t\t\tif (kb.matches(data, \"selectCancel\")) {\n\t\t\t\tthis.cancelAutocomplete();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (kb.matches(data, \"selectUp\") || kb.matches(data, \"selectDown\")) {\n\t\t\t\tthis.autocompleteList.handleInput(data);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (kb.matches(data, \"tab\")) {\n\t\t\t\tconst selected = this.autocompleteList.getSelectedItem();\n\t\t\t\tif (selected && this.autocompleteProvider) {\n\t\t\t\t\tconst result = this.autocompleteProvider.applyCompletion(\n\t\t\t\t\t\tthis.state.lines,\n\t\t\t\t\t\tthis.state.cursorLine,\n\t\t\t\t\t\tthis.state.cursorCol,\n\t\t\t\t\t\tselected,\n\t\t\t\t\t\tthis.autocompletePrefix,\n\t\t\t\t\t);\n\t\t\t\t\tthis.state.lines = result.lines;\n\t\t\t\t\tthis.state.cursorLine = result.cursorLine;\n\t\t\t\t\tthis.state.cursorCol = result.cursorCol;\n\t\t\t\t\tthis.cancelAutocomplete();\n\t\t\t\t\tif (this.onChange) this.onChange(this.getText());\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (kb.matches(data, \"selectConfirm\")) {\n\t\t\t\tconst selected = this.autocompleteList.getSelectedItem();\n\t\t\t\tif (selected && this.autocompleteProvider) {\n\t\t\t\t\tconst result = this.autocompleteProvider.applyCompletion(\n\t\t\t\t\t\tthis.state.lines,\n\t\t\t\t\t\tthis.state.cursorLine,\n\t\t\t\t\t\tthis.state.cursorCol,\n\t\t\t\t\t\tselected,\n\t\t\t\t\t\tthis.autocompletePrefix,\n\t\t\t\t\t);\n\t\t\t\t\tthis.state.lines = result.lines;\n\t\t\t\t\tthis.state.cursorLine = result.cursorLine;\n\t\t\t\t\tthis.state.cursorCol = result.cursorCol;\n\n\t\t\t\t\tif (this.autocompletePrefix.startsWith(\"/\")) {\n\t\t\t\t\t\tthis.cancelAutocomplete();\n\t\t\t\t\t\t// Fall through to submit\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.cancelAutocomplete();\n\t\t\t\t\t\tif (this.onChange) this.onChange(this.getText());\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Tab - trigger completion\n\t\tif (kb.matches(data, \"tab\") && !this.isAutocompleting) {\n\t\t\tthis.handleTabCompletion();\n\t\t\treturn;\n\t\t}\n\n\t\t// Deletion actions\n\t\tif (kb.matches(data, \"deleteToLineEnd\")) {\n\t\t\tthis.deleteToEndOfLine();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"deleteToLineStart\")) {\n\t\t\tthis.deleteToStartOfLine();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"deleteWordBackward\")) {\n\t\t\tthis.deleteWordBackwards();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"deleteCharBackward\") || matchesKey(data, \"shift+backspace\")) {\n\t\t\tthis.handleBackspace();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"deleteCharForward\") || matchesKey(data, \"shift+delete\")) {\n\t\t\tthis.handleForwardDelete();\n\t\t\treturn;\n\t\t}\n\n\t\t// Cursor movement actions\n\t\tif (kb.matches(data, \"cursorLineStart\")) {\n\t\t\tthis.moveToLineStart();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"cursorLineEnd\")) {\n\t\t\tthis.moveToLineEnd();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"cursorWordLeft\")) {\n\t\t\tthis.moveWordBackwards();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"cursorWordRight\")) {\n\t\t\tthis.moveWordForwards();\n\t\t\treturn;\n\t\t}\n\n\t\t// New line (Shift+Enter, Alt+Enter, etc.)\n\t\tif (\n\t\t\tkb.matches(data, \"newLine\") ||\n\t\t\t(data.charCodeAt(0) === 10 && data.length > 1) ||\n\t\t\tdata === \"\\x1b\\r\" ||\n\t\t\tdata === \"\\x1b[13;2~\" ||\n\t\t\t(data.length > 1 && data.includes(\"\\x1b\") && data.includes(\"\\r\")) ||\n\t\t\t(data === \"\\n\" && data.length === 1) ||\n\t\t\tdata === \"\\\\\\r\"\n\t\t) {\n\t\t\tthis.addNewLine();\n\t\t\treturn;\n\t\t}\n\n\t\t// Submit (Enter)\n\t\tif (kb.matches(data, \"submit\")) {\n\t\t\tif (this.disableSubmit) return;\n\n\t\t\tlet result = this.state.lines.join(\"\\n\").trim();\n\t\t\tfor (const [pasteId, pasteContent] of this.pastes) {\n\t\t\t\tconst markerRegex = new RegExp(`\\\\[paste #${pasteId}( (\\\\+\\\\d+ lines|\\\\d+ chars))?\\\\]`, \"g\");\n\t\t\t\tresult = result.replace(markerRegex, pasteContent);\n\t\t\t}\n\n\t\t\tthis.state = { lines: [\"\"], cursorLine: 0, cursorCol: 0 };\n\t\t\tthis.pastes.clear();\n\t\t\tthis.pasteCounter = 0;\n\t\t\tthis.historyIndex = -1;\n\n\t\t\tif (this.onChange) this.onChange(\"\");\n\t\t\tif (this.onSubmit) this.onSubmit(result);\n\t\t\treturn;\n\t\t}\n\n\t\t// Arrow key navigation (with history support)\n\t\tif (kb.matches(data, \"cursorUp\")) {\n\t\t\tif (this.isEditorEmpty()) {\n\t\t\t\tthis.navigateHistory(-1);\n\t\t\t} else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {\n\t\t\t\tthis.navigateHistory(-1);\n\t\t\t} else {\n\t\t\t\tthis.moveCursor(-1, 0);\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"cursorDown\")) {\n\t\t\tif (this.historyIndex > -1 && this.isOnLastVisualLine()) {\n\t\t\t\tthis.navigateHistory(1);\n\t\t\t} else {\n\t\t\t\tthis.moveCursor(1, 0);\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"cursorRight\")) {\n\t\t\tthis.moveCursor(0, 1);\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"cursorLeft\")) {\n\t\t\tthis.moveCursor(0, -1);\n\t\t\treturn;\n\t\t}\n\n\t\t// Shift+Space - insert regular space\n\t\tif (matchesKey(data, \"shift+space\")) {\n\t\t\tthis.insertCharacter(\" \");\n\t\t\treturn;\n\t\t}\n\n\t\t// Regular characters\n\t\tif (data.charCodeAt(0) >= 32) {\n\t\t\tthis.insertCharacter(data);\n\t\t}\n\t}\n\n\tprivate layoutText(contentWidth: number): LayoutLine[] {\n\t\tconst layoutLines: LayoutLine[] = [];\n\n\t\tif (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === \"\")) {\n\t\t\t// Empty editor\n\t\t\tlayoutLines.push({\n\t\t\t\ttext: \"\",\n\t\t\t\thasCursor: true,\n\t\t\t\tcursorPos: 0,\n\t\t\t});\n\t\t\treturn layoutLines;\n\t\t}\n\n\t\t// Process each logical line\n\t\tfor (let i = 0; i < this.state.lines.length; i++) {\n\t\t\tconst line = this.state.lines[i] || \"\";\n\t\t\tconst isCurrentLine = i === this.state.cursorLine;\n\t\t\tconst lineVisibleWidth = visibleWidth(line);\n\n\t\t\tif (lineVisibleWidth <= contentWidth) {\n\t\t\t\t// Line fits in one layout line\n\t\t\t\tif (isCurrentLine) {\n\t\t\t\t\tlayoutLines.push({\n\t\t\t\t\t\ttext: line,\n\t\t\t\t\t\thasCursor: true,\n\t\t\t\t\t\tcursorPos: this.state.cursorCol,\n\t\t\t\t\t});\n\t\t\t\t} else {\n\t\t\t\t\tlayoutLines.push({\n\t\t\t\t\t\ttext: line,\n\t\t\t\t\t\thasCursor: false,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Line needs wrapping - use word-aware wrapping\n\t\t\t\tconst chunks = wordWrapLine(line, contentWidth);\n\n\t\t\t\tfor (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {\n\t\t\t\t\tconst chunk = chunks[chunkIndex];\n\t\t\t\t\tif (!chunk) continue;\n\n\t\t\t\t\tconst cursorPos = this.state.cursorCol;\n\t\t\t\t\tconst isLastChunk = chunkIndex === chunks.length - 1;\n\n\t\t\t\t\t// Determine if cursor is in this chunk\n\t\t\t\t\t// For word-wrapped chunks, we need to handle the case where\n\t\t\t\t\t// cursor might be in trimmed whitespace at end of chunk\n\t\t\t\t\tlet hasCursorInChunk = false;\n\t\t\t\t\tlet adjustedCursorPos = 0;\n\n\t\t\t\t\tif (isCurrentLine) {\n\t\t\t\t\t\tif (isLastChunk) {\n\t\t\t\t\t\t\t// Last chunk: cursor belongs here if >= startIndex\n\t\t\t\t\t\t\thasCursorInChunk = cursorPos >= chunk.startIndex;\n\t\t\t\t\t\t\tadjustedCursorPos = cursorPos - chunk.startIndex;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Non-last chunk: cursor belongs here if in range [startIndex, endIndex)\n\t\t\t\t\t\t\t// But we need to handle the visual position in the trimmed text\n\t\t\t\t\t\t\thasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;\n\t\t\t\t\t\t\tif (hasCursorInChunk) {\n\t\t\t\t\t\t\t\tadjustedCursorPos = cursorPos - chunk.startIndex;\n\t\t\t\t\t\t\t\t// Clamp to text length (in case cursor was in trimmed whitespace)\n\t\t\t\t\t\t\t\tif (adjustedCursorPos > chunk.text.length) {\n\t\t\t\t\t\t\t\t\tadjustedCursorPos = chunk.text.length;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tif (hasCursorInChunk) {\n\t\t\t\t\t\tlayoutLines.push({\n\t\t\t\t\t\t\ttext: chunk.text,\n\t\t\t\t\t\t\thasCursor: true,\n\t\t\t\t\t\t\tcursorPos: adjustedCursorPos,\n\t\t\t\t\t\t});\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlayoutLines.push({\n\t\t\t\t\t\t\ttext: chunk.text,\n\t\t\t\t\t\t\thasCursor: false,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn layoutLines;\n\t}\n\n\tgetText(): string {\n\t\treturn this.state.lines.join(\"\\n\");\n\t}\n\n\t/**\n\t * Get text with paste markers expanded to their actual content.\n\t * Use this when you need the full content (e.g., for external editor).\n\t */\n\tgetExpandedText(): string {\n\t\tlet result = this.state.lines.join(\"\\n\");\n\t\tfor (const [pasteId, pasteContent] of this.pastes) {\n\t\t\tconst markerRegex = new RegExp(`\\\\[paste #${pasteId}( (\\\\+\\\\d+ lines|\\\\d+ chars))?\\\\]`, \"g\");\n\t\t\tresult = result.replace(markerRegex, pasteContent);\n\t\t}\n\t\treturn result;\n\t}\n\n\tgetLines(): string[] {\n\t\treturn [...this.state.lines];\n\t}\n\n\tgetCursor(): { line: number; col: number } {\n\t\treturn { line: this.state.cursorLine, col: this.state.cursorCol };\n\t}\n\n\tsetText(text: string): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\t\tthis.setTextInternal(text);\n\t}\n\n\t/**\n\t * Insert text at the current cursor position.\n\t * Used for programmatic insertion (e.g., clipboard image markers).\n\t */\n\tinsertTextAtCursor(text: string): void {\n\t\tfor (const char of text) {\n\t\t\tthis.insertCharacter(char);\n\t\t}\n\t}\n\n\t// All the editor methods from before...\n\tprivate insertCharacter(char: string): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\n\t\tconst line = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\tconst before = line.slice(0, this.state.cursorCol);\n\t\tconst after = line.slice(this.state.cursorCol);\n\n\t\tthis.state.lines[this.state.cursorLine] = before + char + after;\n\t\tthis.state.cursorCol += char.length; // Fix: increment by the length of the inserted string\n\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\n\t\t// Check if we should trigger or update autocomplete\n\t\tif (!this.isAutocompleting) {\n\t\t\t// Auto-trigger for \"/\" at the start of a line (slash commands)\n\t\t\tif (char === \"/\" && this.isAtStartOfMessage()) {\n\t\t\t\tthis.tryTriggerAutocomplete();\n\t\t\t}\n\t\t\t// Auto-trigger for \"@\" file reference (fuzzy search)\n\t\t\telse if (char === \"@\") {\n\t\t\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\t\tconst textBeforeCursor = currentLine.slice(0, this.state.cursorCol);\n\t\t\t\t// Only trigger if @ is after whitespace or at start of line\n\t\t\t\tconst charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2];\n\t\t\t\tif (textBeforeCursor.length === 1 || charBeforeAt === \" \" || charBeforeAt === \"\\t\") {\n\t\t\t\t\tthis.tryTriggerAutocomplete();\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Also auto-trigger when typing letters in a slash command context\n\t\t\telse if (/[a-zA-Z0-9.\\-_]/.test(char)) {\n\t\t\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\t\tconst textBeforeCursor = currentLine.slice(0, this.state.cursorCol);\n\t\t\t\t// Check if we're in a slash command (with or without space for arguments)\n\t\t\t\tif (textBeforeCursor.trimStart().startsWith(\"/\")) {\n\t\t\t\t\tthis.tryTriggerAutocomplete();\n\t\t\t\t}\n\t\t\t\t// Check if we're in an @ file reference context\n\t\t\t\telse if (textBeforeCursor.match(/(?:^|[\\s])@[^\\s]*$/)) {\n\t\t\t\t\tthis.tryTriggerAutocomplete();\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tthis.updateAutocomplete();\n\t\t}\n\t}\n\n\tprivate handlePaste(pastedText: string): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\n\t\t// Clean the pasted text\n\t\tconst cleanText = pastedText.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\");\n\n\t\t// Convert tabs to spaces (4 spaces per tab)\n\t\tconst tabExpandedText = cleanText.replace(/\\t/g, \" \");\n\n\t\t// Filter out non-printable characters except newlines\n\t\tlet filteredText = tabExpandedText\n\t\t\t.split(\"\")\n\t\t\t.filter((char) => char === \"\\n\" || char.charCodeAt(0) >= 32)\n\t\t\t.join(\"\");\n\n\t\t// If pasting a file path (starts with /, ~, or .) and the character before\n\t\t// the cursor is a word character, prepend a space for better readability\n\t\tif (/^[/~.]/.test(filteredText)) {\n\t\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\tconst charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : \"\";\n\t\t\tif (charBeforeCursor && /\\w/.test(charBeforeCursor)) {\n\t\t\t\tfilteredText = ` ${filteredText}`;\n\t\t\t}\n\t\t}\n\n\t\t// Split into lines\n\t\tconst pastedLines = filteredText.split(\"\\n\");\n\n\t\t// Check if this is a large paste (> 10 lines or > 1000 characters)\n\t\tconst totalChars = filteredText.length;\n\t\tif (pastedLines.length > 10 || totalChars > 1000) {\n\t\t\t// Store the paste and insert a marker\n\t\t\tthis.pasteCounter++;\n\t\t\tconst pasteId = this.pasteCounter;\n\t\t\tthis.pastes.set(pasteId, filteredText);\n\n\t\t\t// Insert marker like \"[paste #1 +123 lines]\" or \"[paste #1 1234 chars]\"\n\t\t\tconst marker =\n\t\t\t\tpastedLines.length > 10\n\t\t\t\t\t? `[paste #${pasteId} +${pastedLines.length} lines]`\n\t\t\t\t\t: `[paste #${pasteId} ${totalChars} chars]`;\n\t\t\tfor (const char of marker) {\n\t\t\t\tthis.insertCharacter(char);\n\t\t\t}\n\n\t\t\treturn;\n\t\t}\n\n\t\tif (pastedLines.length === 1) {\n\t\t\t// Single line - just insert each character\n\t\t\tconst text = pastedLines[0] || \"\";\n\t\t\tfor (const char of text) {\n\t\t\t\tthis.insertCharacter(char);\n\t\t\t}\n\n\t\t\treturn;\n\t\t}\n\n\t\t// Multi-line paste - be very careful with array manipulation\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\tconst beforeCursor = currentLine.slice(0, this.state.cursorCol);\n\t\tconst afterCursor = currentLine.slice(this.state.cursorCol);\n\n\t\t// Build the new lines array step by step\n\t\tconst newLines: string[] = [];\n\n\t\t// Add all lines before current line\n\t\tfor (let i = 0; i < this.state.cursorLine; i++) {\n\t\t\tnewLines.push(this.state.lines[i] || \"\");\n\t\t}\n\n\t\t// Add the first pasted line merged with before cursor text\n\t\tnewLines.push(beforeCursor + (pastedLines[0] || \"\"));\n\n\t\t// Add all middle pasted lines\n\t\tfor (let i = 1; i < pastedLines.length - 1; i++) {\n\t\t\tnewLines.push(pastedLines[i] || \"\");\n\t\t}\n\n\t\t// Add the last pasted line with after cursor text\n\t\tnewLines.push((pastedLines[pastedLines.length - 1] || \"\") + afterCursor);\n\n\t\t// Add all lines after current line\n\t\tfor (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) {\n\t\t\tnewLines.push(this.state.lines[i] || \"\");\n\t\t}\n\n\t\t// Replace the entire lines array\n\t\tthis.state.lines = newLines;\n\n\t\t// Update cursor position to end of pasted content\n\t\tthis.state.cursorLine += pastedLines.length - 1;\n\t\tthis.state.cursorCol = (pastedLines[pastedLines.length - 1] || \"\").length;\n\n\t\t// Notify of change\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\t}\n\n\tprivate addNewLine(): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\tconst before = currentLine.slice(0, this.state.cursorCol);\n\t\tconst after = currentLine.slice(this.state.cursorCol);\n\n\t\t// Split current line\n\t\tthis.state.lines[this.state.cursorLine] = before;\n\t\tthis.state.lines.splice(this.state.cursorLine + 1, 0, after);\n\n\t\t// Move cursor to start of new line\n\t\tthis.state.cursorLine++;\n\t\tthis.state.cursorCol = 0;\n\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\t}\n\n\tprivate handleBackspace(): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\n\t\tif (this.state.cursorCol > 0) {\n\t\t\t// Delete grapheme before cursor (handles emojis, combining characters, etc.)\n\t\t\tconst line = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\tconst beforeCursor = line.slice(0, this.state.cursorCol);\n\n\t\t\t// Find the last grapheme in the text before cursor\n\t\t\tconst graphemes = [...segmenter.segment(beforeCursor)];\n\t\t\tconst lastGrapheme = graphemes[graphemes.length - 1];\n\t\t\tconst graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;\n\n\t\t\tconst before = line.slice(0, this.state.cursorCol - graphemeLength);\n\t\t\tconst after = line.slice(this.state.cursorCol);\n\n\t\t\tthis.state.lines[this.state.cursorLine] = before + after;\n\t\t\tthis.state.cursorCol -= graphemeLength;\n\t\t} else if (this.state.cursorLine > 0) {\n\t\t\t// Merge with previous line\n\t\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\tconst previousLine = this.state.lines[this.state.cursorLine - 1] || \"\";\n\n\t\t\tthis.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;\n\t\t\tthis.state.lines.splice(this.state.cursorLine, 1);\n\n\t\t\tthis.state.cursorLine--;\n\t\t\tthis.state.cursorCol = previousLine.length;\n\t\t}\n\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\n\t\t// Update or re-trigger autocomplete after backspace\n\t\tif (this.isAutocompleting) {\n\t\t\tthis.updateAutocomplete();\n\t\t} else {\n\t\t\t// If autocomplete was cancelled (no matches), re-trigger if we're in a completable context\n\t\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\tconst textBeforeCursor = currentLine.slice(0, this.state.cursorCol);\n\t\t\t// Slash command context\n\t\t\tif (textBeforeCursor.trimStart().startsWith(\"/\")) {\n\t\t\t\tthis.tryTriggerAutocomplete();\n\t\t\t}\n\t\t\t// @ file reference context\n\t\t\telse if (textBeforeCursor.match(/(?:^|[\\s])@[^\\s]*$/)) {\n\t\t\t\tthis.tryTriggerAutocomplete();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate moveToLineStart(): void {\n\t\tthis.state.cursorCol = 0;\n\t}\n\n\tprivate moveToLineEnd(): void {\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\tthis.state.cursorCol = currentLine.length;\n\t}\n\n\tprivate deleteToStartOfLine(): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\tif (this.state.cursorCol > 0) {\n\t\t\t// Delete from start of line up to cursor\n\t\t\tthis.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol);\n\t\t\tthis.state.cursorCol = 0;\n\t\t} else if (this.state.cursorLine > 0) {\n\t\t\t// At start of line - merge with previous line\n\t\t\tconst previousLine = this.state.lines[this.state.cursorLine - 1] || \"\";\n\t\t\tthis.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;\n\t\t\tthis.state.lines.splice(this.state.cursorLine, 1);\n\t\t\tthis.state.cursorLine--;\n\t\t\tthis.state.cursorCol = previousLine.length;\n\t\t}\n\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\t}\n\n\tprivate deleteToEndOfLine(): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\tif (this.state.cursorCol < currentLine.length) {\n\t\t\t// Delete from cursor to end of line\n\t\t\tthis.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol);\n\t\t} else if (this.state.cursorLine < this.state.lines.length - 1) {\n\t\t\t// At end of line - merge with next line\n\t\t\tconst nextLine = this.state.lines[this.state.cursorLine + 1] || \"\";\n\t\t\tthis.state.lines[this.state.cursorLine] = currentLine + nextLine;\n\t\t\tthis.state.lines.splice(this.state.cursorLine + 1, 1);\n\t\t}\n\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\t}\n\n\tprivate deleteWordBackwards(): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\t// If at start of line, behave like backspace at column 0 (merge with previous line)\n\t\tif (this.state.cursorCol === 0) {\n\t\t\tif (this.state.cursorLine > 0) {\n\t\t\t\tconst previousLine = this.state.lines[this.state.cursorLine - 1] || \"\";\n\t\t\t\tthis.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;\n\t\t\t\tthis.state.lines.splice(this.state.cursorLine, 1);\n\t\t\t\tthis.state.cursorLine--;\n\t\t\t\tthis.state.cursorCol = previousLine.length;\n\t\t\t}\n\t\t} else {\n\t\t\tconst oldCursorCol = this.state.cursorCol;\n\t\t\tthis.moveWordBackwards();\n\t\t\tconst deleteFrom = this.state.cursorCol;\n\t\t\tthis.state.cursorCol = oldCursorCol;\n\n\t\t\tthis.state.lines[this.state.cursorLine] =\n\t\t\t\tcurrentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol);\n\t\t\tthis.state.cursorCol = deleteFrom;\n\t\t}\n\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\t}\n\n\tprivate handleForwardDelete(): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\tif (this.state.cursorCol < currentLine.length) {\n\t\t\t// Delete grapheme at cursor position (handles emojis, combining characters, etc.)\n\t\t\tconst afterCursor = currentLine.slice(this.state.cursorCol);\n\n\t\t\t// Find the first grapheme at cursor\n\t\t\tconst graphemes = [...segmenter.segment(afterCursor)];\n\t\t\tconst firstGrapheme = graphemes[0];\n\t\t\tconst graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;\n\n\t\t\tconst before = currentLine.slice(0, this.state.cursorCol);\n\t\t\tconst after = currentLine.slice(this.state.cursorCol + graphemeLength);\n\t\t\tthis.state.lines[this.state.cursorLine] = before + after;\n\t\t} else if (this.state.cursorLine < this.state.lines.length - 1) {\n\t\t\t// At end of line - merge with next line\n\t\t\tconst nextLine = this.state.lines[this.state.cursorLine + 1] || \"\";\n\t\t\tthis.state.lines[this.state.cursorLine] = currentLine + nextLine;\n\t\t\tthis.state.lines.splice(this.state.cursorLine + 1, 1);\n\t\t}\n\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\n\t\t// Update or re-trigger autocomplete after forward delete\n\t\tif (this.isAutocompleting) {\n\t\t\tthis.updateAutocomplete();\n\t\t} else {\n\t\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\tconst textBeforeCursor = currentLine.slice(0, this.state.cursorCol);\n\t\t\t// Slash command context\n\t\t\tif (textBeforeCursor.trimStart().startsWith(\"/\")) {\n\t\t\t\tthis.tryTriggerAutocomplete();\n\t\t\t}\n\t\t\t// @ file reference context\n\t\t\telse if (textBeforeCursor.match(/(?:^|[\\s])@[^\\s]*$/)) {\n\t\t\t\tthis.tryTriggerAutocomplete();\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Build a mapping from visual lines to logical positions.\n\t * Returns an array where each element represents a visual line with:\n\t * - logicalLine: index into this.state.lines\n\t * - startCol: starting column in the logical line\n\t * - length: length of this visual line segment\n\t */\n\tprivate buildVisualLineMap(width: number): Array<{ logicalLine: number; startCol: number; length: number }> {\n\t\tconst visualLines: Array<{ logicalLine: number; startCol: number; length: number }> = [];\n\n\t\tfor (let i = 0; i < this.state.lines.length; i++) {\n\t\t\tconst line = this.state.lines[i] || \"\";\n\t\t\tconst lineVisWidth = visibleWidth(line);\n\t\t\tif (line.length === 0) {\n\t\t\t\t// Empty line still takes one visual line\n\t\t\t\tvisualLines.push({ logicalLine: i, startCol: 0, length: 0 });\n\t\t\t} else if (lineVisWidth <= width) {\n\t\t\t\tvisualLines.push({ logicalLine: i, startCol: 0, length: line.length });\n\t\t\t} else {\n\t\t\t\t// Line needs wrapping - use word-aware wrapping\n\t\t\t\tconst chunks = wordWrapLine(line, width);\n\t\t\t\tfor (const chunk of chunks) {\n\t\t\t\t\tvisualLines.push({\n\t\t\t\t\t\tlogicalLine: i,\n\t\t\t\t\t\tstartCol: chunk.startIndex,\n\t\t\t\t\t\tlength: chunk.endIndex - chunk.startIndex,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn visualLines;\n\t}\n\n\t/**\n\t * Find the visual line index for the current cursor position.\n\t */\n\tprivate findCurrentVisualLine(\n\t\tvisualLines: Array<{ logicalLine: number; startCol: number; length: number }>,\n\t): number {\n\t\tfor (let i = 0; i < visualLines.length; i++) {\n\t\t\tconst vl = visualLines[i];\n\t\t\tif (!vl) continue;\n\t\t\tif (vl.logicalLine === this.state.cursorLine) {\n\t\t\t\tconst colInSegment = this.state.cursorCol - vl.startCol;\n\t\t\t\t// Cursor is in this segment if it's within range\n\t\t\t\t// For the last segment of a logical line, cursor can be at length (end position)\n\t\t\t\tconst isLastSegmentOfLine =\n\t\t\t\t\ti === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;\n\t\t\t\tif (colInSegment >= 0 && (colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))) {\n\t\t\t\t\treturn i;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// Fallback: return last visual line\n\t\treturn visualLines.length - 1;\n\t}\n\n\tprivate moveCursor(deltaLine: number, deltaCol: number): void {\n\t\tconst width = this.lastWidth;\n\n\t\tif (deltaLine !== 0) {\n\t\t\t// Build visual line map for navigation\n\t\t\tconst visualLines = this.buildVisualLineMap(width);\n\t\t\tconst currentVisualLine = this.findCurrentVisualLine(visualLines);\n\n\t\t\t// Calculate column position within current visual line\n\t\t\tconst currentVL = visualLines[currentVisualLine];\n\t\t\tconst visualCol = currentVL ? this.state.cursorCol - currentVL.startCol : 0;\n\n\t\t\t// Move to target visual line\n\t\t\tconst targetVisualLine = currentVisualLine + deltaLine;\n\n\t\t\tif (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {\n\t\t\t\tconst targetVL = visualLines[targetVisualLine];\n\t\t\t\tif (targetVL) {\n\t\t\t\t\tthis.state.cursorLine = targetVL.logicalLine;\n\t\t\t\t\t// Try to maintain visual column position, clamped to line length\n\t\t\t\t\tconst targetCol = targetVL.startCol + Math.min(visualCol, targetVL.length);\n\t\t\t\t\tconst logicalLine = this.state.lines[targetVL.logicalLine] || \"\";\n\t\t\t\t\tthis.state.cursorCol = Math.min(targetCol, logicalLine.length);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (deltaCol !== 0) {\n\t\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\t\tif (deltaCol > 0) {\n\t\t\t\t// Moving right - move by one grapheme (handles emojis, combining characters, etc.)\n\t\t\t\tif (this.state.cursorCol < currentLine.length) {\n\t\t\t\t\tconst afterCursor = currentLine.slice(this.state.cursorCol);\n\t\t\t\t\tconst graphemes = [...segmenter.segment(afterCursor)];\n\t\t\t\t\tconst firstGrapheme = graphemes[0];\n\t\t\t\t\tthis.state.cursorCol += firstGrapheme ? firstGrapheme.segment.length : 1;\n\t\t\t\t} else if (this.state.cursorLine < this.state.lines.length - 1) {\n\t\t\t\t\t// Wrap to start of next logical line\n\t\t\t\t\tthis.state.cursorLine++;\n\t\t\t\t\tthis.state.cursorCol = 0;\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Moving left - move by one grapheme (handles emojis, combining characters, etc.)\n\t\t\t\tif (this.state.cursorCol > 0) {\n\t\t\t\t\tconst beforeCursor = currentLine.slice(0, this.state.cursorCol);\n\t\t\t\t\tconst graphemes = [...segmenter.segment(beforeCursor)];\n\t\t\t\t\tconst lastGrapheme = graphemes[graphemes.length - 1];\n\t\t\t\t\tthis.state.cursorCol -= lastGrapheme ? lastGrapheme.segment.length : 1;\n\t\t\t\t} else if (this.state.cursorLine > 0) {\n\t\t\t\t\t// Wrap to end of previous logical line\n\t\t\t\t\tthis.state.cursorLine--;\n\t\t\t\t\tconst prevLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\t\t\tthis.state.cursorCol = prevLine.length;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate moveWordBackwards(): void {\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\t// If at start of line, move to end of previous line\n\t\tif (this.state.cursorCol === 0) {\n\t\t\tif (this.state.cursorLine > 0) {\n\t\t\t\tthis.state.cursorLine--;\n\t\t\t\tconst prevLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\t\tthis.state.cursorCol = prevLine.length;\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tconst textBeforeCursor = currentLine.slice(0, this.state.cursorCol);\n\t\tconst graphemes = [...segmenter.segment(textBeforeCursor)];\n\t\tlet newCol = this.state.cursorCol;\n\n\t\t// Skip trailing whitespace\n\t\twhile (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || \"\")) {\n\t\t\tnewCol -= graphemes.pop()?.segment.length || 0;\n\t\t}\n\n\t\tif (graphemes.length > 0) {\n\t\t\tconst lastGrapheme = graphemes[graphemes.length - 1]?.segment || \"\";\n\t\t\tif (isPunctuationChar(lastGrapheme)) {\n\t\t\t\t// Skip punctuation run\n\t\t\t\twhile (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || \"\")) {\n\t\t\t\t\tnewCol -= graphemes.pop()?.segment.length || 0;\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Skip word run\n\t\t\t\twhile (\n\t\t\t\t\tgraphemes.length > 0 &&\n\t\t\t\t\t!isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || \"\") &&\n\t\t\t\t\t!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || \"\")\n\t\t\t\t) {\n\t\t\t\t\tnewCol -= graphemes.pop()?.segment.length || 0;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tthis.state.cursorCol = newCol;\n\t}\n\n\tprivate moveWordForwards(): void {\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\t// If at end of line, move to start of next line\n\t\tif (this.state.cursorCol >= currentLine.length) {\n\t\t\tif (this.state.cursorLine < this.state.lines.length - 1) {\n\t\t\t\tthis.state.cursorLine++;\n\t\t\t\tthis.state.cursorCol = 0;\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tconst textAfterCursor = currentLine.slice(this.state.cursorCol);\n\t\tconst segments = segmenter.segment(textAfterCursor);\n\t\tconst iterator = segments[Symbol.iterator]();\n\t\tlet next = iterator.next();\n\n\t\t// Skip leading whitespace\n\t\twhile (!next.done && isWhitespaceChar(next.value.segment)) {\n\t\t\tthis.state.cursorCol += next.value.segment.length;\n\t\t\tnext = iterator.next();\n\t\t}\n\n\t\tif (!next.done) {\n\t\t\tconst firstGrapheme = next.value.segment;\n\t\t\tif (isPunctuationChar(firstGrapheme)) {\n\t\t\t\t// Skip punctuation run\n\t\t\t\twhile (!next.done && isPunctuationChar(next.value.segment)) {\n\t\t\t\t\tthis.state.cursorCol += next.value.segment.length;\n\t\t\t\t\tnext = iterator.next();\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Skip word run\n\t\t\t\twhile (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {\n\t\t\t\t\tthis.state.cursorCol += next.value.segment.length;\n\t\t\t\t\tnext = iterator.next();\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Helper method to check if cursor is at start of message (for slash command detection)\n\tprivate isAtStartOfMessage(): boolean {\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\tconst beforeCursor = currentLine.slice(0, this.state.cursorCol);\n\n\t\t// At start if line is empty, only contains whitespace, or is just \"/\"\n\t\treturn beforeCursor.trim() === \"\" || beforeCursor.trim() === \"/\";\n\t}\n\n\t// Autocomplete methods\n\tprivate tryTriggerAutocomplete(explicitTab: boolean = false): void {\n\t\tif (!this.autocompleteProvider) return;\n\n\t\t// Check if we should trigger file completion on Tab\n\t\tif (explicitTab) {\n\t\t\tconst provider = this.autocompleteProvider as CombinedAutocompleteProvider;\n\t\t\tconst shouldTrigger =\n\t\t\t\t!provider.shouldTriggerFileCompletion ||\n\t\t\t\tprovider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);\n\t\t\tif (!shouldTrigger) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tconst suggestions = this.autocompleteProvider.getSuggestions(\n\t\t\tthis.state.lines,\n\t\t\tthis.state.cursorLine,\n\t\t\tthis.state.cursorCol,\n\t\t);\n\n\t\tif (suggestions && suggestions.items.length > 0) {\n\t\t\tthis.autocompletePrefix = suggestions.prefix;\n\t\t\tthis.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);\n\t\t\tthis.isAutocompleting = true;\n\t\t} else {\n\t\t\tthis.cancelAutocomplete();\n\t\t}\n\t}\n\n\tprivate handleTabCompletion(): void {\n\t\tif (!this.autocompleteProvider) return;\n\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\tconst beforeCursor = currentLine.slice(0, this.state.cursorCol);\n\n\t\t// Check if we're in a slash command context\n\t\tif (beforeCursor.trimStart().startsWith(\"/\") && !beforeCursor.trimStart().includes(\" \")) {\n\t\t\tthis.handleSlashCommandCompletion();\n\t\t} else {\n\t\t\tthis.forceFileAutocomplete();\n\t\t}\n\t}\n\n\tprivate handleSlashCommandCompletion(): void {\n\t\tthis.tryTriggerAutocomplete(true);\n\t}\n\n\t/*\nhttps://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883\n17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19\n536643416/job/55932288317 havea look at .gi\n\t */\n\tprivate forceFileAutocomplete(): void {\n\t\tif (!this.autocompleteProvider) return;\n\n\t\t// Check if provider supports force file suggestions via runtime check\n\t\tconst provider = this.autocompleteProvider as {\n\t\t\tgetForceFileSuggestions?: CombinedAutocompleteProvider[\"getForceFileSuggestions\"];\n\t\t};\n\t\tif (typeof provider.getForceFileSuggestions !== \"function\") {\n\t\t\tthis.tryTriggerAutocomplete(true);\n\t\t\treturn;\n\t\t}\n\n\t\tconst suggestions = provider.getForceFileSuggestions(\n\t\t\tthis.state.lines,\n\t\t\tthis.state.cursorLine,\n\t\t\tthis.state.cursorCol,\n\t\t);\n\n\t\tif (suggestions && suggestions.items.length > 0) {\n\t\t\tthis.autocompletePrefix = suggestions.prefix;\n\t\t\tthis.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);\n\t\t\tthis.isAutocompleting = true;\n\t\t} else {\n\t\t\tthis.cancelAutocomplete();\n\t\t}\n\t}\n\n\tprivate cancelAutocomplete(): void {\n\t\tthis.isAutocompleting = false;\n\t\tthis.autocompleteList = undefined;\n\t\tthis.autocompletePrefix = \"\";\n\t}\n\n\tpublic isShowingAutocomplete(): boolean {\n\t\treturn this.isAutocompleting;\n\t}\n\n\tprivate updateAutocomplete(): void {\n\t\tif (!this.isAutocompleting || !this.autocompleteProvider) return;\n\n\t\tconst suggestions = this.autocompleteProvider.getSuggestions(\n\t\t\tthis.state.lines,\n\t\t\tthis.state.cursorLine,\n\t\t\tthis.state.cursorCol,\n\t\t);\n\n\t\tif (suggestions && suggestions.items.length > 0) {\n\t\t\tthis.autocompletePrefix = suggestions.prefix;\n\t\t\t// Always create new SelectList to ensure update\n\t\t\tthis.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);\n\t\t} else {\n\t\t\tthis.cancelAutocomplete();\n\t\t}\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"editor.d.ts","sourceRoot":"","sources":["../../src/components/editor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAgC,MAAM,oBAAoB,CAAC;AAG7F,OAAO,EAAE,KAAK,SAAS,EAAiB,KAAK,SAAS,EAAE,KAAK,GAAG,EAAE,MAAM,WAAW,CAAC;AAEpF,OAAO,EAAc,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAoMpE,MAAM,WAAW,WAAW;IAC3B,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;IACrC,UAAU,EAAE,eAAe,CAAC;CAC5B;AAED,qBAAa,MAAO,YAAW,SAAS,EAAE,SAAS;IAClD,OAAO,CAAC,KAAK,CAIX;IAEF,0DAA0D;IAC1D,OAAO,EAAE,OAAO,CAAS;IAEzB,SAAS,CAAC,GAAG,EAAE,GAAG,CAAC;IACnB,OAAO,CAAC,KAAK,CAAc;IAG3B,OAAO,CAAC,SAAS,CAAc;IAG/B,OAAO,CAAC,YAAY,CAAa;IAG1B,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;IAG5C,OAAO,CAAC,oBAAoB,CAAC,CAAuB;IACpD,OAAO,CAAC,gBAAgB,CAAC,CAAa;IACtC,OAAO,CAAC,gBAAgB,CAAkB;IAC1C,OAAO,CAAC,kBAAkB,CAAc;IAGxC,OAAO,CAAC,MAAM,CAAkC;IAChD,OAAO,CAAC,YAAY,CAAa;IAGjC,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,SAAS,CAAkB;IACnC,OAAO,CAAC,iBAAiB,CAAkB;IAG3C,OAAO,CAAC,OAAO,CAAgB;IAC/B,OAAO,CAAC,YAAY,CAAc;IAE3B,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,aAAa,EAAE,OAAO,CAAS;IAEtC,YAAY,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,WAAW,EAIvC;IAED,uBAAuB,CAAC,QAAQ,EAAE,oBAAoB,GAAG,IAAI,CAE5D;IAED;;;OAGG;IACH,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAU/B;IAED,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,mBAAmB;IAM3B,OAAO,CAAC,kBAAkB;IAM1B,OAAO,CAAC,eAAe;IAgBvB,kFAAkF;IAClF,OAAO,CAAC,eAAe;IAavB,UAAU,IAAI,IAAI,CAEjB;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAsH9B;IAED,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CA2O9B;IAED,OAAO,CAAC,UAAU;IAwFlB,OAAO,IAAI,MAAM,CAEhB;IAED;;;OAGG;IACH,eAAe,IAAI,MAAM,CAOxB;IAED,QAAQ,IAAI,MAAM,EAAE,CAEnB;IAED,SAAS,IAAI;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAEzC;IAED,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAG1B;IAED;;;OAGG;IACH,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAIrC;IAGD,OAAO,CAAC,eAAe;IAiDvB,OAAO,CAAC,WAAW;IAoGnB,OAAO,CAAC,UAAU;IAqBlB,OAAO,CAAC,eAAe;IAoDvB,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,aAAa;IAKrB,OAAO,CAAC,mBAAmB;IAuB3B,OAAO,CAAC,iBAAiB;IAoBzB,OAAO,CAAC,mBAAmB;IA8B3B,OAAO,CAAC,mBAAmB;IA6C3B;;;;;;OAMG;IACH,OAAO,CAAC,kBAAkB;IA2B1B;;OAEG;IACH,OAAO,CAAC,qBAAqB;IAqB7B,OAAO,CAAC,UAAU;IA2DlB;;;OAGG;IACH,OAAO,CAAC,UAAU;IA0BlB,OAAO,CAAC,iBAAiB;IA4CzB,OAAO,CAAC,gBAAgB;IA0CxB,OAAO,CAAC,kBAAkB;IAS1B,OAAO,CAAC,sBAAsB;IA6B9B,OAAO,CAAC,mBAAmB;IAc3B,OAAO,CAAC,4BAA4B;IASpC,OAAO,CAAC,qBAAqB;IA2B7B,OAAO,CAAC,kBAAkB;IAMnB,qBAAqB,IAAI,OAAO,CAEtC;IAED,OAAO,CAAC,kBAAkB;CAiB1B","sourcesContent":["import type { AutocompleteProvider, CombinedAutocompleteProvider } from \"../autocomplete.js\";\nimport { getEditorKeybindings } from \"../keybindings.js\";\nimport { matchesKey } from \"../keys.js\";\nimport { type Component, CURSOR_MARKER, type Focusable, type TUI } from \"../tui.js\";\nimport { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from \"../utils.js\";\nimport { SelectList, type SelectListTheme } from \"./select-list.js\";\n\nconst segmenter = getSegmenter();\n\n/**\n * Represents a chunk of text for word-wrap layout.\n * Tracks both the text content and its position in the original line.\n */\ninterface TextChunk {\n\ttext: string;\n\tstartIndex: number;\n\tendIndex: number;\n}\n\n/**\n * Split a line into word-wrapped chunks.\n * Wraps at word boundaries when possible, falling back to character-level\n * wrapping for words longer than the available width.\n *\n * @param line - The text line to wrap\n * @param maxWidth - Maximum visible width per chunk\n * @returns Array of chunks with text and position information\n */\nfunction wordWrapLine(line: string, maxWidth: number): TextChunk[] {\n\tif (!line || maxWidth <= 0) {\n\t\treturn [{ text: \"\", startIndex: 0, endIndex: 0 }];\n\t}\n\n\tconst lineWidth = visibleWidth(line);\n\tif (lineWidth <= maxWidth) {\n\t\treturn [{ text: line, startIndex: 0, endIndex: line.length }];\n\t}\n\n\tconst chunks: TextChunk[] = [];\n\n\t// Split into tokens (words and whitespace runs)\n\tconst tokens: { text: string; startIndex: number; endIndex: number; isWhitespace: boolean }[] = [];\n\tlet currentToken = \"\";\n\tlet tokenStart = 0;\n\tlet inWhitespace = false;\n\tlet charIndex = 0;\n\n\tfor (const seg of segmenter.segment(line)) {\n\t\tconst grapheme = seg.segment;\n\t\tconst graphemeIsWhitespace = isWhitespaceChar(grapheme);\n\n\t\tif (currentToken === \"\") {\n\t\t\tinWhitespace = graphemeIsWhitespace;\n\t\t\ttokenStart = charIndex;\n\t\t} else if (graphemeIsWhitespace !== inWhitespace) {\n\t\t\t// Token type changed - save current token\n\t\t\ttokens.push({\n\t\t\t\ttext: currentToken,\n\t\t\t\tstartIndex: tokenStart,\n\t\t\t\tendIndex: charIndex,\n\t\t\t\tisWhitespace: inWhitespace,\n\t\t\t});\n\t\t\tcurrentToken = \"\";\n\t\t\ttokenStart = charIndex;\n\t\t\tinWhitespace = graphemeIsWhitespace;\n\t\t}\n\n\t\tcurrentToken += grapheme;\n\t\tcharIndex += grapheme.length;\n\t}\n\n\t// Push final token\n\tif (currentToken) {\n\t\ttokens.push({\n\t\t\ttext: currentToken,\n\t\t\tstartIndex: tokenStart,\n\t\t\tendIndex: charIndex,\n\t\t\tisWhitespace: inWhitespace,\n\t\t});\n\t}\n\n\t// Build chunks using word wrapping\n\tlet currentChunk = \"\";\n\tlet currentWidth = 0;\n\tlet chunkStartIndex = 0;\n\tlet atLineStart = true; // Track if we're at the start of a line (for skipping whitespace)\n\n\tfor (const token of tokens) {\n\t\tconst tokenWidth = visibleWidth(token.text);\n\n\t\t// Skip leading whitespace at line start\n\t\tif (atLineStart && token.isWhitespace) {\n\t\t\tchunkStartIndex = token.endIndex;\n\t\t\tcontinue;\n\t\t}\n\t\tatLineStart = false;\n\n\t\t// If this single token is wider than maxWidth, we need to break it\n\t\tif (tokenWidth > maxWidth) {\n\t\t\t// First, push any accumulated chunk\n\t\t\tif (currentChunk) {\n\t\t\t\tchunks.push({\n\t\t\t\t\ttext: currentChunk,\n\t\t\t\t\tstartIndex: chunkStartIndex,\n\t\t\t\t\tendIndex: token.startIndex,\n\t\t\t\t});\n\t\t\t\tcurrentChunk = \"\";\n\t\t\t\tcurrentWidth = 0;\n\t\t\t\tchunkStartIndex = token.startIndex;\n\t\t\t}\n\n\t\t\t// Break the long token by grapheme\n\t\t\tlet tokenChunk = \"\";\n\t\t\tlet tokenChunkWidth = 0;\n\t\t\tlet tokenChunkStart = token.startIndex;\n\t\t\tlet tokenCharIndex = token.startIndex;\n\n\t\t\tfor (const seg of segmenter.segment(token.text)) {\n\t\t\t\tconst grapheme = seg.segment;\n\t\t\t\tconst graphemeWidth = visibleWidth(grapheme);\n\n\t\t\t\tif (tokenChunkWidth + graphemeWidth > maxWidth && tokenChunk) {\n\t\t\t\t\tchunks.push({\n\t\t\t\t\t\ttext: tokenChunk,\n\t\t\t\t\t\tstartIndex: tokenChunkStart,\n\t\t\t\t\t\tendIndex: tokenCharIndex,\n\t\t\t\t\t});\n\t\t\t\t\ttokenChunk = grapheme;\n\t\t\t\t\ttokenChunkWidth = graphemeWidth;\n\t\t\t\t\ttokenChunkStart = tokenCharIndex;\n\t\t\t\t} else {\n\t\t\t\t\ttokenChunk += grapheme;\n\t\t\t\t\ttokenChunkWidth += graphemeWidth;\n\t\t\t\t}\n\t\t\t\ttokenCharIndex += grapheme.length;\n\t\t\t}\n\n\t\t\t// Keep remainder as start of next chunk\n\t\t\tif (tokenChunk) {\n\t\t\t\tcurrentChunk = tokenChunk;\n\t\t\t\tcurrentWidth = tokenChunkWidth;\n\t\t\t\tchunkStartIndex = tokenChunkStart;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Check if adding this token would exceed width\n\t\tif (currentWidth + tokenWidth > maxWidth) {\n\t\t\t// Push current chunk (trimming trailing whitespace for display)\n\t\t\tconst trimmedChunk = currentChunk.trimEnd();\n\t\t\tif (trimmedChunk || chunks.length === 0) {\n\t\t\t\tchunks.push({\n\t\t\t\t\ttext: trimmedChunk,\n\t\t\t\t\tstartIndex: chunkStartIndex,\n\t\t\t\t\tendIndex: chunkStartIndex + currentChunk.length,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\t// Start new line - skip leading whitespace\n\t\t\tatLineStart = true;\n\t\t\tif (token.isWhitespace) {\n\t\t\t\tcurrentChunk = \"\";\n\t\t\t\tcurrentWidth = 0;\n\t\t\t\tchunkStartIndex = token.endIndex;\n\t\t\t} else {\n\t\t\t\tcurrentChunk = token.text;\n\t\t\t\tcurrentWidth = tokenWidth;\n\t\t\t\tchunkStartIndex = token.startIndex;\n\t\t\t\tatLineStart = false;\n\t\t\t}\n\t\t} else {\n\t\t\t// Add token to current chunk\n\t\t\tcurrentChunk += token.text;\n\t\t\tcurrentWidth += tokenWidth;\n\t\t}\n\t}\n\n\t// Push final chunk\n\tif (currentChunk) {\n\t\tchunks.push({\n\t\t\ttext: currentChunk,\n\t\t\tstartIndex: chunkStartIndex,\n\t\t\tendIndex: line.length,\n\t\t});\n\t}\n\n\treturn chunks.length > 0 ? chunks : [{ text: \"\", startIndex: 0, endIndex: 0 }];\n}\n\ninterface EditorState {\n\tlines: string[];\n\tcursorLine: number;\n\tcursorCol: number;\n}\n\ninterface LayoutLine {\n\ttext: string;\n\thasCursor: boolean;\n\tcursorPos?: number;\n}\n\nexport interface EditorTheme {\n\tborderColor: (str: string) => string;\n\tselectList: SelectListTheme;\n}\n\nexport class Editor implements Component, Focusable {\n\tprivate state: EditorState = {\n\t\tlines: [\"\"],\n\t\tcursorLine: 0,\n\t\tcursorCol: 0,\n\t};\n\n\t/** Focusable interface - set by TUI when focus changes */\n\tfocused: boolean = false;\n\n\tprotected tui: TUI;\n\tprivate theme: EditorTheme;\n\n\t// Store last render width for cursor navigation\n\tprivate lastWidth: number = 80;\n\n\t// Vertical scrolling support\n\tprivate scrollOffset: number = 0;\n\n\t// Border color (can be changed dynamically)\n\tpublic borderColor: (str: string) => string;\n\n\t// Autocomplete support\n\tprivate autocompleteProvider?: AutocompleteProvider;\n\tprivate autocompleteList?: SelectList;\n\tprivate isAutocompleting: boolean = false;\n\tprivate autocompletePrefix: string = \"\";\n\n\t// Paste tracking for large pastes\n\tprivate pastes: Map<number, string> = new Map();\n\tprivate pasteCounter: number = 0;\n\n\t// Bracketed paste mode buffering\n\tprivate pasteBuffer: string = \"\";\n\tprivate isInPaste: boolean = false;\n\tprivate pendingShiftEnter: boolean = false;\n\n\t// Prompt history for up/down navigation\n\tprivate history: string[] = [];\n\tprivate historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.\n\n\tpublic onSubmit?: (text: string) => void;\n\tpublic onChange?: (text: string) => void;\n\tpublic disableSubmit: boolean = false;\n\n\tconstructor(tui: TUI, theme: EditorTheme) {\n\t\tthis.tui = tui;\n\t\tthis.theme = theme;\n\t\tthis.borderColor = theme.borderColor;\n\t}\n\n\tsetAutocompleteProvider(provider: AutocompleteProvider): void {\n\t\tthis.autocompleteProvider = provider;\n\t}\n\n\t/**\n\t * Add a prompt to history for up/down arrow navigation.\n\t * Called after successful submission.\n\t */\n\taddToHistory(text: string): void {\n\t\tconst trimmed = text.trim();\n\t\tif (!trimmed) return;\n\t\t// Don't add consecutive duplicates\n\t\tif (this.history.length > 0 && this.history[0] === trimmed) return;\n\t\tthis.history.unshift(trimmed);\n\t\t// Limit history size\n\t\tif (this.history.length > 100) {\n\t\t\tthis.history.pop();\n\t\t}\n\t}\n\n\tprivate isEditorEmpty(): boolean {\n\t\treturn this.state.lines.length === 1 && this.state.lines[0] === \"\";\n\t}\n\n\tprivate isOnFirstVisualLine(): boolean {\n\t\tconst visualLines = this.buildVisualLineMap(this.lastWidth);\n\t\tconst currentVisualLine = this.findCurrentVisualLine(visualLines);\n\t\treturn currentVisualLine === 0;\n\t}\n\n\tprivate isOnLastVisualLine(): boolean {\n\t\tconst visualLines = this.buildVisualLineMap(this.lastWidth);\n\t\tconst currentVisualLine = this.findCurrentVisualLine(visualLines);\n\t\treturn currentVisualLine === visualLines.length - 1;\n\t}\n\n\tprivate navigateHistory(direction: 1 | -1): void {\n\t\tif (this.history.length === 0) return;\n\n\t\tconst newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases\n\t\tif (newIndex < -1 || newIndex >= this.history.length) return;\n\n\t\tthis.historyIndex = newIndex;\n\n\t\tif (this.historyIndex === -1) {\n\t\t\t// Returned to \"current\" state - clear editor\n\t\t\tthis.setTextInternal(\"\");\n\t\t} else {\n\t\t\tthis.setTextInternal(this.history[this.historyIndex] || \"\");\n\t\t}\n\t}\n\n\t/** Internal setText that doesn't reset history state - used by navigateHistory */\n\tprivate setTextInternal(text: string): void {\n\t\tconst lines = text.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\").split(\"\\n\");\n\t\tthis.state.lines = lines.length === 0 ? [\"\"] : lines;\n\t\tthis.state.cursorLine = this.state.lines.length - 1;\n\t\tthis.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0;\n\t\t// Reset scroll - render() will adjust to show cursor\n\t\tthis.scrollOffset = 0;\n\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\t}\n\n\tinvalidate(): void {\n\t\t// No cached state to invalidate currently\n\t}\n\n\trender(width: number): string[] {\n\t\t// Store width for cursor navigation\n\t\tthis.lastWidth = width;\n\n\t\tconst horizontal = this.borderColor(\"─\");\n\n\t\t// Layout the text - use full width\n\t\tconst layoutLines = this.layoutText(width);\n\n\t\t// Calculate max visible lines: 30% of terminal height, minimum 5 lines\n\t\tconst terminalRows = this.tui.terminal.rows;\n\t\tconst maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3));\n\n\t\t// Find the cursor line index in layoutLines\n\t\tlet cursorLineIndex = layoutLines.findIndex((line) => line.hasCursor);\n\t\tif (cursorLineIndex === -1) cursorLineIndex = 0;\n\n\t\t// Adjust scroll offset to keep cursor visible\n\t\tif (cursorLineIndex < this.scrollOffset) {\n\t\t\tthis.scrollOffset = cursorLineIndex;\n\t\t} else if (cursorLineIndex >= this.scrollOffset + maxVisibleLines) {\n\t\t\tthis.scrollOffset = cursorLineIndex - maxVisibleLines + 1;\n\t\t}\n\n\t\t// Clamp scroll offset to valid range\n\t\tconst maxScrollOffset = Math.max(0, layoutLines.length - maxVisibleLines);\n\t\tthis.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScrollOffset));\n\n\t\t// Get visible lines slice\n\t\tconst visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);\n\n\t\tconst result: string[] = [];\n\n\t\t// Render top border (with scroll indicator if scrolled down)\n\t\tif (this.scrollOffset > 0) {\n\t\t\tconst indicator = `─── ↑ ${this.scrollOffset} more `;\n\t\t\tconst remaining = width - visibleWidth(indicator);\n\t\t\tresult.push(this.borderColor(indicator + \"─\".repeat(Math.max(0, remaining))));\n\t\t} else {\n\t\t\tresult.push(horizontal.repeat(width));\n\t\t}\n\n\t\t// Render each visible layout line\n\t\t// Emit hardware cursor marker only when focused and not showing autocomplete\n\t\tconst emitCursorMarker = this.focused && !this.isAutocompleting;\n\n\t\tfor (const layoutLine of visibleLines) {\n\t\t\tlet displayText = layoutLine.text;\n\t\t\tlet lineVisibleWidth = visibleWidth(layoutLine.text);\n\n\t\t\t// Add cursor if this line has it\n\t\t\tif (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {\n\t\t\t\tconst before = displayText.slice(0, layoutLine.cursorPos);\n\t\t\t\tconst after = displayText.slice(layoutLine.cursorPos);\n\n\t\t\t\t// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)\n\t\t\t\tconst marker = emitCursorMarker ? CURSOR_MARKER : \"\";\n\n\t\t\t\tif (after.length > 0) {\n\t\t\t\t\t// Cursor is on a character (grapheme) - replace it with highlighted version\n\t\t\t\t\t// Get the first grapheme from 'after'\n\t\t\t\t\tconst afterGraphemes = [...segmenter.segment(after)];\n\t\t\t\t\tconst firstGrapheme = afterGraphemes[0]?.segment || \"\";\n\t\t\t\t\tconst restAfter = after.slice(firstGrapheme.length);\n\t\t\t\t\tconst cursor = `\\x1b[7m${firstGrapheme}\\x1b[0m`;\n\t\t\t\t\tdisplayText = before + marker + cursor + restAfter;\n\t\t\t\t\t// lineVisibleWidth stays the same - we're replacing, not adding\n\t\t\t\t} else {\n\t\t\t\t\t// Cursor is at the end - check if we have room for the space\n\t\t\t\t\tif (lineVisibleWidth < width) {\n\t\t\t\t\t\t// We have room - add highlighted space\n\t\t\t\t\t\tconst cursor = \"\\x1b[7m \\x1b[0m\";\n\t\t\t\t\t\tdisplayText = before + marker + cursor;\n\t\t\t\t\t\t// lineVisibleWidth increases by 1 - we're adding a space\n\t\t\t\t\t\tlineVisibleWidth = lineVisibleWidth + 1;\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Line is at full width - use reverse video on last grapheme if possible\n\t\t\t\t\t\t// or just show cursor at the end without adding space\n\t\t\t\t\t\tconst beforeGraphemes = [...segmenter.segment(before)];\n\t\t\t\t\t\tif (beforeGraphemes.length > 0) {\n\t\t\t\t\t\t\tconst lastGrapheme = beforeGraphemes[beforeGraphemes.length - 1]?.segment || \"\";\n\t\t\t\t\t\t\tconst cursor = `\\x1b[7m${lastGrapheme}\\x1b[0m`;\n\t\t\t\t\t\t\t// Rebuild 'before' without the last grapheme\n\t\t\t\t\t\t\tconst beforeWithoutLast = beforeGraphemes\n\t\t\t\t\t\t\t\t.slice(0, -1)\n\t\t\t\t\t\t\t\t.map((g) => g.segment)\n\t\t\t\t\t\t\t\t.join(\"\");\n\t\t\t\t\t\t\tdisplayText = beforeWithoutLast + marker + cursor;\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// lineVisibleWidth stays the same\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Calculate padding based on actual visible width\n\t\t\tconst padding = \" \".repeat(Math.max(0, width - lineVisibleWidth));\n\n\t\t\t// Render the line (no side borders, just horizontal lines above and below)\n\t\t\tresult.push(displayText + padding);\n\t\t}\n\n\t\t// Render bottom border (with scroll indicator if more content below)\n\t\tconst linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length);\n\t\tif (linesBelow > 0) {\n\t\t\tconst indicator = `─── ↓ ${linesBelow} more `;\n\t\t\tconst remaining = width - visibleWidth(indicator);\n\t\t\tresult.push(this.borderColor(indicator + \"─\".repeat(Math.max(0, remaining))));\n\t\t} else {\n\t\t\tresult.push(horizontal.repeat(width));\n\t\t}\n\n\t\t// Add autocomplete list if active\n\t\tif (this.isAutocompleting && this.autocompleteList) {\n\t\t\tconst autocompleteResult = this.autocompleteList.render(width);\n\t\t\tresult.push(...autocompleteResult);\n\t\t}\n\n\t\treturn result;\n\t}\n\n\thandleInput(data: string): void {\n\t\tconst kb = getEditorKeybindings();\n\n\t\t// Handle bracketed paste mode\n\t\tif (data.includes(\"\\x1b[200~\")) {\n\t\t\tthis.isInPaste = true;\n\t\t\tthis.pasteBuffer = \"\";\n\t\t\tdata = data.replace(\"\\x1b[200~\", \"\");\n\t\t}\n\n\t\tif (this.isInPaste) {\n\t\t\tthis.pasteBuffer += data;\n\t\t\tconst endIndex = this.pasteBuffer.indexOf(\"\\x1b[201~\");\n\t\t\tif (endIndex !== -1) {\n\t\t\t\tconst pasteContent = this.pasteBuffer.substring(0, endIndex);\n\t\t\t\tif (pasteContent.length > 0) {\n\t\t\t\t\tthis.handlePaste(pasteContent);\n\t\t\t\t}\n\t\t\t\tthis.isInPaste = false;\n\t\t\t\tconst remaining = this.pasteBuffer.substring(endIndex + 6);\n\t\t\t\tthis.pasteBuffer = \"\";\n\t\t\t\tif (remaining.length > 0) {\n\t\t\t\t\tthis.handleInput(remaining);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tif (this.pendingShiftEnter) {\n\t\t\tif (data === \"\\r\") {\n\t\t\t\tthis.pendingShiftEnter = false;\n\t\t\t\tthis.addNewLine();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.pendingShiftEnter = false;\n\t\t\tthis.insertCharacter(\"\\\\\");\n\t\t}\n\n\t\tif (data === \"\\\\\") {\n\t\t\tthis.pendingShiftEnter = true;\n\t\t\treturn;\n\t\t}\n\n\t\t// Ctrl+C - let parent handle (exit/clear)\n\t\tif (kb.matches(data, \"copy\")) {\n\t\t\treturn;\n\t\t}\n\n\t\t// Handle autocomplete mode\n\t\tif (this.isAutocompleting && this.autocompleteList) {\n\t\t\tif (kb.matches(data, \"selectCancel\")) {\n\t\t\t\tthis.cancelAutocomplete();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (kb.matches(data, \"selectUp\") || kb.matches(data, \"selectDown\")) {\n\t\t\t\tthis.autocompleteList.handleInput(data);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (kb.matches(data, \"tab\")) {\n\t\t\t\tconst selected = this.autocompleteList.getSelectedItem();\n\t\t\t\tif (selected && this.autocompleteProvider) {\n\t\t\t\t\tconst result = this.autocompleteProvider.applyCompletion(\n\t\t\t\t\t\tthis.state.lines,\n\t\t\t\t\t\tthis.state.cursorLine,\n\t\t\t\t\t\tthis.state.cursorCol,\n\t\t\t\t\t\tselected,\n\t\t\t\t\t\tthis.autocompletePrefix,\n\t\t\t\t\t);\n\t\t\t\t\tthis.state.lines = result.lines;\n\t\t\t\t\tthis.state.cursorLine = result.cursorLine;\n\t\t\t\t\tthis.state.cursorCol = result.cursorCol;\n\t\t\t\t\tthis.cancelAutocomplete();\n\t\t\t\t\tif (this.onChange) this.onChange(this.getText());\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (kb.matches(data, \"selectConfirm\")) {\n\t\t\t\tconst selected = this.autocompleteList.getSelectedItem();\n\t\t\t\tif (selected && this.autocompleteProvider) {\n\t\t\t\t\tconst result = this.autocompleteProvider.applyCompletion(\n\t\t\t\t\t\tthis.state.lines,\n\t\t\t\t\t\tthis.state.cursorLine,\n\t\t\t\t\t\tthis.state.cursorCol,\n\t\t\t\t\t\tselected,\n\t\t\t\t\t\tthis.autocompletePrefix,\n\t\t\t\t\t);\n\t\t\t\t\tthis.state.lines = result.lines;\n\t\t\t\t\tthis.state.cursorLine = result.cursorLine;\n\t\t\t\t\tthis.state.cursorCol = result.cursorCol;\n\n\t\t\t\t\tif (this.autocompletePrefix.startsWith(\"/\")) {\n\t\t\t\t\t\tthis.cancelAutocomplete();\n\t\t\t\t\t\t// Fall through to submit\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.cancelAutocomplete();\n\t\t\t\t\t\tif (this.onChange) this.onChange(this.getText());\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Tab - trigger completion\n\t\tif (kb.matches(data, \"tab\") && !this.isAutocompleting) {\n\t\t\tthis.handleTabCompletion();\n\t\t\treturn;\n\t\t}\n\n\t\t// Deletion actions\n\t\tif (kb.matches(data, \"deleteToLineEnd\")) {\n\t\t\tthis.deleteToEndOfLine();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"deleteToLineStart\")) {\n\t\t\tthis.deleteToStartOfLine();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"deleteWordBackward\")) {\n\t\t\tthis.deleteWordBackwards();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"deleteCharBackward\") || matchesKey(data, \"shift+backspace\")) {\n\t\t\tthis.handleBackspace();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"deleteCharForward\") || matchesKey(data, \"shift+delete\")) {\n\t\t\tthis.handleForwardDelete();\n\t\t\treturn;\n\t\t}\n\n\t\t// Cursor movement actions\n\t\tif (kb.matches(data, \"cursorLineStart\")) {\n\t\t\tthis.moveToLineStart();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"cursorLineEnd\")) {\n\t\t\tthis.moveToLineEnd();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"cursorWordLeft\")) {\n\t\t\tthis.moveWordBackwards();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"cursorWordRight\")) {\n\t\t\tthis.moveWordForwards();\n\t\t\treturn;\n\t\t}\n\n\t\t// New line (Shift+Enter, Alt+Enter, etc.)\n\t\tif (\n\t\t\tkb.matches(data, \"newLine\") ||\n\t\t\t(data.charCodeAt(0) === 10 && data.length > 1) ||\n\t\t\tdata === \"\\x1b\\r\" ||\n\t\t\tdata === \"\\x1b[13;2~\" ||\n\t\t\t(data.length > 1 && data.includes(\"\\x1b\") && data.includes(\"\\r\")) ||\n\t\t\t(data === \"\\n\" && data.length === 1) ||\n\t\t\tdata === \"\\\\\\r\"\n\t\t) {\n\t\t\tthis.addNewLine();\n\t\t\treturn;\n\t\t}\n\n\t\t// Submit (Enter)\n\t\tif (kb.matches(data, \"submit\")) {\n\t\t\tif (this.disableSubmit) return;\n\n\t\t\tlet result = this.state.lines.join(\"\\n\").trim();\n\t\t\tfor (const [pasteId, pasteContent] of this.pastes) {\n\t\t\t\tconst markerRegex = new RegExp(`\\\\[paste #${pasteId}( (\\\\+\\\\d+ lines|\\\\d+ chars))?\\\\]`, \"g\");\n\t\t\t\tresult = result.replace(markerRegex, pasteContent);\n\t\t\t}\n\n\t\t\tthis.state = { lines: [\"\"], cursorLine: 0, cursorCol: 0 };\n\t\t\tthis.pastes.clear();\n\t\t\tthis.pasteCounter = 0;\n\t\t\tthis.historyIndex = -1;\n\t\t\tthis.scrollOffset = 0;\n\n\t\t\tif (this.onChange) this.onChange(\"\");\n\t\t\tif (this.onSubmit) this.onSubmit(result);\n\t\t\treturn;\n\t\t}\n\n\t\t// Arrow key navigation (with history support)\n\t\tif (kb.matches(data, \"cursorUp\")) {\n\t\t\tif (this.isEditorEmpty()) {\n\t\t\t\tthis.navigateHistory(-1);\n\t\t\t} else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {\n\t\t\t\tthis.navigateHistory(-1);\n\t\t\t} else {\n\t\t\t\tthis.moveCursor(-1, 0);\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"cursorDown\")) {\n\t\t\tif (this.historyIndex > -1 && this.isOnLastVisualLine()) {\n\t\t\t\tthis.navigateHistory(1);\n\t\t\t} else {\n\t\t\t\tthis.moveCursor(1, 0);\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"cursorRight\")) {\n\t\t\tthis.moveCursor(0, 1);\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"cursorLeft\")) {\n\t\t\tthis.moveCursor(0, -1);\n\t\t\treturn;\n\t\t}\n\n\t\t// Page up/down - scroll by page and move cursor\n\t\tif (kb.matches(data, \"pageUp\")) {\n\t\t\tthis.pageScroll(-1);\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"pageDown\")) {\n\t\t\tthis.pageScroll(1);\n\t\t\treturn;\n\t\t}\n\n\t\t// Shift+Space - insert regular space\n\t\tif (matchesKey(data, \"shift+space\")) {\n\t\t\tthis.insertCharacter(\" \");\n\t\t\treturn;\n\t\t}\n\n\t\t// Regular characters\n\t\tif (data.charCodeAt(0) >= 32) {\n\t\t\tthis.insertCharacter(data);\n\t\t}\n\t}\n\n\tprivate layoutText(contentWidth: number): LayoutLine[] {\n\t\tconst layoutLines: LayoutLine[] = [];\n\n\t\tif (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === \"\")) {\n\t\t\t// Empty editor\n\t\t\tlayoutLines.push({\n\t\t\t\ttext: \"\",\n\t\t\t\thasCursor: true,\n\t\t\t\tcursorPos: 0,\n\t\t\t});\n\t\t\treturn layoutLines;\n\t\t}\n\n\t\t// Process each logical line\n\t\tfor (let i = 0; i < this.state.lines.length; i++) {\n\t\t\tconst line = this.state.lines[i] || \"\";\n\t\t\tconst isCurrentLine = i === this.state.cursorLine;\n\t\t\tconst lineVisibleWidth = visibleWidth(line);\n\n\t\t\tif (lineVisibleWidth <= contentWidth) {\n\t\t\t\t// Line fits in one layout line\n\t\t\t\tif (isCurrentLine) {\n\t\t\t\t\tlayoutLines.push({\n\t\t\t\t\t\ttext: line,\n\t\t\t\t\t\thasCursor: true,\n\t\t\t\t\t\tcursorPos: this.state.cursorCol,\n\t\t\t\t\t});\n\t\t\t\t} else {\n\t\t\t\t\tlayoutLines.push({\n\t\t\t\t\t\ttext: line,\n\t\t\t\t\t\thasCursor: false,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Line needs wrapping - use word-aware wrapping\n\t\t\t\tconst chunks = wordWrapLine(line, contentWidth);\n\n\t\t\t\tfor (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {\n\t\t\t\t\tconst chunk = chunks[chunkIndex];\n\t\t\t\t\tif (!chunk) continue;\n\n\t\t\t\t\tconst cursorPos = this.state.cursorCol;\n\t\t\t\t\tconst isLastChunk = chunkIndex === chunks.length - 1;\n\n\t\t\t\t\t// Determine if cursor is in this chunk\n\t\t\t\t\t// For word-wrapped chunks, we need to handle the case where\n\t\t\t\t\t// cursor might be in trimmed whitespace at end of chunk\n\t\t\t\t\tlet hasCursorInChunk = false;\n\t\t\t\t\tlet adjustedCursorPos = 0;\n\n\t\t\t\t\tif (isCurrentLine) {\n\t\t\t\t\t\tif (isLastChunk) {\n\t\t\t\t\t\t\t// Last chunk: cursor belongs here if >= startIndex\n\t\t\t\t\t\t\thasCursorInChunk = cursorPos >= chunk.startIndex;\n\t\t\t\t\t\t\tadjustedCursorPos = cursorPos - chunk.startIndex;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Non-last chunk: cursor belongs here if in range [startIndex, endIndex)\n\t\t\t\t\t\t\t// But we need to handle the visual position in the trimmed text\n\t\t\t\t\t\t\thasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;\n\t\t\t\t\t\t\tif (hasCursorInChunk) {\n\t\t\t\t\t\t\t\tadjustedCursorPos = cursorPos - chunk.startIndex;\n\t\t\t\t\t\t\t\t// Clamp to text length (in case cursor was in trimmed whitespace)\n\t\t\t\t\t\t\t\tif (adjustedCursorPos > chunk.text.length) {\n\t\t\t\t\t\t\t\t\tadjustedCursorPos = chunk.text.length;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tif (hasCursorInChunk) {\n\t\t\t\t\t\tlayoutLines.push({\n\t\t\t\t\t\t\ttext: chunk.text,\n\t\t\t\t\t\t\thasCursor: true,\n\t\t\t\t\t\t\tcursorPos: adjustedCursorPos,\n\t\t\t\t\t\t});\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlayoutLines.push({\n\t\t\t\t\t\t\ttext: chunk.text,\n\t\t\t\t\t\t\thasCursor: false,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn layoutLines;\n\t}\n\n\tgetText(): string {\n\t\treturn this.state.lines.join(\"\\n\");\n\t}\n\n\t/**\n\t * Get text with paste markers expanded to their actual content.\n\t * Use this when you need the full content (e.g., for external editor).\n\t */\n\tgetExpandedText(): string {\n\t\tlet result = this.state.lines.join(\"\\n\");\n\t\tfor (const [pasteId, pasteContent] of this.pastes) {\n\t\t\tconst markerRegex = new RegExp(`\\\\[paste #${pasteId}( (\\\\+\\\\d+ lines|\\\\d+ chars))?\\\\]`, \"g\");\n\t\t\tresult = result.replace(markerRegex, pasteContent);\n\t\t}\n\t\treturn result;\n\t}\n\n\tgetLines(): string[] {\n\t\treturn [...this.state.lines];\n\t}\n\n\tgetCursor(): { line: number; col: number } {\n\t\treturn { line: this.state.cursorLine, col: this.state.cursorCol };\n\t}\n\n\tsetText(text: string): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\t\tthis.setTextInternal(text);\n\t}\n\n\t/**\n\t * Insert text at the current cursor position.\n\t * Used for programmatic insertion (e.g., clipboard image markers).\n\t */\n\tinsertTextAtCursor(text: string): void {\n\t\tfor (const char of text) {\n\t\t\tthis.insertCharacter(char);\n\t\t}\n\t}\n\n\t// All the editor methods from before...\n\tprivate insertCharacter(char: string): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\n\t\tconst line = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\tconst before = line.slice(0, this.state.cursorCol);\n\t\tconst after = line.slice(this.state.cursorCol);\n\n\t\tthis.state.lines[this.state.cursorLine] = before + char + after;\n\t\tthis.state.cursorCol += char.length; // Fix: increment by the length of the inserted string\n\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\n\t\t// Check if we should trigger or update autocomplete\n\t\tif (!this.isAutocompleting) {\n\t\t\t// Auto-trigger for \"/\" at the start of a line (slash commands)\n\t\t\tif (char === \"/\" && this.isAtStartOfMessage()) {\n\t\t\t\tthis.tryTriggerAutocomplete();\n\t\t\t}\n\t\t\t// Auto-trigger for \"@\" file reference (fuzzy search)\n\t\t\telse if (char === \"@\") {\n\t\t\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\t\tconst textBeforeCursor = currentLine.slice(0, this.state.cursorCol);\n\t\t\t\t// Only trigger if @ is after whitespace or at start of line\n\t\t\t\tconst charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2];\n\t\t\t\tif (textBeforeCursor.length === 1 || charBeforeAt === \" \" || charBeforeAt === \"\\t\") {\n\t\t\t\t\tthis.tryTriggerAutocomplete();\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Also auto-trigger when typing letters in a slash command context\n\t\t\telse if (/[a-zA-Z0-9.\\-_]/.test(char)) {\n\t\t\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\t\tconst textBeforeCursor = currentLine.slice(0, this.state.cursorCol);\n\t\t\t\t// Check if we're in a slash command (with or without space for arguments)\n\t\t\t\tif (textBeforeCursor.trimStart().startsWith(\"/\")) {\n\t\t\t\t\tthis.tryTriggerAutocomplete();\n\t\t\t\t}\n\t\t\t\t// Check if we're in an @ file reference context\n\t\t\t\telse if (textBeforeCursor.match(/(?:^|[\\s])@[^\\s]*$/)) {\n\t\t\t\t\tthis.tryTriggerAutocomplete();\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tthis.updateAutocomplete();\n\t\t}\n\t}\n\n\tprivate handlePaste(pastedText: string): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\n\t\t// Clean the pasted text\n\t\tconst cleanText = pastedText.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\");\n\n\t\t// Convert tabs to spaces (4 spaces per tab)\n\t\tconst tabExpandedText = cleanText.replace(/\\t/g, \" \");\n\n\t\t// Filter out non-printable characters except newlines\n\t\tlet filteredText = tabExpandedText\n\t\t\t.split(\"\")\n\t\t\t.filter((char) => char === \"\\n\" || char.charCodeAt(0) >= 32)\n\t\t\t.join(\"\");\n\n\t\t// If pasting a file path (starts with /, ~, or .) and the character before\n\t\t// the cursor is a word character, prepend a space for better readability\n\t\tif (/^[/~.]/.test(filteredText)) {\n\t\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\tconst charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : \"\";\n\t\t\tif (charBeforeCursor && /\\w/.test(charBeforeCursor)) {\n\t\t\t\tfilteredText = ` ${filteredText}`;\n\t\t\t}\n\t\t}\n\n\t\t// Split into lines\n\t\tconst pastedLines = filteredText.split(\"\\n\");\n\n\t\t// Check if this is a large paste (> 10 lines or > 1000 characters)\n\t\tconst totalChars = filteredText.length;\n\t\tif (pastedLines.length > 10 || totalChars > 1000) {\n\t\t\t// Store the paste and insert a marker\n\t\t\tthis.pasteCounter++;\n\t\t\tconst pasteId = this.pasteCounter;\n\t\t\tthis.pastes.set(pasteId, filteredText);\n\n\t\t\t// Insert marker like \"[paste #1 +123 lines]\" or \"[paste #1 1234 chars]\"\n\t\t\tconst marker =\n\t\t\t\tpastedLines.length > 10\n\t\t\t\t\t? `[paste #${pasteId} +${pastedLines.length} lines]`\n\t\t\t\t\t: `[paste #${pasteId} ${totalChars} chars]`;\n\t\t\tfor (const char of marker) {\n\t\t\t\tthis.insertCharacter(char);\n\t\t\t}\n\n\t\t\treturn;\n\t\t}\n\n\t\tif (pastedLines.length === 1) {\n\t\t\t// Single line - just insert each character\n\t\t\tconst text = pastedLines[0] || \"\";\n\t\t\tfor (const char of text) {\n\t\t\t\tthis.insertCharacter(char);\n\t\t\t}\n\n\t\t\treturn;\n\t\t}\n\n\t\t// Multi-line paste - be very careful with array manipulation\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\tconst beforeCursor = currentLine.slice(0, this.state.cursorCol);\n\t\tconst afterCursor = currentLine.slice(this.state.cursorCol);\n\n\t\t// Build the new lines array step by step\n\t\tconst newLines: string[] = [];\n\n\t\t// Add all lines before current line\n\t\tfor (let i = 0; i < this.state.cursorLine; i++) {\n\t\t\tnewLines.push(this.state.lines[i] || \"\");\n\t\t}\n\n\t\t// Add the first pasted line merged with before cursor text\n\t\tnewLines.push(beforeCursor + (pastedLines[0] || \"\"));\n\n\t\t// Add all middle pasted lines\n\t\tfor (let i = 1; i < pastedLines.length - 1; i++) {\n\t\t\tnewLines.push(pastedLines[i] || \"\");\n\t\t}\n\n\t\t// Add the last pasted line with after cursor text\n\t\tnewLines.push((pastedLines[pastedLines.length - 1] || \"\") + afterCursor);\n\n\t\t// Add all lines after current line\n\t\tfor (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) {\n\t\t\tnewLines.push(this.state.lines[i] || \"\");\n\t\t}\n\n\t\t// Replace the entire lines array\n\t\tthis.state.lines = newLines;\n\n\t\t// Update cursor position to end of pasted content\n\t\tthis.state.cursorLine += pastedLines.length - 1;\n\t\tthis.state.cursorCol = (pastedLines[pastedLines.length - 1] || \"\").length;\n\n\t\t// Notify of change\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\t}\n\n\tprivate addNewLine(): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\tconst before = currentLine.slice(0, this.state.cursorCol);\n\t\tconst after = currentLine.slice(this.state.cursorCol);\n\n\t\t// Split current line\n\t\tthis.state.lines[this.state.cursorLine] = before;\n\t\tthis.state.lines.splice(this.state.cursorLine + 1, 0, after);\n\n\t\t// Move cursor to start of new line\n\t\tthis.state.cursorLine++;\n\t\tthis.state.cursorCol = 0;\n\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\t}\n\n\tprivate handleBackspace(): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\n\t\tif (this.state.cursorCol > 0) {\n\t\t\t// Delete grapheme before cursor (handles emojis, combining characters, etc.)\n\t\t\tconst line = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\tconst beforeCursor = line.slice(0, this.state.cursorCol);\n\n\t\t\t// Find the last grapheme in the text before cursor\n\t\t\tconst graphemes = [...segmenter.segment(beforeCursor)];\n\t\t\tconst lastGrapheme = graphemes[graphemes.length - 1];\n\t\t\tconst graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;\n\n\t\t\tconst before = line.slice(0, this.state.cursorCol - graphemeLength);\n\t\t\tconst after = line.slice(this.state.cursorCol);\n\n\t\t\tthis.state.lines[this.state.cursorLine] = before + after;\n\t\t\tthis.state.cursorCol -= graphemeLength;\n\t\t} else if (this.state.cursorLine > 0) {\n\t\t\t// Merge with previous line\n\t\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\tconst previousLine = this.state.lines[this.state.cursorLine - 1] || \"\";\n\n\t\t\tthis.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;\n\t\t\tthis.state.lines.splice(this.state.cursorLine, 1);\n\n\t\t\tthis.state.cursorLine--;\n\t\t\tthis.state.cursorCol = previousLine.length;\n\t\t}\n\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\n\t\t// Update or re-trigger autocomplete after backspace\n\t\tif (this.isAutocompleting) {\n\t\t\tthis.updateAutocomplete();\n\t\t} else {\n\t\t\t// If autocomplete was cancelled (no matches), re-trigger if we're in a completable context\n\t\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\tconst textBeforeCursor = currentLine.slice(0, this.state.cursorCol);\n\t\t\t// Slash command context\n\t\t\tif (textBeforeCursor.trimStart().startsWith(\"/\")) {\n\t\t\t\tthis.tryTriggerAutocomplete();\n\t\t\t}\n\t\t\t// @ file reference context\n\t\t\telse if (textBeforeCursor.match(/(?:^|[\\s])@[^\\s]*$/)) {\n\t\t\t\tthis.tryTriggerAutocomplete();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate moveToLineStart(): void {\n\t\tthis.state.cursorCol = 0;\n\t}\n\n\tprivate moveToLineEnd(): void {\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\tthis.state.cursorCol = currentLine.length;\n\t}\n\n\tprivate deleteToStartOfLine(): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\tif (this.state.cursorCol > 0) {\n\t\t\t// Delete from start of line up to cursor\n\t\t\tthis.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol);\n\t\t\tthis.state.cursorCol = 0;\n\t\t} else if (this.state.cursorLine > 0) {\n\t\t\t// At start of line - merge with previous line\n\t\t\tconst previousLine = this.state.lines[this.state.cursorLine - 1] || \"\";\n\t\t\tthis.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;\n\t\t\tthis.state.lines.splice(this.state.cursorLine, 1);\n\t\t\tthis.state.cursorLine--;\n\t\t\tthis.state.cursorCol = previousLine.length;\n\t\t}\n\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\t}\n\n\tprivate deleteToEndOfLine(): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\tif (this.state.cursorCol < currentLine.length) {\n\t\t\t// Delete from cursor to end of line\n\t\t\tthis.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol);\n\t\t} else if (this.state.cursorLine < this.state.lines.length - 1) {\n\t\t\t// At end of line - merge with next line\n\t\t\tconst nextLine = this.state.lines[this.state.cursorLine + 1] || \"\";\n\t\t\tthis.state.lines[this.state.cursorLine] = currentLine + nextLine;\n\t\t\tthis.state.lines.splice(this.state.cursorLine + 1, 1);\n\t\t}\n\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\t}\n\n\tprivate deleteWordBackwards(): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\t// If at start of line, behave like backspace at column 0 (merge with previous line)\n\t\tif (this.state.cursorCol === 0) {\n\t\t\tif (this.state.cursorLine > 0) {\n\t\t\t\tconst previousLine = this.state.lines[this.state.cursorLine - 1] || \"\";\n\t\t\t\tthis.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;\n\t\t\t\tthis.state.lines.splice(this.state.cursorLine, 1);\n\t\t\t\tthis.state.cursorLine--;\n\t\t\t\tthis.state.cursorCol = previousLine.length;\n\t\t\t}\n\t\t} else {\n\t\t\tconst oldCursorCol = this.state.cursorCol;\n\t\t\tthis.moveWordBackwards();\n\t\t\tconst deleteFrom = this.state.cursorCol;\n\t\t\tthis.state.cursorCol = oldCursorCol;\n\n\t\t\tthis.state.lines[this.state.cursorLine] =\n\t\t\t\tcurrentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol);\n\t\t\tthis.state.cursorCol = deleteFrom;\n\t\t}\n\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\t}\n\n\tprivate handleForwardDelete(): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\tif (this.state.cursorCol < currentLine.length) {\n\t\t\t// Delete grapheme at cursor position (handles emojis, combining characters, etc.)\n\t\t\tconst afterCursor = currentLine.slice(this.state.cursorCol);\n\n\t\t\t// Find the first grapheme at cursor\n\t\t\tconst graphemes = [...segmenter.segment(afterCursor)];\n\t\t\tconst firstGrapheme = graphemes[0];\n\t\t\tconst graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;\n\n\t\t\tconst before = currentLine.slice(0, this.state.cursorCol);\n\t\t\tconst after = currentLine.slice(this.state.cursorCol + graphemeLength);\n\t\t\tthis.state.lines[this.state.cursorLine] = before + after;\n\t\t} else if (this.state.cursorLine < this.state.lines.length - 1) {\n\t\t\t// At end of line - merge with next line\n\t\t\tconst nextLine = this.state.lines[this.state.cursorLine + 1] || \"\";\n\t\t\tthis.state.lines[this.state.cursorLine] = currentLine + nextLine;\n\t\t\tthis.state.lines.splice(this.state.cursorLine + 1, 1);\n\t\t}\n\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\n\t\t// Update or re-trigger autocomplete after forward delete\n\t\tif (this.isAutocompleting) {\n\t\t\tthis.updateAutocomplete();\n\t\t} else {\n\t\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\tconst textBeforeCursor = currentLine.slice(0, this.state.cursorCol);\n\t\t\t// Slash command context\n\t\t\tif (textBeforeCursor.trimStart().startsWith(\"/\")) {\n\t\t\t\tthis.tryTriggerAutocomplete();\n\t\t\t}\n\t\t\t// @ file reference context\n\t\t\telse if (textBeforeCursor.match(/(?:^|[\\s])@[^\\s]*$/)) {\n\t\t\t\tthis.tryTriggerAutocomplete();\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Build a mapping from visual lines to logical positions.\n\t * Returns an array where each element represents a visual line with:\n\t * - logicalLine: index into this.state.lines\n\t * - startCol: starting column in the logical line\n\t * - length: length of this visual line segment\n\t */\n\tprivate buildVisualLineMap(width: number): Array<{ logicalLine: number; startCol: number; length: number }> {\n\t\tconst visualLines: Array<{ logicalLine: number; startCol: number; length: number }> = [];\n\n\t\tfor (let i = 0; i < this.state.lines.length; i++) {\n\t\t\tconst line = this.state.lines[i] || \"\";\n\t\t\tconst lineVisWidth = visibleWidth(line);\n\t\t\tif (line.length === 0) {\n\t\t\t\t// Empty line still takes one visual line\n\t\t\t\tvisualLines.push({ logicalLine: i, startCol: 0, length: 0 });\n\t\t\t} else if (lineVisWidth <= width) {\n\t\t\t\tvisualLines.push({ logicalLine: i, startCol: 0, length: line.length });\n\t\t\t} else {\n\t\t\t\t// Line needs wrapping - use word-aware wrapping\n\t\t\t\tconst chunks = wordWrapLine(line, width);\n\t\t\t\tfor (const chunk of chunks) {\n\t\t\t\t\tvisualLines.push({\n\t\t\t\t\t\tlogicalLine: i,\n\t\t\t\t\t\tstartCol: chunk.startIndex,\n\t\t\t\t\t\tlength: chunk.endIndex - chunk.startIndex,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn visualLines;\n\t}\n\n\t/**\n\t * Find the visual line index for the current cursor position.\n\t */\n\tprivate findCurrentVisualLine(\n\t\tvisualLines: Array<{ logicalLine: number; startCol: number; length: number }>,\n\t): number {\n\t\tfor (let i = 0; i < visualLines.length; i++) {\n\t\t\tconst vl = visualLines[i];\n\t\t\tif (!vl) continue;\n\t\t\tif (vl.logicalLine === this.state.cursorLine) {\n\t\t\t\tconst colInSegment = this.state.cursorCol - vl.startCol;\n\t\t\t\t// Cursor is in this segment if it's within range\n\t\t\t\t// For the last segment of a logical line, cursor can be at length (end position)\n\t\t\t\tconst isLastSegmentOfLine =\n\t\t\t\t\ti === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;\n\t\t\t\tif (colInSegment >= 0 && (colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))) {\n\t\t\t\t\treturn i;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// Fallback: return last visual line\n\t\treturn visualLines.length - 1;\n\t}\n\n\tprivate moveCursor(deltaLine: number, deltaCol: number): void {\n\t\tconst width = this.lastWidth;\n\n\t\tif (deltaLine !== 0) {\n\t\t\t// Build visual line map for navigation\n\t\t\tconst visualLines = this.buildVisualLineMap(width);\n\t\t\tconst currentVisualLine = this.findCurrentVisualLine(visualLines);\n\n\t\t\t// Calculate column position within current visual line\n\t\t\tconst currentVL = visualLines[currentVisualLine];\n\t\t\tconst visualCol = currentVL ? this.state.cursorCol - currentVL.startCol : 0;\n\n\t\t\t// Move to target visual line\n\t\t\tconst targetVisualLine = currentVisualLine + deltaLine;\n\n\t\t\tif (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {\n\t\t\t\tconst targetVL = visualLines[targetVisualLine];\n\t\t\t\tif (targetVL) {\n\t\t\t\t\tthis.state.cursorLine = targetVL.logicalLine;\n\t\t\t\t\t// Try to maintain visual column position, clamped to line length\n\t\t\t\t\tconst targetCol = targetVL.startCol + Math.min(visualCol, targetVL.length);\n\t\t\t\t\tconst logicalLine = this.state.lines[targetVL.logicalLine] || \"\";\n\t\t\t\t\tthis.state.cursorCol = Math.min(targetCol, logicalLine.length);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (deltaCol !== 0) {\n\t\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\t\tif (deltaCol > 0) {\n\t\t\t\t// Moving right - move by one grapheme (handles emojis, combining characters, etc.)\n\t\t\t\tif (this.state.cursorCol < currentLine.length) {\n\t\t\t\t\tconst afterCursor = currentLine.slice(this.state.cursorCol);\n\t\t\t\t\tconst graphemes = [...segmenter.segment(afterCursor)];\n\t\t\t\t\tconst firstGrapheme = graphemes[0];\n\t\t\t\t\tthis.state.cursorCol += firstGrapheme ? firstGrapheme.segment.length : 1;\n\t\t\t\t} else if (this.state.cursorLine < this.state.lines.length - 1) {\n\t\t\t\t\t// Wrap to start of next logical line\n\t\t\t\t\tthis.state.cursorLine++;\n\t\t\t\t\tthis.state.cursorCol = 0;\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Moving left - move by one grapheme (handles emojis, combining characters, etc.)\n\t\t\t\tif (this.state.cursorCol > 0) {\n\t\t\t\t\tconst beforeCursor = currentLine.slice(0, this.state.cursorCol);\n\t\t\t\t\tconst graphemes = [...segmenter.segment(beforeCursor)];\n\t\t\t\t\tconst lastGrapheme = graphemes[graphemes.length - 1];\n\t\t\t\t\tthis.state.cursorCol -= lastGrapheme ? lastGrapheme.segment.length : 1;\n\t\t\t\t} else if (this.state.cursorLine > 0) {\n\t\t\t\t\t// Wrap to end of previous logical line\n\t\t\t\t\tthis.state.cursorLine--;\n\t\t\t\t\tconst prevLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\t\t\tthis.state.cursorCol = prevLine.length;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Scroll by a page (direction: -1 for up, 1 for down).\n\t * Moves cursor by the page size while keeping it in bounds.\n\t */\n\tprivate pageScroll(direction: -1 | 1): void {\n\t\tconst width = this.lastWidth;\n\t\tconst terminalRows = this.tui.terminal.rows;\n\t\tconst pageSize = Math.max(5, Math.floor(terminalRows * 0.3));\n\n\t\t// Build visual line map\n\t\tconst visualLines = this.buildVisualLineMap(width);\n\t\tconst currentVisualLine = this.findCurrentVisualLine(visualLines);\n\n\t\t// Calculate target visual line\n\t\tconst targetVisualLine = Math.max(0, Math.min(visualLines.length - 1, currentVisualLine + direction * pageSize));\n\n\t\t// Move cursor to target visual line\n\t\tconst targetVL = visualLines[targetVisualLine];\n\t\tif (targetVL) {\n\t\t\t// Preserve column position within the line\n\t\t\tconst currentVL = visualLines[currentVisualLine];\n\t\t\tconst visualCol = currentVL ? this.state.cursorCol - currentVL.startCol : 0;\n\n\t\t\tthis.state.cursorLine = targetVL.logicalLine;\n\t\t\tconst targetCol = targetVL.startCol + Math.min(visualCol, targetVL.length);\n\t\t\tconst logicalLine = this.state.lines[targetVL.logicalLine] || \"\";\n\t\t\tthis.state.cursorCol = Math.min(targetCol, logicalLine.length);\n\t\t}\n\t}\n\n\tprivate moveWordBackwards(): void {\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\t// If at start of line, move to end of previous line\n\t\tif (this.state.cursorCol === 0) {\n\t\t\tif (this.state.cursorLine > 0) {\n\t\t\t\tthis.state.cursorLine--;\n\t\t\t\tconst prevLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\t\tthis.state.cursorCol = prevLine.length;\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tconst textBeforeCursor = currentLine.slice(0, this.state.cursorCol);\n\t\tconst graphemes = [...segmenter.segment(textBeforeCursor)];\n\t\tlet newCol = this.state.cursorCol;\n\n\t\t// Skip trailing whitespace\n\t\twhile (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || \"\")) {\n\t\t\tnewCol -= graphemes.pop()?.segment.length || 0;\n\t\t}\n\n\t\tif (graphemes.length > 0) {\n\t\t\tconst lastGrapheme = graphemes[graphemes.length - 1]?.segment || \"\";\n\t\t\tif (isPunctuationChar(lastGrapheme)) {\n\t\t\t\t// Skip punctuation run\n\t\t\t\twhile (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || \"\")) {\n\t\t\t\t\tnewCol -= graphemes.pop()?.segment.length || 0;\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Skip word run\n\t\t\t\twhile (\n\t\t\t\t\tgraphemes.length > 0 &&\n\t\t\t\t\t!isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || \"\") &&\n\t\t\t\t\t!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || \"\")\n\t\t\t\t) {\n\t\t\t\t\tnewCol -= graphemes.pop()?.segment.length || 0;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tthis.state.cursorCol = newCol;\n\t}\n\n\tprivate moveWordForwards(): void {\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\t// If at end of line, move to start of next line\n\t\tif (this.state.cursorCol >= currentLine.length) {\n\t\t\tif (this.state.cursorLine < this.state.lines.length - 1) {\n\t\t\t\tthis.state.cursorLine++;\n\t\t\t\tthis.state.cursorCol = 0;\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tconst textAfterCursor = currentLine.slice(this.state.cursorCol);\n\t\tconst segments = segmenter.segment(textAfterCursor);\n\t\tconst iterator = segments[Symbol.iterator]();\n\t\tlet next = iterator.next();\n\n\t\t// Skip leading whitespace\n\t\twhile (!next.done && isWhitespaceChar(next.value.segment)) {\n\t\t\tthis.state.cursorCol += next.value.segment.length;\n\t\t\tnext = iterator.next();\n\t\t}\n\n\t\tif (!next.done) {\n\t\t\tconst firstGrapheme = next.value.segment;\n\t\t\tif (isPunctuationChar(firstGrapheme)) {\n\t\t\t\t// Skip punctuation run\n\t\t\t\twhile (!next.done && isPunctuationChar(next.value.segment)) {\n\t\t\t\t\tthis.state.cursorCol += next.value.segment.length;\n\t\t\t\t\tnext = iterator.next();\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Skip word run\n\t\t\t\twhile (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {\n\t\t\t\t\tthis.state.cursorCol += next.value.segment.length;\n\t\t\t\t\tnext = iterator.next();\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Helper method to check if cursor is at start of message (for slash command detection)\n\tprivate isAtStartOfMessage(): boolean {\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\tconst beforeCursor = currentLine.slice(0, this.state.cursorCol);\n\n\t\t// At start if line is empty, only contains whitespace, or is just \"/\"\n\t\treturn beforeCursor.trim() === \"\" || beforeCursor.trim() === \"/\";\n\t}\n\n\t// Autocomplete methods\n\tprivate tryTriggerAutocomplete(explicitTab: boolean = false): void {\n\t\tif (!this.autocompleteProvider) return;\n\n\t\t// Check if we should trigger file completion on Tab\n\t\tif (explicitTab) {\n\t\t\tconst provider = this.autocompleteProvider as CombinedAutocompleteProvider;\n\t\t\tconst shouldTrigger =\n\t\t\t\t!provider.shouldTriggerFileCompletion ||\n\t\t\t\tprovider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);\n\t\t\tif (!shouldTrigger) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tconst suggestions = this.autocompleteProvider.getSuggestions(\n\t\t\tthis.state.lines,\n\t\t\tthis.state.cursorLine,\n\t\t\tthis.state.cursorCol,\n\t\t);\n\n\t\tif (suggestions && suggestions.items.length > 0) {\n\t\t\tthis.autocompletePrefix = suggestions.prefix;\n\t\t\tthis.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);\n\t\t\tthis.isAutocompleting = true;\n\t\t} else {\n\t\t\tthis.cancelAutocomplete();\n\t\t}\n\t}\n\n\tprivate handleTabCompletion(): void {\n\t\tif (!this.autocompleteProvider) return;\n\n\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\tconst beforeCursor = currentLine.slice(0, this.state.cursorCol);\n\n\t\t// Check if we're in a slash command context\n\t\tif (beforeCursor.trimStart().startsWith(\"/\") && !beforeCursor.trimStart().includes(\" \")) {\n\t\t\tthis.handleSlashCommandCompletion();\n\t\t} else {\n\t\t\tthis.forceFileAutocomplete();\n\t\t}\n\t}\n\n\tprivate handleSlashCommandCompletion(): void {\n\t\tthis.tryTriggerAutocomplete(true);\n\t}\n\n\t/*\nhttps://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883\n17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19\n536643416/job/55932288317 havea look at .gi\n\t */\n\tprivate forceFileAutocomplete(): void {\n\t\tif (!this.autocompleteProvider) return;\n\n\t\t// Check if provider supports force file suggestions via runtime check\n\t\tconst provider = this.autocompleteProvider as {\n\t\t\tgetForceFileSuggestions?: CombinedAutocompleteProvider[\"getForceFileSuggestions\"];\n\t\t};\n\t\tif (typeof provider.getForceFileSuggestions !== \"function\") {\n\t\t\tthis.tryTriggerAutocomplete(true);\n\t\t\treturn;\n\t\t}\n\n\t\tconst suggestions = provider.getForceFileSuggestions(\n\t\t\tthis.state.lines,\n\t\t\tthis.state.cursorLine,\n\t\t\tthis.state.cursorCol,\n\t\t);\n\n\t\tif (suggestions && suggestions.items.length > 0) {\n\t\t\tthis.autocompletePrefix = suggestions.prefix;\n\t\t\tthis.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);\n\t\t\tthis.isAutocompleting = true;\n\t\t} else {\n\t\t\tthis.cancelAutocomplete();\n\t\t}\n\t}\n\n\tprivate cancelAutocomplete(): void {\n\t\tthis.isAutocompleting = false;\n\t\tthis.autocompleteList = undefined;\n\t\tthis.autocompletePrefix = \"\";\n\t}\n\n\tpublic isShowingAutocomplete(): boolean {\n\t\treturn this.isAutocompleting;\n\t}\n\n\tprivate updateAutocomplete(): void {\n\t\tif (!this.isAutocompleting || !this.autocompleteProvider) return;\n\n\t\tconst suggestions = this.autocompleteProvider.getSuggestions(\n\t\t\tthis.state.lines,\n\t\t\tthis.state.cursorLine,\n\t\t\tthis.state.cursorCol,\n\t\t);\n\n\t\tif (suggestions && suggestions.items.length > 0) {\n\t\t\tthis.autocompletePrefix = suggestions.prefix;\n\t\t\t// Always create new SelectList to ensure update\n\t\t\tthis.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);\n\t\t} else {\n\t\t\tthis.cancelAutocomplete();\n\t\t}\n\t}\n}\n"]}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getEditorKeybindings } from "../keybindings.js";
|
|
2
2
|
import { matchesKey } from "../keys.js";
|
|
3
|
+
import { CURSOR_MARKER } from "../tui.js";
|
|
3
4
|
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
|
|
4
5
|
import { SelectList } from "./select-list.js";
|
|
5
6
|
const segmenter = getSegmenter();
|
|
@@ -163,9 +164,14 @@ export class Editor {
|
|
|
163
164
|
cursorLine: 0,
|
|
164
165
|
cursorCol: 0,
|
|
165
166
|
};
|
|
167
|
+
/** Focusable interface - set by TUI when focus changes */
|
|
168
|
+
focused = false;
|
|
169
|
+
tui;
|
|
166
170
|
theme;
|
|
167
171
|
// Store last render width for cursor navigation
|
|
168
172
|
lastWidth = 80;
|
|
173
|
+
// Vertical scrolling support
|
|
174
|
+
scrollOffset = 0;
|
|
169
175
|
// Border color (can be changed dynamically)
|
|
170
176
|
borderColor;
|
|
171
177
|
// Autocomplete support
|
|
@@ -186,7 +192,8 @@ export class Editor {
|
|
|
186
192
|
onSubmit;
|
|
187
193
|
onChange;
|
|
188
194
|
disableSubmit = false;
|
|
189
|
-
constructor(theme) {
|
|
195
|
+
constructor(tui, theme) {
|
|
196
|
+
this.tui = tui;
|
|
190
197
|
this.theme = theme;
|
|
191
198
|
this.borderColor = theme.borderColor;
|
|
192
199
|
}
|
|
@@ -244,6 +251,8 @@ export class Editor {
|
|
|
244
251
|
this.state.lines = lines.length === 0 ? [""] : lines;
|
|
245
252
|
this.state.cursorLine = this.state.lines.length - 1;
|
|
246
253
|
this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0;
|
|
254
|
+
// Reset scroll - render() will adjust to show cursor
|
|
255
|
+
this.scrollOffset = 0;
|
|
247
256
|
if (this.onChange) {
|
|
248
257
|
this.onChange(this.getText());
|
|
249
258
|
}
|
|
@@ -257,17 +266,47 @@ export class Editor {
|
|
|
257
266
|
const horizontal = this.borderColor("─");
|
|
258
267
|
// Layout the text - use full width
|
|
259
268
|
const layoutLines = this.layoutText(width);
|
|
269
|
+
// Calculate max visible lines: 30% of terminal height, minimum 5 lines
|
|
270
|
+
const terminalRows = this.tui.terminal.rows;
|
|
271
|
+
const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3));
|
|
272
|
+
// Find the cursor line index in layoutLines
|
|
273
|
+
let cursorLineIndex = layoutLines.findIndex((line) => line.hasCursor);
|
|
274
|
+
if (cursorLineIndex === -1)
|
|
275
|
+
cursorLineIndex = 0;
|
|
276
|
+
// Adjust scroll offset to keep cursor visible
|
|
277
|
+
if (cursorLineIndex < this.scrollOffset) {
|
|
278
|
+
this.scrollOffset = cursorLineIndex;
|
|
279
|
+
}
|
|
280
|
+
else if (cursorLineIndex >= this.scrollOffset + maxVisibleLines) {
|
|
281
|
+
this.scrollOffset = cursorLineIndex - maxVisibleLines + 1;
|
|
282
|
+
}
|
|
283
|
+
// Clamp scroll offset to valid range
|
|
284
|
+
const maxScrollOffset = Math.max(0, layoutLines.length - maxVisibleLines);
|
|
285
|
+
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScrollOffset));
|
|
286
|
+
// Get visible lines slice
|
|
287
|
+
const visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);
|
|
260
288
|
const result = [];
|
|
261
|
-
// Render top border
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
289
|
+
// Render top border (with scroll indicator if scrolled down)
|
|
290
|
+
if (this.scrollOffset > 0) {
|
|
291
|
+
const indicator = `─── ↑ ${this.scrollOffset} more `;
|
|
292
|
+
const remaining = width - visibleWidth(indicator);
|
|
293
|
+
result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
result.push(horizontal.repeat(width));
|
|
297
|
+
}
|
|
298
|
+
// Render each visible layout line
|
|
299
|
+
// Emit hardware cursor marker only when focused and not showing autocomplete
|
|
300
|
+
const emitCursorMarker = this.focused && !this.isAutocompleting;
|
|
301
|
+
for (const layoutLine of visibleLines) {
|
|
265
302
|
let displayText = layoutLine.text;
|
|
266
303
|
let lineVisibleWidth = visibleWidth(layoutLine.text);
|
|
267
304
|
// Add cursor if this line has it
|
|
268
305
|
if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
|
|
269
306
|
const before = displayText.slice(0, layoutLine.cursorPos);
|
|
270
307
|
const after = displayText.slice(layoutLine.cursorPos);
|
|
308
|
+
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
|
|
309
|
+
const marker = emitCursorMarker ? CURSOR_MARKER : "";
|
|
271
310
|
if (after.length > 0) {
|
|
272
311
|
// Cursor is on a character (grapheme) - replace it with highlighted version
|
|
273
312
|
// Get the first grapheme from 'after'
|
|
@@ -275,7 +314,7 @@ export class Editor {
|
|
|
275
314
|
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
|
276
315
|
const restAfter = after.slice(firstGrapheme.length);
|
|
277
316
|
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
|
278
|
-
displayText = before + cursor + restAfter;
|
|
317
|
+
displayText = before + marker + cursor + restAfter;
|
|
279
318
|
// lineVisibleWidth stays the same - we're replacing, not adding
|
|
280
319
|
}
|
|
281
320
|
else {
|
|
@@ -283,7 +322,7 @@ export class Editor {
|
|
|
283
322
|
if (lineVisibleWidth < width) {
|
|
284
323
|
// We have room - add highlighted space
|
|
285
324
|
const cursor = "\x1b[7m \x1b[0m";
|
|
286
|
-
displayText = before + cursor;
|
|
325
|
+
displayText = before + marker + cursor;
|
|
287
326
|
// lineVisibleWidth increases by 1 - we're adding a space
|
|
288
327
|
lineVisibleWidth = lineVisibleWidth + 1;
|
|
289
328
|
}
|
|
@@ -299,7 +338,7 @@ export class Editor {
|
|
|
299
338
|
.slice(0, -1)
|
|
300
339
|
.map((g) => g.segment)
|
|
301
340
|
.join("");
|
|
302
|
-
displayText = beforeWithoutLast + cursor;
|
|
341
|
+
displayText = beforeWithoutLast + marker + cursor;
|
|
303
342
|
}
|
|
304
343
|
// lineVisibleWidth stays the same
|
|
305
344
|
}
|
|
@@ -310,8 +349,16 @@ export class Editor {
|
|
|
310
349
|
// Render the line (no side borders, just horizontal lines above and below)
|
|
311
350
|
result.push(displayText + padding);
|
|
312
351
|
}
|
|
313
|
-
// Render bottom border
|
|
314
|
-
|
|
352
|
+
// Render bottom border (with scroll indicator if more content below)
|
|
353
|
+
const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length);
|
|
354
|
+
if (linesBelow > 0) {
|
|
355
|
+
const indicator = `─── ↓ ${linesBelow} more `;
|
|
356
|
+
const remaining = width - visibleWidth(indicator);
|
|
357
|
+
result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
result.push(horizontal.repeat(width));
|
|
361
|
+
}
|
|
315
362
|
// Add autocomplete list if active
|
|
316
363
|
if (this.isAutocompleting && this.autocompleteList) {
|
|
317
364
|
const autocompleteResult = this.autocompleteList.render(width);
|
|
@@ -472,6 +519,7 @@ export class Editor {
|
|
|
472
519
|
this.pastes.clear();
|
|
473
520
|
this.pasteCounter = 0;
|
|
474
521
|
this.historyIndex = -1;
|
|
522
|
+
this.scrollOffset = 0;
|
|
475
523
|
if (this.onChange)
|
|
476
524
|
this.onChange("");
|
|
477
525
|
if (this.onSubmit)
|
|
@@ -508,6 +556,15 @@ export class Editor {
|
|
|
508
556
|
this.moveCursor(0, -1);
|
|
509
557
|
return;
|
|
510
558
|
}
|
|
559
|
+
// Page up/down - scroll by page and move cursor
|
|
560
|
+
if (kb.matches(data, "pageUp")) {
|
|
561
|
+
this.pageScroll(-1);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (kb.matches(data, "pageDown")) {
|
|
565
|
+
this.pageScroll(1);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
511
568
|
// Shift+Space - insert regular space
|
|
512
569
|
if (matchesKey(data, "shift+space")) {
|
|
513
570
|
this.insertCharacter(" ");
|
|
@@ -1037,6 +1094,31 @@ export class Editor {
|
|
|
1037
1094
|
}
|
|
1038
1095
|
}
|
|
1039
1096
|
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Scroll by a page (direction: -1 for up, 1 for down).
|
|
1099
|
+
* Moves cursor by the page size while keeping it in bounds.
|
|
1100
|
+
*/
|
|
1101
|
+
pageScroll(direction) {
|
|
1102
|
+
const width = this.lastWidth;
|
|
1103
|
+
const terminalRows = this.tui.terminal.rows;
|
|
1104
|
+
const pageSize = Math.max(5, Math.floor(terminalRows * 0.3));
|
|
1105
|
+
// Build visual line map
|
|
1106
|
+
const visualLines = this.buildVisualLineMap(width);
|
|
1107
|
+
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
|
1108
|
+
// Calculate target visual line
|
|
1109
|
+
const targetVisualLine = Math.max(0, Math.min(visualLines.length - 1, currentVisualLine + direction * pageSize));
|
|
1110
|
+
// Move cursor to target visual line
|
|
1111
|
+
const targetVL = visualLines[targetVisualLine];
|
|
1112
|
+
if (targetVL) {
|
|
1113
|
+
// Preserve column position within the line
|
|
1114
|
+
const currentVL = visualLines[currentVisualLine];
|
|
1115
|
+
const visualCol = currentVL ? this.state.cursorCol - currentVL.startCol : 0;
|
|
1116
|
+
this.state.cursorLine = targetVL.logicalLine;
|
|
1117
|
+
const targetCol = targetVL.startCol + Math.min(visualCol, targetVL.length);
|
|
1118
|
+
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
|
|
1119
|
+
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1040
1122
|
moveWordBackwards() {
|
|
1041
1123
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1042
1124
|
// If at start of line, move to end of previous line
|