phi-code-tui 0.56.3 → 0.74.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 (88) hide show
  1. package/README.md +29 -11
  2. package/dist/autocomplete.d.ts +18 -14
  3. package/dist/autocomplete.d.ts.map +1 -1
  4. package/dist/autocomplete.js +151 -112
  5. package/dist/autocomplete.js.map +1 -1
  6. package/dist/components/box.d.ts.map +1 -1
  7. package/dist/components/box.js +6 -1
  8. package/dist/components/box.js.map +1 -1
  9. package/dist/components/cancellable-loader.d.ts.map +1 -1
  10. package/dist/components/cancellable-loader.js +6 -7
  11. package/dist/components/cancellable-loader.js.map +1 -1
  12. package/dist/components/editor.d.ts +45 -1
  13. package/dist/components/editor.d.ts.map +1 -1
  14. package/dist/components/editor.js +505 -221
  15. package/dist/components/editor.js.map +1 -1
  16. package/dist/components/image.d.ts.map +1 -1
  17. package/dist/components/image.js +22 -7
  18. package/dist/components/image.js.map +1 -1
  19. package/dist/components/input.d.ts.map +1 -1
  20. package/dist/components/input.js +57 -74
  21. package/dist/components/input.js.map +1 -1
  22. package/dist/components/loader.d.ts +12 -2
  23. package/dist/components/loader.d.ts.map +1 -1
  24. package/dist/components/loader.js +36 -13
  25. package/dist/components/loader.js.map +1 -1
  26. package/dist/components/markdown.d.ts +0 -5
  27. package/dist/components/markdown.d.ts.map +1 -1
  28. package/dist/components/markdown.js +101 -114
  29. package/dist/components/markdown.js.map +1 -1
  30. package/dist/components/select-list.d.ts +19 -1
  31. package/dist/components/select-list.d.ts.map +1 -1
  32. package/dist/components/select-list.js +82 -71
  33. package/dist/components/select-list.js.map +1 -1
  34. package/dist/components/settings-list.d.ts.map +1 -1
  35. package/dist/components/settings-list.js +18 -10
  36. package/dist/components/settings-list.js.map +1 -1
  37. package/dist/components/spacer.d.ts.map +1 -1
  38. package/dist/components/spacer.js +1 -0
  39. package/dist/components/spacer.js.map +1 -1
  40. package/dist/components/text.d.ts.map +1 -1
  41. package/dist/components/text.js +8 -0
  42. package/dist/components/text.js.map +1 -1
  43. package/dist/components/truncated-text.d.ts.map +1 -1
  44. package/dist/components/truncated-text.js +3 -0
  45. package/dist/components/truncated-text.js.map +1 -1
  46. package/dist/editor-component.d.ts.map +1 -1
  47. package/dist/fuzzy.d.ts.map +1 -1
  48. package/dist/fuzzy.js +3 -0
  49. package/dist/fuzzy.js.map +1 -1
  50. package/dist/index.d.ts +5 -5
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +3 -3
  53. package/dist/index.js.map +1 -1
  54. package/dist/keybindings.d.ts +187 -33
  55. package/dist/keybindings.d.ts.map +1 -1
  56. package/dist/keybindings.js +156 -95
  57. package/dist/keybindings.js.map +1 -1
  58. package/dist/keys.d.ts +21 -12
  59. package/dist/keys.d.ts.map +1 -1
  60. package/dist/keys.js +270 -112
  61. package/dist/keys.js.map +1 -1
  62. package/dist/kill-ring.d.ts.map +1 -1
  63. package/dist/kill-ring.js +1 -3
  64. package/dist/kill-ring.js.map +1 -1
  65. package/dist/stdin-buffer.d.ts +2 -0
  66. package/dist/stdin-buffer.d.ts.map +1 -1
  67. package/dist/stdin-buffer.js +31 -8
  68. package/dist/stdin-buffer.js.map +1 -1
  69. package/dist/terminal-image.d.ts +17 -0
  70. package/dist/terminal-image.d.ts.map +1 -1
  71. package/dist/terminal-image.js +41 -5
  72. package/dist/terminal-image.js.map +1 -1
  73. package/dist/terminal.d.ts +4 -0
  74. package/dist/terminal.d.ts.map +1 -1
  75. package/dist/terminal.js +56 -8
  76. package/dist/terminal.js.map +1 -1
  77. package/dist/tui.d.ts +21 -5
  78. package/dist/tui.d.ts.map +1 -1
  79. package/dist/tui.js +234 -118
  80. package/dist/tui.js.map +1 -1
  81. package/dist/undo-stack.d.ts.map +1 -1
  82. package/dist/undo-stack.js +1 -3
  83. package/dist/undo-stack.js.map +1 -1
  84. package/dist/utils.d.ts +1 -0
  85. package/dist/utils.d.ts.map +1 -1
  86. package/dist/utils.js +281 -81
  87. package/dist/utils.js.map +1 -1
  88. package/package.json +3 -3
@@ -1,11 +1,71 @@
1
- import { getEditorKeybindings } from "../keybindings.js";
2
- import { decodeKittyPrintable, matchesKey } from "../keys.js";
1
+ import { getKeybindings } from "../keybindings.js";
2
+ import { decodePrintableKey, matchesKey } from "../keys.js";
3
3
  import { KillRing } from "../kill-ring.js";
4
4
  import { CURSOR_MARKER } from "../tui.js";
5
5
  import { UndoStack } from "../undo-stack.js";
6
- import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
6
+ import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.js";
7
7
  import { SelectList } from "./select-list.js";
8
- const segmenter = getSegmenter();
8
+ const baseSegmenter = getSegmenter();
9
+ /** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */
10
+ const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
11
+ /** Non-global version for single-segment testing. */
12
+ const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/;
13
+ /** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */
14
+ function isPasteMarker(segment) {
15
+ return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment);
16
+ }
17
+ /**
18
+ * A segmenter that wraps Intl.Segmenter and merges graphemes that fall
19
+ * within paste markers into single atomic segments. This makes cursor
20
+ * movement, deletion, word-wrap, etc. treat paste markers as single units.
21
+ *
22
+ * Only markers whose numeric ID exists in `validIds` are merged.
23
+ */
24
+ function segmentWithMarkers(text, validIds) {
25
+ // Fast path: no paste markers in the text or no valid IDs.
26
+ if (validIds.size === 0 || !text.includes("[paste #")) {
27
+ return baseSegmenter.segment(text);
28
+ }
29
+ // Find all marker spans with valid IDs.
30
+ const markers = [];
31
+ for (const m of text.matchAll(PASTE_MARKER_REGEX)) {
32
+ const id = Number.parseInt(m[1], 10);
33
+ if (!validIds.has(id))
34
+ continue;
35
+ markers.push({ start: m.index, end: m.index + m[0].length });
36
+ }
37
+ if (markers.length === 0) {
38
+ return baseSegmenter.segment(text);
39
+ }
40
+ // Build merged segment list.
41
+ const baseSegments = baseSegmenter.segment(text);
42
+ const result = [];
43
+ let markerIdx = 0;
44
+ for (const seg of baseSegments) {
45
+ // Skip past markers that are entirely before this segment.
46
+ while (markerIdx < markers.length && markers[markerIdx].end <= seg.index) {
47
+ markerIdx++;
48
+ }
49
+ const marker = markerIdx < markers.length ? markers[markerIdx] : null;
50
+ if (marker && seg.index >= marker.start && seg.index < marker.end) {
51
+ // This segment falls inside a marker.
52
+ // If this is the first segment of the marker, emit a merged segment.
53
+ if (seg.index === marker.start) {
54
+ const markerText = text.slice(marker.start, marker.end);
55
+ result.push({
56
+ segment: markerText,
57
+ index: marker.start,
58
+ input: text,
59
+ });
60
+ }
61
+ // Otherwise skip (already merged into the first segment).
62
+ }
63
+ else {
64
+ result.push(seg);
65
+ }
66
+ }
67
+ return result;
68
+ }
9
69
  /**
10
70
  * Split a line into word-wrapped chunks.
11
71
  * Wraps at word boundaries when possible, falling back to character-level
@@ -13,9 +73,11 @@ const segmenter = getSegmenter();
13
73
  *
14
74
  * @param line - The text line to wrap
15
75
  * @param maxWidth - Maximum visible width per chunk
76
+ * @param preSegmented - Optional pre-segmented graphemes (e.g. with paste-marker awareness).
77
+ * When omitted the default Intl.Segmenter is used.
16
78
  * @returns Array of chunks with text and position information
17
79
  */
