@greppleai/grip-tui 0.0.0-stage → 0.0.1

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 (158) hide show
  1. package/README.md +818 -2
  2. package/dist/autocomplete.d.ts +48 -0
  3. package/dist/autocomplete.d.ts.map +1 -0
  4. package/dist/autocomplete.js +566 -0
  5. package/dist/autocomplete.js.map +1 -0
  6. package/dist/components/alt-screen-flash.d.ts +13 -0
  7. package/dist/components/alt-screen-flash.d.ts.map +1 -0
  8. package/dist/components/alt-screen-flash.js +37 -0
  9. package/dist/components/alt-screen-flash.js.map +1 -0
  10. package/dist/components/box.d.ts +22 -0
  11. package/dist/components/box.d.ts.map +1 -0
  12. package/dist/components/box.js +104 -0
  13. package/dist/components/box.js.map +1 -0
  14. package/dist/components/cancellable-loader.d.ts +22 -0
  15. package/dist/components/cancellable-loader.d.ts.map +1 -0
  16. package/dist/components/cancellable-loader.js +35 -0
  17. package/dist/components/cancellable-loader.js.map +1 -0
  18. package/dist/components/editor.d.ts +292 -0
  19. package/dist/components/editor.d.ts.map +1 -0
  20. package/dist/components/editor.js +1887 -0
  21. package/dist/components/editor.js.map +1 -0
  22. package/dist/components/h-stack.d.ts +7 -0
  23. package/dist/components/h-stack.d.ts.map +1 -0
  24. package/dist/components/h-stack.js +43 -0
  25. package/dist/components/h-stack.js.map +1 -0
  26. package/dist/components/image.d.ts +32 -0
  27. package/dist/components/image.d.ts.map +1 -0
  28. package/dist/components/image.js +76 -0
  29. package/dist/components/image.js.map +1 -0
  30. package/dist/components/input.d.ts +46 -0
  31. package/dist/components/input.d.ts.map +1 -0
  32. package/dist/components/input.js +352 -0
  33. package/dist/components/input.js.map +1 -0
  34. package/dist/components/loader.d.ts +32 -0
  35. package/dist/components/loader.d.ts.map +1 -0
  36. package/dist/components/loader.js +80 -0
  37. package/dist/components/loader.js.map +1 -0
  38. package/dist/components/markdown.d.ts +132 -0
  39. package/dist/components/markdown.d.ts.map +1 -0
  40. package/dist/components/markdown.js +717 -0
  41. package/dist/components/markdown.js.map +1 -0
  42. package/dist/components/scroll-view.d.ts +53 -0
  43. package/dist/components/scroll-view.d.ts.map +1 -0
  44. package/dist/components/scroll-view.js +173 -0
  45. package/dist/components/scroll-view.js.map +1 -0
  46. package/dist/components/select-list.d.ts +50 -0
  47. package/dist/components/select-list.d.ts.map +1 -0
  48. package/dist/components/select-list.js +159 -0
  49. package/dist/components/select-list.js.map +1 -0
  50. package/dist/components/settings-list.d.ts +52 -0
  51. package/dist/components/settings-list.d.ts.map +1 -0
  52. package/dist/components/settings-list.js +187 -0
  53. package/dist/components/settings-list.js.map +1 -0
  54. package/dist/components/spacer.d.ts +12 -0
  55. package/dist/components/spacer.d.ts.map +1 -0
  56. package/dist/components/spacer.js +23 -0
  57. package/dist/components/spacer.js.map +1 -0
  58. package/dist/components/stack.d.ts +32 -0
  59. package/dist/components/stack.d.ts.map +1 -0
  60. package/dist/components/stack.js +122 -0
  61. package/dist/components/stack.js.map +1 -0
  62. package/dist/components/text.d.ts +19 -0
  63. package/dist/components/text.d.ts.map +1 -0
  64. package/dist/components/text.js +89 -0
  65. package/dist/components/text.js.map +1 -0
  66. package/dist/components/truncated-text.d.ts +13 -0
  67. package/dist/components/truncated-text.d.ts.map +1 -0
  68. package/dist/components/truncated-text.js +51 -0
  69. package/dist/components/truncated-text.js.map +1 -0
  70. package/dist/components/v-stack.d.ts +8 -0
  71. package/dist/components/v-stack.d.ts.map +1 -0
  72. package/dist/components/v-stack.js +27 -0
  73. package/dist/components/v-stack.js.map +1 -0
  74. package/dist/editor-component.d.ts +39 -0
  75. package/dist/editor-component.d.ts.map +1 -0
  76. package/dist/editor-component.js +2 -0
  77. package/dist/editor-component.js.map +1 -0
  78. package/dist/fuzzy.d.ts +16 -0
  79. package/dist/fuzzy.d.ts.map +1 -0
  80. package/dist/fuzzy.js +101 -0
  81. package/dist/fuzzy.js.map +1 -0
  82. package/dist/index.d.ts +30 -0
  83. package/dist/index.d.ts.map +1 -0
  84. package/dist/index.js +41 -0
  85. package/dist/index.js.map +1 -0
  86. package/dist/keybindings.d.ts +243 -0
  87. package/dist/keybindings.d.ts.map +1 -0
  88. package/dist/keybindings.js +209 -0
  89. package/dist/keybindings.js.map +1 -0
  90. package/dist/keys.d.ts +184 -0
  91. package/dist/keys.d.ts.map +1 -0
  92. package/dist/keys.js +1053 -0
  93. package/dist/keys.js.map +1 -0
  94. package/dist/kill-ring.d.ts +30 -0
  95. package/dist/kill-ring.d.ts.map +1 -0
  96. package/dist/kill-ring.js +37 -0
  97. package/dist/kill-ring.js.map +1 -0
  98. package/dist/latex.d.ts +10 -0
  99. package/dist/latex.d.ts.map +1 -0
  100. package/dist/latex.js +1406 -0
  101. package/dist/latex.js.map +1 -0
  102. package/dist/layout-node.d.ts +43 -0
  103. package/dist/layout-node.d.ts.map +1 -0
  104. package/dist/layout-node.js +6 -0
  105. package/dist/layout-node.js.map +1 -0
  106. package/dist/layout.d.ts +40 -0
  107. package/dist/layout.d.ts.map +1 -0
  108. package/dist/layout.js +386 -0
  109. package/dist/layout.js.map +1 -0
  110. package/dist/native-modifiers.d.ts +3 -0
  111. package/dist/native-modifiers.d.ts.map +1 -0
  112. package/dist/native-modifiers.js +53 -0
  113. package/dist/native-modifiers.js.map +1 -0
  114. package/dist/stdin-buffer.d.ts +58 -0
  115. package/dist/stdin-buffer.d.ts.map +1 -0
  116. package/dist/stdin-buffer.js +318 -0
  117. package/dist/stdin-buffer.js.map +1 -0
  118. package/dist/terminal-control.d.ts +5 -0
  119. package/dist/terminal-control.d.ts.map +1 -0
  120. package/dist/terminal-control.js +26 -0
  121. package/dist/terminal-control.js.map +1 -0
  122. package/dist/terminal-image.d.ts +112 -0
  123. package/dist/terminal-image.d.ts.map +1 -0
  124. package/dist/terminal-image.js +508 -0
  125. package/dist/terminal-image.js.map +1 -0
  126. package/dist/terminal.d.ts +117 -0
  127. package/dist/terminal.d.ts.map +1 -0
  128. package/dist/terminal.js +490 -0
  129. package/dist/terminal.js.map +1 -0
  130. package/dist/tui-alt-screen.d.ts +114 -0
  131. package/dist/tui-alt-screen.d.ts.map +1 -0
  132. package/dist/tui-alt-screen.js +950 -0
  133. package/dist/tui-alt-screen.js.map +1 -0
  134. package/dist/tui-main-screen.d.ts +52 -0
  135. package/dist/tui-main-screen.d.ts.map +1 -0
  136. package/dist/tui-main-screen.js +511 -0
  137. package/dist/tui-main-screen.js.map +1 -0
  138. package/dist/tui.d.ts +295 -0
  139. package/dist/tui.d.ts.map +1 -0
  140. package/dist/tui.js +868 -0
  141. package/dist/tui.js.map +1 -0
  142. package/dist/undo-stack.d.ts +17 -0
  143. package/dist/undo-stack.d.ts.map +1 -0
  144. package/dist/undo-stack.js +25 -0
  145. package/dist/undo-stack.js.map +1 -0
  146. package/dist/utils.d.ts +96 -0
  147. package/dist/utils.d.ts.map +1 -0
  148. package/dist/utils.js +1106 -0
  149. package/dist/utils.js.map +1 -0
  150. package/dist/word-navigation.d.ts +25 -0
  151. package/dist/word-navigation.d.ts.map +1 -0
  152. package/dist/word-navigation.js +94 -0
  153. package/dist/word-navigation.js.map +1 -0
  154. package/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  155. package/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  156. package/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  157. package/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  158. package/package.json +47 -5
