@elizaos/tui 2.0.0-alpha.10

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.
Files changed (140) hide show
  1. package/README.md +761 -0
  2. package/dist/autocomplete.d.ts +48 -0
  3. package/dist/autocomplete.d.ts.map +1 -0
  4. package/dist/autocomplete.js +555 -0
  5. package/dist/components/box.d.ts +22 -0
  6. package/dist/components/box.d.ts.map +1 -0
  7. package/dist/components/box.js +103 -0
  8. package/dist/components/cancellable-loader.d.ts +22 -0
  9. package/dist/components/cancellable-loader.d.ts.map +1 -0
  10. package/dist/components/cancellable-loader.js +34 -0
  11. package/dist/components/editor/history.d.ts +38 -0
  12. package/dist/components/editor/history.d.ts.map +1 -0
  13. package/dist/components/editor/history.js +76 -0
  14. package/dist/components/editor/index.d.ts +18 -0
  15. package/dist/components/editor/index.d.ts.map +1 -0
  16. package/dist/components/editor/index.js +21 -0
  17. package/dist/components/editor/kill-ring.d.ts +41 -0
  18. package/dist/components/editor/kill-ring.d.ts.map +1 -0
  19. package/dist/components/editor/kill-ring.js +81 -0
  20. package/dist/components/editor/layout.d.ts +40 -0
  21. package/dist/components/editor/layout.d.ts.map +1 -0
  22. package/dist/components/editor/layout.js +205 -0
  23. package/dist/components/editor/types.d.ts +68 -0
  24. package/dist/components/editor/types.d.ts.map +1 -0
  25. package/dist/components/editor/types.js +4 -0
  26. package/dist/components/editor/undo.d.ts +46 -0
  27. package/dist/components/editor/undo.d.ts.map +1 -0
  28. package/dist/components/editor/undo.js +58 -0
  29. package/dist/components/editor.d.ts +196 -0
  30. package/dist/components/editor.d.ts.map +1 -0
  31. package/dist/components/editor.js +1707 -0
  32. package/dist/components/image.d.ts +28 -0
  33. package/dist/components/image.d.ts.map +1 -0
  34. package/dist/components/image.js +68 -0
  35. package/dist/components/input.d.ts +19 -0
  36. package/dist/components/input.d.ts.map +1 -0
  37. package/dist/components/input.js +195 -0
  38. package/dist/components/loader.d.ts +21 -0
  39. package/dist/components/loader.d.ts.map +1 -0
  40. package/dist/components/loader.js +49 -0
  41. package/dist/components/markdown/index.d.ts +13 -0
  42. package/dist/components/markdown/index.d.ts.map +1 -0
  43. package/dist/components/markdown/index.js +9 -0
  44. package/dist/components/markdown/inline-renderer.d.ts +22 -0
  45. package/dist/components/markdown/inline-renderer.d.ts.map +1 -0
  46. package/dist/components/markdown/inline-renderer.js +88 -0
  47. package/dist/components/markdown/list-renderer.d.ts +33 -0
  48. package/dist/components/markdown/list-renderer.d.ts.map +1 -0
  49. package/dist/components/markdown/list-renderer.js +110 -0
  50. package/dist/components/markdown/table-renderer.d.ts +43 -0
  51. package/dist/components/markdown/table-renderer.d.ts.map +1 -0
  52. package/dist/components/markdown/table-renderer.js +184 -0
  53. package/dist/components/markdown/types.d.ts +57 -0
  54. package/dist/components/markdown/types.d.ts.map +1 -0
  55. package/dist/components/markdown/types.js +13 -0
  56. package/dist/components/markdown.d.ts +44 -0
  57. package/dist/components/markdown.d.ts.map +1 -0
  58. package/dist/components/markdown.js +319 -0
  59. package/dist/components/progress-bar.d.ts +67 -0
  60. package/dist/components/progress-bar.d.ts.map +1 -0
  61. package/dist/components/progress-bar.js +124 -0
  62. package/dist/components/select-list.d.ts +32 -0
  63. package/dist/components/select-list.d.ts.map +1 -0
  64. package/dist/components/select-list.js +151 -0
  65. package/dist/components/settings-list.d.ts +50 -0
  66. package/dist/components/settings-list.d.ts.map +1 -0
  67. package/dist/components/settings-list.js +184 -0
  68. package/dist/components/spacer.d.ts +12 -0
  69. package/dist/components/spacer.d.ts.map +1 -0
  70. package/dist/components/spacer.js +22 -0
  71. package/dist/components/text.d.ts +19 -0
  72. package/dist/components/text.d.ts.map +1 -0
  73. package/dist/components/text.js +88 -0
  74. package/dist/components/toast.d.ts +73 -0
  75. package/dist/components/toast.d.ts.map +1 -0
  76. package/dist/components/toast.js +119 -0
  77. package/dist/components/truncated-text.d.ts +13 -0
  78. package/dist/components/truncated-text.d.ts.map +1 -0
  79. package/dist/components/truncated-text.js +50 -0
  80. package/dist/constants.d.ts +97 -0
  81. package/dist/constants.d.ts.map +1 -0
  82. package/dist/constants.js +126 -0
  83. package/dist/core/container.d.ts +32 -0
  84. package/dist/core/container.d.ts.map +1 -0
  85. package/dist/core/container.js +49 -0
  86. package/dist/core/index.d.ts +9 -0
  87. package/dist/core/index.d.ts.map +1 -0
  88. package/dist/core/index.js +10 -0
  89. package/dist/core/overlay.d.ts +44 -0
  90. package/dist/core/overlay.d.ts.map +1 -0
  91. package/dist/core/overlay.js +171 -0
  92. package/dist/core/types.d.ts +116 -0
  93. package/dist/core/types.d.ts.map +1 -0
  94. package/dist/core/types.js +14 -0
  95. package/dist/editor-component.d.ts +37 -0
  96. package/dist/editor-component.d.ts.map +1 -0
  97. package/dist/editor-component.js +1 -0
  98. package/dist/fuzzy.d.ts +16 -0
  99. package/dist/fuzzy.d.ts.map +1 -0
  100. package/dist/fuzzy.js +108 -0
  101. package/dist/index.d.ts +29 -0
  102. package/dist/index.d.ts.map +1 -0
  103. package/dist/index.js +59 -0
  104. package/dist/keybindings.d.ts +39 -0
  105. package/dist/keybindings.d.ts.map +1 -0
  106. package/dist/keybindings.js +113 -0
  107. package/dist/keys.d.ts +153 -0
  108. package/dist/keys.d.ts.map +1 -0
  109. package/dist/keys.js +951 -0
  110. package/dist/stdin-buffer.d.ts +48 -0
  111. package/dist/stdin-buffer.d.ts.map +1 -0
  112. package/dist/stdin-buffer.js +316 -0
  113. package/dist/terminal-image.d.ts +68 -0
  114. package/dist/terminal-image.d.ts.map +1 -0
  115. package/dist/terminal-image.js +287 -0
  116. package/dist/terminal.d.ts +71 -0
  117. package/dist/terminal.d.ts.map +1 -0
  118. package/dist/terminal.js +216 -0
  119. package/dist/themes/index.d.ts +103 -0
  120. package/dist/themes/index.d.ts.map +1 -0
  121. package/dist/themes/index.js +161 -0
  122. package/dist/tui.d.ts +90 -0
  123. package/dist/tui.d.ts.map +1 -0
  124. package/dist/tui.js +745 -0
  125. package/dist/types/marked-tokens.d.ts +57 -0
  126. package/dist/types/marked-tokens.d.ts.map +1 -0
  127. package/dist/types/marked-tokens.js +17 -0
  128. package/dist/utils/cursor-movement.d.ts +127 -0
  129. package/dist/utils/cursor-movement.d.ts.map +1 -0
  130. package/dist/utils/cursor-movement.js +251 -0
  131. package/dist/utils/index.d.ts +6 -0
  132. package/dist/utils/index.d.ts.map +1 -0
  133. package/dist/utils/index.js +7 -0
  134. package/dist/utils/paste-handler.d.ts +86 -0
  135. package/dist/utils/paste-handler.d.ts.map +1 -0
  136. package/dist/utils/paste-handler.js +121 -0
  137. package/dist/utils.d.ts +75 -0
  138. package/dist/utils.d.ts.map +1 -0
  139. package/dist/utils.js +796 -0
  140. package/package.json +53 -0