18
- export function wordWrapLine(line, maxWidth) {
80
+ export function wordWrapLine(line, maxWidth, preSegmented) {
19
81
  if (!line || maxWidth <= 0) {
20
82
  return [{ text: "", startIndex: 0, endIndex: 0 }];
21
83
  }
@@ -24,7 +86,7 @@ export function wordWrapLine(line, maxWidth) {
24
86
  return [{ text: line, startIndex: 0, endIndex: line.length }];
25
87
  }
26
88
  const chunks = [];
27
- const segments = [...segmenter.segment(line)];
89
+ const segments = preSegmented ?? [...baseSegmenter.segment(line)];
28
90
  let currentWidth = 0;
29
91
  let chunkStart = 0;
30
92
  // Wrap opportunity: the position after the last whitespace before a non-whitespace
@@ -36,30 +98,51 @@ export function wordWrapLine(line, maxWidth) {
36
98
  const grapheme = seg.segment;
37
99
  const gWidth = visibleWidth(grapheme);
38
100
  const charIndex = seg.index;
39
- const isWs = isWhitespaceChar(grapheme);
101
+ const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme);
40
102
  // Overflow check before advancing.
41
103
  if (currentWidth + gWidth > maxWidth) {
42
- if (wrapOppIndex >= 0) {
43
- // Backtrack to last wrap opportunity.
104
+ if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) {
105
+ // Backtrack to last wrap opportunity (the remaining content
106
+ // plus the current grapheme still fits within maxWidth).
44
107
  chunks.push({ text: line.slice(chunkStart, wrapOppIndex), startIndex: chunkStart, endIndex: wrapOppIndex });
45
108
  chunkStart = wrapOppIndex;
46
109
  currentWidth -= wrapOppWidth;
47
110
  }
48
111
  else if (chunkStart < charIndex) {
49
- // No wrap opportunity: force-break at current position.
112
+ // No viable wrap opportunity: force-break at current position.
113
+ // This also handles the case where backtracking to a word
114
+ // boundary wouldn't help because the remaining content plus
115
+ // the current grapheme (e.g. a wide character) still exceeds
116
+ // maxWidth.
50
117
  chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex });
51
118
  chunkStart = charIndex;
52
119
  currentWidth = 0;
53
120
  }
54
121
  wrapOppIndex = -1;
55
122
  }
123
+ if (gWidth > maxWidth) {
124
+ // Single atomic segment wider than maxWidth (e.g. paste marker
125
+ // in a narrow terminal). Re-wrap it at grapheme granularity.
126
+ // The segment remains logically atomic for cursor
127
+ // movement / editing — the split is purely visual for word-wrap layout.
128
+ const subChunks = wordWrapLine(grapheme, maxWidth);
129
+ for (let j = 0; j < subChunks.length - 1; j++) {
130
+ const sc = subChunks[j];
131
+ chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex });
132
+ }
133
+ const last = subChunks[subChunks.length - 1];
134
+ chunkStart = charIndex + last.startIndex;
135
+ currentWidth = visibleWidth(last.text);
136
+ wrapOppIndex = -1;
137
+ continue;
138
+ }
56
139
  // Advance.
57
140
  currentWidth += gWidth;
58
141
  // Record wrap opportunity: whitespace followed by non-whitespace.
59
142
  // Multiple spaces join (no break between them); the break point is
60
143
  // after the last space before the next word.
61
144
  const next = segments[i + 1];
62
- if (isWs && next && !isWhitespaceChar(next.segment)) {
145
+ if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
63
146
  wrapOppIndex = next.index;
64
147
  wrapOppWidth = currentWidth;
65
148
  }
@@ -68,42 +151,67 @@ export function wordWrapLine(line, maxWidth) {
68
151
  chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length });
69
152
  return chunks;
70
153
  }