@@ -0,0 +1,1887 @@
1
+ import { getKeybindings } from "../keybindings.js";
2
+ import { decodePrintableKey, matchesKey } from "../keys.js";
3
+ import { KillRing } from "../kill-ring.js";
4
+ import { CURSOR_MARKER } from "../tui.js";
5
+ import { UndoStack } from "../undo-stack.js";
6
+ import { getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.js";
7
+ import { findWordBackward, findWordForward } from "../word-navigation.js";
8
+ import { SelectList } from "./select-list.js";
9
+ const graphemeSegmenter = getGraphemeSegmenter();
10
+ const wordSegmenter = getWordSegmenter();
11
+ /** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */
12
+ const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
13
+ /** Non-global version for single-segment testing. */
14
+ const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/;
15
+ /** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */
16
+ function isPasteMarker(segment) {
17
+ return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment);
18
+ }
19
+ /**
20
+ * A segmenter that wraps Intl.Segmenter and merges graphemes that fall
21
+ * within paste markers into single atomic segments. This makes cursor
22
+ * movement, deletion, word-wrap, etc. treat paste markers as single units.
23
+ *
24
+ * Only markers whose numeric ID exists in `validIds` are merged.
25
+ */
26
+ function findPasteMarkerSpans(text, validIds) {
27
+ const markers = [];
28
+ for (const match of text.matchAll(PASTE_MARKER_REGEX)) {
29
+ const id = Number.parseInt(match[1], 10);
30
+ if (!validIds.has(id))
31
+ continue;
32
+ markers.push({ start: match.index, end: match.index + match[0].length });
33
+ }
34
+ return markers;
35
+ }
36
+ function mergePasteMarkerSegments(text, baseSegments, markers) {
37
+ const result = [];
38
+ let markerIndex = 0;
39
+ for (const segment of baseSegments) {
40
+ while (markerIndex < markers.length && markers[markerIndex].end <= segment.index)
41
+ markerIndex++;
42
+ const marker = markerIndex < markers.length ? markers[markerIndex] : undefined;
43
+ if (marker && segment.index >= marker.start && segment.index < marker.end) {
44
+ if (segment.index === marker.start) {
45
+ result.push({
46
+ segment: text.slice(marker.start, marker.end),
47
+ index: marker.start,
48
+ input: text,
49
+ });
50
+ }
51
+ }
52
+ else {
53
+ result.push(segment);
54
+ }
55
+ }
56
+ return result;
57
+ }
58
+ function segmentWithMarkers(text, baseSegmenter, validIds) {
59
+ if (validIds.size === 0 || !text.includes("[paste #"))
60
+ return baseSegmenter.segment(text);
61
+ const markers = findPasteMarkerSpans(text, validIds);
62
+ if (markers.length === 0)
63
+ return baseSegmenter.segment(text);
64
+ return mergePasteMarkerSegments(text, baseSegmenter.segment(text), markers);
65
+ }
66
+ function resolveWrapOverflow(input) {
67
+ if (input.wrapOpportunityIndex >= 0 &&
68
+ input.currentWidth - input.wrapOpportunityWidth + input.graphemeWidth <= input.maxWidth) {
69
+ return {
70
+ chunk: {
71
+ text: input.line.slice(input.chunkStart, input.wrapOpportunityIndex),
72
+ startIndex: input.chunkStart,
73
+ endIndex: input.wrapOpportunityIndex,
74
+ },
75
+ chunkStart: input.wrapOpportunityIndex,
76
+ currentWidth: input.currentWidth - input.wrapOpportunityWidth,
77
+ };
78
+ }
79
+ if (input.chunkStart < input.charIndex) {
80
+ return {
81
+ chunk: {
82
+ text: input.line.slice(input.chunkStart, input.charIndex),
83
+ startIndex: input.chunkStart,
84
+ endIndex: input.charIndex,
85
+ },
86
+ chunkStart: input.charIndex,
87
+ currentWidth: 0,
88
+ };
89
+ }
90
+ return { chunkStart: input.chunkStart, currentWidth: input.currentWidth };
91
+ }
92
+ function splitOversizedSegment(grapheme, maxWidth, charIndex) {
93
+ const subChunks = wordWrapLine(grapheme, maxWidth);
94
+ const completedChunks = subChunks.slice(0, -1).map((chunk) => ({
95
+ text: chunk.text,
96
+ startIndex: charIndex + chunk.startIndex,
97
+ endIndex: charIndex + chunk.endIndex,
98
+ }));
99
+ const last = subChunks[subChunks.length - 1];
100
+ return {
101
+ completedChunks,
102
+ chunkStart: charIndex + last.startIndex,
103
+ currentWidth: visibleWidth(last.text),
104
+ };
105
+ }
106
+ function findWrapOpportunityAfterWhitespace(currentIsWhitespace, next) {
107
+ if (!currentIsWhitespace || !next)
108
+ return undefined;
109
+ if (!isPasteMarker(next.segment) && isWhitespaceChar(next.segment))
110
+ return undefined;
111
+ return next.index;
112
+ }
113
+ /**
114
+ * Split a line into word-wrapped chunks.
115
+ * Wraps at word boundaries when possible, falling back to character-level
116
+ * wrapping for words longer than the available width.
117
+ *
118
+ * @param line - The text line to wrap
119
+ * @param maxWidth - Maximum visible width per chunk
120
+ * @param preSegmented - Optional pre-segmented graphemes (e.g. with paste-marker awareness).
121
+ * When omitted the default Intl.Segmenter is used.
122
+ * @returns Array of chunks with text and position information
123
+ */
124
+ export function wordWrapLine(line, maxWidth, preSegmented) {
125
+ if (!line || maxWidth <= 0) {
126
+ return [{ text: "", startIndex: 0, endIndex: 0 }];
127
+ }
128
+ const lineWidth = visibleWidth(line);
129
+ if (lineWidth <= maxWidth) {
130
+ return [{ text: line, startIndex: 0, endIndex: line.length }];
131
+ }
132
+ const chunks = [];
133
+ const segments = preSegmented ?? [...graphemeSegmenter.segment(line)];
134
+ let currentWidth = 0;
135
+ let chunkStart = 0;
136
+ // Wrap opportunity: the position after the last whitespace before a non-whitespace
137
+ // grapheme, i.e. where a line break is allowed.
138
+ let wrapOppIndex = -1;
139
+ let wrapOppWidth = 0;
140
+ for (let i = 0; i < segments.length; i++) {
141
+ const seg = segments[i];
142
+ const grapheme = seg.segment;
143
+ const gWidth = visibleWidth(grapheme);
144
+ const charIndex = seg.index;
145
+ const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme);
146
+ // Resolve overflow before advancing to the current grapheme.
147
+ if (currentWidth + gWidth > maxWidth) {
148
+ const resolution = resolveWrapOverflow({
149
+ line,
150
+ maxWidth,
151
+ graphemeWidth: gWidth,
152
+ charIndex,
153
+ chunkStart,
154
+ currentWidth,
155
+ wrapOpportunityIndex: wrapOppIndex,
156
+ wrapOpportunityWidth: wrapOppWidth,
157
+ });
158
+ if (resolution.chunk)
159
+ chunks.push(resolution.chunk);
160
+ chunkStart = resolution.chunkStart;
161
+ currentWidth = resolution.currentWidth;
162
+ wrapOppIndex = -1;
163
+ }
164
+ if (gWidth > maxWidth) {
165
+ // Keep the segment logically atomic for editing while splitting its visual layout.
166
+ const wrapped = splitOversizedSegment(grapheme, maxWidth, charIndex);
167
+ chunks.push(...wrapped.completedChunks);
168
+ chunkStart = wrapped.chunkStart;
169
+ currentWidth = wrapped.currentWidth;
170
+ wrapOppIndex = -1;
171
+ continue;
172
+ }
173
+ // Advance.
174
+ currentWidth += gWidth;
175
+ // Record wrap opportunity: whitespace followed by non-whitespace.
176
+ // Multiple spaces join (no break between them); the break point is
177
+ // after the last space before the next word.
178
+ const next = segments[i + 1];
179
+ const nextWrapOpportunity = findWrapOpportunityAfterWhitespace(isWs, next);
180
+ if (nextWrapOpportunity !== undefined) {
181
+ wrapOppIndex = nextWrapOpportunity;
182
+ wrapOppWidth = currentWidth;
183
+ }
184
+ }
185
+ // Push final chunk.
186
+ chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length });
187
+ return chunks;
188
+ }
189
+ const SLASH_COMMAND_SELECT_LIST_LAYOUT = {
190
+ minPrimaryColumnWidth: 12,
191
+ maxPrimaryColumnWidth: 32,
192
+ };
193
+ const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
194
+ function createScrollBorder(direction, hiddenLineCount, width) {
195
+ const availableWidth = Math.max(0, width);
196
+ const indicator = `─── ${direction} ${hiddenLineCount} more `;
197
+ const remaining = availableWidth - visibleWidth(indicator);
198
+ if (remaining >= 0)
199
+ return indicator + "─".repeat(remaining);
200
+ const ellipsis = "...".slice(0, availableWidth);
201
+ const indicatorWidth = availableWidth - visibleWidth(ellipsis);
202
+ return sliceByColumn(indicator, 0, indicatorWidth, true) + ellipsis;
203
+ }
204
+ export class Editor {
205
+ state = {
206
+ lines: [""],
207
+ cursorLine: 0,
208
+ cursorCol: 0,
209
+ };
210
+ /** Focusable interface - set by TUI when focus changes */
211
+ focused = false;
212
+ tui;
213
+ theme;
214
+ paddingX = 0;
215
+ // Store last render width for cursor navigation
216
+ lastWidth = 80;
217
+ // Vertical scrolling support
218
+ scrollOffset = 0;
219
+ // Border color (can be changed dynamically)
220
+ borderColor;
221
+ // Autocomplete support
222
+ autocompleteProvider;
223
+ autocompleteList;
224
+ autocompleteState = null;
225
+ autocompletePrefix = "";
226
+ autocompleteMaxVisible = 5;
227
+ autocompleteAbort;
228
+ autocompleteDebounceTimer;
229
+ autocompleteRequestTask = Promise.resolve();
230
+ autocompleteStartToken = 0;
231
+ autocompleteRequestId = 0;
232
+ // Paste tracking for large pastes
233
+ pastes = new Map();
234
+ pasteCounter = 0;
235
+ // Bracketed paste mode buffering
236
+ pasteBuffer = "";
237
+ isInPaste = false;
238
+ // Prompt history for up/down navigation
239
+ history = [];
240
+ historyIndex = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
241
+ // Kill ring for Emacs-style kill/yank operations
242
+ killRing = new KillRing();
243
+ lastAction = null;
244
+ // Character jump mode
245
+ jumpMode = null;
246
+ // Preferred visual column for vertical cursor movement (sticky column)
247
+ preferredVisualCol = null;
248
+ // When the cursor is snapped to the start of an atomic segment, e.g. a
249
+ // paste marker, cursorCol no longer reflects where the cursor would have
250
+ // landed. This field stores the pre-snap cursorCol so that the next
251
+ // vertical move can resolve it to a visual column on whatever VL it belongs
252
+ // to.
253
+ snappedFromCursorCol = null;
254
+ // Undo support
255
+ undoStack = new UndoStack();
256
+ onSubmit;
257
+ onChange;
258
+ disableSubmit = false;
259
+ constructor(tui, theme, options = {}) {
260
+ this.tui = tui;
261
+ this.theme = theme;
262
+ this.borderColor = theme.borderColor;
263
+ const paddingX = options.paddingX ?? 0;
264
+ this.paddingX = Number.isFinite(paddingX) ? Math.max(0, Math.floor(paddingX)) : 0;
265
+ const maxVisible = options.autocompleteMaxVisible ?? 5;
266
+ this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
267
+ }
268
+ /** Set of currently valid paste IDs, for marker-aware segmentation. */
269
+ validPasteIds() {
270
+ return new Set(this.pastes.keys());
271
+ }
272
+ /** Segment text with paste-marker awareness, only merging markers with valid IDs. */
273
+ segment(text, mode) {
274
+ return segmentWithMarkers(text, mode === "word" ? wordSegmenter : graphemeSegmenter, this.validPasteIds());
275
+ }
276
+ getPaddingX() {
277
+ return this.paddingX;
278
+ }
279
+ setPaddingX(padding) {
280
+ const newPadding = Number.isFinite(padding) ? Math.max(0, Math.floor(padding)) : 0;
281
+ if (this.paddingX !== newPadding) {
282
+ this.paddingX = newPadding;
283
+ this.tui.requestRender();
284
+ }
285
+ }
286
+ getAutocompleteMaxVisible() {
287
+ return this.autocompleteMaxVisible;
288
+ }
289
+ setAutocompleteMaxVisible(maxVisible) {
290
+ const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
291
+ if (this.autocompleteMaxVisible !== newMaxVisible) {
292
+ this.autocompleteMaxVisible = newMaxVisible;
293
+ this.tui.requestRender();
294
+ }
295
+ }
296
+ setAutocompleteProvider(provider) {
297
+ this.cancelAutocomplete();
298
+ this.autocompleteProvider = provider;
299
+ }
300
+ /**
301
+ * Add a prompt to history for up/down arrow navigation.
302
+ * Called after successful submission.
303
+ */
304
+ addToHistory(text) {
305
+ const trimmed = text.trim();
306
+ if (!trimmed)
307
+ return;
308
+ // Don't add consecutive duplicates
309
+ if (this.history.length > 0 && this.history[0] === trimmed)
310
+ return;
311
+ this.history.unshift(trimmed);
312
+ // Limit history size
313
+ if (this.history.length > 100) {
314
+ this.history.pop();
315
+ }
316
+ }
317
+ isEditorEmpty() {
318
+ return this.state.lines.length === 1 && this.state.lines[0] === "";
319
+ }
320
+ isOnFirstVisualLine() {
321
+ const visualLines = this.buildVisualLineMap(this.lastWidth);
322
+ const currentVisualLine = this.findCurrentVisualLine(visualLines);
323
+ return currentVisualLine === 0;
324
+ }
325
+ isOnLastVisualLine() {
326
+ const visualLines = this.buildVisualLineMap(this.lastWidth);
327
+ const currentVisualLine = this.findCurrentVisualLine(visualLines);
328
+ return currentVisualLine === visualLines.length - 1;
329
+ }
330
+ navigateHistory(direction) {
331
+ this.lastAction = null;
332
+ if (this.history.length === 0)
333
+ return;
334
+ const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases
335
+ if (newIndex < -1 || newIndex >= this.history.length)
336
+ return;
337
+ // Capture state when first entering history browsing mode
338
+ if (this.historyIndex === -1 && newIndex >= 0) {
339
+ this.pushUndoSnapshot();
340
+ }
341
+ this.historyIndex = newIndex;
342
+ if (this.historyIndex === -1) {
343
+ // Returned to "current" state - clear editor
344
+ this.setTextInternal("");
345
+ }
346
+ else {
347
+ this.setTextInternal(this.history[this.historyIndex] || "");
348
+ }
349
+ }
350
+ /** Internal setText that doesn't reset history state - used by navigateHistory */
351
+ setTextInternal(text) {
352
+ const lines = text.split("\n");
353
+ this.state.lines = lines.length === 0 ? [""] : lines;
354
+ this.state.cursorLine = this.state.lines.length - 1;
355
+ this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0);
356
+ // Reset scroll - render() will adjust to show cursor
357
+ this.scrollOffset = 0;
358
+ if (this.onChange) {
359
+ this.onChange(this.getText());
360
+ }
361
+ }
362
+ invalidate() {
363
+ // No cached state to invalidate currently
364
+ }
365
+ getRenderGeometry(width) {
366
+ const maxPadding = Math.max(0, Math.floor((width - 1) / 2));
367
+ const paddingX = Math.min(this.paddingX, maxPadding);
368
+ const contentWidth = Math.max(1, width - paddingX * 2);
369
+ const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1));
370
+ const leftPadding = " ".repeat(paddingX);
371
+ return {
372
+ width,
373
+ paddingX,
374
+ contentWidth,
375
+ layoutWidth,
376
+ horizontal: this.borderColor("─"),
377
+ leftPadding,
378
+ rightPadding: leftPadding,
379
+ };
380
+ }
381
+ getVisibleLayoutLines(layoutLines) {
382
+ const maxVisibleLines = Math.max(5, Math.floor(this.tui.terminal.rows * 0.3));
383
+ const foundCursorLineIndex = layoutLines.findIndex((line) => line.hasCursor);
384
+ const cursorLineIndex = foundCursorLineIndex === -1 ? 0 : foundCursorLineIndex;
385
+ if (cursorLineIndex < this.scrollOffset) {
386
+ this.scrollOffset = cursorLineIndex;
387
+ }
388
+ else if (cursorLineIndex >= this.scrollOffset + maxVisibleLines) {
389
+ this.scrollOffset = cursorLineIndex - maxVisibleLines + 1;
390
+ }
391
+ const maxScrollOffset = Math.max(0, layoutLines.length - maxVisibleLines);
392
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScrollOffset));
393
+ return layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);
394
+ }
395
+ renderLayoutLineCursor(layoutLine, geometry, emitCursorMarker) {
396
+ const lineWidth = visibleWidth(layoutLine.text);
397
+ if (!layoutLine.hasCursor || layoutLine.cursorPos === undefined) {
398
+ return { displayText: layoutLine.text, visibleWidth: lineWidth, cursorInPadding: false };
399
+ }
400
+ const before = layoutLine.text.slice(0, layoutLine.cursorPos);
401
+ const after = layoutLine.text.slice(layoutLine.cursorPos);
402
+ const marker = emitCursorMarker ? CURSOR_MARKER : "";
403
+ if (after.length > 0) {
404
+ const firstGrapheme = [...this.segment(after, "grapheme")][0]?.segment || "";
405
+ const restAfter = after.slice(firstGrapheme.length);
406
+ return {
407
+ displayText: `${before}${marker}\x1b[7m${firstGrapheme}\x1b[0m${restAfter}`,
408
+ visibleWidth: lineWidth,
409
+ cursorInPadding: false,
410
+ };
411
+ }
412
+ const widthWithCursor = lineWidth + 1;
413
+ return {
414
+ displayText: `${before}${marker}\x1b[7m \x1b[0m`,
415
+ visibleWidth: widthWithCursor,
416
+ cursorInPadding: widthWithCursor > geometry.contentWidth && geometry.paddingX > 0,
417
+ };
418
+ }
419
+ renderVisibleLayoutLine(layoutLine, geometry, emitCursorMarker) {
420
+ const rendered = this.renderLayoutLineCursor(layoutLine, geometry, emitCursorMarker);
421
+ const padding = " ".repeat(Math.max(0, geometry.contentWidth - rendered.visibleWidth));
422
+ const rightPadding = rendered.cursorInPadding ? geometry.rightPadding.slice(1) : geometry.rightPadding;
423
+ return `${geometry.leftPadding}${rendered.displayText}${padding}${rightPadding}`;
424
+ }
425
+ renderTopEditorBorder(geometry) {
426
+ if (this.scrollOffset === 0)
427
+ return geometry.horizontal.repeat(geometry.width);
428
+ return this.borderColor(createScrollBorder("↑", this.scrollOffset, geometry.width));
429
+ }
430
+ renderBottomEditorBorder(layoutLineCount, visibleLineCount, geometry) {
431
+ const linesBelow = layoutLineCount - (this.scrollOffset + visibleLineCount);
432
+ if (linesBelow <= 0)
433
+ return geometry.horizontal.repeat(geometry.width);
434
+ return this.borderColor(createScrollBorder("↓", linesBelow, geometry.width));
435
+ }
436
+ appendAutocompleteLines(result, geometry) {
437
+ if (!this.autocompleteState || !this.autocompleteList)
438
+ return;
439
+ for (const line of this.autocompleteList.render(geometry.contentWidth)) {
440
+ const lineWidth = visibleWidth(line);
441
+ const linePadding = " ".repeat(Math.max(0, geometry.contentWidth - lineWidth));
442
+ result.push(`${geometry.leftPadding}${line}${linePadding}${geometry.rightPadding}`);
443
+ }
444
+ }
445
+ render(width) {
446
+ const geometry = this.getRenderGeometry(width);
447
+ this.lastWidth = geometry.layoutWidth;
448
+ const layoutLines = this.layoutText(geometry.layoutWidth);
449
+ const visibleLines = this.getVisibleLayoutLines(layoutLines);
450
+ const result = [this.renderTopEditorBorder(geometry)];
451
+ const emitCursorMarker = this.focused && !this.autocompleteState;
452
+ for (const layoutLine of visibleLines) {
453
+ result.push(this.renderVisibleLayoutLine(layoutLine, geometry, emitCursorMarker));
454
+ }
455
+ result.push(this.renderBottomEditorBorder(layoutLines.length, visibleLines.length, geometry));
456
+ this.appendAutocompleteLines(result, geometry);
457
+ return result;
458
+ }
459
+ handleJumpModeInput(data, keybindings) {
460
+ if (this.jumpMode === null)
461
+ return false;
462
+ if (keybindings.matches(data, "tui.editor.jumpForward") || keybindings.matches(data, "tui.editor.jumpBackward")) {
463
+ this.jumpMode = null;
464
+ return true;
465
+ }
466
+ const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined);
467
+ if (printable !== undefined) {
468
+ const direction = this.jumpMode;
469
+ this.jumpMode = null;
470
+ this.jumpToChar(printable, direction);
471
+ return true;
472
+ }
473
+ this.jumpMode = null;
474
+ return false;
475
+ }
476
+ handleBracketedPasteInput(data) {
477
+ let remainingData = data;
478
+ if (remainingData.includes("\x1b[200~")) {
479
+ this.isInPaste = true;
480
+ this.pasteBuffer = "";
481
+ remainingData = remainingData.replace("\x1b[200~", "");
482
+ }
483
+ if (!this.isInPaste)
484
+ return { data: remainingData, consumed: false };
485
+ this.pasteBuffer += remainingData;
486
+ const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
487
+ if (endIndex === -1)
488
+ return { data: remainingData, consumed: true };
489
+ const pasteContent = this.pasteBuffer.substring(0, endIndex);
490
+ if (pasteContent.length > 0)
491
+ this.handlePaste(pasteContent);
492
+ this.isInPaste = false;
493
+ const trailingInput = this.pasteBuffer.substring(endIndex + 6);
494
+ this.pasteBuffer = "";
495
+ if (trailingInput.length > 0)
496
+ this.handleInput(trailingInput);
497
+ return { data: remainingData, consumed: true };
498
+ }
499
+ applySelectedAutocomplete() {
500
+ const selected = this.autocompleteList?.getSelectedItem();
501
+ if (!selected || !this.autocompleteProvider)
502
+ return false;
503
+ this.pushUndoSnapshot();
504
+ this.lastAction = null;
505
+ const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
506
+ this.state.lines = result.lines;
507
+ this.state.cursorLine = result.cursorLine;
508
+ this.setCursorCol(result.cursorCol);
509
+ return true;
510
+ }
511
+ handleAutocompleteInput(data, keybindings) {
512
+ if (!this.autocompleteState || !this.autocompleteList)
513
+ return false;
514
+ if (keybindings.matches(data, "tui.select.cancel")) {
515
+ this.cancelAutocomplete();
516
+ return true;
517
+ }
518
+ if (keybindings.matches(data, "tui.select.up") || keybindings.matches(data, "tui.select.down")) {
519
+ this.autocompleteList.handleInput(data);
520
+ return true;
521
+ }
522
+ if (keybindings.matches(data, "tui.input.tab")) {
523
+ if (this.applySelectedAutocomplete()) {
524
+ this.cancelAutocomplete();
525
+ this.onChange?.(this.getText());
526
+ }
527
+ return true;
528
+ }
529
+ if (!keybindings.matches(data, "tui.select.confirm") || !this.applySelectedAutocomplete())
530
+ return false;
531
+ const submitCompletion = this.autocompletePrefix.startsWith("/");
532
+ this.cancelAutocomplete();
533
+ if (submitCompletion)
534
+ return false;
535
+ this.onChange?.(this.getText());
536
+ return true;
537
+ }
538
+ handleGlobalEditorInput(data, keybindings) {
539
+ if (keybindings.matches(data, "tui.input.copy"))
540
+ return true;
541
+ if (!keybindings.matches(data, "tui.editor.undo"))
542
+ return false;
543
+ this.undo();
544
+ return true;
545
+ }
546
+ handleTabInput(data, keybindings) {
547
+ if (!keybindings.matches(data, "tui.input.tab") || this.autocompleteState)
548
+ return false;
549
+ this.handleTabCompletion();
550
+ return true;
551
+ }
552
+ handleDeletionInput(data, keybindings) {
553
+ if (keybindings.matches(data, "tui.editor.deleteToLineEnd"))
554
+ this.deleteToEndOfLine();
555
+ else if (keybindings.matches(data, "tui.editor.deleteToLineStart"))
556
+ this.deleteToStartOfLine();
557
+ else if (keybindings.matches(data, "tui.editor.deleteWordBackward"))
558
+ this.deleteWordBackwards();
559
+ else if (keybindings.matches(data, "tui.editor.deleteWordForward"))
560
+ this.deleteWordForward();
561
+ else if (keybindings.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace"))
562
+ this.handleBackspace();
563
+ else if (keybindings.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete"))
564
+ this.handleForwardDelete();
565
+ else
566
+ return false;
567
+ return true;
568
+ }
569
+ handleKillRingInput(data, keybindings) {
570
+ if (keybindings.matches(data, "tui.editor.yank"))
571
+ this.yank();
572
+ else if (keybindings.matches(data, "tui.editor.yankPop"))
573
+ this.yankPop();
574
+ else
575
+ return false;
576
+ return true;
577
+ }
578
+ handleHistoryInput(data, keybindings) {
579
+ let direction;
580
+ if (keybindings.matches(data, "tui.editor.historyPrevious"))
581
+ direction = -1;
582
+ else if (keybindings.matches(data, "tui.editor.historyNext"))
583
+ direction = 1;
584
+ else
585
+ return false;
586
+ this.cancelAutocomplete();
587
+ this.navigateHistory(direction);
588
+ return true;
589
+ }
590
+ handleLineAndWordMovement(data, keybindings) {
591
+ if (keybindings.matches(data, "tui.editor.cursorLineStart"))
592
+ this.moveToLineStart();
593
+ else if (keybindings.matches(data, "tui.editor.cursorLineEnd"))
594
+ this.moveToLineEnd();
595
+ else if (keybindings.matches(data, "tui.editor.cursorWordLeft"))
596
+ this.moveWordBackwards();
597
+ else if (keybindings.matches(data, "tui.editor.cursorWordRight"))
598
+ this.moveWordForwards();
599
+ else
600
+ return false;
601
+ return true;
602
+ }
603
+ isLineBreakInput(data, keybindings) {
604
+ return (keybindings.matches(data, "tui.input.newLine") ||
605
+ (data.charCodeAt(0) === 10 && data.length > 1) ||
606
+ data === "\x1b\r" ||
607
+ data === "\x1b[13;2~" ||
608
+ (data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
609
+ data === "\n");
610
+ }
611
+ handleLineBreakInput(data, keybindings) {
612
+ if (!this.isLineBreakInput(data, keybindings))
613
+ return false;
614
+ if (this.shouldSubmitOnBackslashEnter(data, keybindings)) {
615
+ this.handleBackspace();
616
+ this.submitValue();
617
+ }
618
+ else {
619
+ this.addNewLine();
620
+ }
621
+ return true;
622
+ }
623
+ handleSubmitInput(data, keybindings) {
624
+ if (!keybindings.matches(data, "tui.input.submit"))
625
+ return false;
626
+ if (this.disableSubmit)
627
+ return true;
628
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
629
+ if (this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\") {
630
+ this.handleBackspace();
631
+ this.addNewLine();
632
+ }
633
+ else {
634
+ this.submitValue();
635
+ }
636
+ return true;
637
+ }
638
+ handleVerticalNavigation(data, keybindings) {
639
+ if (keybindings.matches(data, "tui.editor.cursorUp")) {
640
+ if (this.isEditorEmpty() || (this.historyIndex > -1 && this.isOnFirstVisualLine()))
641
+ this.navigateHistory(-1);
642
+ else if (this.isOnFirstVisualLine())
643
+ this.moveToLineStart();
644
+ else
645
+ this.moveCursor(-1, 0);
646
+ return true;
647
+ }
648
+ if (!keybindings.matches(data, "tui.editor.cursorDown"))
649
+ return false;
650
+ if (this.historyIndex > -1 && this.isOnLastVisualLine())
651
+ this.navigateHistory(1);
652
+ else if (this.isOnLastVisualLine())
653
+ this.moveToLineEnd();
654
+ else
655
+ this.moveCursor(1, 0);
656
+ return true;
657
+ }
658
+ handleHorizontalNavigation(data, keybindings) {
659
+ if (keybindings.matches(data, "tui.editor.cursorRight"))
660
+ this.moveCursor(0, 1);
661
+ else if (keybindings.matches(data, "tui.editor.cursorLeft"))
662
+ this.moveCursor(0, -1);
663
+ else
664
+ return false;
665
+ return true;
666
+ }
667
+ handlePageAndJumpInput(data, keybindings) {
668
+ if (keybindings.matches(data, "tui.editor.pageUp"))
669
+ this.pageScroll(-1);
670
+ else if (keybindings.matches(data, "tui.editor.pageDown"))
671
+ this.pageScroll(1);
672
+ else if (keybindings.matches(data, "tui.editor.jumpForward"))
673
+ this.jumpMode = "forward";
674
+ else if (keybindings.matches(data, "tui.editor.jumpBackward"))
675
+ this.jumpMode = "backward";
676
+ else
677
+ return false;
678
+ return true;
679
+ }
680
+ handlePrintableInput(data) {
681
+ if (matchesKey(data, "shift+space")) {
682
+ this.insertCharacter(" ");
683
+ return;
684
+ }
685
+ const printable = decodePrintableKey(data);
686
+ if (printable !== undefined)
687
+ this.insertCharacter(printable);
688
+ else if (data.charCodeAt(0) >= 32)
689
+ this.insertCharacter(data);
690
+ }
691
+ handleInput(data) {
692
+ const keybindings = getKeybindings();
693
+ if (this.handleJumpModeInput(data, keybindings))
694
+ return;
695
+ const paste = this.handleBracketedPasteInput(data);
696
+ if (paste.consumed)
697
+ return;
698
+ data = paste.data;
699
+ if (this.handleGlobalEditorInput(data, keybindings))
700
+ return;
701
+ if (this.handleAutocompleteInput(data, keybindings))
702
+ return;
703
+ if (this.handleTabInput(data, keybindings))
704
+ return;
705
+ if (this.handleDeletionInput(data, keybindings))
706
+ return;
707
+ if (this.handleKillRingInput(data, keybindings))
708
+ return;
709
+ if (this.handleHistoryInput(data, keybindings))
710
+ return;
711
+ if (this.handleLineAndWordMovement(data, keybindings))
712
+ return;
713
+ if (this.handleLineBreakInput(data, keybindings))
714
+ return;
715
+ if (this.handleSubmitInput(data, keybindings))
716
+ return;
717
+ if (this.handleVerticalNavigation(data, keybindings))
718
+ return;
719
+ if (this.handleHorizontalNavigation(data, keybindings))
720
+ return;
721
+ if (this.handlePageAndJumpInput(data, keybindings))
722
+ return;
723
+ this.handlePrintableInput(data);
724
+ }
725
+ getWrappedChunkCursorPosition(chunk, isLastChunk, isCurrentLine) {
726
+ if (!isCurrentLine)
727
+ return undefined;
728
+ const cursorPosition = this.state.cursorCol;
729
+ if (isLastChunk) {
730
+ return cursorPosition >= chunk.startIndex ? cursorPosition - chunk.startIndex : undefined;
731
+ }
732
+ if (cursorPosition < chunk.startIndex || cursorPosition >= chunk.endIndex)
733
+ return undefined;
734
+ return Math.min(cursorPosition - chunk.startIndex, chunk.text.length);
735
+ }
736
+ layoutWrappedLine(line, contentWidth, isCurrentLine) {
737
+ const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]);
738
+ const layoutLines = [];
739
+ for (let index = 0; index < chunks.length; index++) {
740
+ const chunk = chunks[index];
741
+ if (!chunk)
742
+ continue;
743
+ const cursorPos = this.getWrappedChunkCursorPosition(chunk, index === chunks.length - 1, isCurrentLine);
744
+ layoutLines.push({
745
+ text: chunk.text,
746
+ hasCursor: cursorPos !== undefined,
747
+ ...(cursorPos === undefined ? {} : { cursorPos }),
748
+ });
749
+ }
750
+ return layoutLines;
751
+ }
752
+ layoutLogicalLine(line, contentWidth, isCurrentLine) {
753
+ if (visibleWidth(line) > contentWidth)
754
+ return this.layoutWrappedLine(line, contentWidth, isCurrentLine);
755
+ return [
756
+ {
757
+ text: line,
758
+ hasCursor: isCurrentLine,
759
+ ...(isCurrentLine ? { cursorPos: this.state.cursorCol } : {}),
760
+ },
761
+ ];
762
+ }
763
+ layoutText(contentWidth) {
764
+ if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {
765
+ return [{ text: "", hasCursor: true, cursorPos: 0 }];
766
+ }
767
+ const layoutLines = [];
768
+ for (let index = 0; index < this.state.lines.length; index++) {
769
+ layoutLines.push(...this.layoutLogicalLine(this.state.lines[index] || "", contentWidth, index === this.state.cursorLine));
770
+ }
771
+ return layoutLines;
772
+ }
773
+ getText() {
774
+ return this.state.lines.join("\n");
775
+ }
776
+ expandPasteMarkers(text) {
777
+ let result = text;
778
+ for (const [pasteId, pasteContent] of this.pastes) {
779
+ const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
780
+ result = result.replace(markerRegex, () => pasteContent);
781
+ }
782
+ return result;
783
+ }
784
+ /**
785
+ * Get text with paste markers expanded to their actual content.
786
+ * Use this when you need the full content (e.g., for external editor).
787
+ */
788
+ getExpandedText() {
789
+ return this.expandPasteMarkers(this.state.lines.join("\n"));
790
+ }
791
+ getLines() {
792
+ return [...this.state.lines];
793
+ }
794
+ getCursor() {
795
+ return { line: this.state.cursorLine, col: this.state.cursorCol };
796
+ }
797
+ setText(text) {
798
+ this.cancelAutocomplete();
799
+ this.lastAction = null;
800
+ this.historyIndex = -1; // Exit history browsing mode
801
+ const normalized = this.normalizeText(text);
802
+ // Push undo snapshot if content differs (makes programmatic changes undoable)
803
+ if (this.getText() !== normalized) {
804
+ this.pushUndoSnapshot();
805
+ }
806
+ this.pastes.clear();
807
+ this.pasteCounter = 0;
808
+ this.setTextInternal(normalized);
809
+ }
810
+ /**
811
+ * Insert text at the current cursor position.
812
+ * Used for programmatic insertion (e.g., clipboard image markers).
813
+ * This is atomic for undo - single undo restores entire pre-insert state.
814
+ */
815
+ insertTextAtCursor(text) {
816
+ if (!text)
817
+ return;
818
+ this.cancelAutocomplete();
819
+ this.pushUndoSnapshot();
820
+ this.lastAction = null;
821
+ this.historyIndex = -1;
822
+ this.insertTextAtCursorInternal(text);
823
+ }
824
+ /**
825
+ * Normalize text for editor storage:
826
+ * - Normalize line endings (\r\n and \r -> \n)
827
+ * - Expand tabs to 4 spaces
828
+ */
829
+ normalizeText(text) {
830
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
831
+ }
832
+ /**
833
+ * Internal text insertion at cursor. Handles single and multi-line text.
834
+ * Does not push undo snapshots or trigger autocomplete - caller is responsible.
835
+ * Normalizes line endings and calls onChange once at the end.
836
+ */
837
+ insertTextAtCursorInternal(text) {
838
+ if (!text)
839
+ return;
840
+ // Normalize line endings and tabs
841
+ const normalized = this.normalizeText(text);
842
+ const insertedLines = normalized.split("\n");
843
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
844
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol);
845
+ const afterCursor = currentLine.slice(this.state.cursorCol);
846
+ if (insertedLines.length === 1) {
847
+ // Single line - insert at cursor position
848
+ this.state.lines[this.state.cursorLine] = beforeCursor + normalized + afterCursor;
849
+ this.setCursorCol(this.state.cursorCol + normalized.length);
850
+ }
851
+ else {
852
+ // Multi-line insertion
853
+ this.state.lines = [
854
+ // All lines before current line
855
+ ...this.state.lines.slice(0, this.state.cursorLine),
856
+ // The first inserted line merged with text before cursor
857
+ beforeCursor + insertedLines[0],
858
+ // All middle inserted lines
859
+ ...insertedLines.slice(1, -1),
860
+ // The last inserted line with text after cursor
861
+ insertedLines[insertedLines.length - 1] + afterCursor,
862
+ // All lines after current line
863
+ ...this.state.lines.slice(this.state.cursorLine + 1),
864
+ ];
865
+ this.state.cursorLine += insertedLines.length - 1;
866
+ this.setCursorCol((insertedLines[insertedLines.length - 1] || "").length);
867
+ }
868
+ if (this.onChange) {
869
+ this.onChange(this.getText());
870
+ }
871
+ }
872
+ refreshAutocompleteAfterCharacterInsertion(char) {
873
+ if (this.autocompleteState) {
874
+ this.updateAutocomplete();
875
+ return;
876
+ }
877
+ if (char === "/" && this.isAtStartOfMessage()) {
878
+ this.tryTriggerAutocomplete();
879
+ return;
880
+ }
881
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
882
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
883
+ if (char === "@" || char === "#") {
884
+ const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
885
+ if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
886
+ this.tryTriggerAutocomplete();
887
+ }
888
+ return;
889
+ }
890
+ if (!/[a-zA-Z0-9.\-_]/.test(char))
891
+ return;
892
+ if (this.isInSlashCommandContext(textBeforeCursor) || textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
893
+ this.tryTriggerAutocomplete();
894
+ }
895
+ }
896
+ // All the editor methods from before...
897
+ insertCharacter(char, skipUndoCoalescing) {
898
+ this.historyIndex = -1; // Exit history browsing mode
899
+ // Undo coalescing (fish-style):
900
+ // - Consecutive word chars coalesce into one undo unit
901
+ // - Space captures state before itself (so undo removes space+following word together)
902
+ // - Each space is separately undoable
903
+ // Skip coalescing when called from atomic operations (e.g., handlePaste)
904
+ if (!skipUndoCoalescing) {
905
+ if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
906
+ this.pushUndoSnapshot();
907
+ }
908
+ this.lastAction = "type-word";
909
+ }
910
+ const line = this.state.lines[this.state.cursorLine] || "";
911
+ const before = line.slice(0, this.state.cursorCol);
912
+ const after = line.slice(this.state.cursorCol);
913
+ this.state.lines[this.state.cursorLine] = before + char + after;
914
+ this.setCursorCol(this.state.cursorCol + char.length);
915
+ if (this.onChange) {
916
+ this.onChange(this.getText());
917
+ }
918
+ this.refreshAutocompleteAfterCharacterInsertion(char);
919
+ }
920
+ handlePaste(pastedText) {
921
+ this.cancelAutocomplete();
922
+ this.historyIndex = -1; // Exit history browsing mode
923
+ this.lastAction = null;
924
+ this.pushUndoSnapshot();
925
+ // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
926
+ // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
927
+ // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
928
+ // per-char filter below preserves newlines instead of stripping ESC and
929
+ // leaking the printable tail (e.g. "[106;5u") into the editor.
930
+ const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
931
+ const cp = Number(code);
932
+ if (cp >= 97 && cp <= 122)
933
+ return String.fromCharCode(cp - 96);
934
+ if (cp >= 65 && cp <= 90)
935
+ return String.fromCharCode(cp - 64);
936
+ return match;
937
+ });
938
+ // Clean the pasted text: normalize line endings, expand tabs
939
+ const cleanText = this.normalizeText(decodedText);
940
+ // Filter out non-printable characters except newlines
941
+ let filteredText = cleanText
942
+ .split("")
943
+ .filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
944
+ .join("");
945
+ // If pasting a file path (starts with /, ~, or .) and the character before
946
+ // the cursor is a word character, prepend a space for better readability
947
+ if (/^[/~.]/.test(filteredText)) {
948
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
949
+ const charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : "";
950
+ if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
951
+ filteredText = ` ${filteredText}`;
952
+ }
953
+ }
954
+ // Split into lines to check for large paste
955
+ const pastedLines = filteredText.split("\n");
956
+ // Check if this is a large paste (> 10 lines or > 1000 characters)
957
+ const totalChars = filteredText.length;
958
+ if (pastedLines.length > 10 || totalChars > 1000) {
959
+ // Store the paste and insert a marker
960
+ this.pasteCounter++;
961
+ const pasteId = this.pasteCounter;
962
+ this.pastes.set(pasteId, filteredText);
963
+ // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]"
964
+ const marker = pastedLines.length > 10
965
+ ? `[paste #${pasteId} +${pastedLines.length} lines]`
966
+ : `[paste #${pasteId} ${totalChars} chars]`;
967
+ this.insertTextAtCursorInternal(marker);
968
+ return;
969
+ }
970
+ if (pastedLines.length === 1) {
971
+ // Single line - insert atomically (do not trigger autocomplete during paste)
972
+ this.insertTextAtCursorInternal(filteredText);
973
+ return;
974
+ }
975
+ // Multi-line paste - use direct state manipulation
976
+ this.insertTextAtCursorInternal(filteredText);
977
+ }
978
+ addNewLine() {
979
+ this.cancelAutocomplete();
980
+ this.historyIndex = -1; // Exit history browsing mode
981
+ this.lastAction = null;
982
+ this.pushUndoSnapshot();
983
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
984
+ const before = currentLine.slice(0, this.state.cursorCol);
985
+ const after = currentLine.slice(this.state.cursorCol);
986
+ // Split current line
987
+ this.state.lines[this.state.cursorLine] = before;
988
+ this.state.lines.splice(this.state.cursorLine + 1, 0, after);
989
+ // Move cursor to start of new line
990
+ this.state.cursorLine++;
991
+ this.setCursorCol(0);
992
+ if (this.onChange) {
993
+ this.onChange(this.getText());
994
+ }
995
+ }
996
+ shouldSubmitOnBackslashEnter(data, kb) {
997
+ if (this.disableSubmit)
998
+ return false;
999
+ if (!matchesKey(data, "enter"))
1000
+ return false;
1001
+ const submitKeys = kb.getKeys("tui.input.submit");
1002
+ const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
1003
+ if (!hasShiftEnter)
1004
+ return false;
1005
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1006
+ return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\";
1007
+ }
1008
+ submitValue() {
1009
+ this.cancelAutocomplete();
1010
+ const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim();
1011
+ this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
1012
+ this.pastes.clear();
1013
+ this.pasteCounter = 0;
1014
+ this.historyIndex = -1;
1015
+ this.scrollOffset = 0;
1016
+ this.undoStack.clear();
1017
+ this.lastAction = null;
1018
+ if (this.onChange)
1019
+ this.onChange("");
1020
+ if (this.onSubmit)
1021
+ this.onSubmit(result);
1022
+ }
1023
+ deleteGraphemeBeforeCursor() {
1024
+ const line = this.state.lines[this.state.cursorLine] || "";
1025
+ const beforeCursor = line.slice(0, this.state.cursorCol);
1026
+ const graphemes = [...this.segment(beforeCursor, "grapheme")];
1027
+ const lastGrapheme = graphemes[graphemes.length - 1];
1028
+ const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
1029
+ const pastedSegment = lastGrapheme ? PASTE_MARKER_SINGLE.exec(lastGrapheme.segment) : null;
1030
+ if (!pastedSegment) {
1031
+ const before = line.slice(0, this.state.cursorCol - graphemeLength);
1032
+ const after = line.slice(this.state.cursorCol);
1033
+ this.state.lines[this.state.cursorLine] = before + after;
1034
+ this.setCursorCol(this.state.cursorCol - graphemeLength);
1035
+ return;
1036
+ }
1037
+ const targetId = Number(pastedSegment[1]);
1038
+ const markerStart = this.state.cursorCol - graphemeLength;
1039
+ const beforeMarker = line.slice(0, markerStart);
1040
+ const afterMarker = line.slice(this.state.cursorCol);
1041
+ this.pastes.delete(targetId);
1042
+ this.pasteCounter--;
1043
+ // Shift registry entries down in ascending id order, independent
1044
+ // of marker order in the text ([paste #3] becomes [paste #2] when
1045
+ // [paste #1] is removed).
1046
+ const higherIds = [...this.pastes.keys()].filter((id) => id > targetId).sort((a, b) => a - b);
1047
+ for (const id of higherIds) {
1048
+ this.pastes.set(id - 1, this.pastes.get(id));
1049
+ this.pastes.delete(id);
1050
+ }
1051
+ const renumberMarkers = (text) => text.replace(PASTE_MARKER_REGEX, (fullMatch, idGroup, suffixGroup) => {
1052
+ const id = Number(idGroup);
1053
+ if (id <= targetId)
1054
+ return fullMatch;
1055
+ return `[paste #${id - 1}${suffixGroup}]`;
1056
+ });
1057
+ // Delete the target before renumbering. Renumber the text on each side
1058
+ // separately so an earlier #10 -> #9 change cannot stale cursorCol.
1059
+ const before = renumberMarkers(beforeMarker);
1060
+ const after = renumberMarkers(afterMarker);
1061
+ this.state.lines = this.state.lines.map((line, index) => index === this.state.cursorLine ? before + after : renumberMarkers(line));
1062
+ this.setCursorCol(before.length);
1063
+ }
1064
+ handleBackspace() {
1065
+ this.historyIndex = -1; // Exit history browsing mode
1066
+ this.lastAction = null;
1067
+ if (this.state.cursorCol > 0) {
1068
+ this.pushUndoSnapshot();
1069
+ this.deleteGraphemeBeforeCursor();
1070
+ }
1071
+ else if (this.state.cursorLine > 0) {
1072
+ this.pushUndoSnapshot();
1073
+ // Merge with previous line
1074
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1075
+ const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
1076
+ this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
1077
+ this.state.lines.splice(this.state.cursorLine, 1);
1078
+ this.state.cursorLine--;
1079
+ this.setCursorCol(previousLine.length);
1080
+ }
1081
+ if (this.onChange) {
1082
+ this.onChange(this.getText());
1083
+ }
1084
+ // Update or re-trigger autocomplete after backspace
1085
+ if (this.autocompleteState) {
1086
+ this.updateAutocomplete();
1087
+ }
1088
+ else {
1089
+ // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
1090
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1091
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1092
+ // Slash command context
1093
+ if (this.isInSlashCommandContext(textBeforeCursor)) {
1094
+ this.tryTriggerAutocomplete();
1095
+ }
1096
+ // Symbol-based completion context like @ or #
1097
+ else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
1098
+ this.tryTriggerAutocomplete();
1099
+ }
1100
+ }
1101
+ }
1102
+ /**
1103
+ * Set cursor column and clear preferredVisualCol.
1104
+ * Use this for all non-vertical cursor movements to reset sticky column behavior.
1105
+ */
1106
+ setCursorCol(col) {
1107
+ this.state.cursorCol = col;
1108
+ this.preferredVisualCol = null;
1109
+ this.snappedFromCursorCol = null;
1110
+ }
1111
+ /**
1112
+ * Move cursor to a target visual line, applying sticky column logic.
1113
+ * Shared by moveCursor() and pageScroll().
1114
+ */
1115
+ resolveCurrentVisualColumn(visualLines, currentVL) {
1116
+ if (this.snappedFromCursorCol === null)
1117
+ return this.state.cursorCol - currentVL.startCol;
1118
+ const visualLineIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
1119
+ return this.snappedFromCursorCol - visualLines[visualLineIndex].startCol;
1120
+ }
1121
+ getVisualSegmentMaxColumn(visualLines, visualLineIndex, visualLine) {
1122
+ const isLastSegment = visualLineIndex === visualLines.length - 1 ||
1123
+ visualLines[visualLineIndex + 1]?.logicalLine !== visualLine.logicalLine;
1124
+ return isLastSegment ? visualLine.length : Math.max(0, visualLine.length - 1);
1125
+ }
1126
+ findAtomicSegmentAtCursor(logicalLine) {
1127
+ for (const segment of this.segment(logicalLine, "grapheme")) {
1128
+ if (segment.index > this.state.cursorCol)
1129
+ return undefined;
1130
+ if (segment.segment.length > 1 && this.state.cursorCol < segment.index + segment.segment.length)
1131
+ return segment;
1132
+ }
1133
+ return undefined;
1134
+ }
1135
+ findVisualLineAfterSegment(visualLines, targetVisualLine, segmentEnd) {
1136
+ const target = visualLines[targetVisualLine];
1137
+ if (!target)
1138
+ return undefined;
1139
+ let next = targetVisualLine + 1;
1140
+ while (next < visualLines.length &&
1141
+ visualLines[next].logicalLine === target.logicalLine &&
1142
+ visualLines[next].startCol < segmentEnd) {
1143
+ next++;
1144
+ }
1145
+ return next < visualLines.length ? next : undefined;
1146
+ }
1147
+ snapCursorToAtomicSegment(visualLines, currentVisualLine, targetVisualLine, logicalLine) {
1148
+ const segment = this.findAtomicSegmentAtCursor(logicalLine);
1149
+ if (!segment) {
1150
+ this.snappedFromCursorCol = null;
1151
+ return;
1152
+ }
1153
+ const target = visualLines[targetVisualLine];
1154
+ const isContinuation = target ? segment.index < target.startCol : false;
1155
+ if (isContinuation && targetVisualLine > currentVisualLine) {
1156
+ const next = this.findVisualLineAfterSegment(visualLines, targetVisualLine, segment.index + segment.segment.length);
1157
+ if (next !== undefined) {
1158
+ this.moveToVisualLine(visualLines, currentVisualLine, next);
1159
+ return;
1160
+ }
1161
+ }
1162
+ this.snappedFromCursorCol = this.state.cursorCol;
1163
+ this.state.cursorCol = segment.index;
1164
+ }
1165
+ moveToVisualLine(visualLines, currentVisualLine, targetVisualLine) {
1166
+ const currentVL = visualLines[currentVisualLine];
1167
+ const targetVL = visualLines[targetVisualLine];
1168
+ if (!(currentVL && targetVL))
1169
+ return;
1170
+ const currentVisualCol = this.resolveCurrentVisualColumn(visualLines, currentVL);
1171
+ const sourceMaxVisualCol = this.getVisualSegmentMaxColumn(visualLines, currentVisualLine, currentVL);
1172
+ const targetMaxVisualCol = this.getVisualSegmentMaxColumn(visualLines, targetVisualLine, targetVL);
1173
+ const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
1174
+ this.state.cursorLine = targetVL.logicalLine;
1175
+ const targetCol = targetVL.startCol + moveToVisualCol;
1176
+ const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1177
+ this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1178
+ this.snapCursorToAtomicSegment(visualLines, currentVisualLine, targetVisualLine, logicalLine);
1179
+ }
1180
+ /**
1181
+ * Compute the target visual column for vertical cursor movement.
1182
+ * Implements the sticky column decision table:
1183
+ *
1184
+ * | P | S | T | U | Scenario | Set Preferred | Move To |
1185
+ * |---|---|---|---| ---------------------------------------------------- |---------------|-------------|
1186
+ * | 0 | * | 0 | - | Start nav, target fits | null | current |
1187
+ * | 0 | * | 1 | - | Start nav, target shorter | current | target end |
1188
+ * | 1 | 0 | 0 | 0 | Clamped, target fits preferred | null | preferred |
1189
+ * | 1 | 0 | 0 | 1 | Clamped, target longer but still can't fit preferred | keep | target end |
1190
+ * | 1 | 0 | 1 | - | Clamped, target even shorter | keep | target end |
1191
+ * | 1 | 1 | 0 | - | Rewrapped, target fits current | null | current |
1192
+ * | 1 | 1 | 1 | - | Rewrapped, target shorter than current | current | target end |
1193
+ *
1194
+ * Where:
1195
+ * - P = preferred col is set
1196
+ * - S = cursor in middle of source line (not clamped to end)
1197
+ * - T = target line shorter than current visual col
1198
+ * - U = target line shorter than preferred col
1199
+ */
1200
+ computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol) {
1201
+ const hasPreferred = this.preferredVisualCol !== null; // P
1202
+ const cursorInMiddle = currentVisualCol < sourceMaxVisualCol; // S
1203
+ const targetTooShort = targetMaxVisualCol < currentVisualCol; // T
1204
+ if (!hasPreferred || cursorInMiddle) {
1205
+ if (targetTooShort) {
1206
+ // Cases 2 and 7
1207
+ this.preferredVisualCol = currentVisualCol;
1208
+ return targetMaxVisualCol;
1209
+ }
1210
+ // Cases 1 and 6
1211
+ this.preferredVisualCol = null;
1212
+ return currentVisualCol;
1213
+ }
1214
+ const targetCantFitPreferred = targetMaxVisualCol < this.preferredVisualCol; // U
1215
+ if (targetTooShort || targetCantFitPreferred) {
1216
+ // Cases 4 and 5
1217
+ return targetMaxVisualCol;
1218
+ }
1219
+ // Case 3
1220
+ const result = this.preferredVisualCol;
1221
+ this.preferredVisualCol = null;
1222
+ return result;
1223
+ }
1224
+ moveToLineStart() {
1225
+ this.lastAction = null;
1226
+ this.setCursorCol(0);
1227
+ }
1228
+ moveToLineEnd() {
1229
+ this.lastAction = null;
1230
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1231
+ this.setCursorCol(currentLine.length);
1232
+ }
1233
+ deleteToStartOfLine() {
1234
+ this.historyIndex = -1; // Exit history browsing mode
1235
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1236
+ if (this.state.cursorCol > 0) {
1237
+ this.pushUndoSnapshot();
1238
+ // Calculate text to be deleted and save to kill ring (backward deletion = prepend)
1239
+ const deletedText = currentLine.slice(0, this.state.cursorCol);
1240
+ this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" });
1241
+ this.lastAction = "kill";
1242
+ // Delete from start of line up to cursor
1243
+ this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol);
1244
+ this.setCursorCol(0);
1245
+ }
1246
+ else if (this.state.cursorLine > 0) {
1247
+ this.pushUndoSnapshot();
1248
+ // At start of line - merge with previous line, treating newline as deleted text
1249
+ this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" });
1250
+ this.lastAction = "kill";
1251
+ const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
1252
+ this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
1253
+ this.state.lines.splice(this.state.cursorLine, 1);
1254
+ this.state.cursorLine--;
1255
+ this.setCursorCol(previousLine.length);
1256
+ }
1257
+ if (this.onChange) {
1258
+ this.onChange(this.getText());
1259
+ }
1260
+ }
1261
+ deleteToEndOfLine() {
1262
+ this.historyIndex = -1; // Exit history browsing mode
1263
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1264
+ if (this.state.cursorCol < currentLine.length) {
1265
+ this.pushUndoSnapshot();
1266
+ // Calculate text to be deleted and save to kill ring (forward deletion = append)
1267
+ const deletedText = currentLine.slice(this.state.cursorCol);
1268
+ this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" });
1269
+ this.lastAction = "kill";
1270
+ // Delete from cursor to end of line
1271
+ this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol);
1272
+ }
1273
+ else if (this.state.cursorLine < this.state.lines.length - 1) {
1274
+ this.pushUndoSnapshot();
1275
+ // At end of line - merge with next line, treating newline as deleted text
1276
+ this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" });
1277
+ this.lastAction = "kill";
1278
+ const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
1279
+ this.state.lines[this.state.cursorLine] = currentLine + nextLine;
1280
+ this.state.lines.splice(this.state.cursorLine + 1, 1);
1281
+ }
1282
+ if (this.onChange) {
1283
+ this.onChange(this.getText());
1284
+ }
1285
+ }
1286
+ deleteWordBackwards() {
1287
+ this.historyIndex = -1; // Exit history browsing mode
1288
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1289
+ // If at start of line, behave like backspace at column 0 (merge with previous line)
1290
+ if (this.state.cursorCol === 0) {
1291
+ if (this.state.cursorLine > 0) {
1292
+ this.pushUndoSnapshot();
1293
+ // Treat newline as deleted text (backward deletion = prepend)
1294
+ this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" });
1295
+ this.lastAction = "kill";
1296
+ const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
1297
+ this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
1298
+ this.state.lines.splice(this.state.cursorLine, 1);
1299
+ this.state.cursorLine--;
1300
+ this.setCursorCol(previousLine.length);
1301
+ }
1302
+ }
1303
+ else {
1304
+ this.pushUndoSnapshot();
1305
+ // Save lastAction before cursor movement (moveWordBackwards resets it)
1306
+ const wasKill = this.lastAction === "kill";
1307
+ const oldCursorCol = this.state.cursorCol;
1308
+ this.moveWordBackwards();
1309
+ const deleteFrom = this.state.cursorCol;
1310
+ this.setCursorCol(oldCursorCol);
1311
+ const deletedText = currentLine.slice(deleteFrom, this.state.cursorCol);
1312
+ this.killRing.push(deletedText, { prepend: true, accumulate: wasKill });
1313
+ this.lastAction = "kill";
1314
+ this.state.lines[this.state.cursorLine] =
1315
+ currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol);
1316
+ this.setCursorCol(deleteFrom);
1317
+ }
1318
+ if (this.onChange) {
1319
+ this.onChange(this.getText());
1320
+ }
1321
+ }
1322
+ deleteWordForward() {
1323
+ this.historyIndex = -1; // Exit history browsing mode
1324
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1325
+ // If at end of line, merge with next line (delete the newline)
1326
+ if (this.state.cursorCol >= currentLine.length) {
1327
+ if (this.state.cursorLine < this.state.lines.length - 1) {
1328
+ this.pushUndoSnapshot();
1329
+ // Treat newline as deleted text (forward deletion = append)
1330
+ this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" });
1331
+ this.lastAction = "kill";
1332
+ const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
1333
+ this.state.lines[this.state.cursorLine] = currentLine + nextLine;
1334
+ this.state.lines.splice(this.state.cursorLine + 1, 1);
1335
+ }
1336
+ }
1337
+ else {
1338
+ this.pushUndoSnapshot();
1339
+ // Save lastAction before cursor movement (moveWordForwards resets it)
1340
+ const wasKill = this.lastAction === "kill";
1341
+ const oldCursorCol = this.state.cursorCol;
1342
+ this.moveWordForwards();
1343
+ const deleteTo = this.state.cursorCol;
1344
+ this.setCursorCol(oldCursorCol);
1345
+ const deletedText = currentLine.slice(this.state.cursorCol, deleteTo);
1346
+ this.killRing.push(deletedText, { prepend: false, accumulate: wasKill });
1347
+ this.lastAction = "kill";
1348
+ this.state.lines[this.state.cursorLine] =
1349
+ currentLine.slice(0, this.state.cursorCol) + currentLine.slice(deleteTo);
1350
+ }
1351
+ if (this.onChange) {
1352
+ this.onChange(this.getText());
1353
+ }
1354
+ }
1355
+ handleForwardDelete() {
1356
+ this.historyIndex = -1; // Exit history browsing mode
1357
+ this.lastAction = null;
1358
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1359
+ if (this.state.cursorCol < currentLine.length) {
1360
+ this.pushUndoSnapshot();
1361
+ // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
1362
+ const afterCursor = currentLine.slice(this.state.cursorCol);
1363
+ // Find the first grapheme at cursor
1364
+ const graphemes = [...this.segment(afterCursor, "grapheme")];
1365
+ const firstGrapheme = graphemes[0];
1366
+ const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
1367
+ const before = currentLine.slice(0, this.state.cursorCol);
1368
+ const after = currentLine.slice(this.state.cursorCol + graphemeLength);
1369
+ this.state.lines[this.state.cursorLine] = before + after;
1370
+ }
1371
+ else if (this.state.cursorLine < this.state.lines.length - 1) {
1372
+ this.pushUndoSnapshot();
1373
+ // At end of line - merge with next line
1374
+ const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
1375
+ this.state.lines[this.state.cursorLine] = currentLine + nextLine;
1376
+ this.state.lines.splice(this.state.cursorLine + 1, 1);
1377
+ }
1378
+ if (this.onChange) {
1379
+ this.onChange(this.getText());
1380
+ }
1381
+ // Update or re-trigger autocomplete after forward delete
1382
+ if (this.autocompleteState) {
1383
+ this.updateAutocomplete();
1384
+ }
1385
+ else {
1386
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1387
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1388
+ // Slash command context
1389
+ if (this.isInSlashCommandContext(textBeforeCursor)) {
1390
+ this.tryTriggerAutocomplete();
1391
+ }
1392
+ // Symbol-based completion context like @ or #
1393
+ else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
1394
+ this.tryTriggerAutocomplete();
1395
+ }
1396
+ }
1397
+ }
1398
+ /**
1399
+ * Build a mapping from visual lines to logical positions.
1400
+ * Returns an array where each element represents a visual line with:
1401
+ * - logicalLine: index into this.state.lines
1402
+ * - startCol: starting column in the logical line
1403
+ * - length: length of this visual line segment
1404
+ */
1405
+ buildVisualLineMap(width) {
1406
+ const visualLines = [];
1407
+ for (let i = 0; i < this.state.lines.length; i++) {
1408
+ const line = this.state.lines[i] || "";
1409
+ const lineVisWidth = visibleWidth(line);
1410
+ if (line.length === 0) {
1411
+ // Empty line still takes one visual line
1412
+ visualLines.push({ logicalLine: i, startCol: 0, length: 0 });
1413
+ }
1414
+ else if (lineVisWidth <= width) {
1415
+ visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
1416
+ }
1417
+ else {
1418
+ // Line needs wrapping - use word-aware wrapping
1419
+ const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]);
1420
+ for (const chunk of chunks) {
1421
+ visualLines.push({
1422
+ logicalLine: i,
1423
+ startCol: chunk.startIndex,
1424
+ length: chunk.endIndex - chunk.startIndex,
1425
+ });
1426
+ }
1427
+ }
1428
+ }
1429
+ return visualLines;
1430
+ }
1431
+ /**
1432
+ * Find the visual line index that contains the given logical position.
1433
+ */
1434
+ findVisualLineAt(visualLines, line, col) {
1435
+ for (let i = 0; i < visualLines.length; i++) {
1436
+ const vl = visualLines[i];
1437
+ if (!vl || vl.logicalLine !== line)
1438
+ continue;
1439
+ const offset = col - vl.startCol;
1440
+ // Cursor is in this segment if it's within range. For the last
1441
+ // segment of a logical line, cursor can be at length (end position)
1442
+ const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
1443
+ if (offset >= 0 && (offset < vl.length || (isLastSegmentOfLine && offset === vl.length))) {
1444
+ return i;
1445
+ }
1446
+ }
1447
+ return visualLines.length - 1;
1448
+ }
1449
+ /**
1450
+ * Find the visual line index for the current cursor position.
1451
+ */
1452
+ findCurrentVisualLine(visualLines) {
1453
+ return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol);
1454
+ }
1455
+ moveCursorVertically(visualLines, currentVisualLine, deltaLine) {
1456
+ if (deltaLine === 0)
1457
+ return;
1458
+ const targetVisualLine = currentVisualLine + deltaLine;
1459
+ if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
1460
+ this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
1461
+ }
1462
+ }
1463
+ moveCursorRight(visualLines, currentVisualLine) {
1464
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1465
+ if (this.state.cursorCol < currentLine.length) {
1466
+ const afterCursor = currentLine.slice(this.state.cursorCol);
1467
+ const firstGrapheme = [...this.segment(afterCursor, "grapheme")][0];
1468
+ this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
1469
+ return;
1470
+ }
1471
+ if (this.state.cursorLine < this.state.lines.length - 1) {
1472
+ this.state.cursorLine++;
1473
+ this.setCursorCol(0);
1474
+ return;
1475
+ }
1476
+ const currentVL = visualLines[currentVisualLine];
1477
+ if (currentVL)
1478
+ this.preferredVisualCol = this.state.cursorCol - currentVL.startCol;
1479
+ }
1480
+ moveCursorLeft() {
1481
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1482
+ if (this.state.cursorCol > 0) {
1483
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1484
+ const graphemes = [...this.segment(beforeCursor, "grapheme")];
1485
+ const lastGrapheme = graphemes[graphemes.length - 1];
1486
+ this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
1487
+ return;
1488
+ }
1489
+ if (this.state.cursorLine > 0) {
1490
+ this.state.cursorLine--;
1491
+ const previousLine = this.state.lines[this.state.cursorLine] || "";
1492
+ this.setCursorCol(previousLine.length);
1493
+ }
1494
+ }
1495
+ moveCursorHorizontally(visualLines, currentVisualLine, deltaCol) {
1496
+ if (deltaCol > 0)
1497
+ this.moveCursorRight(visualLines, currentVisualLine);
1498
+ else if (deltaCol < 0)
1499
+ this.moveCursorLeft();
1500
+ }
1501
+ moveCursor(deltaLine, deltaCol) {
1502
+ this.lastAction = null;
1503
+ const visualLines = this.buildVisualLineMap(this.lastWidth);
1504
+ const currentVisualLine = this.findCurrentVisualLine(visualLines);
1505
+ this.moveCursorVertically(visualLines, currentVisualLine, deltaLine);
1506
+ this.moveCursorHorizontally(visualLines, currentVisualLine, deltaCol);
1507
+ }
1508
+ /**
1509
+ * Scroll by a page (direction: -1 for up, 1 for down).
1510
+ * Moves cursor by the page size while keeping it in bounds.
1511
+ */
1512
+ pageScroll(direction) {
1513
+ this.lastAction = null;
1514
+ const terminalRows = this.tui.terminal.rows;
1515
+ const pageSize = Math.max(5, Math.floor(terminalRows * 0.3));
1516
+ const visualLines = this.buildVisualLineMap(this.lastWidth);
1517
+ const currentVisualLine = this.findCurrentVisualLine(visualLines);
1518
+ const targetVisualLine = Math.max(0, Math.min(visualLines.length - 1, currentVisualLine + direction * pageSize));
1519
+ this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
1520
+ }
1521
+ moveWordBackwards() {
1522
+ this.lastAction = null;
1523
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1524
+ // If at start of line, move to end of previous line
1525
+ if (this.state.cursorCol === 0) {
1526
+ if (this.state.cursorLine > 0) {
1527
+ this.state.cursorLine--;
1528
+ const prevLine = this.state.lines[this.state.cursorLine] || "";
1529
+ this.setCursorCol(prevLine.length);
1530
+ }
1531
+ return;
1532
+ }
1533
+ this.setCursorCol(findWordBackward(currentLine, this.state.cursorCol, {
1534
+ segment: (text) => this.segment(text, "word"),
1535
+ isAtomicSegment: isPasteMarker,
1536
+ }));
1537
+ }
1538
+ /**
1539
+ * Yank (paste) the most recent kill ring entry at cursor position.
1540
+ */
1541
+ yank() {
1542
+ if (this.killRing.length === 0)
1543
+ return;
1544
+ this.pushUndoSnapshot();
1545
+ const text = this.killRing.peek();
1546
+ this.insertYankedText(text);
1547
+ this.lastAction = "yank";
1548
+ }
1549
+ /**
1550
+ * Cycle through kill ring (only works immediately after yank or yank-pop).
1551
+ * Replaces the last yanked text with the previous entry in the ring.
1552
+ */
1553
+ yankPop() {
1554
+ // Only works if we just yanked and have more than one entry
1555
+ if (this.lastAction !== "yank" || this.killRing.length <= 1)
1556
+ return;
1557
+ this.pushUndoSnapshot();
1558
+ // Delete the previously yanked text (still at end of ring before rotation)
1559
+ this.deleteYankedText();
1560
+ // Rotate the ring: move end to front
1561
+ this.killRing.rotate();
1562
+ // Insert the new most recent entry (now at end after rotation)
1563
+ const text = this.killRing.peek();
1564
+ this.insertYankedText(text);
1565
+ this.lastAction = "yank";
1566
+ }
1567
+ /**
1568
+ * Insert text at cursor position (used by yank operations).
1569
+ */
1570
+ insertYankedText(text) {
1571
+ this.historyIndex = -1; // Exit history browsing mode
1572
+ const lines = text.split("\n");
1573
+ if (lines.length === 1) {
1574
+ // Single line - insert at cursor
1575
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1576
+ const before = currentLine.slice(0, this.state.cursorCol);
1577
+ const after = currentLine.slice(this.state.cursorCol);
1578
+ this.state.lines[this.state.cursorLine] = before + text + after;
1579
+ this.setCursorCol(this.state.cursorCol + text.length);
1580
+ }
1581
+ else {
1582
+ // Multi-line insert
1583
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1584
+ const before = currentLine.slice(0, this.state.cursorCol);
1585
+ const after = currentLine.slice(this.state.cursorCol);
1586
+ // First line merges with text before cursor
1587
+ this.state.lines[this.state.cursorLine] = before + (lines[0] || "");
1588
+ // Insert middle lines
1589
+ for (let i = 1; i < lines.length - 1; i++) {
1590
+ this.state.lines.splice(this.state.cursorLine + i, 0, lines[i] || "");
1591
+ }
1592
+ // Last line merges with text after cursor
1593
+ const lastLineIndex = this.state.cursorLine + lines.length - 1;
1594
+ this.state.lines.splice(lastLineIndex, 0, (lines[lines.length - 1] || "") + after);
1595
+ // Update cursor position
1596
+ this.state.cursorLine = lastLineIndex;
1597
+ this.setCursorCol((lines[lines.length - 1] || "").length);
1598
+ }
1599
+ if (this.onChange) {
1600
+ this.onChange(this.getText());
1601
+ }
1602
+ }
1603
+ /**
1604
+ * Delete the previously yanked text (used by yank-pop).
1605
+ * The yanked text is derived from killRing[end] since it hasn't been rotated yet.
1606
+ */
1607
+ deleteYankedText() {
1608
+ const yankedText = this.killRing.peek();
1609
+ if (!yankedText)
1610
+ return;
1611
+ const yankLines = yankedText.split("\n");
1612
+ if (yankLines.length === 1) {
1613
+ // Single line - delete backward from cursor
1614
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1615
+ const deleteLen = yankedText.length;
1616
+ const before = currentLine.slice(0, this.state.cursorCol - deleteLen);
1617
+ const after = currentLine.slice(this.state.cursorCol);
1618
+ this.state.lines[this.state.cursorLine] = before + after;
1619
+ this.setCursorCol(this.state.cursorCol - deleteLen);
1620
+ }
1621
+ else {
1622
+ // Multi-line delete - cursor is at end of last yanked line
1623
+ const startLine = this.state.cursorLine - (yankLines.length - 1);
1624
+ const startCol = (this.state.lines[startLine] || "").length - (yankLines[0] || "").length;
1625
+ // Get text after cursor on current line
1626
+ const afterCursor = (this.state.lines[this.state.cursorLine] || "").slice(this.state.cursorCol);
1627
+ // Get text before yank start position
1628
+ const beforeYank = (this.state.lines[startLine] || "").slice(0, startCol);
1629
+ // Remove all lines from startLine to cursorLine and replace with merged line
1630
+ this.state.lines.splice(startLine, yankLines.length, beforeYank + afterCursor);
1631
+ // Update cursor
1632
+ this.state.cursorLine = startLine;
1633
+ this.setCursorCol(startCol);
1634
+ }
1635
+ if (this.onChange) {
1636
+ this.onChange(this.getText());
1637
+ }
1638
+ }
1639
+ pushUndoSnapshot() {
1640
+ this.undoStack.push({ state: this.state, pastes: this.pastes, pasteCounter: this.pasteCounter });
1641
+ }
1642
+ undo() {
1643
+ this.historyIndex = -1; // Exit history browsing mode
1644
+ const snapshot = this.undoStack.pop();
1645
+ if (!snapshot)
1646
+ return;
1647
+ Object.assign(this.state, snapshot.state);
1648
+ this.pastes = snapshot.pastes;
1649
+ this.pasteCounter = snapshot.pasteCounter;
1650
+ this.lastAction = null;
1651
+ this.preferredVisualCol = null;
1652
+ if (this.onChange) {
1653
+ this.onChange(this.getText());
1654
+ }
1655
+ }
1656
+ /**
1657
+ * Jump to the first occurrence of a character in the specified direction.
1658
+ * Multi-line search. Case-sensitive. Skips the current cursor position.
1659
+ */
1660
+ jumpToChar(char, direction) {
1661
+ this.lastAction = null;
1662
+ const isForward = direction === "forward";
1663
+ const lines = this.state.lines;
1664
+ const end = isForward ? lines.length : -1;
1665
+ const step = isForward ? 1 : -1;
1666
+ for (let lineIdx = this.state.cursorLine; lineIdx !== end; lineIdx += step) {
1667
+ const line = lines[lineIdx] || "";
1668
+ const isCurrentLine = lineIdx === this.state.cursorLine;
1669
+ // Current line: start after/before cursor; other lines: search full line
1670
+ const searchFrom = isCurrentLine
1671
+ ? isForward
1672
+ ? this.state.cursorCol + 1
1673
+ : this.state.cursorCol - 1
1674
+ : undefined;
1675
+ const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom);
1676
+ if (idx !== -1) {
1677
+ this.state.cursorLine = lineIdx;
1678
+ this.setCursorCol(idx);
1679
+ return;
1680
+ }
1681
+ }
1682
+ // No match found - cursor stays in place
1683
+ }
1684
+ moveWordForwards() {
1685
+ this.lastAction = null;
1686
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1687
+ // If at end of line, move to start of next line
1688
+ if (this.state.cursorCol >= currentLine.length) {
1689
+ if (this.state.cursorLine < this.state.lines.length - 1) {
1690
+ this.state.cursorLine++;
1691
+ this.setCursorCol(0);
1692
+ }
1693
+ return;
1694
+ }
1695
+ this.setCursorCol(findWordForward(currentLine, this.state.cursorCol, {
1696
+ segment: (text) => this.segment(text, "word"),
1697
+ isAtomicSegment: isPasteMarker,
1698
+ }));
1699
+ }
1700
+ // Slash menu only allowed on the first line of the editor
1701
+ isSlashMenuAllowed() {
1702
+ return this.state.cursorLine === 0;
1703
+ }
1704
+ // Helper method to check if cursor is at start of message (for slash command detection)
1705
+ isAtStartOfMessage() {
1706
+ if (!this.isSlashMenuAllowed())
1707
+ return false;
1708
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1709
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1710
+ return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
1711
+ }
1712
+ isInSlashCommandContext(textBeforeCursor) {
1713
+ return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/");
1714
+ }
1715
+ // Autocomplete methods
1716
+ /**
1717
+ * Find the best autocomplete item index for the given prefix.
1718
+ * Returns -1 if no match is found.
1719
+ *
1720
+ * Match priority:
1721
+ * 1. Exact match (prefix === item.value) -> always selected
1722
+ * 2. Prefix match -> first item whose value starts with prefix
1723
+ * 3. No match -> -1 (keep default highlight)
1724
+ *
1725
+ * Matching is case-sensitive and checks item.value only.
1726
+ */
1727
+ getBestAutocompleteMatchIndex(items, prefix) {
1728
+ if (!prefix)
1729
+ return -1;
1730
+ let firstPrefixIndex = -1;
1731
+ for (let i = 0; i < items.length; i++) {
1732
+ const value = items[i].value;
1733
+ if (value === prefix) {
1734
+ return i; // Exact match always wins
1735
+ }
1736
+ if (firstPrefixIndex === -1 && value.startsWith(prefix)) {
1737
+ firstPrefixIndex = i;
1738
+ }
1739
+ }
1740
+ return firstPrefixIndex;
1741
+ }
1742
+ createAutocompleteList(prefix, items) {
1743
+ const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined;
1744
+ return new SelectList(items, this.autocompleteMaxVisible, this.theme.selectList, layout);
1745
+ }
1746
+ tryTriggerAutocomplete(explicitTab = false) {
1747
+ this.requestAutocomplete({ force: false, explicitTab });
1748
+ }
1749
+ handleTabCompletion() {
1750
+ if (!this.autocompleteProvider)
1751
+ return;
1752
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1753
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1754
+ if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) {
1755
+ this.handleSlashCommandCompletion();
1756
+ }
1757
+ else {
1758
+ this.forceFileAutocomplete(true);
1759
+ }
1760
+ }
1761
+ handleSlashCommandCompletion() {
1762
+ this.requestAutocomplete({ force: false, explicitTab: true });
1763
+ }
1764
+ forceFileAutocomplete(explicitTab = false) {
1765
+ this.requestAutocomplete({ force: true, explicitTab });
1766
+ }
1767
+ requestAutocomplete(options) {
1768
+ if (!this.autocompleteProvider)
1769
+ return;
1770
+ if (options.force) {
1771
+ const shouldTrigger = !this.autocompleteProvider.shouldTriggerFileCompletion ||
1772
+ this.autocompleteProvider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1773
+ if (!shouldTrigger) {
1774
+ return;
1775
+ }
1776
+ }
1777
+ this.cancelAutocompleteRequest();
1778
+ const startToken = ++this.autocompleteStartToken;
1779
+ const debounceMs = this.getAutocompleteDebounceMs(options);
1780
+ if (debounceMs > 0) {
1781
+ this.autocompleteDebounceTimer = setTimeout(() => {
1782
+ this.autocompleteDebounceTimer = undefined;
1783
+ void this.startAutocompleteRequest(startToken, options);
1784
+ }, debounceMs);
1785
+ return;
1786
+ }
1787
+ void this.startAutocompleteRequest(startToken, options);
1788
+ }
1789
+ async startAutocompleteRequest(startToken, options) {
1790
+ const previousTask = this.autocompleteRequestTask;
1791
+ this.autocompleteRequestTask = (async () => {
1792
+ await previousTask;
1793
+ if (startToken !== this.autocompleteStartToken || !this.autocompleteProvider) {
1794
+ return;
1795
+ }
1796
+ const controller = new AbortController();
1797
+ this.autocompleteAbort = controller;
1798
+ const requestId = ++this.autocompleteRequestId;
1799
+ const snapshotText = this.getText();
1800
+ const snapshotLine = this.state.cursorLine;
1801
+ const snapshotCol = this.state.cursorCol;
1802
+ await this.runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options);
1803
+ })();
1804
+ await this.autocompleteRequestTask;
1805
+ }
1806
+ getAutocompleteDebounceMs(options) {
1807
+ if (options.explicitTab || options.force) {
1808
+ return 0;
1809
+ }
1810
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1811
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1812
+ const isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(textBeforeCursor);
1813
+ return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
1814
+ }
1815
+ async runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options) {
1816
+ if (!this.autocompleteProvider)
1817
+ return;
1818
+ const suggestions = await this.autocompleteProvider.getSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol, { signal: controller.signal, force: options.force });
1819
+ if (!this.isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol)) {
1820
+ return;
1821
+ }
1822
+ this.autocompleteAbort = undefined;
1823
+ if (!suggestions || !Array.isArray(suggestions.items) || suggestions.items.length === 0) {
1824
+ this.cancelAutocomplete();
1825
+ this.tui.requestRender();
1826
+ return;
1827
+ }
1828
+ if (options.force && options.explicitTab && suggestions.items.length === 1) {
1829
+ const item = suggestions.items[0];
1830
+ this.pushUndoSnapshot();
1831
+ this.lastAction = null;
1832
+ const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, item, suggestions.prefix);
1833
+ this.state.lines = result.lines;
1834
+ this.state.cursorLine = result.cursorLine;
1835
+ this.setCursorCol(result.cursorCol);
1836
+ if (this.onChange)
1837
+ this.onChange(this.getText());
1838
+ this.tui.requestRender();
1839
+ return;
1840
+ }
1841
+ this.applyAutocompleteSuggestions(suggestions, options.force ? "force" : "regular");
1842
+ this.tui.requestRender();
1843
+ }
1844
+ isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol) {
1845
+ return (!controller.signal.aborted &&
1846
+ requestId === this.autocompleteRequestId &&
1847
+ this.getText() === snapshotText &&
1848
+ this.state.cursorLine === snapshotLine &&
1849
+ this.state.cursorCol === snapshotCol);
1850
+ }
1851
+ applyAutocompleteSuggestions(suggestions, state) {
1852
+ this.autocompletePrefix = suggestions.prefix;
1853
+ this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
1854
+ const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
1855
+ if (bestMatchIndex >= 0) {
1856
+ this.autocompleteList.setSelectedIndex(bestMatchIndex);
1857
+ }
1858
+ this.autocompleteState = state;
1859
+ }
1860
+ cancelAutocompleteRequest() {
1861
+ this.autocompleteStartToken += 1;
1862
+ if (this.autocompleteDebounceTimer) {
1863
+ clearTimeout(this.autocompleteDebounceTimer);
1864
+ this.autocompleteDebounceTimer = undefined;
1865
+ }
1866
+ this.autocompleteAbort?.abort();
1867
+ this.autocompleteAbort = undefined;
1868
+ }
1869
+ clearAutocompleteUi() {
1870
+ this.autocompleteState = null;
1871
+ this.autocompleteList = undefined;
1872
+ this.autocompletePrefix = "";
1873
+ }
1874
+ cancelAutocomplete() {
1875
+ this.cancelAutocompleteRequest();
1876
+ this.clearAutocompleteUi();
1877
+ }
1878
+ isShowingAutocomplete() {
1879
+ return this.autocompleteState !== null;
1880
+ }
1881
+ updateAutocomplete() {
1882
+ if (!this.autocompleteState || !this.autocompleteProvider)
1883
+ return;
1884
+ this.requestAutocomplete({ force: this.autocompleteState === "force", explicitTab: false });
1885
+ }
1886
+ }
1887
+ //# sourceMappingURL=editor.js.map