@@ -0,0 +1,1707 @@
1
+ import { getEditorKeybindings } from "../keybindings.js";
2
+ import { matchesKey } from "../keys.js";
3
+ import { CURSOR_MARKER, } from "../tui.js";
4
+ import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth, } from "../utils.js";
5
+ import { SelectList } from "./select-list.js";
6
+ export { DEFAULT_HISTORY_LIMIT, EditorHistory } from "./editor/history.js";
7
+ export { KillRing } from "./editor/kill-ring.js";
8
+ export { buildVisualLineMap, findCurrentVisualLine, layoutText, wordWrapLine, } from "./editor/layout.js";
9
+ export { restoreSnapshot, UndoManager } from "./editor/undo.js";
10
+ // Import for internal use
11
+ import { wordWrapLine as wordWrapLineInternal } from "./editor/layout.js";
12
+ const segmenter = getSegmenter();
13
+ // Kitty CSI-u sequences for printable keys, including optional shifted/base codepoints.
14
+ const KITTY_CSI_U_REGEX = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/;
15
+ const KITTY_MOD_SHIFT = 1;
16
+ const KITTY_MOD_ALT = 2;
17
+ const KITTY_MOD_CTRL = 4;
18
+ // Decode a printable CSI-u sequence, preferring the shifted key when present.
19
+ function decodeKittyPrintable(data) {
20
+ const match = data.match(KITTY_CSI_U_REGEX);
21
+ if (!match)
22
+ return undefined;
23
+ // CSI-u groups: <codepoint>[:<shifted>[:<base>]];<mod>u
24
+ const codepoint = Number.parseInt(match[1] ?? "", 10);
25
+ if (!Number.isFinite(codepoint))
26
+ return undefined;
27
+ const shiftedKey = match[2] && match[2].length > 0 ? Number.parseInt(match[2], 10) : undefined;
28
+ const modValue = match[4] ? Number.parseInt(match[4], 10) : 1;
29
+ // Modifiers are 1-indexed in CSI-u; normalize to our bitmask.
30
+ const modifier = Number.isFinite(modValue) ? modValue - 1 : 0;
31
+ // Ignore CSI-u sequences used for Alt/Ctrl shortcuts.
32
+ if (modifier & (KITTY_MOD_ALT | KITTY_MOD_CTRL))
33
+ return undefined;
34
+ // Prefer the shifted keycode when Shift is held.
35
+ let effectiveCodepoint = codepoint;
36
+ if (modifier & KITTY_MOD_SHIFT && typeof shiftedKey === "number") {
37
+ effectiveCodepoint = shiftedKey;
38
+ }
39
+ // Drop control characters or invalid codepoints.
40
+ // Valid Unicode range is 0x0 to 0x10FFFF
41
+ if (!Number.isFinite(effectiveCodepoint) ||
42
+ effectiveCodepoint < 32 ||
43
+ effectiveCodepoint > 0x10ffff) {
44
+ return undefined;
45
+ }
46
+ return String.fromCodePoint(effectiveCodepoint);
47
+ }
48
+ export class Editor {
49
+ state = {
50
+ lines: [""],
51
+ cursorLine: 0,
52
+ cursorCol: 0,
53
+ };
54
+ /** Get line at index, returns empty string for invalid indices */
55
+ getLine(index) {
56
+ return this.state.lines[index];
57
+ }
58
+ /** Get current line at cursor */
59
+ get currentLine() {
60
+ return this.state.lines[this.state.cursorLine];
61
+ }
62
+ /** Focusable interface - set by TUI when focus changes */
63
+ focused = false;
64
+ tui;
65
+ theme;
66
+ paddingX = 0;
67
+ // Store last render width for cursor navigation
68
+ lastWidth = 80;
69
+ // Vertical scrolling support
70
+ scrollOffset = 0;
71
+ // Border color (can be changed dynamically)
72
+ borderColor;
73
+ // Autocomplete support
74
+ autocompleteProvider;
75
+ autocompleteList;
76
+ autocompleteState = null;
77
+ autocompletePrefix = "";
78
+ autocompleteMaxVisible = 5;
79
+ // Paste tracking for large pastes
80
+ pastes = new Map();
81
+ pasteCounter = 0;
82
+ // Bracketed paste mode buffering
83
+ pasteBuffer = "";
84
+ isInPaste = false;
85
+ // Prompt history for up/down navigation
86
+ history = [];
87
+ historyIndex = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
88
+ // Kill ring for Emacs-style kill/yank operations
89
+ // Also tracks undo coalescing: "type-word" means we're mid-word (coalescing)
90
+ killRing = [];
91
+ lastAction = null;
92
+ // Character jump mode
93
+ jumpMode = null;
94
+ // Preferred visual column for vertical cursor movement (sticky column)
95
+ preferredVisualCol = null;
96
+ // Undo support
97
+ undoStack = [];
98
+ onSubmit;
99
+ onChange;
100
+ disableSubmit = false;
101
+ constructor(tui, theme, options = {}) {
102
+ this.tui = tui;
103
+ this.theme = theme;
104
+ this.borderColor = theme.borderColor;
105
+ const paddingX = options.paddingX ?? 0;
106
+ this.paddingX = Number.isFinite(paddingX)
107
+ ? Math.max(0, Math.floor(paddingX))
108
+ : 0;
109
+ const maxVisible = options.autocompleteMaxVisible ?? 5;
110
+ this.autocompleteMaxVisible = Number.isFinite(maxVisible)
111
+ ? Math.max(3, Math.min(20, Math.floor(maxVisible)))
112
+ : 5;
113
+ }
114
+ getPaddingX() {
115
+ return this.paddingX;
116
+ }
117
+ setPaddingX(padding) {
118
+ const newPadding = Number.isFinite(padding)
119
+ ? Math.max(0, Math.floor(padding))
120
+ : 0;
121
+ if (this.paddingX !== newPadding) {
122
+ this.paddingX = newPadding;
123
+ this.tui.requestRender();
124
+ }
125
+ }
126
+ getAutocompleteMaxVisible() {
127
+ return this.autocompleteMaxVisible;
128
+ }
129
+ setAutocompleteMaxVisible(maxVisible) {
130
+ const newMaxVisible = Number.isFinite(maxVisible)
131
+ ? Math.max(3, Math.min(20, Math.floor(maxVisible)))
132
+ : 5;
133
+ if (this.autocompleteMaxVisible !== newMaxVisible) {
134
+ this.autocompleteMaxVisible = newMaxVisible;
135
+ this.tui.requestRender();
136
+ }
137
+ }
138
+ setAutocompleteProvider(provider) {
139
+ this.autocompleteProvider = provider;
140
+ }
141
+ /**
142
+ * Add a prompt to history for up/down arrow navigation.
143
+ * Called after successful submission.
144
+ */
145
+ addToHistory(text) {
146
+ const trimmed = text.trim();
147
+ if (!trimmed)
148
+ return;
149
+ // Don't add consecutive duplicates
150
+ if (this.history.length > 0 && this.history[0] === trimmed)
151
+ return;
152
+ this.history.unshift(trimmed);
153
+ // Limit history size
154
+ if (this.history.length > 100) {
155
+ this.history.pop();
156
+ }
157
+ }
158
+ isEditorEmpty() {
159
+ return this.state.lines.length === 1 && this.state.lines[0] === "";
160
+ }
161
+ isOnFirstVisualLine() {
162
+ const visualLines = this.buildVisualLineMap(this.lastWidth);
163
+ const currentVisualLine = this.findCurrentVisualLine(visualLines);
164
+ return currentVisualLine === 0;
165
+ }
166
+ isOnLastVisualLine() {
167
+ const visualLines = this.buildVisualLineMap(this.lastWidth);
168
+ const currentVisualLine = this.findCurrentVisualLine(visualLines);
169
+ return currentVisualLine === visualLines.length - 1;
170
+ }
171
+ navigateHistory(direction) {
172
+ this.lastAction = null;
173
+ if (this.history.length === 0)
174
+ return;
175
+ const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases
176
+ if (newIndex < -1 || newIndex >= this.history.length)
177
+ return;
178
+ // Capture state when first entering history browsing mode
179
+ if (this.historyIndex === -1 && newIndex >= 0) {
180
+ this.pushUndoSnapshot();
181
+ }
182
+ this.historyIndex = newIndex;
183
+ if (this.historyIndex === -1) {
184
+ // Returned to "current" state - clear editor
185
+ this.setTextInternal("");
186
+ }
187
+ else {
188
+ // historyIndex is guaranteed >= 0 and < history.length here
189
+ this.setTextInternal(this.history[this.historyIndex]);
190
+ }
191
+ }
192
+ /** Internal setText that doesn't reset history state - used by navigateHistory */
193
+ setTextInternal(text) {
194
+ const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
195
+ this.state.lines = lines.length === 0 ? [""] : lines;
196
+ this.state.cursorLine = this.state.lines.length - 1;
197
+ this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0);
198
+ // Reset scroll - render() will adjust to show cursor
199
+ this.scrollOffset = 0;
200
+ if (this.onChange) {
201
+ this.onChange(this.getText());
202
+ }
203
+ }
204
+ invalidate() {
205
+ // No cached state to invalidate currently
206
+ }
207
+ render(width) {
208
+ const maxPadding = Math.max(0, Math.floor((width - 1) / 2));
209
+ const paddingX = Math.min(this.paddingX, maxPadding);
210
+ const contentWidth = Math.max(1, width - paddingX * 2);
211
+ // Layout width: with padding the cursor can overflow into it,
212
+ // without padding we reserve 1 column for the cursor.
213
+ const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1));
214
+ // Store for cursor navigation (must match wrapping width)
215
+ this.lastWidth = layoutWidth;
216
+ const horizontal = this.borderColor("─");
217
+ // Layout the text
218
+ const layoutLines = this.layoutText(layoutWidth);
219
+ // Calculate max visible lines: 30% of terminal height, minimum 5 lines
220
+ const terminalRows = this.tui.terminal.rows;
221
+ const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3));
222
+ // Find the cursor line index in layoutLines
223
+ let cursorLineIndex = layoutLines.findIndex((line) => line.hasCursor);
224
+ if (cursorLineIndex === -1)
225
+ cursorLineIndex = 0;
226
+ // Adjust scroll offset to keep cursor visible
227
+ if (cursorLineIndex < this.scrollOffset) {
228
+ this.scrollOffset = cursorLineIndex;
229
+ }
230
+ else if (cursorLineIndex >= this.scrollOffset + maxVisibleLines) {
231
+ this.scrollOffset = cursorLineIndex - maxVisibleLines + 1;
232
+ }
233
+ // Clamp scroll offset to valid range
234
+ const maxScrollOffset = Math.max(0, layoutLines.length - maxVisibleLines);
235
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScrollOffset));
236
+ // Get visible lines slice
237
+ const visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);
238
+ const result = [];
239
+ const leftPadding = " ".repeat(paddingX);
240
+ const rightPadding = leftPadding;
241
+ // Render top border (with scroll indicator if scrolled down)
242
+ if (this.scrollOffset > 0) {
243
+ const indicator = `─── ↑ ${this.scrollOffset} more `;
244
+ const remaining = width - visibleWidth(indicator);
245
+ result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
246
+ }
247
+ else {
248
+ result.push(horizontal.repeat(width));
249
+ }
250
+ // Render each visible layout line
251
+ // Emit hardware cursor marker only when focused and not showing autocomplete
252
+ const emitCursorMarker = this.focused && !this.autocompleteState;
253
+ for (const layoutLine of visibleLines) {
254
+ let displayText = layoutLine.text;
255
+ let lineVisibleWidth = visibleWidth(layoutLine.text);
256
+ let cursorInPadding = false;
257
+ // Add cursor if this line has it
258
+ if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
259
+ const before = displayText.slice(0, layoutLine.cursorPos);
260
+ const after = displayText.slice(layoutLine.cursorPos);
261
+ // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
262
+ const marker = emitCursorMarker ? CURSOR_MARKER : "";
263
+ if (after.length > 0) {
264
+ // Cursor is on a character (grapheme) - replace it with highlighted version
265
+ // Get the first grapheme from 'after'
266
+ const afterGraphemes = [...segmenter.segment(after)];
267
+ const firstGrapheme = afterGraphemes[0]?.segment || "";
268
+ const restAfter = after.slice(firstGrapheme.length);
269
+ const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
270
+ displayText = before + marker + cursor + restAfter;
271
+ // lineVisibleWidth stays the same - we're replacing, not adding
272
+ }
273
+ else {
274
+ // Cursor is at the end - add highlighted space
275
+ const cursor = "\x1b[7m \x1b[0m";
276
+ displayText = before + marker + cursor;
277
+ lineVisibleWidth = lineVisibleWidth + 1;
278
+ // If cursor overflows content width into the padding, flag it
279
+ if (lineVisibleWidth > contentWidth && paddingX > 0) {
280
+ cursorInPadding = true;
281
+ }
282
+ }
283
+ }
284
+ // Calculate padding based on actual visible width
285
+ const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth));
286
+ const lineRightPadding = cursorInPadding
287
+ ? rightPadding.slice(1)
288
+ : rightPadding;
289
+ // Render the line (no side borders, just horizontal lines above and below)
290
+ result.push(`${leftPadding}${displayText}${padding}${lineRightPadding}`);
291
+ }
292
+ // Render bottom border (with scroll indicator if more content below)
293
+ const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length);
294
+ if (linesBelow > 0) {
295
+ const indicator = `─── ↓ ${linesBelow} more `;
296
+ const remaining = width - visibleWidth(indicator);
297
+ result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
298
+ }
299
+ else {
300
+ result.push(horizontal.repeat(width));
301
+ }
302
+ // Add autocomplete list if active
303
+ if (this.autocompleteState && this.autocompleteList) {
304
+ const autocompleteResult = this.autocompleteList.render(contentWidth);
305
+ for (const line of autocompleteResult) {
306
+ const lineWidth = visibleWidth(line);
307
+ const linePadding = " ".repeat(Math.max(0, contentWidth - lineWidth));
308
+ result.push(`${leftPadding}${line}${linePadding}${rightPadding}`);
309
+ }
310
+ }
311
+ return result;
312
+ }
313
+ handleInput(data) {
314
+ const kb = getEditorKeybindings();
315
+ // Handle character jump mode (awaiting next character to jump to)
316
+ if (this.jumpMode !== null) {
317
+ // Cancel if the hotkey is pressed again
318
+ if (kb.matches(data, "jumpForward") || kb.matches(data, "jumpBackward")) {
319
+ this.jumpMode = null;
320
+ return;
321
+ }
322
+ if (data.charCodeAt(0) >= 32) {
323
+ // Printable character - perform the jump
324
+ const direction = this.jumpMode;
325
+ this.jumpMode = null;
326
+ this.jumpToChar(data, direction);
327
+ return;
328
+ }
329
+ // Control character - cancel and fall through to normal handling
330
+ this.jumpMode = null;
331
+ }
332
+ // Handle bracketed paste mode
333
+ if (data.includes("\x1b[200~")) {
334
+ this.isInPaste = true;
335
+ this.pasteBuffer = "";
336
+ data = data.replace("\x1b[200~", "");
337
+ }
338
+ if (this.isInPaste) {
339
+ this.pasteBuffer += data;
340
+ const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
341
+ if (endIndex !== -1) {
342
+ const pasteContent = this.pasteBuffer.substring(0, endIndex);
343
+ if (pasteContent.length > 0) {
344
+ this.handlePaste(pasteContent);
345
+ }
346
+ this.isInPaste = false;
347
+ const remaining = this.pasteBuffer.substring(endIndex + 6);
348
+ this.pasteBuffer = "";
349
+ if (remaining.length > 0) {
350
+ this.handleInput(remaining);
351
+ }
352
+ return;
353
+ }
354
+ return;
355
+ }
356
+ // Ctrl+C - let parent handle (exit/clear)
357
+ if (kb.matches(data, "copy")) {
358
+ return;
359
+ }
360
+ // Undo
361
+ if (kb.matches(data, "undo")) {
362
+ this.undo();
363
+ return;
364
+ }
365
+ // Handle autocomplete mode
366
+ if (this.autocompleteState && this.autocompleteList) {
367
+ if (kb.matches(data, "selectCancel")) {
368
+ this.cancelAutocomplete();
369
+ return;
370
+ }
371
+ if (kb.matches(data, "selectUp") || kb.matches(data, "selectDown")) {
372
+ this.autocompleteList.handleInput(data);
373
+ return;
374
+ }
375
+ if (kb.matches(data, "tab")) {
376
+ const selected = this.autocompleteList.getSelectedItem();
377
+ if (selected && this.autocompleteProvider) {
378
+ this.pushUndoSnapshot();
379
+ this.lastAction = null;
380
+ const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
381
+ this.state.lines = result.lines;
382
+ this.state.cursorLine = result.cursorLine;
383
+ this.setCursorCol(result.cursorCol);
384
+ this.cancelAutocomplete();
385
+ if (this.onChange)
386
+ this.onChange(this.getText());
387
+ }
388
+ return;
389
+ }
390
+ if (kb.matches(data, "selectConfirm")) {
391
+ const selected = this.autocompleteList.getSelectedItem();
392
+ if (selected && this.autocompleteProvider) {
393
+ this.pushUndoSnapshot();
394
+ this.lastAction = null;
395
+ const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
396
+ this.state.lines = result.lines;
397
+ this.state.cursorLine = result.cursorLine;
398
+ this.setCursorCol(result.cursorCol);
399
+ if (this.autocompletePrefix.startsWith("/")) {
400
+ this.cancelAutocomplete();
401
+ // Fall through to submit
402
+ }
403
+ else {
404
+ this.cancelAutocomplete();
405
+ if (this.onChange)
406
+ this.onChange(this.getText());
407
+ return;
408
+ }
409
+ }
410
+ }
411
+ }
412
+ // Tab - trigger completion
413
+ if (kb.matches(data, "tab") && !this.autocompleteState) {
414
+ this.handleTabCompletion();
415
+ return;
416
+ }
417
+ // Deletion actions
418
+ if (kb.matches(data, "deleteToLineEnd")) {
419
+ this.deleteToEndOfLine();
420
+ return;
421
+ }
422
+ if (kb.matches(data, "deleteToLineStart")) {
423
+ this.deleteToStartOfLine();
424
+ return;
425
+ }
426
+ if (kb.matches(data, "deleteWordBackward")) {
427
+ this.deleteWordBackwards();
428
+ return;
429
+ }
430
+ if (kb.matches(data, "deleteWordForward")) {
431
+ this.deleteWordForward();
432
+ return;
433
+ }
434
+ if (kb.matches(data, "deleteCharBackward") ||
435
+ matchesKey(data, "shift+backspace")) {
436
+ this.handleBackspace();
437
+ return;
438
+ }
439
+ if (kb.matches(data, "deleteCharForward") ||
440
+ matchesKey(data, "shift+delete")) {
441
+ this.handleForwardDelete();
442
+ return;
443
+ }
444
+ // Kill ring actions
445
+ if (kb.matches(data, "yank")) {
446
+ this.yank();
447
+ return;
448
+ }
449
+ if (kb.matches(data, "yankPop")) {
450
+ this.yankPop();
451
+ return;
452
+ }
453
+ // Cursor movement actions
454
+ if (kb.matches(data, "cursorLineStart")) {
455
+ this.moveToLineStart();
456
+ return;
457
+ }
458
+ if (kb.matches(data, "cursorLineEnd")) {
459
+ this.moveToLineEnd();
460
+ return;
461
+ }
462
+ if (kb.matches(data, "cursorWordLeft")) {
463
+ this.moveWordBackwards();
464
+ return;
465
+ }
466
+ if (kb.matches(data, "cursorWordRight")) {
467
+ this.moveWordForwards();
468
+ return;
469
+ }
470
+ // New line
471
+ if (kb.matches(data, "newLine") ||
472
+ (data.charCodeAt(0) === 10 && data.length > 1) ||
473
+ data === "\x1b\r" ||
474
+ data === "\x1b[13;2~" ||
475
+ (data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
476
+ (data === "\n" && data.length === 1)) {
477
+ if (this.shouldSubmitOnBackslashEnter(data, kb)) {
478
+ this.handleBackspace();
479
+ this.submitValue();
480
+ return;
481
+ }
482
+ this.addNewLine();
483
+ return;
484
+ }
485
+ // Submit (Enter)
486
+ if (kb.matches(data, "submit")) {
487
+ if (this.disableSubmit)
488
+ return;
489
+ // Workaround for terminals without Shift+Enter support:
490
+ // If char before cursor is \, delete it and insert newline instead of submitting.
491
+ const currentLine = this.currentLine;
492
+ if (this.state.cursorCol > 0 &&
493
+ currentLine[this.state.cursorCol - 1] === "\\") {
494
+ this.handleBackspace();
495
+ this.addNewLine();
496
+ return;
497
+ }
498
+ this.submitValue();
499
+ return;
500
+ }
501
+ // Arrow key navigation (with history support)
502
+ if (kb.matches(data, "cursorUp")) {
503
+ if (this.isEditorEmpty()) {
504
+ this.navigateHistory(-1);
505
+ }
506
+ else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
507
+ this.navigateHistory(-1);
508
+ }
509
+ else if (this.isOnFirstVisualLine()) {
510
+ // Already at top - jump to start of line
511
+ this.moveToLineStart();
512
+ }
513
+ else {
514
+ this.moveCursor(-1, 0);
515
+ }
516
+ return;
517
+ }
518
+ if (kb.matches(data, "cursorDown")) {
519
+ if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
520
+ this.navigateHistory(1);
521
+ }
522
+ else if (this.isOnLastVisualLine()) {
523
+ // Already at bottom - jump to end of line
524
+ this.moveToLineEnd();
525
+ }
526
+ else {
527
+ this.moveCursor(1, 0);
528
+ }
529
+ return;
530
+ }
531
+ if (kb.matches(data, "cursorRight")) {
532
+ this.moveCursor(0, 1);
533
+ return;
534
+ }
535
+ if (kb.matches(data, "cursorLeft")) {
536
+ this.moveCursor(0, -1);
537
+ return;
538
+ }
539
+ // Page up/down - scroll by page and move cursor
540
+ if (kb.matches(data, "pageUp")) {
541
+ this.pageScroll(-1);
542
+ return;
543
+ }
544
+ if (kb.matches(data, "pageDown")) {
545
+ this.pageScroll(1);
546
+ return;
547
+ }
548
+ // Character jump mode triggers
549
+ if (kb.matches(data, "jumpForward")) {
550
+ this.jumpMode = "forward";
551
+ return;
552
+ }
553
+ if (kb.matches(data, "jumpBackward")) {
554
+ this.jumpMode = "backward";
555
+ return;
556
+ }
557
+ // Shift+Space - insert regular space
558
+ if (matchesKey(data, "shift+space")) {
559
+ this.insertCharacter(" ");
560
+ return;
561
+ }
562
+ const kittyPrintable = decodeKittyPrintable(data);
563
+ if (kittyPrintable !== undefined) {
564
+ this.insertCharacter(kittyPrintable);
565
+ return;
566
+ }
567
+ // Regular characters
568
+ if (data.charCodeAt(0) >= 32) {
569
+ this.insertCharacter(data);
570
+ }
571
+ }
572
+ layoutText(contentWidth) {
573
+ const layoutLines = [];
574
+ if (this.state.lines.length === 0 ||
575
+ (this.state.lines.length === 1 && this.state.lines[0] === "")) {
576
+ // Empty editor
577
+ layoutLines.push({
578
+ text: "",
579
+ hasCursor: true,
580
+ cursorPos: 0,
581
+ });
582
+ return layoutLines;
583
+ }
584
+ // Process each logical line
585
+ for (let i = 0; i < this.state.lines.length; i++) {
586
+ const line = this.getLine(i);
587
+ const isCurrentLine = i === this.state.cursorLine;
588
+ const lineVisibleWidth = visibleWidth(line);
589
+ if (lineVisibleWidth <= contentWidth) {
590
+ // Line fits in one layout line
591
+ if (isCurrentLine) {
592
+ layoutLines.push({
593
+ text: line,
594
+ hasCursor: true,
595
+ cursorPos: this.state.cursorCol,
596
+ });
597
+ }
598
+ else {
599
+ layoutLines.push({
600
+ text: line,
601
+ hasCursor: false,
602
+ });
603
+ }
604
+ }
605
+ else {
606
+ // Line needs wrapping - use word-aware wrapping
607
+ const chunks = wordWrapLineInternal(line, contentWidth);
608
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
609
+ const chunk = chunks[chunkIndex];
610
+ if (!chunk)
611
+ continue;
612
+ const cursorPos = this.state.cursorCol;
613
+ const isLastChunk = chunkIndex === chunks.length - 1;
614
+ // Determine if cursor is in this chunk
615
+ // For word-wrapped chunks, we need to handle the case where
616
+ // cursor might be in trimmed whitespace at end of chunk
617
+ let hasCursorInChunk = false;
618
+ let adjustedCursorPos = 0;
619
+ if (isCurrentLine) {
620
+ if (isLastChunk) {
621
+ // Last chunk: cursor belongs here if >= startIndex
622
+ hasCursorInChunk = cursorPos >= chunk.startIndex;
623
+ adjustedCursorPos = cursorPos - chunk.startIndex;
624
+ }
625
+ else {
626
+ // Non-last chunk: cursor belongs here if in range [startIndex, endIndex)
627
+ // But we need to handle the visual position in the trimmed text
628
+ hasCursorInChunk =
629
+ cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
630
+ if (hasCursorInChunk) {
631
+ adjustedCursorPos = cursorPos - chunk.startIndex;
632
+ // Clamp to text length (in case cursor was in trimmed whitespace)
633
+ if (adjustedCursorPos > chunk.text.length) {
634
+ adjustedCursorPos = chunk.text.length;
635
+ }
636
+ }
637
+ }
638
+ }
639
+ if (hasCursorInChunk) {
640
+ layoutLines.push({
641
+ text: chunk.text,
642
+ hasCursor: true,
643
+ cursorPos: adjustedCursorPos,
644
+ });
645
+ }
646
+ else {
647
+ layoutLines.push({
648
+ text: chunk.text,
649
+ hasCursor: false,
650
+ });
651
+ }
652
+ }
653
+ }
654
+ }
655
+ return layoutLines;
656
+ }
657
+ getText() {
658
+ return this.state.lines.join("\n");
659
+ }
660
+ /**
661
+ * Get text with paste markers expanded to their actual content.
662
+ * Use this when you need the full content (e.g., for external editor).
663
+ */
664
+ getExpandedText() {
665
+ let result = this.state.lines.join("\n");
666
+ for (const [pasteId, pasteContent] of this.pastes) {
667
+ const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
668
+ result = result.replace(markerRegex, pasteContent);
669
+ }
670
+ return result;
671
+ }
672
+ getLines() {
673
+ return [...this.state.lines];
674
+ }
675
+ getCursor() {
676
+ return { line: this.state.cursorLine, col: this.state.cursorCol };
677
+ }
678
+ setText(text) {
679
+ this.lastAction = null;
680
+ this.historyIndex = -1; // Exit history browsing mode
681
+ // Push undo snapshot if content differs (makes programmatic changes undoable)
682
+ if (this.getText() !== text) {
683
+ this.pushUndoSnapshot();
684
+ }
685
+ this.setTextInternal(text);
686
+ }
687
+ /**
688
+ * Insert text at the current cursor position.
689
+ * Used for programmatic insertion (e.g., clipboard image markers).
690
+ * This is atomic for undo - single undo restores entire pre-insert state.
691
+ */
692
+ insertTextAtCursor(text) {
693
+ if (!text)
694
+ return;
695
+ this.pushUndoSnapshot();
696
+ this.lastAction = null;
697
+ this.historyIndex = -1;
698
+ this.insertTextAtCursorInternal(text);
699
+ }
700
+ /**
701
+ * Internal text insertion at cursor. Handles single and multi-line text.
702
+ * Does not push undo snapshots or trigger autocomplete - caller is responsible.
703
+ * Normalizes line endings and calls onChange once at the end.
704
+ */
705
+ insertTextAtCursorInternal(text) {
706
+ if (!text)
707
+ return;
708
+ // Normalize line endings
709
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
710
+ const insertedLines = normalized.split("\n");
711
+ const currentLine = this.currentLine;
712
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol);
713
+ const afterCursor = currentLine.slice(this.state.cursorCol);
714
+ if (insertedLines.length === 1) {
715
+ // Single line - insert at cursor position
716
+ this.state.lines[this.state.cursorLine] =
717
+ beforeCursor + normalized + afterCursor;
718
+ this.setCursorCol(this.state.cursorCol + normalized.length);
719
+ }
720
+ else {
721
+ // Multi-line insertion
722
+ this.state.lines = [
723
+ // All lines before current line
724
+ ...this.state.lines.slice(0, this.state.cursorLine),
725
+ // The first inserted line merged with text before cursor
726
+ beforeCursor + insertedLines[0],
727
+ // All middle inserted lines
728
+ ...insertedLines.slice(1, -1),
729
+ // The last inserted line with text after cursor
730
+ insertedLines[insertedLines.length - 1] + afterCursor,
731
+ // All lines after current line
732
+ ...this.state.lines.slice(this.state.cursorLine + 1),
733
+ ];
734
+ this.state.cursorLine += insertedLines.length - 1;
735
+ this.setCursorCol(insertedLines[insertedLines.length - 1].length);
736
+ }
737
+ if (this.onChange) {
738
+ this.onChange(this.getText());
739
+ }
740
+ }
741
+ // All the editor methods from before...
742
+ insertCharacter(char, skipUndoCoalescing) {
743
+ this.historyIndex = -1; // Exit history browsing mode
744
+ // Undo coalescing (fish-style):
745
+ // - Consecutive word chars coalesce into one undo unit
746
+ // - Space captures state before itself (so undo removes space+following word together)
747
+ // - Each space is separately undoable
748
+ // Skip coalescing when called from atomic operations (e.g., handlePaste)
749
+ if (!skipUndoCoalescing) {
750
+ if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
751
+ this.pushUndoSnapshot();
752
+ }
753
+ this.lastAction = "type-word";
754
+ }
755
+ const line = this.currentLine;
756
+ const before = line.slice(0, this.state.cursorCol);
757
+ const after = line.slice(this.state.cursorCol);
758
+ this.state.lines[this.state.cursorLine] = before + char + after;
759
+ this.setCursorCol(this.state.cursorCol + char.length);
760
+ if (this.onChange) {
761
+ this.onChange(this.getText());
762
+ }
763
+ // Check if we should trigger or update autocomplete
764
+ if (!this.autocompleteState) {
765
+ // Auto-trigger for "/" at the start of a line (slash commands)
766
+ if (char === "/" && this.isAtStartOfMessage()) {
767
+ this.tryTriggerAutocomplete();
768
+ }
769
+ // Auto-trigger for "@" file reference (fuzzy search)
770
+ else if (char === "@") {
771
+ const currentLine = this.currentLine;
772
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
773
+ // Only trigger if @ is after whitespace or at start of line
774
+ const charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2];
775
+ if (textBeforeCursor.length === 1 ||
776
+ charBeforeAt === " " ||
777
+ charBeforeAt === "\t") {
778
+ this.tryTriggerAutocomplete();
779
+ }
780
+ }
781
+ // Also auto-trigger when typing letters in a slash command context
782
+ else if (/[a-zA-Z0-9.\-_]/.test(char)) {
783
+ const currentLine = this.currentLine;
784
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
785
+ // Check if we're in a slash command (with or without space for arguments)
786
+ if (this.isInSlashCommandContext(textBeforeCursor)) {
787
+ this.tryTriggerAutocomplete();
788
+ }
789
+ // Check if we're in an @ file reference context
790
+ else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
791
+ this.tryTriggerAutocomplete();
792
+ }
793
+ }
794
+ }
795
+ else {
796
+ this.updateAutocomplete();
797
+ }
798
+ }
799
+ handlePaste(pastedText) {
800
+ this.historyIndex = -1; // Exit history browsing mode
801
+ this.lastAction = null;
802
+ this.pushUndoSnapshot();
803
+ // Clean the pasted text
804
+ const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
805
+ // Convert tabs to spaces (4 spaces per tab)
806
+ const tabExpandedText = cleanText.replace(/\t/g, " ");
807
+ // Filter out non-printable characters except newlines
808
+ let filteredText = tabExpandedText
809
+ .split("")
810
+ .filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
811
+ .join("");
812
+ // If pasting a file path (starts with /, ~, or .) and the character before
813
+ // the cursor is a word character, prepend a space for better readability
814
+ if (/^[/~.]/.test(filteredText)) {
815
+ const currentLine = this.currentLine;
816
+ const charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : "";
817
+ if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
818
+ filteredText = ` ${filteredText}`;
819
+ }
820
+ }
821
+ // Split into lines to check for large paste
822
+ const pastedLines = filteredText.split("\n");
823
+ // Check if this is a large paste (> 10 lines or > 1000 characters)
824
+ const totalChars = filteredText.length;
825
+ if (pastedLines.length > 10 || totalChars > 1000) {
826
+ // Store the paste and insert a marker
827
+ this.pasteCounter++;
828
+ const pasteId = this.pasteCounter;
829
+ this.pastes.set(pasteId, filteredText);
830
+ // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]"
831
+ const marker = pastedLines.length > 10
832
+ ? `[paste #${pasteId} +${pastedLines.length} lines]`
833
+ : `[paste #${pasteId} ${totalChars} chars]`;
834
+ this.insertTextAtCursorInternal(marker);
835
+ return;
836
+ }
837
+ if (pastedLines.length === 1) {
838
+ // Single line - insert character by character to trigger autocomplete
839
+ for (const char of filteredText) {
840
+ this.insertCharacter(char, true);
841
+ }
842
+ return;
843
+ }
844
+ // Multi-line paste - use direct state manipulation
845
+ this.insertTextAtCursorInternal(filteredText);
846
+ }
847
+ addNewLine() {
848
+ this.historyIndex = -1; // Exit history browsing mode
849
+ this.lastAction = null;
850
+ this.pushUndoSnapshot();
851
+ const currentLine = this.currentLine;
852
+ const before = currentLine.slice(0, this.state.cursorCol);
853
+ const after = currentLine.slice(this.state.cursorCol);
854
+ // Split current line
855
+ this.state.lines[this.state.cursorLine] = before;
856
+ this.state.lines.splice(this.state.cursorLine + 1, 0, after);
857
+ // Move cursor to start of new line
858
+ this.state.cursorLine++;
859
+ this.setCursorCol(0);
860
+ if (this.onChange) {
861
+ this.onChange(this.getText());
862
+ }
863
+ }
864
+ shouldSubmitOnBackslashEnter(data, kb) {
865
+ if (this.disableSubmit)
866
+ return false;
867
+ if (!matchesKey(data, "enter"))
868
+ return false;
869
+ const submitKeys = kb.getKeys("submit");
870
+ const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
871
+ if (!hasShiftEnter)
872
+ return false;
873
+ const currentLine = this.currentLine;
874
+ return (this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\");
875
+ }
876
+ submitValue() {
877
+ let result = this.state.lines.join("\n").trim();
878
+ for (const [pasteId, pasteContent] of this.pastes) {
879
+ const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
880
+ result = result.replace(markerRegex, pasteContent);
881
+ }
882
+ this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
883
+ this.pastes.clear();
884
+ this.pasteCounter = 0;
885
+ this.historyIndex = -1;
886
+ this.scrollOffset = 0;
887
+ this.undoStack.length = 0;
888
+ this.lastAction = null;
889
+ if (this.onChange)
890
+ this.onChange("");
891
+ if (this.onSubmit)
892
+ this.onSubmit(result);
893
+ }
894
+ handleBackspace() {
895
+ this.historyIndex = -1; // Exit history browsing mode
896
+ this.lastAction = null;
897
+ if (this.state.cursorCol > 0) {
898
+ this.pushUndoSnapshot();
899
+ // Delete grapheme before cursor (handles emojis, combining characters, etc.)
900
+ const line = this.currentLine;
901
+ const beforeCursor = line.slice(0, this.state.cursorCol);
902
+ // Find the last grapheme in the text before cursor
903
+ const graphemes = [...segmenter.segment(beforeCursor)];
904
+ const lastGrapheme = graphemes[graphemes.length - 1];
905
+ const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
906
+ const before = line.slice(0, this.state.cursorCol - graphemeLength);
907
+ const after = line.slice(this.state.cursorCol);
908
+ this.state.lines[this.state.cursorLine] = before + after;
909
+ this.setCursorCol(this.state.cursorCol - graphemeLength);
910
+ }
911
+ else if (this.state.cursorLine > 0) {
912
+ this.pushUndoSnapshot();
913
+ // Merge with previous line
914
+ const currentLine = this.currentLine;
915
+ const previousLine = this.getLine(this.state.cursorLine - 1);
916
+ this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
917
+ this.state.lines.splice(this.state.cursorLine, 1);
918
+ this.state.cursorLine--;
919
+ this.setCursorCol(previousLine.length);
920
+ }
921
+ if (this.onChange) {
922
+ this.onChange(this.getText());
923
+ }
924
+ // Update or re-trigger autocomplete after backspace
925
+ if (this.autocompleteState) {
926
+ this.updateAutocomplete();
927
+ }
928
+ else {
929
+ // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
930
+ const currentLine = this.currentLine;
931
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
932
+ // Slash command context
933
+ if (this.isInSlashCommandContext(textBeforeCursor)) {
934
+ this.tryTriggerAutocomplete();
935
+ }
936
+ // @ file reference context
937
+ else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
938
+ this.tryTriggerAutocomplete();
939
+ }
940
+ }
941
+ }
942
+ /**
943
+ * Set cursor column and clear preferredVisualCol.
944
+ * Use this for all non-vertical cursor movements to reset sticky column behavior.
945
+ */
946
+ setCursorCol(col) {
947
+ this.state.cursorCol = col;
948
+ this.preferredVisualCol = null;
949
+ }
950
+ /**
951
+ * Move cursor to a target visual line, applying sticky column logic.
952
+ * Shared by moveCursor() and pageScroll().
953
+ */
954
+ moveToVisualLine(visualLines, currentVisualLine, targetVisualLine) {
955
+ const currentVL = visualLines[currentVisualLine];
956
+ const targetVL = visualLines[targetVisualLine];
957
+ if (currentVL && targetVL) {
958
+ const currentVisualCol = this.state.cursorCol - currentVL.startCol;
959
+ // For non-last segments, clamp to length-1 to stay within the segment
960
+ const isLastSourceSegment = currentVisualLine === visualLines.length - 1 ||
961
+ visualLines[currentVisualLine + 1]?.logicalLine !==
962
+ currentVL.logicalLine;
963
+ const sourceMaxVisualCol = isLastSourceSegment
964
+ ? currentVL.length
965
+ : Math.max(0, currentVL.length - 1);
966
+ const isLastTargetSegment = targetVisualLine === visualLines.length - 1 ||
967
+ visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
968
+ const targetMaxVisualCol = isLastTargetSegment
969
+ ? targetVL.length
970
+ : Math.max(0, targetVL.length - 1);
971
+ const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
972
+ // Set cursor position
973
+ this.state.cursorLine = targetVL.logicalLine;
974
+ const targetCol = targetVL.startCol + moveToVisualCol;
975
+ const logicalLine = this.getLine(targetVL.logicalLine);
976
+ this.state.cursorCol = Math.min(targetCol, logicalLine.length);
977
+ }
978
+ }
979
+ /**
980
+ * Compute the target visual column for vertical cursor movement.
981
+ * Implements the sticky column decision table:
982
+ *
983
+ * | P | S | T | U | Scenario | Set Preferred | Move To |
984
+ * |---|---|---|---| ---------------------------------------------------- |---------------|-------------|
985
+ * | 0 | * | 0 | - | Start nav, target fits | null | current |
986
+ * | 0 | * | 1 | - | Start nav, target shorter | current | target end |
987
+ * | 1 | 0 | 0 | 0 | Clamped, target fits preferred | null | preferred |
988
+ * | 1 | 0 | 0 | 1 | Clamped, target longer but still can't fit preferred | keep | target end |
989
+ * | 1 | 0 | 1 | - | Clamped, target even shorter | keep | target end |
990
+ * | 1 | 1 | 0 | - | Rewrapped, target fits current | null | current |
991
+ * | 1 | 1 | 1 | - | Rewrapped, target shorter than current | current | target end |
992
+ *
993
+ * Where:
994
+ * - P = preferred col is set
995
+ * - S = cursor in middle of source line (not clamped to end)
996
+ * - T = target line shorter than current visual col
997
+ * - U = target line shorter than preferred col
998
+ */
999
+ computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol) {
1000
+ const cursorInMiddle = currentVisualCol < sourceMaxVisualCol; // S
1001
+ const targetTooShort = targetMaxVisualCol < currentVisualCol; // T
1002
+ if (this.preferredVisualCol === null || cursorInMiddle) {
1003
+ if (targetTooShort) {
1004
+ // Cases 2 and 7
1005
+ this.preferredVisualCol = currentVisualCol;
1006
+ return targetMaxVisualCol;
1007
+ }
1008
+ // Cases 1 and 6
1009
+ this.preferredVisualCol = null;
1010
+ return currentVisualCol;
1011
+ }
1012
+ // At this point, preferredVisualCol is guaranteed non-null
1013
+ const preferred = this.preferredVisualCol;
1014
+ const targetCantFitPreferred = targetMaxVisualCol < preferred; // U
1015
+ if (targetTooShort || targetCantFitPreferred) {
1016
+ // Cases 4 and 5
1017
+ return targetMaxVisualCol;
1018
+ }
1019
+ // Case 3
1020
+ this.preferredVisualCol = null;
1021
+ return preferred;
1022
+ }
1023
+ moveToLineStart() {
1024
+ this.lastAction = null;
1025
+ this.setCursorCol(0);
1026
+ }
1027
+ moveToLineEnd() {
1028
+ this.lastAction = null;
1029
+ const currentLine = this.currentLine;
1030
+ this.setCursorCol(currentLine.length);
1031
+ }
1032
+ deleteToStartOfLine() {
1033
+ this.historyIndex = -1; // Exit history browsing mode
1034
+ const currentLine = this.currentLine;
1035
+ if (this.state.cursorCol > 0) {
1036
+ this.pushUndoSnapshot();
1037
+ // Calculate text to be deleted and save to kill ring (backward deletion = prepend)
1038
+ const deletedText = currentLine.slice(0, this.state.cursorCol);
1039
+ this.addToKillRing(deletedText, true);
1040
+ this.lastAction = "kill";
1041
+ // Delete from start of line up to cursor
1042
+ this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol);
1043
+ this.setCursorCol(0);
1044
+ }
1045
+ else if (this.state.cursorLine > 0) {
1046
+ this.pushUndoSnapshot();
1047
+ // At start of line - merge with previous line, treating newline as deleted text
1048
+ this.addToKillRing("\n", true);
1049
+ this.lastAction = "kill";
1050
+ const previousLine = this.getLine(this.state.cursorLine - 1);
1051
+ this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
1052
+ this.state.lines.splice(this.state.cursorLine, 1);
1053
+ this.state.cursorLine--;
1054
+ this.setCursorCol(previousLine.length);
1055
+ }
1056
+ if (this.onChange) {
1057
+ this.onChange(this.getText());
1058
+ }
1059
+ }
1060
+ deleteToEndOfLine() {
1061
+ this.historyIndex = -1; // Exit history browsing mode
1062
+ const currentLine = this.currentLine;
1063
+ if (this.state.cursorCol < currentLine.length) {
1064
+ this.pushUndoSnapshot();
1065
+ // Calculate text to be deleted and save to kill ring (forward deletion = append)
1066
+ const deletedText = currentLine.slice(this.state.cursorCol);
1067
+ this.addToKillRing(deletedText, false);
1068
+ this.lastAction = "kill";
1069
+ // Delete from cursor to end of line
1070
+ this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol);
1071
+ }
1072
+ else if (this.state.cursorLine < this.state.lines.length - 1) {
1073
+ this.pushUndoSnapshot();
1074
+ // At end of line - merge with next line, treating newline as deleted text
1075
+ this.addToKillRing("\n", false);
1076
+ this.lastAction = "kill";
1077
+ const nextLine = this.getLine(this.state.cursorLine + 1);
1078
+ this.state.lines[this.state.cursorLine] = currentLine + nextLine;
1079
+ this.state.lines.splice(this.state.cursorLine + 1, 1);
1080
+ }
1081
+ if (this.onChange) {
1082
+ this.onChange(this.getText());
1083
+ }
1084
+ }
1085
+ deleteWordBackwards() {
1086
+ this.historyIndex = -1; // Exit history browsing mode
1087
+ const currentLine = this.currentLine;
1088
+ // If at start of line, behave like backspace at column 0 (merge with previous line)
1089
+ if (this.state.cursorCol === 0) {
1090
+ if (this.state.cursorLine > 0) {
1091
+ this.pushUndoSnapshot();
1092
+ // Treat newline as deleted text (backward deletion = prepend)
1093
+ this.addToKillRing("\n", true);
1094
+ this.lastAction = "kill";
1095
+ const previousLine = this.getLine(this.state.cursorLine - 1);
1096
+ this.state.lines[this.state.cursorLine - 1] =
1097
+ previousLine + currentLine;
1098
+ this.state.lines.splice(this.state.cursorLine, 1);
1099
+ this.state.cursorLine--;
1100
+ this.setCursorCol(previousLine.length);
1101
+ }
1102
+ }
1103
+ else {
1104
+ this.pushUndoSnapshot();
1105
+ // Save lastAction before cursor movement (moveWordBackwards resets it)
1106
+ const wasKill = this.lastAction === "kill";
1107
+ const oldCursorCol = this.state.cursorCol;
1108
+ this.moveWordBackwards();
1109
+ const deleteFrom = this.state.cursorCol;
1110
+ this.setCursorCol(oldCursorCol);
1111
+ // Restore kill state for accumulation check, then save to kill ring
1112
+ this.lastAction = wasKill ? "kill" : null;
1113
+ const deletedText = currentLine.slice(deleteFrom, this.state.cursorCol);
1114
+ this.addToKillRing(deletedText, true);
1115
+ this.lastAction = "kill";
1116
+ this.state.lines[this.state.cursorLine] =
1117
+ currentLine.slice(0, deleteFrom) +
1118
+ currentLine.slice(this.state.cursorCol);
1119
+ this.setCursorCol(deleteFrom);
1120
+ }
1121
+ if (this.onChange) {
1122
+ this.onChange(this.getText());
1123
+ }
1124
+ }
1125
+ deleteWordForward() {
1126
+ this.historyIndex = -1; // Exit history browsing mode
1127
+ const currentLine = this.currentLine;
1128
+ // If at end of line, merge with next line (delete the newline)
1129
+ if (this.state.cursorCol >= currentLine.length) {
1130
+ if (this.state.cursorLine < this.state.lines.length - 1) {
1131
+ this.pushUndoSnapshot();
1132
+ // Treat newline as deleted text (forward deletion = append)
1133
+ this.addToKillRing("\n", false);
1134
+ this.lastAction = "kill";
1135
+ const nextLine = this.getLine(this.state.cursorLine + 1);
1136
+ this.state.lines[this.state.cursorLine] = currentLine + nextLine;
1137
+ this.state.lines.splice(this.state.cursorLine + 1, 1);
1138
+ }
1139
+ }
1140
+ else {
1141
+ this.pushUndoSnapshot();
1142
+ // Save lastAction before cursor movement (moveWordForwards resets it)
1143
+ const wasKill = this.lastAction === "kill";
1144
+ const oldCursorCol = this.state.cursorCol;
1145
+ this.moveWordForwards();
1146
+ const deleteTo = this.state.cursorCol;
1147
+ this.setCursorCol(oldCursorCol);
1148
+ // Restore kill state for accumulation check, then save to kill ring
1149
+ this.lastAction = wasKill ? "kill" : null;
1150
+ const deletedText = currentLine.slice(this.state.cursorCol, deleteTo);
1151
+ this.addToKillRing(deletedText, false);
1152
+ this.lastAction = "kill";
1153
+ this.state.lines[this.state.cursorLine] =
1154
+ currentLine.slice(0, this.state.cursorCol) +
1155
+ currentLine.slice(deleteTo);
1156
+ }
1157
+ if (this.onChange) {
1158
+ this.onChange(this.getText());
1159
+ }
1160
+ }
1161
+ handleForwardDelete() {
1162
+ this.historyIndex = -1; // Exit history browsing mode
1163
+ this.lastAction = null;
1164
+ const currentLine = this.currentLine;
1165
+ if (this.state.cursorCol < currentLine.length) {
1166
+ this.pushUndoSnapshot();
1167
+ // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
1168
+ const afterCursor = currentLine.slice(this.state.cursorCol);
1169
+ // Find the first grapheme at cursor
1170
+ const graphemes = [...segmenter.segment(afterCursor)];
1171
+ const firstGrapheme = graphemes[0];
1172
+ const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
1173
+ const before = currentLine.slice(0, this.state.cursorCol);
1174
+ const after = currentLine.slice(this.state.cursorCol + graphemeLength);
1175
+ this.state.lines[this.state.cursorLine] = before + after;
1176
+ }
1177
+ else if (this.state.cursorLine < this.state.lines.length - 1) {
1178
+ this.pushUndoSnapshot();
1179
+ // At end of line - merge with next line
1180
+ const nextLine = this.getLine(this.state.cursorLine + 1);
1181
+ this.state.lines[this.state.cursorLine] = currentLine + nextLine;
1182
+ this.state.lines.splice(this.state.cursorLine + 1, 1);
1183
+ }
1184
+ if (this.onChange) {
1185
+ this.onChange(this.getText());
1186
+ }
1187
+ // Update or re-trigger autocomplete after forward delete
1188
+ if (this.autocompleteState) {
1189
+ this.updateAutocomplete();
1190
+ }
1191
+ else {
1192
+ const currentLine = this.currentLine;
1193
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1194
+ // Slash command context
1195
+ if (this.isInSlashCommandContext(textBeforeCursor)) {
1196
+ this.tryTriggerAutocomplete();
1197
+ }
1198
+ // @ file reference context
1199
+ else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
1200
+ this.tryTriggerAutocomplete();
1201
+ }
1202
+ }
1203
+ }
1204
+ /**
1205
+ * Build a mapping from visual lines to logical positions.
1206
+ * Returns an array where each element represents a visual line with:
1207
+ * - logicalLine: index into this.state.lines
1208
+ * - startCol: starting column in the logical line
1209
+ * - length: length of this visual line segment
1210
+ */
1211
+ buildVisualLineMap(width) {
1212
+ const visualLines = [];
1213
+ for (let i = 0; i < this.state.lines.length; i++) {
1214
+ const line = this.getLine(i);
1215
+ const lineVisWidth = visibleWidth(line);
1216
+ if (line.length === 0) {
1217
+ // Empty line still takes one visual line
1218
+ visualLines.push({ logicalLine: i, startCol: 0, length: 0 });
1219
+ }
1220
+ else if (lineVisWidth <= width) {
1221
+ visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
1222
+ }
1223
+ else {
1224
+ // Line needs wrapping - use word-aware wrapping
1225
+ const chunks = wordWrapLineInternal(line, width);
1226
+ for (const chunk of chunks) {
1227
+ visualLines.push({
1228
+ logicalLine: i,
1229
+ startCol: chunk.startIndex,
1230
+ length: chunk.endIndex - chunk.startIndex,
1231
+ });
1232
+ }
1233
+ }
1234
+ }
1235
+ return visualLines;
1236
+ }
1237
+ /**
1238
+ * Find the visual line index for the current cursor position.
1239
+ */
1240
+ findCurrentVisualLine(visualLines) {
1241
+ for (let i = 0; i < visualLines.length; i++) {
1242
+ const vl = visualLines[i];
1243
+ if (!vl)
1244
+ continue;
1245
+ if (vl.logicalLine === this.state.cursorLine) {
1246
+ const colInSegment = this.state.cursorCol - vl.startCol;
1247
+ // Cursor is in this segment if it's within range
1248
+ // For the last segment of a logical line, cursor can be at length (end position)
1249
+ const isLastSegmentOfLine = i === visualLines.length - 1 ||
1250
+ visualLines[i + 1]?.logicalLine !== vl.logicalLine;
1251
+ if (colInSegment >= 0 &&
1252
+ (colInSegment < vl.length ||
1253
+ (isLastSegmentOfLine && colInSegment <= vl.length))) {
1254
+ return i;
1255
+ }
1256
+ }
1257
+ }
1258
+ // Fallback: return last visual line
1259
+ return visualLines.length - 1;
1260
+ }
1261
+ moveCursor(deltaLine, deltaCol) {
1262
+ this.lastAction = null;
1263
+ const visualLines = this.buildVisualLineMap(this.lastWidth);
1264
+ const currentVisualLine = this.findCurrentVisualLine(visualLines);
1265
+ if (deltaLine !== 0) {
1266
+ const targetVisualLine = currentVisualLine + deltaLine;
1267
+ if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
1268
+ this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
1269
+ }
1270
+ }
1271
+ if (deltaCol !== 0) {
1272
+ const currentLine = this.currentLine;
1273
+ if (deltaCol > 0) {
1274
+ // Moving right - move by one grapheme (handles emojis, combining characters, etc.)
1275
+ if (this.state.cursorCol < currentLine.length) {
1276
+ const afterCursor = currentLine.slice(this.state.cursorCol);
1277
+ const graphemes = [...segmenter.segment(afterCursor)];
1278
+ const firstGrapheme = graphemes[0];
1279
+ this.setCursorCol(this.state.cursorCol +
1280
+ (firstGrapheme ? firstGrapheme.segment.length : 1));
1281
+ }
1282
+ else if (this.state.cursorLine < this.state.lines.length - 1) {
1283
+ // Wrap to start of next logical line
1284
+ this.state.cursorLine++;
1285
+ this.setCursorCol(0);
1286
+ }
1287
+ else {
1288
+ // At end of last line - can't move, but set preferredVisualCol for up/down navigation
1289
+ const currentVL = visualLines[currentVisualLine];
1290
+ if (currentVL) {
1291
+ this.preferredVisualCol = this.state.cursorCol - currentVL.startCol;
1292
+ }
1293
+ }
1294
+ }
1295
+ else {
1296
+ // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
1297
+ if (this.state.cursorCol > 0) {
1298
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1299
+ const graphemes = [...segmenter.segment(beforeCursor)];
1300
+ const lastGrapheme = graphemes[graphemes.length - 1];
1301
+ this.setCursorCol(this.state.cursorCol -
1302
+ (lastGrapheme ? lastGrapheme.segment.length : 1));
1303
+ }
1304
+ else if (this.state.cursorLine > 0) {
1305
+ // Wrap to end of previous logical line
1306
+ this.state.cursorLine--;
1307
+ const prevLine = this.currentLine;
1308
+ this.setCursorCol(prevLine.length);
1309
+ }
1310
+ }
1311
+ }
1312
+ }
1313
+ /**
1314
+ * Scroll by a page (direction: -1 for up, 1 for down).
1315
+ * Moves cursor by the page size while keeping it in bounds.
1316
+ */
1317
+ pageScroll(direction) {
1318
+ this.lastAction = null;
1319
+ const terminalRows = this.tui.terminal.rows;
1320
+ const pageSize = Math.max(5, Math.floor(terminalRows * 0.3));
1321
+ const visualLines = this.buildVisualLineMap(this.lastWidth);
1322
+ const currentVisualLine = this.findCurrentVisualLine(visualLines);
1323
+ const targetVisualLine = Math.max(0, Math.min(visualLines.length - 1, currentVisualLine + direction * pageSize));
1324
+ this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
1325
+ }
1326
+ moveWordBackwards() {
1327
+ this.lastAction = null;
1328
+ const currentLine = this.currentLine;
1329
+ // If at start of line, move to end of previous line
1330
+ if (this.state.cursorCol === 0) {
1331
+ if (this.state.cursorLine > 0) {
1332
+ this.state.cursorLine--;
1333
+ const prevLine = this.currentLine;
1334
+ this.setCursorCol(prevLine.length);
1335
+ }
1336
+ return;
1337
+ }
1338
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1339
+ const graphemes = [...segmenter.segment(textBeforeCursor)];
1340
+ let newCol = this.state.cursorCol;
1341
+ const popGrapheme = () => {
1342
+ const g = graphemes.pop();
1343
+ return g ? g.segment.length : 0;
1344
+ };
1345
+ const peekSegment = () => {
1346
+ const last = graphemes[graphemes.length - 1];
1347
+ return last ? last.segment : "";
1348
+ };
1349
+ // Skip trailing whitespace
1350
+ while (graphemes.length > 0 && isWhitespaceChar(peekSegment())) {
1351
+ newCol -= popGrapheme();
1352
+ }
1353
+ if (graphemes.length > 0) {
1354
+ if (isPunctuationChar(peekSegment())) {
1355
+ // Skip punctuation run
1356
+ while (graphemes.length > 0 && isPunctuationChar(peekSegment())) {
1357
+ newCol -= popGrapheme();
1358
+ }
1359
+ }
1360
+ else {
1361
+ // Skip word run
1362
+ while (graphemes.length > 0 &&
1363
+ !isWhitespaceChar(peekSegment()) &&
1364
+ !isPunctuationChar(peekSegment())) {
1365
+ newCol -= popGrapheme();
1366
+ }
1367
+ }
1368
+ }
1369
+ this.setCursorCol(newCol);
1370
+ }
1371
+ /**
1372
+ * Yank (paste) the most recent kill ring entry at cursor position.
1373
+ */
1374
+ yank() {
1375
+ if (this.killRing.length === 0)
1376
+ return;
1377
+ this.pushUndoSnapshot();
1378
+ const text = this.killRing[this.killRing.length - 1];
1379
+ this.insertYankedText(text);
1380
+ this.lastAction = "yank";
1381
+ }
1382
+ /**
1383
+ * Cycle through kill ring (only works immediately after yank or yank-pop).
1384
+ * Replaces the last yanked text with the previous entry in the ring.
1385
+ */
1386
+ yankPop() {
1387
+ // Only works if we just yanked and have more than one entry
1388
+ if (this.lastAction !== "yank" || this.killRing.length <= 1)
1389
+ return;
1390
+ this.pushUndoSnapshot();
1391
+ // Delete the previously yanked text (still at end of ring before rotation)
1392
+ this.deleteYankedText();
1393
+ // Rotate the ring: move end to front (length > 1 guaranteed by check above)
1394
+ const lastEntry = this.killRing.pop();
1395
+ this.killRing.unshift(lastEntry);
1396
+ // Insert the new most recent entry (now at end after rotation)
1397
+ const text = this.killRing[this.killRing.length - 1];
1398
+ this.insertYankedText(text);
1399
+ this.lastAction = "yank";
1400
+ }
1401
+ /**
1402
+ * Insert text at cursor position (used by yank operations).
1403
+ */
1404
+ insertYankedText(text) {
1405
+ this.historyIndex = -1; // Exit history browsing mode
1406
+ const lines = text.split("\n");
1407
+ if (lines.length === 1) {
1408
+ // Single line - insert at cursor
1409
+ const currentLine = this.currentLine;
1410
+ const before = currentLine.slice(0, this.state.cursorCol);
1411
+ const after = currentLine.slice(this.state.cursorCol);
1412
+ this.state.lines[this.state.cursorLine] = before + text + after;
1413
+ this.setCursorCol(this.state.cursorCol + text.length);
1414
+ }
1415
+ else {
1416
+ // Multi-line insert
1417
+ const currentLine = this.currentLine;
1418
+ const before = currentLine.slice(0, this.state.cursorCol);
1419
+ const after = currentLine.slice(this.state.cursorCol);
1420
+ // First line merges with text before cursor
1421
+ this.state.lines[this.state.cursorLine] = before + lines[0];
1422
+ // Insert middle lines
1423
+ for (let i = 1; i < lines.length - 1; i++) {
1424
+ this.state.lines.splice(this.state.cursorLine + i, 0, lines[i]);
1425
+ }
1426
+ // Last line merges with text after cursor
1427
+ const lastLineIndex = this.state.cursorLine + lines.length - 1;
1428
+ const lastLine = lines[lines.length - 1];
1429
+ this.state.lines.splice(lastLineIndex, 0, lastLine + after);
1430
+ // Update cursor position
1431
+ this.state.cursorLine = lastLineIndex;
1432
+ this.setCursorCol(lastLine.length);
1433
+ }
1434
+ if (this.onChange) {
1435
+ this.onChange(this.getText());
1436
+ }
1437
+ }
1438
+ /**
1439
+ * Delete the previously yanked text (used by yank-pop).
1440
+ * The yanked text is derived from killRing[end] since it hasn't been rotated yet.
1441
+ */
1442
+ deleteYankedText() {
1443
+ if (this.killRing.length === 0)
1444
+ return;
1445
+ const yankedText = this.killRing[this.killRing.length - 1];
1446
+ const yankLines = yankedText.split("\n");
1447
+ if (yankLines.length === 1) {
1448
+ // Single line - delete backward from cursor
1449
+ const currentLine = this.currentLine;
1450
+ const deleteLen = yankedText.length;
1451
+ const before = currentLine.slice(0, this.state.cursorCol - deleteLen);
1452
+ const after = currentLine.slice(this.state.cursorCol);
1453
+ this.state.lines[this.state.cursorLine] = before + after;
1454
+ this.setCursorCol(this.state.cursorCol - deleteLen);
1455
+ }
1456
+ else {
1457
+ // Multi-line delete - cursor is at end of last yanked line
1458
+ const startLine = this.state.cursorLine - (yankLines.length - 1);
1459
+ const startLineContent = this.getLine(startLine);
1460
+ const startCol = startLineContent.length - yankLines[0].length;
1461
+ // Get text after cursor on current line
1462
+ const afterCursor = this.currentLine.slice(this.state.cursorCol);
1463
+ // Get text before yank start position
1464
+ const beforeYank = startLineContent.slice(0, startCol);
1465
+ // Remove all lines from startLine to cursorLine and replace with merged line
1466
+ this.state.lines.splice(startLine, yankLines.length, beforeYank + afterCursor);
1467
+ // Update cursor
1468
+ this.state.cursorLine = startLine;
1469
+ this.setCursorCol(startCol);
1470
+ }
1471
+ if (this.onChange) {
1472
+ this.onChange(this.getText());
1473
+ }
1474
+ }
1475
+ /**
1476
+ * Add text to the kill ring.
1477
+ * If lastAction is "kill", accumulates with the previous entry.
1478
+ * @param text - The text to add
1479
+ * @param prepend - If accumulating, prepend (true) or append (false) to existing entry
1480
+ */
1481
+ addToKillRing(text, prepend) {
1482
+ if (!text)
1483
+ return;
1484
+ if (this.lastAction === "kill" && this.killRing.length > 0) {
1485
+ // Accumulate with the most recent entry (at end of array)
1486
+ const lastEntry = this.killRing.pop();
1487
+ if (prepend) {
1488
+ this.killRing.push(text + lastEntry);
1489
+ }
1490
+ else {
1491
+ this.killRing.push(lastEntry + text);
1492
+ }
1493
+ }
1494
+ else {
1495
+ // Add new entry to end of ring
1496
+ this.killRing.push(text);
1497
+ }
1498
+ }
1499
+ captureUndoSnapshot() {
1500
+ return structuredClone(this.state);
1501
+ }
1502
+ restoreUndoSnapshot(snapshot) {
1503
+ Object.assign(this.state, structuredClone(snapshot));
1504
+ }
1505
+ pushUndoSnapshot() {
1506
+ this.undoStack.push(this.captureUndoSnapshot());
1507
+ }
1508
+ undo() {
1509
+ this.historyIndex = -1; // Exit history browsing mode
1510
+ if (this.undoStack.length === 0)
1511
+ return;
1512
+ // undoStack.length > 0 guaranteed by check above
1513
+ const snapshot = this.undoStack.pop();
1514
+ this.restoreUndoSnapshot(snapshot);
1515
+ this.lastAction = null;
1516
+ this.preferredVisualCol = null;
1517
+ if (this.onChange) {
1518
+ this.onChange(this.getText());
1519
+ }
1520
+ }
1521
+ /**
1522
+ * Jump to the first occurrence of a character in the specified direction.
1523
+ * Multi-line search. Case-sensitive. Skips the current cursor position.
1524
+ */
1525
+ jumpToChar(char, direction) {
1526
+ this.lastAction = null;
1527
+ const isForward = direction === "forward";
1528
+ const lines = this.state.lines;
1529
+ const end = isForward ? lines.length : -1;
1530
+ const step = isForward ? 1 : -1;
1531
+ for (let lineIdx = this.state.cursorLine; lineIdx !== end; lineIdx += step) {
1532
+ const line = lines[lineIdx];
1533
+ const isCurrentLine = lineIdx === this.state.cursorLine;
1534
+ // Current line: start after/before cursor; other lines: search full line
1535
+ const searchFrom = isCurrentLine
1536
+ ? isForward
1537
+ ? this.state.cursorCol + 1
1538
+ : this.state.cursorCol - 1
1539
+ : undefined;
1540
+ const idx = isForward
1541
+ ? line.indexOf(char, searchFrom)
1542
+ : line.lastIndexOf(char, searchFrom);
1543
+ if (idx !== -1) {
1544
+ this.state.cursorLine = lineIdx;
1545
+ this.setCursorCol(idx);
1546
+ return;
1547
+ }
1548
+ }
1549
+ // No match found - cursor stays in place
1550
+ }
1551
+ moveWordForwards() {
1552
+ this.lastAction = null;
1553
+ const currentLine = this.currentLine;
1554
+ // If at end of line, move to start of next line
1555
+ if (this.state.cursorCol >= currentLine.length) {
1556
+ if (this.state.cursorLine < this.state.lines.length - 1) {
1557
+ this.state.cursorLine++;
1558
+ this.setCursorCol(0);
1559
+ }
1560
+ return;
1561
+ }
1562
+ const textAfterCursor = currentLine.slice(this.state.cursorCol);
1563
+ const segments = segmenter.segment(textAfterCursor);
1564
+ const iterator = segments[Symbol.iterator]();
1565
+ let next = iterator.next();
1566
+ let newCol = this.state.cursorCol;
1567
+ // Skip leading whitespace
1568
+ while (!next.done && isWhitespaceChar(next.value.segment)) {
1569
+ newCol += next.value.segment.length;
1570
+ next = iterator.next();
1571
+ }
1572
+ if (!next.done) {
1573
+ const firstGrapheme = next.value.segment;
1574
+ if (isPunctuationChar(firstGrapheme)) {
1575
+ // Skip punctuation run
1576
+ while (!next.done && isPunctuationChar(next.value.segment)) {
1577
+ newCol += next.value.segment.length;
1578
+ next = iterator.next();
1579
+ }
1580
+ }
1581
+ else {
1582
+ // Skip word run
1583
+ while (!next.done &&
1584
+ !isWhitespaceChar(next.value.segment) &&
1585
+ !isPunctuationChar(next.value.segment)) {
1586
+ newCol += next.value.segment.length;
1587
+ next = iterator.next();
1588
+ }
1589
+ }
1590
+ }
1591
+ this.setCursorCol(newCol);
1592
+ }
1593
+ // Slash menu only allowed on the first line of the editor
1594
+ isSlashMenuAllowed() {
1595
+ return this.state.cursorLine === 0;
1596
+ }
1597
+ // Helper method to check if cursor is at start of message (for slash command detection)
1598
+ isAtStartOfMessage() {
1599
+ if (!this.isSlashMenuAllowed())
1600
+ return false;
1601
+ const currentLine = this.currentLine;
1602
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1603
+ return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
1604
+ }
1605
+ isInSlashCommandContext(textBeforeCursor) {
1606
+ return (this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/"));
1607
+ }
1608
+ // Autocomplete methods
1609
+ tryTriggerAutocomplete(explicitTab = false) {
1610
+ if (!this.autocompleteProvider)
1611
+ return;
1612
+ // Check if we should trigger file completion on Tab
1613
+ if (explicitTab) {
1614
+ const provider = this
1615
+ .autocompleteProvider;
1616
+ const shouldTrigger = !provider.shouldTriggerFileCompletion ||
1617
+ provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1618
+ if (!shouldTrigger) {
1619
+ return;
1620
+ }
1621
+ }
1622
+ const suggestions = this.autocompleteProvider.getSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1623
+ if (suggestions && suggestions.items.length > 0) {
1624
+ this.autocompletePrefix = suggestions.prefix;
1625
+ this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1626
+ this.autocompleteState = "regular";
1627
+ }
1628
+ else {
1629
+ this.cancelAutocomplete();
1630
+ }
1631
+ }
1632
+ handleTabCompletion() {
1633
+ if (!this.autocompleteProvider)
1634
+ return;
1635
+ const currentLine = this.currentLine;
1636
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1637
+ // Check if we're in a slash command context
1638
+ if (this.isInSlashCommandContext(beforeCursor) &&
1639
+ !beforeCursor.trimStart().includes(" ")) {
1640
+ this.handleSlashCommandCompletion();
1641
+ }
1642
+ else {
1643
+ this.forceFileAutocomplete(true);
1644
+ }
1645
+ }
1646
+ handleSlashCommandCompletion() {
1647
+ this.tryTriggerAutocomplete(true);
1648
+ }
1649
+ forceFileAutocomplete(explicitTab = false) {
1650
+ if (!this.autocompleteProvider)
1651
+ return;
1652
+ // Check if provider supports force file suggestions via runtime check
1653
+ const provider = this.autocompleteProvider;
1654
+ if (typeof provider.getForceFileSuggestions !== "function") {
1655
+ this.tryTriggerAutocomplete(true);
1656
+ return;
1657
+ }
1658
+ const suggestions = provider.getForceFileSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1659
+ if (suggestions && suggestions.items.length > 0) {
1660
+ // If there's exactly one suggestion, apply it immediately
1661
+ if (explicitTab && suggestions.items.length === 1) {
1662
+ // items.length === 1 guaranteed by check above
1663
+ const item = suggestions.items[0];
1664
+ this.pushUndoSnapshot();
1665
+ this.lastAction = null;
1666
+ const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, item, suggestions.prefix);
1667
+ this.state.lines = result.lines;
1668
+ this.state.cursorLine = result.cursorLine;
1669
+ this.setCursorCol(result.cursorCol);
1670
+ if (this.onChange)
1671
+ this.onChange(this.getText());
1672
+ return;
1673
+ }
1674
+ this.autocompletePrefix = suggestions.prefix;
1675
+ this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1676
+ this.autocompleteState = "force";
1677
+ }
1678
+ else {
1679
+ this.cancelAutocomplete();
1680
+ }
1681
+ }
1682
+ cancelAutocomplete() {
1683
+ this.autocompleteState = null;
1684
+ this.autocompleteList = undefined;
1685
+ this.autocompletePrefix = "";
1686
+ }
1687
+ isShowingAutocomplete() {
1688
+ return this.autocompleteState !== null;
1689
+ }
1690
+ updateAutocomplete() {
1691
+ if (!this.autocompleteState || !this.autocompleteProvider)
1692
+ return;
1693
+ if (this.autocompleteState === "force") {
1694
+ this.forceFileAutocomplete();
1695
+ return;
1696
+ }
1697
+ const suggestions = this.autocompleteProvider.getSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1698
+ if (suggestions && suggestions.items.length > 0) {
1699
+ this.autocompletePrefix = suggestions.prefix;
1700
+ // Always create new SelectList to ensure update
1701
+ this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1702
+ }
1703
+ else {
1704
+ this.cancelAutocomplete();
1705
+ }
1706
+ }
1707
+ }