154
+ const SLASH_COMMAND_SELECT_LIST_LAYOUT = {
155
+ minPrimaryColumnWidth: 12,
156
+ maxPrimaryColumnWidth: 32,
157
+ };
158
+ const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
71
159
  export class Editor {
160
+ state = {
161
+ lines: [""],
162
+ cursorLine: 0,
163
+ cursorCol: 0,
164
+ };
165
+ /** Focusable interface - set by TUI when focus changes */
166
+ focused = false;
167
+ tui;
168
+ theme;
169
+ paddingX = 0;
170
+ // Store last render width for cursor navigation
171
+ lastWidth = 80;
172
+ // Vertical scrolling support
173
+ scrollOffset = 0;
174
+ // Border color (can be changed dynamically)
175
+ borderColor;
176
+ // Autocomplete support
177
+ autocompleteProvider;
178
+ autocompleteList;
179
+ autocompleteState = null;
180
+ autocompletePrefix = "";
181
+ autocompleteMaxVisible = 5;
182
+ autocompleteAbort;
183
+ autocompleteDebounceTimer;
184
+ autocompleteRequestTask = Promise.resolve();
185
+ autocompleteStartToken = 0;
186
+ autocompleteRequestId = 0;
187
+ // Paste tracking for large pastes
188
+ pastes = new Map();
189
+ pasteCounter = 0;
190
+ // Bracketed paste mode buffering
191
+ pasteBuffer = "";
192
+ isInPaste = false;
193
+ // Prompt history for up/down navigation
194
+ history = [];
195
+ historyIndex = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
196
+ // Kill ring for Emacs-style kill/yank operations
197
+ killRing = new KillRing();
198
+ lastAction = null;
199
+ // Character jump mode
200
+ jumpMode = null;
201
+ // Preferred visual column for vertical cursor movement (sticky column)
202
+ preferredVisualCol = null;
203
+ // When the cursor is snapped to the start of an atomic segment, e.g. a
204
+ // paste marker, cursorCol no longer reflects where the cursor would have
205
+ // landed. This field stores the pre-snap cursorCol so that the next
206
+ // vertical move can resolve it to a visual column on whatever VL it belongs
207
+ // to.
208
+ snappedFromCursorCol = null;
209
+ // Undo support
210
+ undoStack = new UndoStack();
211
+ onSubmit;
212
+ onChange;
213
+ disableSubmit = false;
72
214
  constructor(tui, theme, options = {}) {
73
- this.state = {
74
- lines: [""],
75
- cursorLine: 0,
76
- cursorCol: 0,
77
- };
78
- /** Focusable interface - set by TUI when focus changes */
79
- this.focused = false;
80
- this.paddingX = 0;
81
- // Store last render width for cursor navigation
82
- this.lastWidth = 80;
83
- // Vertical scrolling support
84
- this.scrollOffset = 0;
85
- this.autocompleteState = null;
86
- this.autocompletePrefix = "";
87
- this.autocompleteMaxVisible = 5;
88
- // Paste tracking for large pastes
89
- this.pastes = new Map();
90
- this.pasteCounter = 0;
91
- // Bracketed paste mode buffering
92
- this.pasteBuffer = "";
93
- this.isInPaste = false;
94
- // Prompt history for up/down navigation
95
- this.history = [];
96
- this.historyIndex = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
97
- // Kill ring for Emacs-style kill/yank operations
98
- this.killRing = new KillRing();
99
- this.lastAction = null;
100
- // Character jump mode
101
- this.jumpMode = null;
102
- // Preferred visual column for vertical cursor movement (sticky column)
103
- this.preferredVisualCol = null;
104
- // Undo support
105
- this.undoStack = new UndoStack();
106
- this.disableSubmit = false;
107
215
  this.tui = tui;
108
216
  this.theme = theme;
109
217
  this.borderColor = theme.borderColor;
@@ -112,6 +220,14 @@ export class Editor {
112
220
  const maxVisible = options.autocompleteMaxVisible ?? 5;
113
221
  this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
114
222
  }
223
+ /** Set of currently valid paste IDs, for marker-aware segmentation. */
224
+ validPasteIds() {
225
+ return new Set(this.pastes.keys());
226
+ }
227
+ /** Segment text with paste-marker awareness, only merging markers with valid IDs. */
228
+ segment(text) {
229
+ return segmentWithMarkers(text, this.validPasteIds());
230
+ }
115
231
  getPaddingX() {
116
232
  return this.paddingX;
117
233
  }
@@ -133,6 +249,7 @@ export class Editor {
133
249
  }
134
250
  }
135
251
  setAutocompleteProvider(provider) {
252
+ this.cancelAutocomplete();
136
253
  this.autocompleteProvider = provider;
137
254
  }
138
255
  /**
@@ -187,7 +304,7 @@ export class Editor {
187
304
  }
188
305
  /** Internal setText that doesn't reset history state - used by navigateHistory */
189
306
  setTextInternal(text) {
190
- const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
307
+ const lines = text.split("\n");
191
308
  this.state.lines = lines.length === 0 ? [""] : lines;
192
309
  this.state.cursorLine = this.state.lines.length - 1;
193
310
  this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0);
@@ -238,7 +355,12 @@ export class Editor {
238
355
  if (this.scrollOffset > 0) {
239
356
  const indicator = `─── ↑ ${this.scrollOffset} more `;
240
357
  const remaining = width - visibleWidth(indicator);
241
- result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
358
+ if (remaining >= 0) {
359
+ result.push(this.borderColor(indicator + "─".repeat(remaining)));
360
+ }
361
+ else {
362
+ result.push(this.borderColor(truncateToWidth(indicator, width)));
363
+ }
242
364
  }
243
365
  else {
244
366
  result.push(horizontal.repeat(width));
@@ -259,7 +381,7 @@ export class Editor {
259
381
  if (after.length > 0) {
260
382
  // Cursor is on a character (grapheme) - replace it with highlighted version
261
383
  // Get the first grapheme from 'after'
262
- const afterGraphemes = [...segmenter.segment(after)];
384
+ const afterGraphemes = [...this.segment(after)];
263
385
  const firstGrapheme = afterGraphemes[0]?.segment || "";
264
386
  const restAfter = after.slice(firstGrapheme.length);
265
387
  const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
@@ -305,19 +427,20 @@ export class Editor {
305
427
  return result;
306
428
  }
307
429
  handleInput(data) {
308
- const kb = getEditorKeybindings();
430
+ const kb = getKeybindings();
309
431
  // Handle character jump mode (awaiting next character to jump to)
310
432
  if (this.jumpMode !== null) {
311
433
  // Cancel if the hotkey is pressed again
312
- if (kb.matches(data, "jumpForward") || kb.matches(data, "jumpBackward")) {
434
+ if (kb.matches(data, "tui.editor.jumpForward") || kb.matches(data, "tui.editor.jumpBackward")) {
313
435
  this.jumpMode = null;
314
436
  return;
315
437
  }
316
- if (data.charCodeAt(0) >= 32) {
438
+ const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined);
439
+ if (printable !== undefined) {
317
440
  // Printable character - perform the jump
318
441
  const direction = this.jumpMode;
319
442
  this.jumpMode = null;
320
- this.jumpToChar(data, direction);
443
+ this.jumpToChar(printable, direction);
321
444
  return;
322
445
  }
323
446
  // Control character - cancel and fall through to normal handling
@@ -348,25 +471,25 @@ export class Editor {
348
471
  return;
349
472
  }
350
473
  // Ctrl+C - let parent handle (exit/clear)
351
- if (kb.matches(data, "copy")) {
474
+ if (kb.matches(data, "tui.input.copy")) {
352
475
  return;
353
476
  }
354
477
  // Undo
355
- if (kb.matches(data, "undo")) {
478
+ if (kb.matches(data, "tui.editor.undo")) {
356
479
  this.undo();
357
480
  return;
358
481
  }
359
482
  // Handle autocomplete mode
360
483
  if (this.autocompleteState && this.autocompleteList) {
361
- if (kb.matches(data, "selectCancel")) {
484
+ if (kb.matches(data, "tui.select.cancel")) {
362
485
  this.cancelAutocomplete();
363
486
  return;
364
487
  }
365
- if (kb.matches(data, "selectUp") || kb.matches(data, "selectDown")) {
488
+ if (kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")) {
366
489
  this.autocompleteList.handleInput(data);
367
490
  return;
368
491
  }
369
- if (kb.matches(data, "tab")) {
492
+ if (kb.matches(data, "tui.input.tab")) {
370
493
  const selected = this.autocompleteList.getSelectedItem();
371
494
  if (selected && this.autocompleteProvider) {
372
495
  this.pushUndoSnapshot();
@@ -381,7 +504,7 @@ export class Editor {
381
504
  }
382
505
  return;
383
506
  }
384
- if (kb.matches(data, "selectConfirm")) {
507
+ if (kb.matches(data, "tui.select.confirm")) {
385
508
  const selected = this.autocompleteList.getSelectedItem();
386
509
  if (selected && this.autocompleteProvider) {
387
510
  this.pushUndoSnapshot();
@@ -404,63 +527,63 @@ export class Editor {
404
527
  }
405
528
  }
406
529
  // Tab - trigger completion
407
- if (kb.matches(data, "tab") && !this.autocompleteState) {
530
+ if (kb.matches(data, "tui.input.tab") && !this.autocompleteState) {
408
531
  this.handleTabCompletion();
409
532
  return;
410
533
  }
411
534
  // Deletion actions
412
- if (kb.matches(data, "deleteToLineEnd")) {
535
+ if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
413
536
  this.deleteToEndOfLine();
414
537
  return;
415
538
  }
416
- if (kb.matches(data, "deleteToLineStart")) {
539
+ if (kb.matches(data, "tui.editor.deleteToLineStart")) {
417
540
  this.deleteToStartOfLine();
418
541
  return;
419
542
  }
420
- if (kb.matches(data, "deleteWordBackward")) {
543
+ if (kb.matches(data, "tui.editor.deleteWordBackward")) {
421
544
  this.deleteWordBackwards();
422
545
  return;
423
546
  }
424
- if (kb.matches(data, "deleteWordForward")) {
547
+ if (kb.matches(data, "tui.editor.deleteWordForward")) {
425
548
  this.deleteWordForward();
426
549
  return;
427
550
  }
428
- if (kb.matches(data, "deleteCharBackward") || matchesKey(data, "shift+backspace")) {
551
+ if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
429
552
  this.handleBackspace();
430
553
  return;
431
554
  }
432
- if (kb.matches(data, "deleteCharForward") || matchesKey(data, "shift+delete")) {
555
+ if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
433
556
  this.handleForwardDelete();
434
557
  return;
435
558
  }
436
559
  // Kill ring actions
437
- if (kb.matches(data, "yank")) {
560
+ if (kb.matches(data, "tui.editor.yank")) {
438
561
  this.yank();
439
562
  return;
440
563
  }
441
- if (kb.matches(data, "yankPop")) {
564
+ if (kb.matches(data, "tui.editor.yankPop")) {
442
565
  this.yankPop();
443
566
  return;
444
567
  }
445
568
  // Cursor movement actions
446
- if (kb.matches(data, "cursorLineStart")) {
569
+ if (kb.matches(data, "tui.editor.cursorLineStart")) {
447
570
  this.moveToLineStart();
448
571
  return;
449
572
  }
450
- if (kb.matches(data, "cursorLineEnd")) {
573
+ if (kb.matches(data, "tui.editor.cursorLineEnd")) {
451
574
  this.moveToLineEnd();
452
575
  return;
453
576
  }
454
- if (kb.matches(data, "cursorWordLeft")) {
577
+ if (kb.matches(data, "tui.editor.cursorWordLeft")) {
455
578
  this.moveWordBackwards();
456
579
  return;
457
580
  }
458
- if (kb.matches(data, "cursorWordRight")) {
581
+ if (kb.matches(data, "tui.editor.cursorWordRight")) {
459
582
  this.moveWordForwards();
460
583
  return;
461
584
  }
462
585
  // New line
463
- if (kb.matches(data, "newLine") ||
586
+ if (kb.matches(data, "tui.input.newLine") ||
464
587
  (data.charCodeAt(0) === 10 && data.length > 1) ||
465
588
  data === "\x1b\r" ||
466
589
  data === "\x1b[13;2~" ||
@@ -475,7 +598,7 @@ export class Editor {
475
598
  return;
476
599
  }
477
600
  // Submit (Enter)
478
- if (kb.matches(data, "submit")) {
601
+ if (kb.matches(data, "tui.input.submit")) {
479
602
  if (this.disableSubmit)
480
603
  return;
481
604
  // Workaround for terminals without Shift+Enter support:
@@ -490,7 +613,7 @@ export class Editor {
490
613
  return;
491
614
  }
492
615
  // Arrow key navigation (with history support)
493
- if (kb.matches(data, "cursorUp")) {
616
+ if (kb.matches(data, "tui.editor.cursorUp")) {
494
617
  if (this.isEditorEmpty()) {
495
618
  this.navigateHistory(-1);
496
619
  }
@@ -506,7 +629,7 @@ export class Editor {
506
629
  }
507
630
  return;
508
631
  }
509
- if (kb.matches(data, "cursorDown")) {
632
+ if (kb.matches(data, "tui.editor.cursorDown")) {
510
633
  if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
511
634
  this.navigateHistory(1);
512
635
  }
@@ -519,29 +642,29 @@ export class Editor {
519
642
  }
520
643
  return;
521
644
  }
522
- if (kb.matches(data, "cursorRight")) {
645
+ if (kb.matches(data, "tui.editor.cursorRight")) {
523
646
  this.moveCursor(0, 1);
524
647
  return;
525
648
  }
526
- if (kb.matches(data, "cursorLeft")) {
649
+ if (kb.matches(data, "tui.editor.cursorLeft")) {
527
650
  this.moveCursor(0, -1);
528
651
  return;
529
652
  }
530
653
  // Page up/down - scroll by page and move cursor
531
- if (kb.matches(data, "pageUp")) {
654
+ if (kb.matches(data, "tui.editor.pageUp")) {
532
655
  this.pageScroll(-1);
533
656
  return;
534
657
  }
535
- if (kb.matches(data, "pageDown")) {
658
+ if (kb.matches(data, "tui.editor.pageDown")) {
536
659
  this.pageScroll(1);
537
660
  return;
538
661
  }
539
662
  // Character jump mode triggers
540
- if (kb.matches(data, "jumpForward")) {
663
+ if (kb.matches(data, "tui.editor.jumpForward")) {
541
664
  this.jumpMode = "forward";
542
665
  return;
543
666
  }
544
- if (kb.matches(data, "jumpBackward")) {
667
+ if (kb.matches(data, "tui.editor.jumpBackward")) {
545
668
  this.jumpMode = "backward";
546
669
  return;
547
670
  }
@@ -550,9 +673,9 @@ export class Editor {
550
673
  this.insertCharacter(" ");
551
674
  return;
552
675
  }
553
- const kittyPrintable = decodeKittyPrintable(data);
554
- if (kittyPrintable !== undefined) {
555
- this.insertCharacter(kittyPrintable);
676
+ const printable = decodePrintableKey(data);
677
+ if (printable !== undefined) {
678
+ this.insertCharacter(printable);
556
679
  return;
557
680
  }
558
681
  // Regular characters
@@ -594,7 +717,7 @@ export class Editor {
594
717
  }
595
718
  else {
596
719
  // Line needs wrapping - use word-aware wrapping
597
- const chunks = wordWrapLine(line, contentWidth);
720
+ const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]);
598
721
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
599
722
  const chunk = chunks[chunkIndex];
600
723
  if (!chunk)
@@ -646,17 +769,20 @@ export class Editor {
646
769
  getText() {
647
770
  return this.state.lines.join("\n");
648
771
  }
772
+ expandPasteMarkers(text) {
773
+ let result = text;
774
+ for (const [pasteId, pasteContent] of this.pastes) {
775
+ const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
776
+ result = result.replace(markerRegex, () => pasteContent);
777
+ }
778
+ return result;
779
+ }
649
780
  /**
650
781
  * Get text with paste markers expanded to their actual content.
651
782
  * Use this when you need the full content (e.g., for external editor).
652
783
  */
653
784
  getExpandedText() {
654
- let result = this.state.lines.join("\n");
655
- for (const [pasteId, pasteContent] of this.pastes) {
656
- const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
657
- result = result.replace(markerRegex, pasteContent);
658
- }
659
- return result;
785
+ return this.expandPasteMarkers(this.state.lines.join("\n"));
660
786
  }
661
787
  getLines() {
662
788
  return [...this.state.lines];
@@ -665,13 +791,15 @@ export class Editor {
665
791
  return { line: this.state.cursorLine, col: this.state.cursorCol };
666
792
  }
667
793
  setText(text) {
794
+ this.cancelAutocomplete();
668
795
  this.lastAction = null;
669
796
  this.historyIndex = -1; // Exit history browsing mode
797
+ const normalized = this.normalizeText(text);
670
798
  // Push undo snapshot if content differs (makes programmatic changes undoable)
671
- if (this.getText() !== text) {
799
+ if (this.getText() !== normalized) {
672
800
  this.pushUndoSnapshot();
673
801
  }
674
- this.setTextInternal(text);
802
+ this.setTextInternal(normalized);
675
803
  }
676
804
  /**
677
805
  * Insert text at the current cursor position.
@@ -681,11 +809,20 @@ export class Editor {
681
809
  insertTextAtCursor(text) {
682
810
  if (!text)
683
811
  return;
812
+ this.cancelAutocomplete();
684
813
  this.pushUndoSnapshot();
685
814
  this.lastAction = null;
686
815
  this.historyIndex = -1;
687
816
  this.insertTextAtCursorInternal(text);
688
817
  }
818
+ /**
819
+ * Normalize text for editor storage:
820
+ * - Normalize line endings (\r\n and \r -> \n)
821
+ * - Expand tabs to 4 spaces
822
+ */
823
+ normalizeText(text) {
824
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
825
+ }
689
826
  /**
690
827
  * Internal text insertion at cursor. Handles single and multi-line text.
691
828
  * Does not push undo snapshots or trigger autocomplete - caller is responsible.
@@ -694,8 +831,8 @@ export class Editor {
694
831
  insertTextAtCursorInternal(text) {
695
832
  if (!text)
696
833
  return;
697
- // Normalize line endings
698
- const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
834
+ // Normalize line endings and tabs
835
+ const normalized = this.normalizeText(text);
699
836
  const insertedLines = normalized.split("\n");
700
837
  const currentLine = this.state.lines[this.state.cursorLine] || "";
701
838
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
@@ -754,17 +891,16 @@ export class Editor {
754
891
  if (char === "/" && this.isAtStartOfMessage()) {
755
892
  this.tryTriggerAutocomplete();
756
893
  }
757
- // Auto-trigger for "@" file reference (fuzzy search)
758
- else if (char === "@") {
894
+ // Auto-trigger for symbol-based completion like @ or # at token boundaries
895
+ else if (char === "@" || char === "#") {
759
896
  const currentLine = this.state.lines[this.state.cursorLine] || "";
760
897
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
761
- // Only trigger if @ is after whitespace or at start of line
762
- const charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2];
763
- if (textBeforeCursor.length === 1 || charBeforeAt === " " || charBeforeAt === "\t") {
898
+ const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
899
+ if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
764
900
  this.tryTriggerAutocomplete();
765
901
  }
766
902
  }
767
- // Also auto-trigger when typing letters in a slash command context
903
+ // Also auto-trigger when typing letters in a slash command or symbol completion context
768
904
  else if (/[a-zA-Z0-9.\-_]/.test(char)) {
769
905
  const currentLine = this.state.lines[this.state.cursorLine] || "";
770
906
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
@@ -772,8 +908,8 @@ export class Editor {
772
908
  if (this.isInSlashCommandContext(textBeforeCursor)) {
773
909
  this.tryTriggerAutocomplete();
774
910
  }
775
- // Check if we're in an @ file reference context
776
- else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
911
+ // Check if we're in a symbol-based completion context like @ or #
912
+ else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
777
913
  this.tryTriggerAutocomplete();
778
914
  }
779
915
  }
@@ -783,15 +919,27 @@ export class Editor {
783
919
  }
784
920
  }
785
921
  handlePaste(pastedText) {
922
+ this.cancelAutocomplete();
786
923
  this.historyIndex = -1; // Exit history browsing mode
787
924
  this.lastAction = null;
788
925
  this.pushUndoSnapshot();
789
- // Clean the pasted text
790
- const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
791
- // Convert tabs to spaces (4 spaces per tab)
792
- const tabExpandedText = cleanText.replace(/\t/g, " ");
926
+ // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
927
+ // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
928
+ // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
929
+ // per-char filter below preserves newlines instead of stripping ESC and
930
+ // leaking the printable tail (e.g. "[106;5u") into the editor.
931
+ const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
932
+ const cp = Number(code);
933
+ if (cp >= 97 && cp <= 122)
934
+ return String.fromCharCode(cp - 96);
935
+ if (cp >= 65 && cp <= 90)
936
+ return String.fromCharCode(cp - 64);
937
+ return match;
938
+ });
939
+ // Clean the pasted text: normalize line endings, expand tabs
940
+ const cleanText = this.normalizeText(decodedText);
793
941
  // Filter out non-printable characters except newlines
794
- let filteredText = tabExpandedText
942
+ let filteredText = cleanText
795
943
  .split("")
796
944
  .filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
797
945
  .join("");
@@ -829,6 +977,7 @@ export class Editor {
829
977
  this.insertTextAtCursorInternal(filteredText);
830
978
  }
831
979
  addNewLine() {
980
+ this.cancelAutocomplete();
832
981
  this.historyIndex = -1; // Exit history browsing mode
833
982
  this.lastAction = null;
834
983
  this.pushUndoSnapshot();
@@ -850,7 +999,7 @@ export class Editor {
850
999
  return false;
851
1000
  if (!matchesKey(data, "enter"))
852
1001
  return false;
853
- const submitKeys = kb.getKeys("submit");
1002
+ const submitKeys = kb.getKeys("tui.input.submit");
854
1003
  const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
855
1004
  if (!hasShiftEnter)
856
1005
  return false;
@@ -858,11 +1007,8 @@ export class Editor {
858
1007
  return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\";
859
1008
  }
860
1009
  submitValue() {
861
- let result = this.state.lines.join("\n").trim();
862
- for (const [pasteId, pasteContent] of this.pastes) {
863
- const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
864
- result = result.replace(markerRegex, pasteContent);
865
- }
1010
+ this.cancelAutocomplete();
1011
+ const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim();
866
1012
  this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
867
1013
  this.pastes.clear();
868
1014
  this.pasteCounter = 0;
@@ -884,7 +1030,7 @@ export class Editor {
884
1030
  const line = this.state.lines[this.state.cursorLine] || "";
885
1031
  const beforeCursor = line.slice(0, this.state.cursorCol);
886
1032
  // Find the last grapheme in the text before cursor
887
- const graphemes = [...segmenter.segment(beforeCursor)];
1033
+ const graphemes = [...this.segment(beforeCursor)];
888
1034
  const lastGrapheme = graphemes[graphemes.length - 1];
889
1035
  const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
890
1036
  const before = line.slice(0, this.state.cursorCol - graphemeLength);
@@ -917,8 +1063,8 @@ export class Editor {
917
1063
  if (this.isInSlashCommandContext(textBeforeCursor)) {
918
1064
  this.tryTriggerAutocomplete();
919
1065
  }
920
- // @ file reference context
921
- else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
1066
+ // Symbol-based completion context like @ or #
1067
+ else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
922
1068
  this.tryTriggerAutocomplete();
923
1069
  }
924
1070
  }
@@ -930,6 +1076,7 @@ export class Editor {
930
1076
  setCursorCol(col) {
931
1077
  this.state.cursorCol = col;
932
1078
  this.preferredVisualCol = null;
1079
+ this.snappedFromCursorCol = null;
933
1080
  }
934
1081
  /**
935
1082
  * Move cursor to a target visual line, applying sticky column logic.
@@ -938,22 +1085,70 @@ export class Editor {
938
1085
  moveToVisualLine(visualLines, currentVisualLine, targetVisualLine) {
939
1086
  const currentVL = visualLines[currentVisualLine];
940
1087
  const targetVL = visualLines[targetVisualLine];
941
- if (currentVL && targetVL) {
942
- const currentVisualCol = this.state.cursorCol - currentVL.startCol;
943
- // For non-last segments, clamp to length-1 to stay within the segment
944
- const isLastSourceSegment = currentVisualLine === visualLines.length - 1 ||
945
- visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
946
- const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
947
- const isLastTargetSegment = targetVisualLine === visualLines.length - 1 ||
948
- visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
949
- const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
950
- const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
951
- // Set cursor position
952
- this.state.cursorLine = targetVL.logicalLine;
953
- const targetCol = targetVL.startCol + moveToVisualCol;
954
- const logicalLine = this.state.lines[targetVL.logicalLine] || "";
955
- this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1088
+ if (!(currentVL && targetVL))
1089
+ return;
1090
+ // When the cursor was snapped to a segment start, resolve the pre-snap
1091
+ // position against the VL it belongs to. This gives the correct visual
1092
+ // column even after a resize reshuffles VLs.
1093
+ let currentVisualCol;
1094
+ if (this.snappedFromCursorCol !== null) {
1095
+ const vlIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
1096
+ currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex].startCol;
956
1097
  }
1098
+ else {
1099
+ currentVisualCol = this.state.cursorCol - currentVL.startCol;
1100
+ }
1101
+ // For non-last segments, clamp to length-1 to stay within the segment
1102
+ const isLastSourceSegment = currentVisualLine === visualLines.length - 1 ||
1103
+ visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
1104
+ const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
1105
+ const isLastTargetSegment = targetVisualLine === visualLines.length - 1 ||
1106
+ visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
1107
+ const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
1108
+ const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
1109
+ // Set cursor position
1110
+ this.state.cursorLine = targetVL.logicalLine;
1111
+ const targetCol = targetVL.startCol + moveToVisualCol;
1112
+ const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1113
+ this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1114
+ // Snap cursor to atomic segment boundary (e.g. paste markers)
1115
+ // so the cursor never lands in the middle of a multi-grapheme unit.
1116
+ // Single-grapheme segments don't need snapping.
1117
+ const segments = [...this.segment(logicalLine)];
1118
+ for (const seg of segments) {
1119
+ if (seg.index > this.state.cursorCol)
1120
+ break;
1121
+ if (seg.segment.length <= 1)
1122
+ continue;
1123
+ if (this.state.cursorCol < seg.index + seg.segment.length) {
1124
+ const isContinuation = seg.index < targetVL.startCol;
1125
+ const isMovingDown = targetVisualLine > currentVisualLine;
1126
+ if (isContinuation && isMovingDown) {
1127
+ // The segment started on a previous visual line, and we
1128
+ // already visited it on the way down. Skip all remaining
1129
+ // continuation VLs and land on the first VL past it.
1130
+ const segEnd = seg.index + seg.segment.length;
1131
+ let next = targetVisualLine + 1;
1132
+ while (next < visualLines.length &&
1133
+ visualLines[next].logicalLine === targetVL.logicalLine &&
1134
+ visualLines[next].startCol < segEnd) {
1135
+ next++;
1136
+ }
1137
+ if (next < visualLines.length) {
1138
+ this.moveToVisualLine(visualLines, currentVisualLine, next);
1139
+ return;
1140
+ }
1141
+ }
1142
+ // Snap to the start of the segment so it gets highlighted.
1143
+ // Store the pre-snap position so the next vertical move can
1144
+ // resolve it to the correct visual column.
1145
+ this.snappedFromCursorCol = this.state.cursorCol;
1146
+ this.state.cursorCol = seg.index;
1147
+ return;
1148
+ }
1149
+ }
1150
+ // No snap occurred – we moved out of the atomic segment.
1151
+ this.snappedFromCursorCol = null;
957
1152
  }
958
1153
  /**
959
1154
  * Compute the target visual column for vertical cursor movement.
@@ -1139,7 +1334,7 @@ export class Editor {
1139
1334
  // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
1140
1335
  const afterCursor = currentLine.slice(this.state.cursorCol);
1141
1336
  // Find the first grapheme at cursor
1142
- const graphemes = [...segmenter.segment(afterCursor)];
1337
+ const graphemes = [...this.segment(afterCursor)];
1143
1338
  const firstGrapheme = graphemes[0];
1144
1339
  const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
1145
1340
  const before = currentLine.slice(0, this.state.cursorCol);
@@ -1167,8 +1362,8 @@ export class Editor {
1167
1362
  if (this.isInSlashCommandContext(textBeforeCursor)) {
1168
1363
  this.tryTriggerAutocomplete();
1169
1364
  }
1170
- // @ file reference context
1171
- else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
1365
+ // Symbol-based completion context like @ or #
1366
+ else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
1172
1367
  this.tryTriggerAutocomplete();
1173
1368
  }
1174
1369
  }
@@ -1194,7 +1389,7 @@ export class Editor {
1194
1389
  }
1195
1390
  else {
1196
1391
  // Line needs wrapping - use word-aware wrapping
1197
- const chunks = wordWrapLine(line, width);
1392
+ const chunks = wordWrapLine(line, width, [...this.segment(line)]);
1198
1393
  for (const chunk of chunks) {
1199
1394
  visualLines.push({
1200
1395
  logicalLine: i,
@@ -1207,26 +1402,29 @@ export class Editor {
1207
1402
  return visualLines;
1208
1403
  }
1209
1404
  /**
1210
- * Find the visual line index for the current cursor position.
1405
+ * Find the visual line index that contains the given logical position.
1211
1406
  */
1212
- findCurrentVisualLine(visualLines) {
1407
+ findVisualLineAt(visualLines, line, col) {
1213
1408
  for (let i = 0; i < visualLines.length; i++) {
1214
1409
  const vl = visualLines[i];
1215
- if (!vl)
1410
+ if (!vl || vl.logicalLine !== line)
1216
1411
  continue;
1217
- if (vl.logicalLine === this.state.cursorLine) {
1218
- const colInSegment = this.state.cursorCol - vl.startCol;
1219
- // Cursor is in this segment if it's within range
1220
- // For the last segment of a logical line, cursor can be at length (end position)
1221
- const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
1222
- if (colInSegment >= 0 && (colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))) {
1223
- return i;
1224
- }
1412
+ const offset = col - vl.startCol;
1413
+ // Cursor is in this segment if it's within range. For the last
1414
+ // segment of a logical line, cursor can be at length (end position)
1415
+ const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
1416
+ if (offset >= 0 && (offset < vl.length || (isLastSegmentOfLine && offset === vl.length))) {
1417
+ return i;
1225
1418
  }
1226
1419
  }
1227
- // Fallback: return last visual line
1228
1420
  return visualLines.length - 1;
1229
1421
  }
1422
+ /**
1423
+ * Find the visual line index for the current cursor position.
1424
+ */
1425
+ findCurrentVisualLine(visualLines) {
1426
+ return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol);
1427
+ }
1230
1428
  moveCursor(deltaLine, deltaCol) {
1231
1429
  this.lastAction = null;
1232
1430
  const visualLines = this.buildVisualLineMap(this.lastWidth);
@@ -1243,7 +1441,7 @@ export class Editor {
1243
1441
  // Moving right - move by one grapheme (handles emojis, combining characters, etc.)
1244
1442
  if (this.state.cursorCol < currentLine.length) {
1245
1443
  const afterCursor = currentLine.slice(this.state.cursorCol);
1246
- const graphemes = [...segmenter.segment(afterCursor)];
1444
+ const graphemes = [...this.segment(afterCursor)];
1247
1445
  const firstGrapheme = graphemes[0];
1248
1446
  this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
1249
1447
  }
@@ -1264,7 +1462,7 @@ export class Editor {
1264
1462
  // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
1265
1463
  if (this.state.cursorCol > 0) {
1266
1464
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1267
- const graphemes = [...segmenter.segment(beforeCursor)];
1465
+ const graphemes = [...this.segment(beforeCursor)];
1268
1466
  const lastGrapheme = graphemes[graphemes.length - 1];
1269
1467
  this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
1270
1468
  }
@@ -1303,17 +1501,25 @@ export class Editor {
1303
1501
  return;
1304
1502
  }
1305
1503
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1306
- const graphemes = [...segmenter.segment(textBeforeCursor)];
1504
+ const graphemes = [...this.segment(textBeforeCursor)];
1307
1505
  let newCol = this.state.cursorCol;
1308
1506
  // Skip trailing whitespace
1309
- while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
1507
+ while (graphemes.length > 0 &&
1508
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") &&
1509
+ isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
1310
1510
  newCol -= graphemes.pop()?.segment.length || 0;
1311
1511
  }
1312
1512
  if (graphemes.length > 0) {
1313
1513
  const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
1314
- if (isPunctuationChar(lastGrapheme)) {
1514
+ if (isPasteMarker(lastGrapheme)) {
1515
+ // Paste marker is a single atomic word
1516
+ newCol -= graphemes.pop()?.segment.length || 0;
1517
+ }
1518
+ else if (isPunctuationChar(lastGrapheme)) {
1315
1519
  // Skip punctuation run
1316
- while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
1520
+ while (graphemes.length > 0 &&
1521
+ isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
1522
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
1317
1523
  newCol -= graphemes.pop()?.segment.length || 0;
1318
1524
  }
1319
1525
  }
@@ -1321,7 +1527,8 @@ export class Editor {
1321
1527
  // Skip word run
1322
1528
  while (graphemes.length > 0 &&
1323
1529
  !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
1324
- !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
1530
+ !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
1531
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
1325
1532
  newCol -= graphemes.pop()?.segment.length || 0;
1326
1533
  }
1327
1534
  }
@@ -1484,27 +1691,34 @@ export class Editor {
1484
1691
  return;
1485
1692
  }
1486
1693
  const textAfterCursor = currentLine.slice(this.state.cursorCol);
1487
- const segments = segmenter.segment(textAfterCursor);
1694
+ const segments = this.segment(textAfterCursor);
1488
1695
  const iterator = segments[Symbol.iterator]();
1489
1696
  let next = iterator.next();
1490
1697
  let newCol = this.state.cursorCol;
1491
1698
  // Skip leading whitespace
1492
- while (!next.done && isWhitespaceChar(next.value.segment)) {
1699
+ while (!next.done && !isPasteMarker(next.value.segment) && isWhitespaceChar(next.value.segment)) {
1493
1700
  newCol += next.value.segment.length;
1494
1701
  next = iterator.next();
1495
1702
  }
1496
1703
  if (!next.done) {
1497
1704
  const firstGrapheme = next.value.segment;
1498
- if (isPunctuationChar(firstGrapheme)) {
1705
+ if (isPasteMarker(firstGrapheme)) {
1706
+ // Paste marker is a single atomic word
1707
+ newCol += firstGrapheme.length;
1708
+ }
1709
+ else if (isPunctuationChar(firstGrapheme)) {
1499
1710
  // Skip punctuation run
1500
- while (!next.done && isPunctuationChar(next.value.segment)) {
1711
+ while (!next.done && isPunctuationChar(next.value.segment) && !isPasteMarker(next.value.segment)) {
1501
1712
  newCol += next.value.segment.length;
1502
1713
  next = iterator.next();
1503
1714
  }
1504
1715
  }
1505
1716
  else {
1506
1717
  // Skip word run
1507
- while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
1718
+ while (!next.done &&
1719
+ !isWhitespaceChar(next.value.segment) &&
1720
+ !isPunctuationChar(next.value.segment) &&
1721
+ !isPasteMarker(next.value.segment)) {
1508
1722
  newCol += next.value.segment.length;
1509
1723
  next = iterator.next();
1510
1724
  }
@@ -1528,34 +1742,44 @@ export class Editor {
1528
1742
  return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/");
1529
1743
  }
1530
1744
  // Autocomplete methods
1531
- tryTriggerAutocomplete(explicitTab = false) {
1532
- if (!this.autocompleteProvider)
1533
- return;
1534
- // Check if we should trigger file completion on Tab
1535
- if (explicitTab) {
1536
- const provider = this.autocompleteProvider;
1537
- const shouldTrigger = !provider.shouldTriggerFileCompletion ||
1538
- provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1539
- if (!shouldTrigger) {
1540
- return;
1745
+ /**
1746
+ * Find the best autocomplete item index for the given prefix.
1747
+ * Returns -1 if no match is found.
1748
+ *
1749
+ * Match priority:
1750
+ * 1. Exact match (prefix === item.value) -> always selected
1751
+ * 2. Prefix match -> first item whose value starts with prefix
1752
+ * 3. No match -> -1 (keep default highlight)
1753
+ *
1754
+ * Matching is case-sensitive and checks item.value only.
1755
+ */
1756
+ getBestAutocompleteMatchIndex(items, prefix) {
1757
+ if (!prefix)
1758
+ return -1;
1759
+ let firstPrefixIndex = -1;
1760
+ for (let i = 0; i < items.length; i++) {
1761
+ const value = items[i].value;
1762
+ if (value === prefix) {
1763
+ return i; // Exact match always wins
1764
+ }
1765
+ if (firstPrefixIndex === -1 && value.startsWith(prefix)) {
1766
+ firstPrefixIndex = i;
1541
1767
  }
1542
1768
  }
1543
- const suggestions = this.autocompleteProvider.getSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1544
- if (suggestions && suggestions.items.length > 0) {
1545
- this.autocompletePrefix = suggestions.prefix;
1546
- this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1547
- this.autocompleteState = "regular";
1548
- }
1549
- else {
1550
- this.cancelAutocomplete();
1551
- }
1769
+ return firstPrefixIndex;
1770
+ }
1771
+ createAutocompleteList(prefix, items) {
1772
+ const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined;
1773
+ return new SelectList(items, this.autocompleteMaxVisible, this.theme.selectList, layout);
1774
+ }
1775
+ tryTriggerAutocomplete(explicitTab = false) {
1776
+ this.requestAutocomplete({ force: false, explicitTab });
1552
1777
  }
1553
1778
  handleTabCompletion() {
1554
1779
  if (!this.autocompleteProvider)
1555
1780
  return;
1556
1781
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1557
1782
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1558
- // Check if we're in a slash command context
1559
1783
  if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) {
1560
1784
  this.handleSlashCommandCompletion();
1561
1785
  }
@@ -1564,69 +1788,129 @@ export class Editor {
1564
1788
  }
1565
1789
  }
1566
1790
  handleSlashCommandCompletion() {
1567
- this.tryTriggerAutocomplete(true);
1791
+ this.requestAutocomplete({ force: false, explicitTab: true });
1568
1792
  }
1569
- /*
1570
- https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883
1571
- 17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19
1572
- 536643416/job/55932288317 havea look at .gi
1573
- */
1574
1793
  forceFileAutocomplete(explicitTab = false) {
1794
+ this.requestAutocomplete({ force: true, explicitTab });
1795
+ }
1796
+ requestAutocomplete(options) {
1575
1797
  if (!this.autocompleteProvider)
1576
1798
  return;
1577
- // Check if provider supports force file suggestions via runtime check
1578
- const provider = this.autocompleteProvider;
1579
- if (typeof provider.getForceFileSuggestions !== "function") {
1580
- this.tryTriggerAutocomplete(true);
1799
+ if (options.force) {
1800
+ const shouldTrigger = !this.autocompleteProvider.shouldTriggerFileCompletion ||
1801
+ this.autocompleteProvider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1802
+ if (!shouldTrigger) {
1803
+ return;
1804
+ }
1805
+ }
1806
+ this.cancelAutocompleteRequest();
1807
+ const startToken = ++this.autocompleteStartToken;
1808
+ const debounceMs = this.getAutocompleteDebounceMs(options);
1809
+ if (debounceMs > 0) {
1810
+ this.autocompleteDebounceTimer = setTimeout(() => {
1811
+ this.autocompleteDebounceTimer = undefined;
1812
+ void this.startAutocompleteRequest(startToken, options);
1813
+ }, debounceMs);
1581
1814
  return;
1582
1815
  }
1583
- const suggestions = provider.getForceFileSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1584
- if (suggestions && suggestions.items.length > 0) {
1585
- // If there's exactly one suggestion, apply it immediately
1586
- if (explicitTab && suggestions.items.length === 1) {
1587
- const item = suggestions.items[0];
1588
- this.pushUndoSnapshot();
1589
- this.lastAction = null;
1590
- const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, item, suggestions.prefix);
1591
- this.state.lines = result.lines;
1592
- this.state.cursorLine = result.cursorLine;
1593
- this.setCursorCol(result.cursorCol);
1594
- if (this.onChange)
1595
- this.onChange(this.getText());
1816
+ void this.startAutocompleteRequest(startToken, options);
1817
+ }
1818
+ async startAutocompleteRequest(startToken, options) {
1819
+ const previousTask = this.autocompleteRequestTask;
1820
+ this.autocompleteRequestTask = (async () => {
1821
+ await previousTask;
1822
+ if (startToken !== this.autocompleteStartToken || !this.autocompleteProvider) {
1596
1823
  return;
1597
1824
  }
1598
- this.autocompletePrefix = suggestions.prefix;
1599
- this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1600
- this.autocompleteState = "force";
1825
+ const controller = new AbortController();
1826
+ this.autocompleteAbort = controller;
1827
+ const requestId = ++this.autocompleteRequestId;
1828
+ const snapshotText = this.getText();
1829
+ const snapshotLine = this.state.cursorLine;
1830
+ const snapshotCol = this.state.cursorCol;
1831
+ await this.runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options);
1832
+ })();
1833
+ await this.autocompleteRequestTask;
1834
+ }
1835
+ getAutocompleteDebounceMs(options) {
1836
+ if (options.explicitTab || options.force) {
1837
+ return 0;
1601
1838
  }
1602
- else {
1839
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1840
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1841
+ const isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(textBeforeCursor);
1842
+ return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
1843
+ }
1844
+ async runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options) {
1845
+ if (!this.autocompleteProvider)
1846
+ return;
1847
+ const suggestions = await this.autocompleteProvider.getSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol, { signal: controller.signal, force: options.force });
1848
+ if (!this.isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol)) {
1849
+ return;
1850
+ }
1851
+ this.autocompleteAbort = undefined;
1852
+ if (!suggestions || !Array.isArray(suggestions.items) || suggestions.items.length === 0) {
1603
1853
  this.cancelAutocomplete();
1854
+ this.tui.requestRender();
1855
+ return;
1856
+ }
1857
+ if (options.force && options.explicitTab && suggestions.items.length === 1) {
1858
+ const item = suggestions.items[0];
1859
+ this.pushUndoSnapshot();
1860
+ this.lastAction = null;
1861
+ const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, item, suggestions.prefix);
1862
+ this.state.lines = result.lines;
1863
+ this.state.cursorLine = result.cursorLine;
1864
+ this.setCursorCol(result.cursorCol);
1865
+ if (this.onChange)
1866
+ this.onChange(this.getText());
1867
+ this.tui.requestRender();
1868
+ return;
1604
1869
  }
1870
+ this.applyAutocompleteSuggestions(suggestions, options.force ? "force" : "regular");
1871
+ this.tui.requestRender();
1605
1872
  }
1606
- cancelAutocomplete() {
1873
+ isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol) {
1874
+ return (!controller.signal.aborted &&
1875
+ requestId === this.autocompleteRequestId &&
1876
+ this.getText() === snapshotText &&
1877
+ this.state.cursorLine === snapshotLine &&
1878
+ this.state.cursorCol === snapshotCol);
1879
+ }
1880
+ applyAutocompleteSuggestions(suggestions, state) {
1881
+ this.autocompletePrefix = suggestions.prefix;
1882
+ this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
1883
+ const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
1884
+ if (bestMatchIndex >= 0) {
1885
+ this.autocompleteList.setSelectedIndex(bestMatchIndex);
1886
+ }
1887
+ this.autocompleteState = state;
1888
+ }
1889
+ cancelAutocompleteRequest() {
1890
+ this.autocompleteStartToken += 1;
1891
+ if (this.autocompleteDebounceTimer) {
1892
+ clearTimeout(this.autocompleteDebounceTimer);
1893
+ this.autocompleteDebounceTimer = undefined;
1894
+ }
1895
+ this.autocompleteAbort?.abort();
1896
+ this.autocompleteAbort = undefined;
1897
+ }
1898
+ clearAutocompleteUi() {
1607
1899
  this.autocompleteState = null;
1608
1900
  this.autocompleteList = undefined;
1609
1901
  this.autocompletePrefix = "";
1610
1902
  }
1903
+ cancelAutocomplete() {
1904
+ this.cancelAutocompleteRequest();
1905
+ this.clearAutocompleteUi();
1906
+ }
1611
1907
  isShowingAutocomplete() {
1612
1908
  return this.autocompleteState !== null;
1613
1909
  }
1614
1910
  updateAutocomplete() {
1615
1911
  if (!this.autocompleteState || !this.autocompleteProvider)
1616
1912
  return;
1617
- if (this.autocompleteState === "force") {
1618
- this.forceFileAutocomplete();
1619
- return;
1620
- }
1621
- const suggestions = this.autocompleteProvider.getSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1622
- if (suggestions && suggestions.items.length > 0) {
1623
- this.autocompletePrefix = suggestions.prefix;
1624
- // Always create new SelectList to ensure update
1625
- this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1626
- }
1627
- else {
1628
- this.cancelAutocomplete();
1629
- }
1913
+ this.requestAutocomplete({ force: this.autocompleteState === "force", explicitTab: false });
1630
1914
  }
1631
1915
  }
1632
1916
  //# sourceMappingURL=editor.js.map