@mariozechner/pi-tui 0.12.11 → 0.12.12

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 CHANGED
@@ -93,6 +93,14 @@ input.onSubmit = (value) => console.log(value);
93
93
  input.setValue("initial");
94
94
  ```
95
95
 
96
+ **Key Bindings:**
97
+ - `Enter` - Submit
98
+ - `Ctrl+A` / `Ctrl+E` - Line start/end
99
+ - `Ctrl+W` or `Option+Backspace` - Delete word backwards
100
+ - `Ctrl+U` - Delete to start of line
101
+ - `Ctrl+K` - Delete to end of line
102
+ - Arrow keys, Backspace, Delete work as expected
103
+
96
104
  ### Editor
97
105
 
98
106
  Multi-line text editor with autocomplete, file completion, and paste handling.
@@ -18,11 +18,24 @@ export declare class Editor implements Component {
18
18
  private pasteCounter;
19
19
  private pasteBuffer;
20
20
  private isInPaste;
21
+ private history;
22
+ private historyIndex;
21
23
  onSubmit?: (text: string) => void;
22
24
  onChange?: (text: string) => void;
23
25
  disableSubmit: boolean;
24
26
  constructor(theme: EditorTheme);
25
27
  setAutocompleteProvider(provider: AutocompleteProvider): void;
28
+ /**
29
+ * Add a prompt to history for up/down arrow navigation.
30
+ * Called after successful submission.
31
+ */
32
+ addToHistory(text: string): void;
33
+ private isEditorEmpty;
34
+ private isOnFirstVisualLine;
35
+ private isOnLastVisualLine;
36
+ private navigateHistory;
37
+ /** Internal setText that doesn't reset history state - used by navigateHistory */
38
+ private setTextInternal;
26
39
  invalidate(): void;
27
40
  render(width: number): string[];
28
41
  handleInput(data: string): void;
@@ -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;AAC7F,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAc,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAcpE,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;IAE5B,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,UAAU,IAAI,IAAI,CAEjB;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAoE9B;IAED,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAwQ9B;IAED,OAAO,CAAC,UAAU;IAyElB,OAAO,IAAI,MAAM,CAEhB;IAED,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAe1B;IAGD,OAAO,CAAC,eAAe;IA+CvB,OAAO,CAAC,WAAW;IAwFnB,OAAO,CAAC,UAAU;IAmBlB,OAAO,CAAC,eAAe;IA4CvB,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,aAAa;IAKrB,OAAO,CAAC,mBAAmB;IAqB3B,OAAO,CAAC,iBAAiB;IAkBzB,OAAO,CAAC,mBAAmB;IAgD3B,OAAO,CAAC,mBAAmB;IAoC3B;;;;;;OAMG;IACH,OAAO,CAAC,kBAAkB;IAsB1B;;OAEG;IACH,OAAO,CAAC,qBAAqB;IAqB7B,OAAO,CAAC,UAAU;IAqDlB,OAAO,CAAC,cAAc;IAItB,OAAO,CAAC,iBAAiB;IAkCzB,OAAO,CAAC,gBAAgB;IAiCxB,OAAO,CAAC,kBAAkB;IAS1B,OAAO,CAAC,sBAAsB;IA6B9B,OAAO,CAAC,mBAAmB;IAc3B,OAAO,CAAC,4BAA4B;IAWpC,OAAO,CAAC,qBAAqB;IAyB7B,OAAO,CAAC,kBAAkB;IAMnB,qBAAqB,IAAI,OAAO,CAEtC;IAED,OAAO,CAAC,kBAAkB;CAqB1B","sourcesContent":["import type { AutocompleteProvider, CombinedAutocompleteProvider } from \"../autocomplete.js\";\nimport type { Component } from \"../tui.js\";\nimport { SelectList, type SelectListTheme } from \"./select-list.js\";\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\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\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 visibleLength = layoutLine.text.length;\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 - replace it with highlighted version\n\t\t\t\t\tconst cursor = `\\x1b[7m${after[0]}\\x1b[0m`;\n\t\t\t\t\tconst restAfter = after.slice(1);\n\t\t\t\t\tdisplayText = before + cursor + restAfter;\n\t\t\t\t\t// visibleLength 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 (layoutLine.text.length < 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// visibleLength increases by 1 - we're adding a space\n\t\t\t\t\t\tvisibleLength = layoutLine.text.length + 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 character 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\tif (before.length > 0) {\n\t\t\t\t\t\t\tconst lastChar = before[before.length - 1];\n\t\t\t\t\t\t\tconst cursor = `\\x1b[7m${lastChar}\\x1b[0m`;\n\t\t\t\t\t\t\tdisplayText = before.slice(0, -1) + cursor;\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// visibleLength 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 length\n\t\t\tconst padding = \" \".repeat(Math.max(0, width - visibleLength));\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\t// Handle bracketed paste mode\n\t\t// Start of paste: \\x1b[200~\n\t\t// End of paste: \\x1b[201~\n\n\t\t// Check if we're starting a bracketed paste\n\t\tif (data.includes(\"\\x1b[200~\")) {\n\t\t\tthis.isInPaste = true;\n\t\t\tthis.pasteBuffer = \"\";\n\t\t\t// Remove the start marker and keep the rest\n\t\t\tdata = data.replace(\"\\x1b[200~\", \"\");\n\t\t}\n\n\t\t// If we're in a paste, buffer the data\n\t\tif (this.isInPaste) {\n\t\t\t// Append data to buffer first (end marker could be split across chunks)\n\t\t\tthis.pasteBuffer += data;\n\n\t\t\t// Check if the accumulated buffer contains the end marker\n\t\t\tconst endIndex = this.pasteBuffer.indexOf(\"\\x1b[201~\");\n\t\t\tif (endIndex !== -1) {\n\t\t\t\t// Extract content before the end marker\n\t\t\t\tconst pasteContent = this.pasteBuffer.substring(0, endIndex);\n\n\t\t\t\t// Process the complete paste\n\t\t\t\tthis.handlePaste(pasteContent);\n\n\t\t\t\t// Reset paste state\n\t\t\t\tthis.isInPaste = false;\n\n\t\t\t\t// Process any remaining data after the end marker\n\t\t\t\tconst remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \\x1b[201~\n\t\t\t\tthis.pasteBuffer = \"\";\n\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} else {\n\t\t\t\t// Still accumulating, wait for more data\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\t// Handle special key combinations first\n\n\t\t// Ctrl+C - Exit (let parent handle this)\n\t\tif (data.charCodeAt(0) === 3) {\n\t\t\treturn;\n\t\t}\n\n\t\t// Handle autocomplete special keys first (but don't block other input)\n\t\tif (this.isAutocompleting && this.autocompleteList) {\n\t\t\t// Escape - cancel autocomplete\n\t\t\tif (data === \"\\x1b\") {\n\t\t\t\tthis.cancelAutocomplete();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// Let the autocomplete list handle navigation and selection\n\t\t\telse if (data === \"\\x1b[A\" || data === \"\\x1b[B\" || data === \"\\r\" || data === \"\\t\") {\n\t\t\t\t// Only pass arrow keys to the list, not Enter/Tab (we handle those directly)\n\t\t\t\tif (data === \"\\x1b[A\" || data === \"\\x1b[B\") {\n\t\t\t\t\tthis.autocompleteList.handleInput(data);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// If Tab was pressed, always apply the selection\n\t\t\t\tif (data === \"\\t\") {\n\t\t\t\t\tconst selected = this.autocompleteList.getSelectedItem();\n\t\t\t\t\tif (selected && this.autocompleteProvider) {\n\t\t\t\t\t\tconst result = this.autocompleteProvider.applyCompletion(\n\t\t\t\t\t\t\tthis.state.lines,\n\t\t\t\t\t\t\tthis.state.cursorLine,\n\t\t\t\t\t\t\tthis.state.cursorCol,\n\t\t\t\t\t\t\tselected,\n\t\t\t\t\t\t\tthis.autocompletePrefix,\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\tthis.state.lines = result.lines;\n\t\t\t\t\t\tthis.state.cursorLine = result.cursorLine;\n\t\t\t\t\t\tthis.state.cursorCol = result.cursorCol;\n\n\t\t\t\t\t\tthis.cancelAutocomplete();\n\n\t\t\t\t\t\tif (this.onChange) {\n\t\t\t\t\t\t\tthis.onChange(this.getText());\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// If Enter was pressed on a slash command, apply completion and submit\n\t\t\t\tif (data === \"\\r\" && this.autocompletePrefix.startsWith(\"/\")) {\n\t\t\t\t\tconst selected = this.autocompleteList.getSelectedItem();\n\t\t\t\t\tif (selected && this.autocompleteProvider) {\n\t\t\t\t\t\tconst result = this.autocompleteProvider.applyCompletion(\n\t\t\t\t\t\t\tthis.state.lines,\n\t\t\t\t\t\t\tthis.state.cursorLine,\n\t\t\t\t\t\t\tthis.state.cursorCol,\n\t\t\t\t\t\t\tselected,\n\t\t\t\t\t\t\tthis.autocompletePrefix,\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\tthis.state.lines = result.lines;\n\t\t\t\t\t\tthis.state.cursorLine = result.cursorLine;\n\t\t\t\t\t\tthis.state.cursorCol = result.cursorCol;\n\t\t\t\t\t}\n\t\t\t\t\tthis.cancelAutocomplete();\n\t\t\t\t\t// Don't return - fall through to submission logic\n\t\t\t\t}\n\t\t\t\t// If Enter was pressed on a file path, apply completion\n\t\t\t\telse if (data === \"\\r\") {\n\t\t\t\t\tconst selected = this.autocompleteList.getSelectedItem();\n\t\t\t\t\tif (selected && this.autocompleteProvider) {\n\t\t\t\t\t\tconst result = this.autocompleteProvider.applyCompletion(\n\t\t\t\t\t\t\tthis.state.lines,\n\t\t\t\t\t\t\tthis.state.cursorLine,\n\t\t\t\t\t\t\tthis.state.cursorCol,\n\t\t\t\t\t\t\tselected,\n\t\t\t\t\t\t\tthis.autocompletePrefix,\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\tthis.state.lines = result.lines;\n\t\t\t\t\t\tthis.state.cursorLine = result.cursorLine;\n\t\t\t\t\t\tthis.state.cursorCol = result.cursorCol;\n\n\t\t\t\t\t\tthis.cancelAutocomplete();\n\n\t\t\t\t\t\tif (this.onChange) {\n\t\t\t\t\t\t\tthis.onChange(this.getText());\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\t\t\t// For other keys (like regular typing), DON'T return here\n\t\t\t// Let them fall through to normal character handling\n\t\t}\n\n\t\t// Tab key - context-aware completion (but not when already autocompleting)\n\t\tif (data === \"\\t\" && !this.isAutocompleting) {\n\t\t\tthis.handleTabCompletion();\n\t\t\treturn;\n\t\t}\n\n\t\t// Continue with rest of input handling\n\t\t// Ctrl+K - Delete to end of line\n\t\tif (data.charCodeAt(0) === 11) {\n\t\t\tthis.deleteToEndOfLine();\n\t\t}\n\t\t// Ctrl+U - Delete to start of line\n\t\telse if (data.charCodeAt(0) === 21) {\n\t\t\tthis.deleteToStartOfLine();\n\t\t}\n\t\t// Ctrl+W - Delete word backwards\n\t\telse if (data.charCodeAt(0) === 23) {\n\t\t\tthis.deleteWordBackwards();\n\t\t}\n\t\t// Option/Alt+Backspace (e.g. Ghostty sends ESC + DEL)\n\t\telse if (data === \"\\x1b\\x7f\") {\n\t\t\tthis.deleteWordBackwards();\n\t\t}\n\t\t// Ctrl+A - Move to start of line\n\t\telse if (data.charCodeAt(0) === 1) {\n\t\t\tthis.moveToLineStart();\n\t\t}\n\t\t// Ctrl+E - Move to end of line\n\t\telse if (data.charCodeAt(0) === 5) {\n\t\t\tthis.moveToLineEnd();\n\t\t}\n\t\t// New line shortcuts (but not plain LF/CR which should be submit)\n\t\telse if (\n\t\t\t(data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers\n\t\t\tdata === \"\\x1b\\r\" || // Option+Enter in some terminals\n\t\t\tdata === \"\\x1b[13;2~\" || // Shift+Enter in some terminals\n\t\t\t(data.length > 1 && data.includes(\"\\x1b\") && data.includes(\"\\r\")) ||\n\t\t\t(data === \"\\n\" && data.length === 1) || // Shift+Enter from iTerm2 mapping\n\t\t\tdata === \"\\\\\\r\" // Shift+Enter in VS Code terminal\n\t\t) {\n\t\t\t// Modifier + Enter = new line\n\t\t\tthis.addNewLine();\n\t\t}\n\t\t// Plain Enter (char code 13 for CR) - only CR submits, LF adds new line\n\t\telse if (data.charCodeAt(0) === 13 && data.length === 1) {\n\t\t\t// If submit is disabled, do nothing\n\t\t\tif (this.disableSubmit) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Get text and substitute paste markers with actual content\n\t\t\tlet result = this.state.lines.join(\"\\n\").trim();\n\n\t\t\t// Replace all [paste #N +xxx lines] or [paste #N xxx chars] markers with actual paste content\n\t\t\tfor (const [pasteId, pasteContent] of this.pastes) {\n\t\t\t\t// Match formats: [paste #N], [paste #N +xxx lines], or [paste #N xxx chars]\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\t// Reset editor and clear pastes\n\t\t\tthis.state = {\n\t\t\t\tlines: [\"\"],\n\t\t\t\tcursorLine: 0,\n\t\t\t\tcursorCol: 0,\n\t\t\t};\n\t\t\tthis.pastes.clear();\n\t\t\tthis.pasteCounter = 0;\n\n\t\t\t// Notify that editor is now empty\n\t\t\tif (this.onChange) {\n\t\t\t\tthis.onChange(\"\");\n\t\t\t}\n\n\t\t\tif (this.onSubmit) {\n\t\t\t\tthis.onSubmit(result);\n\t\t\t}\n\t\t}\n\t\t// Backspace\n\t\telse if (data.charCodeAt(0) === 127 || data.charCodeAt(0) === 8) {\n\t\t\tthis.handleBackspace();\n\t\t}\n\t\t// Line navigation shortcuts (Home/End keys)\n\t\telse if (data === \"\\x1b[H\" || data === \"\\x1b[1~\" || data === \"\\x1b[7~\") {\n\t\t\t// Home key\n\t\t\tthis.moveToLineStart();\n\t\t} else if (data === \"\\x1b[F\" || data === \"\\x1b[4~\" || data === \"\\x1b[8~\") {\n\t\t\t// End key\n\t\t\tthis.moveToLineEnd();\n\t\t}\n\t\t// Forward delete (Fn+Backspace or Delete key)\n\t\telse if (data === \"\\x1b[3~\") {\n\t\t\t// Delete key\n\t\t\tthis.handleForwardDelete();\n\t\t}\n\t\t// Word navigation (Option/Alt + Arrow or Ctrl + Arrow)\n\t\t// Option+Left: \\x1b[1;3D or \\x1bb\n\t\t// Option+Right: \\x1b[1;3C or \\x1bf\n\t\t// Ctrl+Left: \\x1b[1;5D\n\t\t// Ctrl+Right: \\x1b[1;5C\n\t\telse if (data === \"\\x1b[1;3D\" || data === \"\\x1bb\" || data === \"\\x1b[1;5D\") {\n\t\t\t// Word left\n\t\t\tthis.moveWordBackwards();\n\t\t} else if (data === \"\\x1b[1;3C\" || data === \"\\x1bf\" || data === \"\\x1b[1;5C\") {\n\t\t\t// Word right\n\t\t\tthis.moveWordForwards();\n\t\t}\n\t\t// Arrow keys\n\t\telse if (data === \"\\x1b[A\") {\n\t\t\t// Up\n\t\t\tthis.moveCursor(-1, 0);\n\t\t} else if (data === \"\\x1b[B\") {\n\t\t\t// Down\n\t\t\tthis.moveCursor(1, 0);\n\t\t} else if (data === \"\\x1b[C\") {\n\t\t\t// Right\n\t\t\tthis.moveCursor(0, 1);\n\t\t} else if (data === \"\\x1b[D\") {\n\t\t\t// Left\n\t\t\tthis.moveCursor(0, -1);\n\t\t}\n\t\t// Regular characters (printable characters and unicode, but not control characters)\n\t\telse if (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 maxLineLength = contentWidth;\n\n\t\t\tif (line.length <= maxLineLength) {\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\n\t\t\t\tconst chunks = [];\n\t\t\t\tfor (let pos = 0; pos < line.length; pos += maxLineLength) {\n\t\t\t\t\tchunks.push(line.slice(pos, pos + maxLineLength));\n\t\t\t\t}\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 chunkStart = chunkIndex * maxLineLength;\n\t\t\t\t\tconst chunkEnd = chunkStart + chunk.length;\n\t\t\t\t\tconst cursorPos = this.state.cursorCol;\n\t\t\t\t\tconst isLastChunk = chunkIndex === chunks.length - 1;\n\t\t\t\t\t// For non-last chunks, cursor at chunkEnd belongs to the next chunk\n\t\t\t\t\tconst hasCursorInChunk =\n\t\t\t\t\t\tisCurrentLine &&\n\t\t\t\t\t\tcursorPos >= chunkStart &&\n\t\t\t\t\t\t(isLastChunk ? cursorPos <= chunkEnd : cursorPos < chunkEnd);\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,\n\t\t\t\t\t\t\thasCursor: true,\n\t\t\t\t\t\t\tcursorPos: cursorPos - chunkStart,\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,\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\tsetText(text: string): void {\n\t\t// Split text into lines, handling different line endings\n\t\tconst lines = text.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\").split(\"\\n\");\n\n\t\t// Ensure at least one empty line\n\t\tthis.state.lines = lines.length === 0 ? [\"\"] : lines;\n\n\t\t// Reset cursor to end of text\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\t// Notify of change\n\t\tif (this.onChange) {\n\t\t\tthis.onChange(this.getText());\n\t\t}\n\t}\n\n\t// All the editor methods from before...\n\tprivate insertCharacter(char: string): void {\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\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\tconst 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// 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\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\tif (this.state.cursorCol > 0) {\n\t\t\t// Delete character in current line\n\t\t\tconst line = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\t\tconst before = line.slice(0, this.state.cursorCol - 1);\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--;\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\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\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\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 textBeforeCursor = currentLine.slice(0, this.state.cursorCol);\n\n\t\t\tconst isWhitespace = (char: string): boolean => /\\s/.test(char);\n\t\t\tconst isPunctuation = (char: string): boolean => {\n\t\t\t\t// Treat obvious code punctuation as boundaries\n\t\t\t\treturn /[(){}[\\]<>.,;:'\"!?+\\-=*/\\\\|&%^$#@~`]/.test(char);\n\t\t\t};\n\n\t\t\tlet deleteFrom = this.state.cursorCol;\n\t\t\tconst lastChar = textBeforeCursor[deleteFrom - 1] ?? \"\";\n\n\t\t\t// If immediately on whitespace or punctuation, delete that single boundary char\n\t\t\tif (isWhitespace(lastChar) || isPunctuation(lastChar)) {\n\t\t\t\tdeleteFrom -= 1;\n\t\t\t} else {\n\t\t\t\t// Otherwise, delete a run of non-boundary characters (the \"word\")\n\t\t\t\twhile (deleteFrom > 0) {\n\t\t\t\t\tconst ch = textBeforeCursor[deleteFrom - 1] ?? \"\";\n\t\t\t\t\tif (isWhitespace(ch) || isPunctuation(ch)) {\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t\tdeleteFrom -= 1;\n\t\t\t\t}\n\t\t\t}\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\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\tif (this.state.cursorCol < currentLine.length) {\n\t\t\t// Delete character at cursor position (forward delete)\n\t\t\tconst before = currentLine.slice(0, this.state.cursorCol);\n\t\t\tconst after = currentLine.slice(this.state.cursorCol + 1);\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\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 (line.length <= 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\n\t\t\t\tfor (let pos = 0; pos < line.length; pos += width) {\n\t\t\t\t\tconst segmentLength = Math.min(width, line.length - pos);\n\t\t\t\t\tvisualLines.push({ logicalLine: i, startCol: pos, length: segmentLength });\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\n\t\t\t\tif (this.state.cursorCol < currentLine.length) {\n\t\t\t\t\tthis.state.cursorCol++;\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\n\t\t\t\tif (this.state.cursorCol > 0) {\n\t\t\t\t\tthis.state.cursorCol--;\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 isWordBoundary(char: string): boolean {\n\t\treturn /\\s/.test(char) || /[(){}[\\]<>.,;:'\"!?+\\-=*/\\\\|&%^$#@~`]/.test(char);\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\tlet newCol = this.state.cursorCol;\n\t\tconst lastChar = textBeforeCursor[newCol - 1] ?? \"\";\n\n\t\t// If immediately on whitespace or punctuation, skip that single boundary char\n\t\tif (this.isWordBoundary(lastChar)) {\n\t\t\tnewCol -= 1;\n\t\t}\n\n\t\t// Now skip the \"word\" (non-boundary characters)\n\t\twhile (newCol > 0) {\n\t\t\tconst ch = textBeforeCursor[newCol - 1] ?? \"\";\n\t\t\tif (this.isWordBoundary(ch)) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tnewCol -= 1;\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\tlet newCol = this.state.cursorCol;\n\t\tconst charAtCursor = currentLine[newCol] ?? \"\";\n\n\t\t// If on whitespace or punctuation, skip it\n\t\tif (this.isWordBoundary(charAtCursor)) {\n\t\t\tnewCol += 1;\n\t\t}\n\n\t\t// Skip the \"word\" (non-boundary characters)\n\t\twhile (newCol < currentLine.length) {\n\t\t\tconst ch = currentLine[newCol] ?? \"\";\n\t\t\tif (this.isWordBoundary(ch)) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tnewCol += 1;\n\t\t}\n\n\t\tthis.state.cursorCol = newCol;\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(\"/\")) {\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\t// For now, fall back to regular autocomplete (slash commands)\n\t\t// This can be extended later to handle command-specific argument completion\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 has the force method\n\t\tconst provider = this.autocompleteProvider as any;\n\t\tif (!provider.getForceFileSuggestions) {\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 as any;\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\t// No matches - check if we're still in a valid context before cancelling\n\t\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\tconst textBeforeCursor = currentLine.slice(0, this.state.cursorCol);\n\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;AAC7F,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAc,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAcpE,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;IAGnC,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,CAoE9B;IAED,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAmR9B;IAED,OAAO,CAAC,UAAU;IAyElB,OAAO,IAAI,MAAM,CAEhB;IAED,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAG1B;IAGD,OAAO,CAAC,eAAe;IAiDvB,OAAO,CAAC,WAAW;IA0FnB,OAAO,CAAC,UAAU;IAqBlB,OAAO,CAAC,eAAe;IA8CvB,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,aAAa;IAKrB,OAAO,CAAC,mBAAmB;IAuB3B,OAAO,CAAC,iBAAiB;IAoBzB,OAAO,CAAC,mBAAmB;IAkD3B,OAAO,CAAC,mBAAmB;IAsC3B;;;;;;OAMG;IACH,OAAO,CAAC,kBAAkB;IAsB1B;;OAEG;IACH,OAAO,CAAC,qBAAqB;IAqB7B,OAAO,CAAC,UAAU;IAqDlB,OAAO,CAAC,cAAc;IAItB,OAAO,CAAC,iBAAiB;IAkCzB,OAAO,CAAC,gBAAgB;IAiCxB,OAAO,CAAC,kBAAkB;IAS1B,OAAO,CAAC,sBAAsB;IA6B9B,OAAO,CAAC,mBAAmB;IAc3B,OAAO,CAAC,4BAA4B;IAWpC,OAAO,CAAC,qBAAqB;IAyB7B,OAAO,CAAC,kBAAkB;IAMnB,qBAAqB,IAAI,OAAO,CAEtC;IAED,OAAO,CAAC,kBAAkB;CAqB1B","sourcesContent":["import type { AutocompleteProvider, CombinedAutocompleteProvider } from \"../autocomplete.js\";\nimport type { Component } from \"../tui.js\";\nimport { SelectList, type SelectListTheme } from \"./select-list.js\";\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\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 visibleLength = layoutLine.text.length;\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 - replace it with highlighted version\n\t\t\t\t\tconst cursor = `\\x1b[7m${after[0]}\\x1b[0m`;\n\t\t\t\t\tconst restAfter = after.slice(1);\n\t\t\t\t\tdisplayText = before + cursor + restAfter;\n\t\t\t\t\t// visibleLength 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 (layoutLine.text.length < 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// visibleLength increases by 1 - we're adding a space\n\t\t\t\t\t\tvisibleLength = layoutLine.text.length + 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 character 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\tif (before.length > 0) {\n\t\t\t\t\t\t\tconst lastChar = before[before.length - 1];\n\t\t\t\t\t\t\tconst cursor = `\\x1b[7m${lastChar}\\x1b[0m`;\n\t\t\t\t\t\t\tdisplayText = before.slice(0, -1) + cursor;\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// visibleLength 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 length\n\t\t\tconst padding = \" \".repeat(Math.max(0, width - visibleLength));\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\t// Handle bracketed paste mode\n\t\t// Start of paste: \\x1b[200~\n\t\t// End of paste: \\x1b[201~\n\n\t\t// Check if we're starting a bracketed paste\n\t\tif (data.includes(\"\\x1b[200~\")) {\n\t\t\tthis.isInPaste = true;\n\t\t\tthis.pasteBuffer = \"\";\n\t\t\t// Remove the start marker and keep the rest\n\t\t\tdata = data.replace(\"\\x1b[200~\", \"\");\n\t\t}\n\n\t\t// If we're in a paste, buffer the data\n\t\tif (this.isInPaste) {\n\t\t\t// Append data to buffer first (end marker could be split across chunks)\n\t\t\tthis.pasteBuffer += data;\n\n\t\t\t// Check if the accumulated buffer contains the end marker\n\t\t\tconst endIndex = this.pasteBuffer.indexOf(\"\\x1b[201~\");\n\t\t\tif (endIndex !== -1) {\n\t\t\t\t// Extract content before the end marker\n\t\t\t\tconst pasteContent = this.pasteBuffer.substring(0, endIndex);\n\n\t\t\t\t// Process the complete paste\n\t\t\t\tthis.handlePaste(pasteContent);\n\n\t\t\t\t// Reset paste state\n\t\t\t\tthis.isInPaste = false;\n\n\t\t\t\t// Process any remaining data after the end marker\n\t\t\t\tconst remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \\x1b[201~\n\t\t\t\tthis.pasteBuffer = \"\";\n\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} else {\n\t\t\t\t// Still accumulating, wait for more data\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\t// Handle special key combinations first\n\n\t\t// Ctrl+C - Exit (let parent handle this)\n\t\tif (data.charCodeAt(0) === 3) {\n\t\t\treturn;\n\t\t}\n\n\t\t// Handle autocomplete special keys first (but don't block other input)\n\t\tif (this.isAutocompleting && this.autocompleteList) {\n\t\t\t// Escape - cancel autocomplete\n\t\t\tif (data === \"\\x1b\") {\n\t\t\t\tthis.cancelAutocomplete();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// Let the autocomplete list handle navigation and selection\n\t\t\telse if (data === \"\\x1b[A\" || data === \"\\x1b[B\" || data === \"\\r\" || data === \"\\t\") {\n\t\t\t\t// Only pass arrow keys to the list, not Enter/Tab (we handle those directly)\n\t\t\t\tif (data === \"\\x1b[A\" || data === \"\\x1b[B\") {\n\t\t\t\t\tthis.autocompleteList.handleInput(data);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// If Tab was pressed, always apply the selection\n\t\t\t\tif (data === \"\\t\") {\n\t\t\t\t\tconst selected = this.autocompleteList.getSelectedItem();\n\t\t\t\t\tif (selected && this.autocompleteProvider) {\n\t\t\t\t\t\tconst result = this.autocompleteProvider.applyCompletion(\n\t\t\t\t\t\t\tthis.state.lines,\n\t\t\t\t\t\t\tthis.state.cursorLine,\n\t\t\t\t\t\t\tthis.state.cursorCol,\n\t\t\t\t\t\t\tselected,\n\t\t\t\t\t\t\tthis.autocompletePrefix,\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\tthis.state.lines = result.lines;\n\t\t\t\t\t\tthis.state.cursorLine = result.cursorLine;\n\t\t\t\t\t\tthis.state.cursorCol = result.cursorCol;\n\n\t\t\t\t\t\tthis.cancelAutocomplete();\n\n\t\t\t\t\t\tif (this.onChange) {\n\t\t\t\t\t\t\tthis.onChange(this.getText());\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// If Enter was pressed on a slash command, apply completion and submit\n\t\t\t\tif (data === \"\\r\" && this.autocompletePrefix.startsWith(\"/\")) {\n\t\t\t\t\tconst selected = this.autocompleteList.getSelectedItem();\n\t\t\t\t\tif (selected && this.autocompleteProvider) {\n\t\t\t\t\t\tconst result = this.autocompleteProvider.applyCompletion(\n\t\t\t\t\t\t\tthis.state.lines,\n\t\t\t\t\t\t\tthis.state.cursorLine,\n\t\t\t\t\t\t\tthis.state.cursorCol,\n\t\t\t\t\t\t\tselected,\n\t\t\t\t\t\t\tthis.autocompletePrefix,\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\tthis.state.lines = result.lines;\n\t\t\t\t\t\tthis.state.cursorLine = result.cursorLine;\n\t\t\t\t\t\tthis.state.cursorCol = result.cursorCol;\n\t\t\t\t\t}\n\t\t\t\t\tthis.cancelAutocomplete();\n\t\t\t\t\t// Don't return - fall through to submission logic\n\t\t\t\t}\n\t\t\t\t// If Enter was pressed on a file path, apply completion\n\t\t\t\telse if (data === \"\\r\") {\n\t\t\t\t\tconst selected = this.autocompleteList.getSelectedItem();\n\t\t\t\t\tif (selected && this.autocompleteProvider) {\n\t\t\t\t\t\tconst result = this.autocompleteProvider.applyCompletion(\n\t\t\t\t\t\t\tthis.state.lines,\n\t\t\t\t\t\t\tthis.state.cursorLine,\n\t\t\t\t\t\t\tthis.state.cursorCol,\n\t\t\t\t\t\t\tselected,\n\t\t\t\t\t\t\tthis.autocompletePrefix,\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\tthis.state.lines = result.lines;\n\t\t\t\t\t\tthis.state.cursorLine = result.cursorLine;\n\t\t\t\t\t\tthis.state.cursorCol = result.cursorCol;\n\n\t\t\t\t\t\tthis.cancelAutocomplete();\n\n\t\t\t\t\t\tif (this.onChange) {\n\t\t\t\t\t\t\tthis.onChange(this.getText());\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\t\t\t// For other keys (like regular typing), DON'T return here\n\t\t\t// Let them fall through to normal character handling\n\t\t}\n\n\t\t// Tab key - context-aware completion (but not when already autocompleting)\n\t\tif (data === \"\\t\" && !this.isAutocompleting) {\n\t\t\tthis.handleTabCompletion();\n\t\t\treturn;\n\t\t}\n\n\t\t// Continue with rest of input handling\n\t\t// Ctrl+K - Delete to end of line\n\t\tif (data.charCodeAt(0) === 11) {\n\t\t\tthis.deleteToEndOfLine();\n\t\t}\n\t\t// Ctrl+U - Delete to start of line\n\t\telse if (data.charCodeAt(0) === 21) {\n\t\t\tthis.deleteToStartOfLine();\n\t\t}\n\t\t// Ctrl+W - Delete word backwards\n\t\telse if (data.charCodeAt(0) === 23) {\n\t\t\tthis.deleteWordBackwards();\n\t\t}\n\t\t// Option/Alt+Backspace (e.g. Ghostty sends ESC + DEL)\n\t\telse if (data === \"\\x1b\\x7f\") {\n\t\t\tthis.deleteWordBackwards();\n\t\t}\n\t\t// Ctrl+A - Move to start of line\n\t\telse if (data.charCodeAt(0) === 1) {\n\t\t\tthis.moveToLineStart();\n\t\t}\n\t\t// Ctrl+E - Move to end of line\n\t\telse if (data.charCodeAt(0) === 5) {\n\t\t\tthis.moveToLineEnd();\n\t\t}\n\t\t// New line shortcuts (but not plain LF/CR which should be submit)\n\t\telse if (\n\t\t\t(data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers\n\t\t\tdata === \"\\x1b\\r\" || // Option+Enter in some terminals\n\t\t\tdata === \"\\x1b[13;2~\" || // Shift+Enter in some terminals\n\t\t\t(data.length > 1 && data.includes(\"\\x1b\") && data.includes(\"\\r\")) ||\n\t\t\t(data === \"\\n\" && data.length === 1) || // Shift+Enter from iTerm2 mapping\n\t\t\tdata === \"\\\\\\r\" // Shift+Enter in VS Code terminal\n\t\t) {\n\t\t\t// Modifier + Enter = new line\n\t\t\tthis.addNewLine();\n\t\t}\n\t\t// Plain Enter (char code 13 for CR) - only CR submits, LF adds new line\n\t\telse if (data.charCodeAt(0) === 13 && data.length === 1) {\n\t\t\t// If submit is disabled, do nothing\n\t\t\tif (this.disableSubmit) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Get text and substitute paste markers with actual content\n\t\t\tlet result = this.state.lines.join(\"\\n\").trim();\n\n\t\t\t// Replace all [paste #N +xxx lines] or [paste #N xxx chars] markers with actual paste content\n\t\t\tfor (const [pasteId, pasteContent] of this.pastes) {\n\t\t\t\t// Match formats: [paste #N], [paste #N +xxx lines], or [paste #N xxx chars]\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\t// Reset editor and clear pastes\n\t\t\tthis.state = {\n\t\t\t\tlines: [\"\"],\n\t\t\t\tcursorLine: 0,\n\t\t\t\tcursorCol: 0,\n\t\t\t};\n\t\t\tthis.pastes.clear();\n\t\t\tthis.pasteCounter = 0;\n\t\t\tthis.historyIndex = -1; // Exit history browsing mode\n\n\t\t\t// Notify that editor is now empty\n\t\t\tif (this.onChange) {\n\t\t\t\tthis.onChange(\"\");\n\t\t\t}\n\n\t\t\tif (this.onSubmit) {\n\t\t\t\tthis.onSubmit(result);\n\t\t\t}\n\t\t}\n\t\t// Backspace\n\t\telse if (data.charCodeAt(0) === 127 || data.charCodeAt(0) === 8) {\n\t\t\tthis.handleBackspace();\n\t\t}\n\t\t// Line navigation shortcuts (Home/End keys)\n\t\telse if (data === \"\\x1b[H\" || data === \"\\x1b[1~\" || data === \"\\x1b[7~\") {\n\t\t\t// Home key\n\t\t\tthis.moveToLineStart();\n\t\t} else if (data === \"\\x1b[F\" || data === \"\\x1b[4~\" || data === \"\\x1b[8~\") {\n\t\t\t// End key\n\t\t\tthis.moveToLineEnd();\n\t\t}\n\t\t// Forward delete (Fn+Backspace or Delete key)\n\t\telse if (data === \"\\x1b[3~\") {\n\t\t\t// Delete key\n\t\t\tthis.handleForwardDelete();\n\t\t}\n\t\t// Word navigation (Option/Alt + Arrow or Ctrl + Arrow)\n\t\t// Option+Left: \\x1b[1;3D or \\x1bb\n\t\t// Option+Right: \\x1b[1;3C or \\x1bf\n\t\t// Ctrl+Left: \\x1b[1;5D\n\t\t// Ctrl+Right: \\x1b[1;5C\n\t\telse if (data === \"\\x1b[1;3D\" || data === \"\\x1bb\" || data === \"\\x1b[1;5D\") {\n\t\t\t// Word left\n\t\t\tthis.moveWordBackwards();\n\t\t} else if (data === \"\\x1b[1;3C\" || data === \"\\x1bf\" || data === \"\\x1b[1;5C\") {\n\t\t\t// Word right\n\t\t\tthis.moveWordForwards();\n\t\t}\n\t\t// Arrow keys\n\t\telse if (data === \"\\x1b[A\") {\n\t\t\t// Up - history navigation or cursor movement\n\t\t\tif (this.isEditorEmpty()) {\n\t\t\t\tthis.navigateHistory(-1); // Start browsing history\n\t\t\t} else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {\n\t\t\t\tthis.navigateHistory(-1); // Navigate to older history entry\n\t\t\t} else {\n\t\t\t\tthis.moveCursor(-1, 0); // Cursor movement (within text or history entry)\n\t\t\t}\n\t\t} else if (data === \"\\x1b[B\") {\n\t\t\t// Down - history navigation or cursor movement\n\t\t\tif (this.historyIndex > -1 && this.isOnLastVisualLine()) {\n\t\t\t\tthis.navigateHistory(1); // Navigate to newer history entry or clear\n\t\t\t} else {\n\t\t\t\tthis.moveCursor(1, 0); // Cursor movement (within text or history entry)\n\t\t\t}\n\t\t} else if (data === \"\\x1b[C\") {\n\t\t\t// Right\n\t\t\tthis.moveCursor(0, 1);\n\t\t} else if (data === \"\\x1b[D\") {\n\t\t\t// Left\n\t\t\tthis.moveCursor(0, -1);\n\t\t}\n\t\t// Regular characters (printable characters and unicode, but not control characters)\n\t\telse if (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 maxLineLength = contentWidth;\n\n\t\t\tif (line.length <= maxLineLength) {\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\n\t\t\t\tconst chunks = [];\n\t\t\t\tfor (let pos = 0; pos < line.length; pos += maxLineLength) {\n\t\t\t\t\tchunks.push(line.slice(pos, pos + maxLineLength));\n\t\t\t\t}\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 chunkStart = chunkIndex * maxLineLength;\n\t\t\t\t\tconst chunkEnd = chunkStart + chunk.length;\n\t\t\t\t\tconst cursorPos = this.state.cursorCol;\n\t\t\t\t\tconst isLastChunk = chunkIndex === chunks.length - 1;\n\t\t\t\t\t// For non-last chunks, cursor at chunkEnd belongs to the next chunk\n\t\t\t\t\tconst hasCursorInChunk =\n\t\t\t\t\t\tisCurrentLine &&\n\t\t\t\t\t\tcursorPos >= chunkStart &&\n\t\t\t\t\t\t(isLastChunk ? cursorPos <= chunkEnd : cursorPos < chunkEnd);\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,\n\t\t\t\t\t\t\thasCursor: true,\n\t\t\t\t\t\t\tcursorPos: cursorPos - chunkStart,\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,\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\tsetText(text: string): void {\n\t\tthis.historyIndex = -1; // Exit history browsing mode\n\t\tthis.setTextInternal(text);\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\tconst 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// 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 character in current line\n\t\t\tconst line = this.state.lines[this.state.cursorLine] || \"\";\n\n\t\t\tconst before = line.slice(0, this.state.cursorCol - 1);\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--;\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 textBeforeCursor = currentLine.slice(0, this.state.cursorCol);\n\n\t\t\tconst isWhitespace = (char: string): boolean => /\\s/.test(char);\n\t\t\tconst isPunctuation = (char: string): boolean => {\n\t\t\t\t// Treat obvious code punctuation as boundaries\n\t\t\t\treturn /[(){}[\\]<>.,;:'\"!?+\\-=*/\\\\|&%^$#@~`]/.test(char);\n\t\t\t};\n\n\t\t\tlet deleteFrom = this.state.cursorCol;\n\t\t\tconst lastChar = textBeforeCursor[deleteFrom - 1] ?? \"\";\n\n\t\t\t// If immediately on whitespace or punctuation, delete that single boundary char\n\t\t\tif (isWhitespace(lastChar) || isPunctuation(lastChar)) {\n\t\t\t\tdeleteFrom -= 1;\n\t\t\t} else {\n\t\t\t\t// Otherwise, delete a run of non-boundary characters (the \"word\")\n\t\t\t\twhile (deleteFrom > 0) {\n\t\t\t\t\tconst ch = textBeforeCursor[deleteFrom - 1] ?? \"\";\n\t\t\t\t\tif (isWhitespace(ch) || isPunctuation(ch)) {\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t\tdeleteFrom -= 1;\n\t\t\t\t}\n\t\t\t}\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 character at cursor position (forward delete)\n\t\t\tconst before = currentLine.slice(0, this.state.cursorCol);\n\t\t\tconst after = currentLine.slice(this.state.cursorCol + 1);\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\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 (line.length <= 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\n\t\t\t\tfor (let pos = 0; pos < line.length; pos += width) {\n\t\t\t\t\tconst segmentLength = Math.min(width, line.length - pos);\n\t\t\t\t\tvisualLines.push({ logicalLine: i, startCol: pos, length: segmentLength });\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\n\t\t\t\tif (this.state.cursorCol < currentLine.length) {\n\t\t\t\t\tthis.state.cursorCol++;\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\n\t\t\t\tif (this.state.cursorCol > 0) {\n\t\t\t\t\tthis.state.cursorCol--;\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 isWordBoundary(char: string): boolean {\n\t\treturn /\\s/.test(char) || /[(){}[\\]<>.,;:'\"!?+\\-=*/\\\\|&%^$#@~`]/.test(char);\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\tlet newCol = this.state.cursorCol;\n\t\tconst lastChar = textBeforeCursor[newCol - 1] ?? \"\";\n\n\t\t// If immediately on whitespace or punctuation, skip that single boundary char\n\t\tif (this.isWordBoundary(lastChar)) {\n\t\t\tnewCol -= 1;\n\t\t}\n\n\t\t// Now skip the \"word\" (non-boundary characters)\n\t\twhile (newCol > 0) {\n\t\t\tconst ch = textBeforeCursor[newCol - 1] ?? \"\";\n\t\t\tif (this.isWordBoundary(ch)) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tnewCol -= 1;\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\tlet newCol = this.state.cursorCol;\n\t\tconst charAtCursor = currentLine[newCol] ?? \"\";\n\n\t\t// If on whitespace or punctuation, skip it\n\t\tif (this.isWordBoundary(charAtCursor)) {\n\t\t\tnewCol += 1;\n\t\t}\n\n\t\t// Skip the \"word\" (non-boundary characters)\n\t\twhile (newCol < currentLine.length) {\n\t\t\tconst ch = currentLine[newCol] ?? \"\";\n\t\t\tif (this.isWordBoundary(ch)) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tnewCol += 1;\n\t\t}\n\n\t\tthis.state.cursorCol = newCol;\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(\"/\")) {\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\t// For now, fall back to regular autocomplete (slash commands)\n\t\t// This can be extended later to handle command-specific argument completion\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 has the force method\n\t\tconst provider = this.autocompleteProvider as any;\n\t\tif (!provider.getForceFileSuggestions) {\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 as any;\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\t// No matches - check if we're still in a valid context before cancelling\n\t\t\tconst currentLine = this.state.lines[this.state.cursorLine] || \"\";\n\t\t\tconst textBeforeCursor = currentLine.slice(0, this.state.cursorCol);\n\n\t\t\tthis.cancelAutocomplete();\n\t\t}\n\t}\n}\n"]}
@@ -21,6 +21,9 @@ export class Editor {
21
21
  // Bracketed paste mode buffering
22
22
  pasteBuffer = "";
23
23
  isInPaste = false;
24
+ // Prompt history for up/down navigation
25
+ history = [];
26
+ historyIndex = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
24
27
  onSubmit;
25
28
  onChange;
26
29
  disableSubmit = false;
@@ -31,6 +34,61 @@ export class Editor {
31
34
  setAutocompleteProvider(provider) {
32
35
  this.autocompleteProvider = provider;
33
36
  }
37
+ /**
38
+ * Add a prompt to history for up/down arrow navigation.
39
+ * Called after successful submission.
40
+ */
41
+ addToHistory(text) {
42
+ const trimmed = text.trim();
43
+ if (!trimmed)
44
+ return;
45
+ // Don't add consecutive duplicates
46
+ if (this.history.length > 0 && this.history[0] === trimmed)
47
+ return;
48
+ this.history.unshift(trimmed);
49
+ // Limit history size
50
+ if (this.history.length > 100) {
51
+ this.history.pop();
52
+ }
53
+ }
54
+ isEditorEmpty() {
55
+ return this.state.lines.length === 1 && this.state.lines[0] === "";
56
+ }
57
+ isOnFirstVisualLine() {
58
+ const visualLines = this.buildVisualLineMap(this.lastWidth);
59
+ const currentVisualLine = this.findCurrentVisualLine(visualLines);
60
+ return currentVisualLine === 0;
61
+ }
62
+ isOnLastVisualLine() {
63
+ const visualLines = this.buildVisualLineMap(this.lastWidth);
64
+ const currentVisualLine = this.findCurrentVisualLine(visualLines);
65
+ return currentVisualLine === visualLines.length - 1;
66
+ }
67
+ navigateHistory(direction) {
68
+ if (this.history.length === 0)
69
+ return;
70
+ const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases
71
+ if (newIndex < -1 || newIndex >= this.history.length)
72
+ return;
73
+ this.historyIndex = newIndex;
74
+ if (this.historyIndex === -1) {
75
+ // Returned to "current" state - clear editor
76
+ this.setTextInternal("");
77
+ }
78
+ else {
79
+ this.setTextInternal(this.history[this.historyIndex] || "");
80
+ }
81
+ }
82
+ /** Internal setText that doesn't reset history state - used by navigateHistory */
83
+ setTextInternal(text) {
84
+ const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
85
+ this.state.lines = lines.length === 0 ? [""] : lines;
86
+ this.state.cursorLine = this.state.lines.length - 1;
87
+ this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0;
88
+ if (this.onChange) {
89
+ this.onChange(this.getText());
90
+ }
91
+ }
34
92
  invalidate() {
35
93
  // No cached state to invalidate currently
36
94
  }
@@ -258,6 +316,7 @@ export class Editor {
258
316
  };
259
317
  this.pastes.clear();
260
318
  this.pasteCounter = 0;
319
+ this.historyIndex = -1; // Exit history browsing mode
261
320
  // Notify that editor is now empty
262
321
  if (this.onChange) {
263
322
  this.onChange("");
@@ -299,12 +358,25 @@ export class Editor {
299
358
  }
300
359
  // Arrow keys
301
360
  else if (data === "\x1b[A") {
302
- // Up
303
- this.moveCursor(-1, 0);
361
+ // Up - history navigation or cursor movement
362
+ if (this.isEditorEmpty()) {
363
+ this.navigateHistory(-1); // Start browsing history
364
+ }
365
+ else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
366
+ this.navigateHistory(-1); // Navigate to older history entry
367
+ }
368
+ else {
369
+ this.moveCursor(-1, 0); // Cursor movement (within text or history entry)
370
+ }
304
371
  }
305
372
  else if (data === "\x1b[B") {
306
- // Down
307
- this.moveCursor(1, 0);
373
+ // Down - history navigation or cursor movement
374
+ if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
375
+ this.navigateHistory(1); // Navigate to newer history entry or clear
376
+ }
377
+ else {
378
+ this.moveCursor(1, 0); // Cursor movement (within text or history entry)
379
+ }
308
380
  }
309
381
  else if (data === "\x1b[C") {
310
382
  // Right
@@ -391,20 +463,12 @@ export class Editor {
391
463
  return this.state.lines.join("\n");
392
464
  }
393
465
  setText(text) {
394
- // Split text into lines, handling different line endings
395
- const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
396
- // Ensure at least one empty line
397
- this.state.lines = lines.length === 0 ? [""] : lines;
398
- // Reset cursor to end of text
399
- this.state.cursorLine = this.state.lines.length - 1;
400
- this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0;
401
- // Notify of change
402
- if (this.onChange) {
403
- this.onChange(this.getText());
404
- }
466
+ this.historyIndex = -1; // Exit history browsing mode
467
+ this.setTextInternal(text);
405
468
  }
406
469
  // All the editor methods from before...
407
470
  insertCharacter(char) {
471
+ this.historyIndex = -1; // Exit history browsing mode
408
472
  const line = this.state.lines[this.state.cursorLine] || "";
409
473
  const before = line.slice(0, this.state.cursorCol);
410
474
  const after = line.slice(this.state.cursorCol);
@@ -448,6 +512,7 @@ export class Editor {
448
512
  }
449
513
  }
450
514
  handlePaste(pastedText) {
515
+ this.historyIndex = -1; // Exit history browsing mode
451
516
  // Clean the pasted text
452
517
  const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
453
518
  // Convert tabs to spaces (4 spaces per tab)
@@ -516,6 +581,7 @@ export class Editor {
516
581
  }
517
582
  }
518
583
  addNewLine() {
584
+ this.historyIndex = -1; // Exit history browsing mode
519
585
  const currentLine = this.state.lines[this.state.cursorLine] || "";
520
586
  const before = currentLine.slice(0, this.state.cursorCol);
521
587
  const after = currentLine.slice(this.state.cursorCol);
@@ -530,6 +596,7 @@ export class Editor {
530
596
  }
531
597
  }
532
598
  handleBackspace() {
599
+ this.historyIndex = -1; // Exit history browsing mode
533
600
  if (this.state.cursorCol > 0) {
534
601
  // Delete character in current line
535
602
  const line = this.state.lines[this.state.cursorLine] || "";
@@ -576,6 +643,7 @@ export class Editor {
576
643
  this.state.cursorCol = currentLine.length;
577
644
  }
578
645
  deleteToStartOfLine() {
646
+ this.historyIndex = -1; // Exit history browsing mode
579
647
  const currentLine = this.state.lines[this.state.cursorLine] || "";
580
648
  if (this.state.cursorCol > 0) {
581
649
  // Delete from start of line up to cursor
@@ -595,6 +663,7 @@ export class Editor {
595
663
  }
596
664
  }
597
665
  deleteToEndOfLine() {
666
+ this.historyIndex = -1; // Exit history browsing mode
598
667
  const currentLine = this.state.lines[this.state.cursorLine] || "";
599
668
  if (this.state.cursorCol < currentLine.length) {
600
669
  // Delete from cursor to end of line
@@ -611,6 +680,7 @@ export class Editor {
611
680
  }
612
681
  }
613
682
  deleteWordBackwards() {
683
+ this.historyIndex = -1; // Exit history browsing mode
614
684
  const currentLine = this.state.lines[this.state.cursorLine] || "";
615
685
  // If at start of line, behave like backspace at column 0 (merge with previous line)
616
686
  if (this.state.cursorCol === 0) {
@@ -654,6 +724,7 @@ export class Editor {
654
724
  }
655
725
  }
656
726
  handleForwardDelete() {
727
+ this.historyIndex = -1; // Exit history browsing mode
657
728
  const currentLine = this.state.lines[this.state.cursorLine] || "";
658
729
  if (this.state.cursorCol < currentLine.length) {
659
730
  // Delete character at cursor position (forward delete)