@draht/tui 2026.5.12 → 2026.7.7-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 (97) hide show
  1. package/dist/autocomplete.d.ts +5 -1
  2. package/dist/autocomplete.d.ts.map +1 -1
  3. package/dist/autocomplete.js +16 -7
  4. package/dist/autocomplete.js.map +1 -1
  5. package/dist/components/box.d.ts +1 -1
  6. package/dist/components/box.d.ts.map +1 -1
  7. package/dist/components/box.js.map +1 -1
  8. package/dist/components/cancellable-loader.d.ts +1 -1
  9. package/dist/components/cancellable-loader.d.ts.map +1 -1
  10. package/dist/components/cancellable-loader.js.map +1 -1
  11. package/dist/components/editor.d.ts +14 -3
  12. package/dist/components/editor.d.ts.map +1 -1
  13. package/dist/components/editor.js +234 -175
  14. package/dist/components/editor.js.map +1 -1
  15. package/dist/components/image.d.ts +2 -2
  16. package/dist/components/image.d.ts.map +1 -1
  17. package/dist/components/image.js +31 -11
  18. package/dist/components/image.js.map +1 -1
  19. package/dist/components/input.d.ts +1 -1
  20. package/dist/components/input.d.ts.map +1 -1
  21. package/dist/components/input.js +7 -55
  22. package/dist/components/input.js.map +1 -1
  23. package/dist/components/loader.d.ts +17 -7
  24. package/dist/components/loader.d.ts.map +1 -1
  25. package/dist/components/loader.js +34 -14
  26. package/dist/components/loader.js.map +1 -1
  27. package/dist/components/markdown.d.ts +11 -7
  28. package/dist/components/markdown.d.ts.map +1 -1
  29. package/dist/components/markdown.js +119 -102
  30. package/dist/components/markdown.js.map +1 -1
  31. package/dist/components/select-list.d.ts +1 -1
  32. package/dist/components/select-list.d.ts.map +1 -1
  33. package/dist/components/select-list.js.map +1 -1
  34. package/dist/components/settings-list.d.ts +1 -1
  35. package/dist/components/settings-list.d.ts.map +1 -1
  36. package/dist/components/settings-list.js.map +1 -1
  37. package/dist/components/spacer.d.ts +1 -1
  38. package/dist/components/spacer.d.ts.map +1 -1
  39. package/dist/components/spacer.js.map +1 -1
  40. package/dist/components/text.d.ts +1 -1
  41. package/dist/components/text.d.ts.map +1 -1
  42. package/dist/components/text.js.map +1 -1
  43. package/dist/components/truncated-text.d.ts +1 -1
  44. package/dist/components/truncated-text.d.ts.map +1 -1
  45. package/dist/components/truncated-text.js.map +1 -1
  46. package/dist/editor-component.d.ts +2 -2
  47. package/dist/editor-component.d.ts.map +1 -1
  48. package/dist/editor-component.js.map +1 -1
  49. package/dist/fuzzy.d.ts +1 -1
  50. package/dist/fuzzy.d.ts.map +1 -1
  51. package/dist/fuzzy.js +5 -2
  52. package/dist/fuzzy.js.map +1 -1
  53. package/dist/index.d.ts +23 -22
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +4 -2
  56. package/dist/index.js.map +1 -1
  57. package/dist/keybindings.d.ts +2 -2
  58. package/dist/keybindings.d.ts.map +1 -1
  59. package/dist/keybindings.js +1 -1
  60. package/dist/keybindings.js.map +1 -1
  61. package/dist/keys.d.ts +19 -5
  62. package/dist/keys.d.ts.map +1 -1
  63. package/dist/keys.js +82 -33
  64. package/dist/keys.js.map +1 -1
  65. package/dist/native-modifiers.d.ts +3 -0
  66. package/dist/native-modifiers.d.ts.map +1 -0
  67. package/dist/native-modifiers.js +53 -0
  68. package/dist/native-modifiers.js.map +1 -0
  69. package/dist/stdin-buffer.d.ts +2 -0
  70. package/dist/stdin-buffer.d.ts.map +1 -1
  71. package/dist/stdin-buffer.js +48 -4
  72. package/dist/stdin-buffer.js.map +1 -1
  73. package/dist/terminal-colors.d.ts +10 -0
  74. package/dist/terminal-colors.d.ts.map +1 -0
  75. package/dist/terminal-colors.js +59 -0
  76. package/dist/terminal-colors.js.map +1 -0
  77. package/dist/terminal-image.d.ts +23 -1
  78. package/dist/terminal-image.d.ts.map +1 -1
  79. package/dist/terminal-image.js +98 -16
  80. package/dist/terminal-image.js.map +1 -1
  81. package/dist/terminal.d.ts +36 -10
  82. package/dist/terminal.d.ts.map +1 -1
  83. package/dist/terminal.js +212 -64
  84. package/dist/terminal.js.map +1 -1
  85. package/dist/tui.d.ts +51 -5
  86. package/dist/tui.d.ts.map +1 -1
  87. package/dist/tui.js +431 -32
  88. package/dist/tui.js.map +1 -1
  89. package/dist/utils.d.ts +8 -1
  90. package/dist/utils.d.ts.map +1 -1
  91. package/dist/utils.js +143 -43
  92. package/dist/utils.js.map +1 -1
  93. package/dist/word-navigation.d.ts +25 -0
  94. package/dist/word-navigation.d.ts.map +1 -0
  95. package/dist/word-navigation.js +96 -0
  96. package/dist/word-navigation.js.map +1 -0
  97. package/package.json +1 -1
@@ -1,11 +1,13 @@
1
1
  import { getKeybindings } from "../keybindings.js";
2
- import { decodeKittyPrintable, matchesKey } from "../keys.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, truncateToWidth, visibleWidth } from "../utils.js";
6
+ import { cjkBreakRegex, getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, truncateToWidth, visibleWidth, } from "../utils.js";
7
+ import { findWordBackward, findWordForward } from "../word-navigation.js";
7
8
  import { SelectList } from "./select-list.js";
8
- const baseSegmenter = getSegmenter();
9
+ const graphemeSegmenter = getGraphemeSegmenter();
10
+ const wordSegmenter = getWordSegmenter();
9
11
  /** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */
10
12
  const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
11
13
  /** Non-global version for single-segment testing. */
@@ -21,7 +23,7 @@ function isPasteMarker(segment) {
21
23
  *
22
24
  * Only markers whose numeric ID exists in `validIds` are merged.
23
25
  */
24
- function segmentWithMarkers(text, validIds) {
26
+ function segmentWithMarkers(text, baseSegmenter, validIds) {
25
27
  // Fast path: no paste markers in the text or no valid IDs.
26
28
  if (validIds.size === 0 || !text.includes("[paste #")) {
27
29
  return baseSegmenter.segment(text);
@@ -86,7 +88,7 @@ export function wordWrapLine(line, maxWidth, preSegmented) {
86
88
  return [{ text: line, startIndex: 0, endIndex: line.length }];
87
89
  }
88
90
  const chunks = [];
89
- const segments = preSegmented ?? [...baseSegmenter.segment(line)];
91
+ const segments = preSegmented ?? [...graphemeSegmenter.segment(line)];
90
92
  let currentWidth = 0;
91
93
  let chunkStart = 0;
92
94
  // Wrap opportunity: the position after the last whitespace before a non-whitespace
@@ -138,14 +140,23 @@ export function wordWrapLine(line, maxWidth, preSegmented) {
138
140
  }
139
141
  // Advance.
140
142
  currentWidth += gWidth;
141
- // Record wrap opportunity: whitespace followed by non-whitespace.
142
- // Multiple spaces join (no break between them); the break point is
143
- // after the last space before the next word.
143
+ // Record wrap opportunity: whitespace followed by non-whitespace
144
+ // (multiple spaces join; the break point is after the last space),
145
+ // or at a boundary where either side is CJK (CJK allows breaking
146
+ // between any adjacent characters).
144
147
  const next = segments[i + 1];
145
148
  if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
146
149
  wrapOppIndex = next.index;
147
150
  wrapOppWidth = currentWidth;
148
151
  }
152
+ else if (!isWs && next && !isWhitespaceChar(next.segment)) {
153
+ const isCjk = !isPasteMarker(grapheme) && cjkBreakRegex.test(grapheme);
154
+ const nextIsCjk = !isPasteMarker(next.segment) && cjkBreakRegex.test(next.segment);
155
+ if (isCjk || nextIsCjk) {
156
+ wrapOppIndex = next.index;
157
+ wrapOppWidth = currentWidth;
158
+ }
159
+ }
149
160
  }
150
161
  // Push final chunk.
151
162
  chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length });
@@ -156,6 +167,17 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT = {
156
167
  maxPrimaryColumnWidth: 32,
157
168
  };
158
169
  const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
170
+ const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"];
171
+ function escapeCharacterClass(value) {
172
+ return value.replace(/[\\^$.*+?()[\]{}|-]/g, "\\$&");
173
+ }
174
+ function buildTriggerPattern(triggerCharacters) {
175
+ return new RegExp(`(?:^|[\\s])[${triggerCharacters.map(escapeCharacterClass).join("")}][^\\s]*$`);
176
+ }
177
+ function buildDebouncePattern(triggerCharacters) {
178
+ const escapedWithoutAt = triggerCharacters.filter((character) => character !== "@").map(escapeCharacterClass);
179
+ return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`);
180
+ }
159
181
  export class Editor {
160
182
  state = {
161
183
  lines: [""],
@@ -175,6 +197,9 @@ export class Editor {
175
197
  borderColor;
176
198
  // Autocomplete support
177
199
  autocompleteProvider;
200
+ autocompleteTriggerCharacters = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
201
+ autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters);
202
+ autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters);
178
203
  autocompleteList;
179
204
  autocompleteState = null;
180
205
  autocompletePrefix = "";
@@ -193,6 +218,7 @@ export class Editor {
193
218
  // Prompt history for up/down navigation
194
219
  history = [];
195
220
  historyIndex = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
221
+ historyDraft = null;
196
222
  // Kill ring for Emacs-style kill/yank operations
197
223
  killRing = new KillRing();
198
224
  lastAction = null;
@@ -200,6 +226,12 @@ export class Editor {
200
226
  jumpMode = null;
201
227
  // Preferred visual column for vertical cursor movement (sticky column)
202
228
  preferredVisualCol = null;
229
+ // When the cursor is snapped to the start of an atomic segment, e.g. a
230
+ // paste marker, cursorCol no longer reflects where the cursor would have
231
+ // landed. This field stores the pre-snap cursorCol so that the next
232
+ // vertical move can resolve it to a visual column on whatever VL it belongs
233
+ // to.
234
+ snappedFromCursorCol = null;
203
235
  // Undo support
204
236
  undoStack = new UndoStack();
205
237
  onSubmit;
@@ -219,8 +251,8 @@ export class Editor {
219
251
  return new Set(this.pastes.keys());
220
252
  }
221
253
  /** Segment text with paste-marker awareness, only merging markers with valid IDs. */
222
- segment(text) {
223
- return segmentWithMarkers(text, this.validPasteIds());
254
+ segment(text, mode) {
255
+ return segmentWithMarkers(text, mode === "word" ? wordSegmenter : graphemeSegmenter, this.validPasteIds());
224
256
  }
225
257
  getPaddingX() {
226
258
  return this.paddingX;
@@ -245,6 +277,7 @@ export class Editor {
245
277
  setAutocompleteProvider(provider) {
246
278
  this.cancelAutocomplete();
247
279
  this.autocompleteProvider = provider;
280
+ this.setAutocompleteTriggerCharacters(provider.triggerCharacters ?? []);
248
281
  }
249
282
  /**
250
283
  * Add a prompt to history for up/down arrow navigation.
@@ -286,22 +319,38 @@ export class Editor {
286
319
  // Capture state when first entering history browsing mode
287
320
  if (this.historyIndex === -1 && newIndex >= 0) {
288
321
  this.pushUndoSnapshot();
322
+ this.historyDraft = structuredClone(this.state);
289
323
  }
290
324
  this.historyIndex = newIndex;
291
325
  if (this.historyIndex === -1) {
292
- // Returned to "current" state - clear editor
293
- this.setTextInternal("");
326
+ const draft = this.historyDraft;
327
+ this.historyDraft = null;
328
+ if (draft) {
329
+ this.state = draft;
330
+ this.preferredVisualCol = null;
331
+ this.snappedFromCursorCol = null;
332
+ this.scrollOffset = 0;
333
+ if (this.onChange)
334
+ this.onChange(this.getText());
335
+ }
336
+ else {
337
+ this.setTextInternal("");
338
+ }
294
339
  }
295
340
  else {
296
- this.setTextInternal(this.history[this.historyIndex] || "");
341
+ this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end");
297
342
  }
298
343
  }
344
+ exitHistoryBrowsing() {
345
+ this.historyIndex = -1;
346
+ this.historyDraft = null;
347
+ }
299
348
  /** Internal setText that doesn't reset history state - used by navigateHistory */
300
- setTextInternal(text) {
349
+ setTextInternal(text, cursorPlacement = "end") {
301
350
  const lines = text.split("\n");
302
351
  this.state.lines = lines.length === 0 ? [""] : lines;
303
- this.state.cursorLine = this.state.lines.length - 1;
304
- this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0);
352
+ this.state.cursorLine = cursorPlacement === "start" ? 0 : this.state.lines.length - 1;
353
+ this.setCursorCol(cursorPlacement === "start" ? 0 : this.state.lines[this.state.cursorLine]?.length || 0);
305
354
  // Reset scroll - render() will adjust to show cursor
306
355
  this.scrollOffset = 0;
307
356
  if (this.onChange) {
@@ -360,8 +409,10 @@ export class Editor {
360
409
  result.push(horizontal.repeat(width));
361
410
  }
362
411
  // Render each visible layout line
363
- // Emit hardware cursor marker only when focused and not showing autocomplete
364
- const emitCursorMarker = this.focused && !this.autocompleteState;
412
+ // Emit hardware cursor marker when focused so TUI can position the
413
+ // hardware cursor for IME candidate-window placement even while
414
+ // autocomplete (e.g. slash-command menu) is visible.
415
+ const emitCursorMarker = this.focused;
365
416
  for (const layoutLine of visibleLines) {
366
417
  let displayText = layoutLine.text;
367
418
  let lineVisibleWidth = visibleWidth(layoutLine.text);
@@ -375,7 +426,7 @@ export class Editor {
375
426
  if (after.length > 0) {
376
427
  // Cursor is on a character (grapheme) - replace it with highlighted version
377
428
  // Get the first grapheme from 'after'
378
- const afterGraphemes = [...this.segment(after)];
429
+ const afterGraphemes = [...this.segment(after, "grapheme")];
379
430
  const firstGrapheme = afterGraphemes[0]?.segment || "";
380
431
  const restAfter = after.slice(firstGrapheme.length);
381
432
  const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
@@ -429,11 +480,12 @@ export class Editor {
429
480
  this.jumpMode = null;
430
481
  return;
431
482
  }
432
- if (data.charCodeAt(0) >= 32) {
483
+ const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined);
484
+ if (printable !== undefined) {
433
485
  // Printable character - perform the jump
434
486
  const direction = this.jumpMode;
435
487
  this.jumpMode = null;
436
- this.jumpToChar(data, direction);
488
+ this.jumpToChar(printable, direction);
437
489
  return;
438
490
  }
439
491
  // Control character - cancel and fall through to normal handling
@@ -607,10 +659,8 @@ export class Editor {
607
659
  }
608
660
  // Arrow key navigation (with history support)
609
661
  if (kb.matches(data, "tui.editor.cursorUp")) {
610
- if (this.isEditorEmpty()) {
611
- this.navigateHistory(-1);
612
- }
613
- else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
662
+ if (this.isOnFirstVisualLine() &&
663
+ (this.isEditorEmpty() || this.historyIndex > -1 || this.state.cursorCol === 0)) {
614
664
  this.navigateHistory(-1);
615
665
  }
616
666
  else if (this.isOnFirstVisualLine()) {
@@ -666,9 +716,9 @@ export class Editor {
666
716
  this.insertCharacter(" ");
667
717
  return;
668
718
  }
669
- const kittyPrintable = decodeKittyPrintable(data);
670
- if (kittyPrintable !== undefined) {
671
- this.insertCharacter(kittyPrintable);
719
+ const printable = decodePrintableKey(data);
720
+ if (printable !== undefined) {
721
+ this.insertCharacter(printable);
672
722
  return;
673
723
  }
674
724
  // Regular characters
@@ -710,7 +760,7 @@ export class Editor {
710
760
  }
711
761
  else {
712
762
  // Line needs wrapping - use word-aware wrapping
713
- const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]);
763
+ const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]);
714
764
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
715
765
  const chunk = chunks[chunkIndex];
716
766
  if (!chunk)
@@ -786,7 +836,7 @@ export class Editor {
786
836
  setText(text) {
787
837
  this.cancelAutocomplete();
788
838
  this.lastAction = null;
789
- this.historyIndex = -1; // Exit history browsing mode
839
+ this.exitHistoryBrowsing();
790
840
  const normalized = this.normalizeText(text);
791
841
  // Push undo snapshot if content differs (makes programmatic changes undoable)
792
842
  if (this.getText() !== normalized) {
@@ -805,7 +855,7 @@ export class Editor {
805
855
  this.cancelAutocomplete();
806
856
  this.pushUndoSnapshot();
807
857
  this.lastAction = null;
808
- this.historyIndex = -1;
858
+ this.exitHistoryBrowsing();
809
859
  this.insertTextAtCursorInternal(text);
810
860
  }
811
861
  /**
@@ -858,7 +908,7 @@ export class Editor {
858
908
  }
859
909
  // All the editor methods from before...
860
910
  insertCharacter(char, skipUndoCoalescing) {
861
- this.historyIndex = -1; // Exit history browsing mode
911
+ this.exitHistoryBrowsing();
862
912
  // Undo coalescing (fish-style):
863
913
  // - Consecutive word chars coalesce into one undo unit
864
914
  // - Space captures state before itself (so undo removes space+following word together)
@@ -884,17 +934,16 @@ export class Editor {
884
934
  if (char === "/" && this.isAtStartOfMessage()) {
885
935
  this.tryTriggerAutocomplete();
886
936
  }
887
- // Auto-trigger for "@" file reference (fuzzy search)
888
- else if (char === "@") {
937
+ // Auto-trigger for symbol-based completion like @, #, or provider triggers at token boundaries
938
+ else if (this.autocompleteTriggerCharacters.includes(char)) {
889
939
  const currentLine = this.state.lines[this.state.cursorLine] || "";
890
940
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
891
- // Only trigger if @ is after whitespace or at start of line
892
- const charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2];
893
- if (textBeforeCursor.length === 1 || charBeforeAt === " " || charBeforeAt === "\t") {
941
+ const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
942
+ if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
894
943
  this.tryTriggerAutocomplete();
895
944
  }
896
945
  }
897
- // Also auto-trigger when typing letters in a slash command context
946
+ // Also auto-trigger when typing letters in a slash command or symbol completion context
898
947
  else if (/[a-zA-Z0-9.\-_]/.test(char)) {
899
948
  const currentLine = this.state.lines[this.state.cursorLine] || "";
900
949
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
@@ -902,8 +951,8 @@ export class Editor {
902
951
  if (this.isInSlashCommandContext(textBeforeCursor)) {
903
952
  this.tryTriggerAutocomplete();
904
953
  }
905
- // Check if we're in an @ file reference context
906
- else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
954
+ // Check if we're in a symbol-based completion context like @, #, or provider triggers
955
+ else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
907
956
  this.tryTriggerAutocomplete();
908
957
  }
909
958
  }
@@ -914,11 +963,24 @@ export class Editor {
914
963
  }
915
964
  handlePaste(pastedText) {
916
965
  this.cancelAutocomplete();
917
- this.historyIndex = -1; // Exit history browsing mode
966
+ this.exitHistoryBrowsing();
918
967
  this.lastAction = null;
919
968
  this.pushUndoSnapshot();
969
+ // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
970
+ // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
971
+ // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
972
+ // per-char filter below preserves newlines instead of stripping ESC and
973
+ // leaking the printable tail (e.g. "[106;5u") into the editor.
974
+ const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
975
+ const cp = Number(code);
976
+ if (cp >= 97 && cp <= 122)
977
+ return String.fromCharCode(cp - 96);
978
+ if (cp >= 65 && cp <= 90)
979
+ return String.fromCharCode(cp - 64);
980
+ return match;
981
+ });
920
982
  // Clean the pasted text: normalize line endings, expand tabs
921
- const cleanText = this.normalizeText(pastedText);
983
+ const cleanText = this.normalizeText(decodedText);
922
984
  // Filter out non-printable characters except newlines
923
985
  let filteredText = cleanText
924
986
  .split("")
@@ -959,7 +1021,7 @@ export class Editor {
959
1021
  }
960
1022
  addNewLine() {
961
1023
  this.cancelAutocomplete();
962
- this.historyIndex = -1; // Exit history browsing mode
1024
+ this.exitHistoryBrowsing();
963
1025
  this.lastAction = null;
964
1026
  this.pushUndoSnapshot();
965
1027
  const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -993,7 +1055,7 @@ export class Editor {
993
1055
  this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
994
1056
  this.pastes.clear();
995
1057
  this.pasteCounter = 0;
996
- this.historyIndex = -1;
1058
+ this.exitHistoryBrowsing();
997
1059
  this.scrollOffset = 0;
998
1060
  this.undoStack.clear();
999
1061
  this.lastAction = null;
@@ -1003,7 +1065,7 @@ export class Editor {
1003
1065
  this.onSubmit(result);
1004
1066
  }
1005
1067
  handleBackspace() {
1006
- this.historyIndex = -1; // Exit history browsing mode
1068
+ this.exitHistoryBrowsing();
1007
1069
  this.lastAction = null;
1008
1070
  if (this.state.cursorCol > 0) {
1009
1071
  this.pushUndoSnapshot();
@@ -1011,7 +1073,7 @@ export class Editor {
1011
1073
  const line = this.state.lines[this.state.cursorLine] || "";
1012
1074
  const beforeCursor = line.slice(0, this.state.cursorCol);
1013
1075
  // Find the last grapheme in the text before cursor
1014
- const graphemes = [...this.segment(beforeCursor)];
1076
+ const graphemes = [...this.segment(beforeCursor, "grapheme")];
1015
1077
  const lastGrapheme = graphemes[graphemes.length - 1];
1016
1078
  const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
1017
1079
  const before = line.slice(0, this.state.cursorCol - graphemeLength);
@@ -1044,8 +1106,8 @@ export class Editor {
1044
1106
  if (this.isInSlashCommandContext(textBeforeCursor)) {
1045
1107
  this.tryTriggerAutocomplete();
1046
1108
  }
1047
- // @ file reference context
1048
- else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
1109
+ // Symbol-based completion context like @, #, or provider triggers
1110
+ else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
1049
1111
  this.tryTriggerAutocomplete();
1050
1112
  }
1051
1113
  }
@@ -1057,6 +1119,7 @@ export class Editor {
1057
1119
  setCursorCol(col) {
1058
1120
  this.state.cursorCol = col;
1059
1121
  this.preferredVisualCol = null;
1122
+ this.snappedFromCursorCol = null;
1060
1123
  }
1061
1124
  /**
1062
1125
  * Move cursor to a target visual line, applying sticky column logic.
@@ -1065,37 +1128,70 @@ export class Editor {
1065
1128
  moveToVisualLine(visualLines, currentVisualLine, targetVisualLine) {
1066
1129
  const currentVL = visualLines[currentVisualLine];
1067
1130
  const targetVL = visualLines[targetVisualLine];
1068
- if (currentVL && targetVL) {
1069
- const currentVisualCol = this.state.cursorCol - currentVL.startCol;
1070
- // For non-last segments, clamp to length-1 to stay within the segment
1071
- const isLastSourceSegment = currentVisualLine === visualLines.length - 1 ||
1072
- visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
1073
- const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
1074
- const isLastTargetSegment = targetVisualLine === visualLines.length - 1 ||
1075
- visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
1076
- const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
1077
- const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
1078
- // Set cursor position
1079
- this.state.cursorLine = targetVL.logicalLine;
1080
- const targetCol = targetVL.startCol + moveToVisualCol;
1081
- const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1082
- this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1083
- // Snap cursor to atomic segment boundary (e.g. paste markers)
1084
- // so the cursor never lands in the middle of a multi-grapheme unit.
1085
- // Single-grapheme segments don't need snapping.
1086
- const segments = [...this.segment(logicalLine)];
1087
- for (const seg of segments) {
1088
- if (seg.index > this.state.cursorCol)
1089
- break;
1090
- if (seg.segment.length <= 1)
1091
- continue;
1092
- if (this.state.cursorCol < seg.index + seg.segment.length) {
1093
- // jump to the start of the segment when moving up, to the end when moving down.
1094
- this.state.cursorCol = currentVisualLine > targetVisualLine ? seg.index : seg.index + seg.segment.length;
1095
- break;
1131
+ if (!(currentVL && targetVL))
1132
+ return;
1133
+ // When the cursor was snapped to a segment start, resolve the pre-snap
1134
+ // position against the VL it belongs to. This gives the correct visual
1135
+ // column even after a resize reshuffles VLs.
1136
+ let currentVisualCol;
1137
+ if (this.snappedFromCursorCol !== null) {
1138
+ const vlIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
1139
+ currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex].startCol;
1140
+ }
1141
+ else {
1142
+ currentVisualCol = this.state.cursorCol - currentVL.startCol;
1143
+ }
1144
+ // For non-last segments, clamp to length-1 to stay within the segment
1145
+ const isLastSourceSegment = currentVisualLine === visualLines.length - 1 ||
1146
+ visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
1147
+ const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
1148
+ const isLastTargetSegment = targetVisualLine === visualLines.length - 1 ||
1149
+ visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
1150
+ const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
1151
+ const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
1152
+ // Set cursor position
1153
+ this.state.cursorLine = targetVL.logicalLine;
1154
+ const targetCol = targetVL.startCol + moveToVisualCol;
1155
+ const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1156
+ this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1157
+ // Snap cursor to atomic segment boundary (e.g. paste markers)
1158
+ // so the cursor never lands in the middle of a multi-grapheme unit.
1159
+ // Single-grapheme segments don't need snapping.
1160
+ const segments = [...this.segment(logicalLine, "grapheme")];
1161
+ for (const seg of segments) {
1162
+ if (seg.index > this.state.cursorCol)
1163
+ break;
1164
+ if (seg.segment.length <= 1)
1165
+ continue;
1166
+ if (this.state.cursorCol < seg.index + seg.segment.length) {
1167
+ const isContinuation = seg.index < targetVL.startCol;
1168
+ const isMovingDown = targetVisualLine > currentVisualLine;
1169
+ if (isContinuation && isMovingDown) {
1170
+ // The segment started on a previous visual line, and we
1171
+ // already visited it on the way down. Skip all remaining
1172
+ // continuation VLs and land on the first VL past it.
1173
+ const segEnd = seg.index + seg.segment.length;
1174
+ let next = targetVisualLine + 1;
1175
+ while (next < visualLines.length &&
1176
+ visualLines[next].logicalLine === targetVL.logicalLine &&
1177
+ visualLines[next].startCol < segEnd) {
1178
+ next++;
1179
+ }
1180
+ if (next < visualLines.length) {
1181
+ this.moveToVisualLine(visualLines, currentVisualLine, next);
1182
+ return;
1183
+ }
1096
1184
  }
1185
+ // Snap to the start of the segment so it gets highlighted.
1186
+ // Store the pre-snap position so the next vertical move can
1187
+ // resolve it to the correct visual column.
1188
+ this.snappedFromCursorCol = this.state.cursorCol;
1189
+ this.state.cursorCol = seg.index;
1190
+ return;
1097
1191
  }
1098
1192
  }
1193
+ // No snap occurred – we moved out of the atomic segment.
1194
+ this.snappedFromCursorCol = null;
1099
1195
  }
1100
1196
  /**
1101
1197
  * Compute the target visual column for vertical cursor movement.
@@ -1151,7 +1247,7 @@ export class Editor {
1151
1247
  this.setCursorCol(currentLine.length);
1152
1248
  }
1153
1249
  deleteToStartOfLine() {
1154
- this.historyIndex = -1; // Exit history browsing mode
1250
+ this.exitHistoryBrowsing();
1155
1251
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1156
1252
  if (this.state.cursorCol > 0) {
1157
1253
  this.pushUndoSnapshot();
@@ -1179,7 +1275,7 @@ export class Editor {
1179
1275
  }
1180
1276
  }
1181
1277
  deleteToEndOfLine() {
1182
- this.historyIndex = -1; // Exit history browsing mode
1278
+ this.exitHistoryBrowsing();
1183
1279
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1184
1280
  if (this.state.cursorCol < currentLine.length) {
1185
1281
  this.pushUndoSnapshot();
@@ -1204,7 +1300,7 @@ export class Editor {
1204
1300
  }
1205
1301
  }
1206
1302
  deleteWordBackwards() {
1207
- this.historyIndex = -1; // Exit history browsing mode
1303
+ this.exitHistoryBrowsing();
1208
1304
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1209
1305
  // If at start of line, behave like backspace at column 0 (merge with previous line)
1210
1306
  if (this.state.cursorCol === 0) {
@@ -1240,7 +1336,7 @@ export class Editor {
1240
1336
  }
1241
1337
  }
1242
1338
  deleteWordForward() {
1243
- this.historyIndex = -1; // Exit history browsing mode
1339
+ this.exitHistoryBrowsing();
1244
1340
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1245
1341
  // If at end of line, merge with next line (delete the newline)
1246
1342
  if (this.state.cursorCol >= currentLine.length) {
@@ -1273,7 +1369,7 @@ export class Editor {
1273
1369
  }
1274
1370
  }
1275
1371
  handleForwardDelete() {
1276
- this.historyIndex = -1; // Exit history browsing mode
1372
+ this.exitHistoryBrowsing();
1277
1373
  this.lastAction = null;
1278
1374
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1279
1375
  if (this.state.cursorCol < currentLine.length) {
@@ -1281,7 +1377,7 @@ export class Editor {
1281
1377
  // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
1282
1378
  const afterCursor = currentLine.slice(this.state.cursorCol);
1283
1379
  // Find the first grapheme at cursor
1284
- const graphemes = [...this.segment(afterCursor)];
1380
+ const graphemes = [...this.segment(afterCursor, "grapheme")];
1285
1381
  const firstGrapheme = graphemes[0];
1286
1382
  const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
1287
1383
  const before = currentLine.slice(0, this.state.cursorCol);
@@ -1309,8 +1405,8 @@ export class Editor {
1309
1405
  if (this.isInSlashCommandContext(textBeforeCursor)) {
1310
1406
  this.tryTriggerAutocomplete();
1311
1407
  }
1312
- // @ file reference context
1313
- else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
1408
+ // Symbol-based completion context like @, #, or provider triggers
1409
+ else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
1314
1410
  this.tryTriggerAutocomplete();
1315
1411
  }
1316
1412
  }
@@ -1336,7 +1432,7 @@ export class Editor {
1336
1432
  }
1337
1433
  else {
1338
1434
  // Line needs wrapping - use word-aware wrapping
1339
- const chunks = wordWrapLine(line, width, [...this.segment(line)]);
1435
+ const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]);
1340
1436
  for (const chunk of chunks) {
1341
1437
  visualLines.push({
1342
1438
  logicalLine: i,
@@ -1349,26 +1445,29 @@ export class Editor {
1349
1445
  return visualLines;
1350
1446
  }
1351
1447
  /**
1352
- * Find the visual line index for the current cursor position.
1448
+ * Find the visual line index that contains the given logical position.
1353
1449
  */
1354
- findCurrentVisualLine(visualLines) {
1450
+ findVisualLineAt(visualLines, line, col) {
1355
1451
  for (let i = 0; i < visualLines.length; i++) {
1356
1452
  const vl = visualLines[i];
1357
- if (!vl)
1453
+ if (!vl || vl.logicalLine !== line)
1358
1454
  continue;
1359
- if (vl.logicalLine === this.state.cursorLine) {
1360
- const colInSegment = this.state.cursorCol - vl.startCol;
1361
- // Cursor is in this segment if it's within range
1362
- // For the last segment of a logical line, cursor can be at length (end position)
1363
- const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
1364
- if (colInSegment >= 0 && (colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))) {
1365
- return i;
1366
- }
1455
+ const offset = col - vl.startCol;
1456
+ // Cursor is in this segment if it's within range. For the last
1457
+ // segment of a logical line, cursor can be at length (end position)
1458
+ const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
1459
+ if (offset >= 0 && (offset < vl.length || (isLastSegmentOfLine && offset === vl.length))) {
1460
+ return i;
1367
1461
  }
1368
1462
  }
1369
- // Fallback: return last visual line
1370
1463
  return visualLines.length - 1;
1371
1464
  }
1465
+ /**
1466
+ * Find the visual line index for the current cursor position.
1467
+ */
1468
+ findCurrentVisualLine(visualLines) {
1469
+ return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol);
1470
+ }
1372
1471
  moveCursor(deltaLine, deltaCol) {
1373
1472
  this.lastAction = null;
1374
1473
  const visualLines = this.buildVisualLineMap(this.lastWidth);
@@ -1385,7 +1484,7 @@ export class Editor {
1385
1484
  // Moving right - move by one grapheme (handles emojis, combining characters, etc.)
1386
1485
  if (this.state.cursorCol < currentLine.length) {
1387
1486
  const afterCursor = currentLine.slice(this.state.cursorCol);
1388
- const graphemes = [...this.segment(afterCursor)];
1487
+ const graphemes = [...this.segment(afterCursor, "grapheme")];
1389
1488
  const firstGrapheme = graphemes[0];
1390
1489
  this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
1391
1490
  }
@@ -1406,7 +1505,7 @@ export class Editor {
1406
1505
  // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
1407
1506
  if (this.state.cursorCol > 0) {
1408
1507
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1409
- const graphemes = [...this.segment(beforeCursor)];
1508
+ const graphemes = [...this.segment(beforeCursor, "grapheme")];
1410
1509
  const lastGrapheme = graphemes[graphemes.length - 1];
1411
1510
  this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
1412
1511
  }
@@ -1418,6 +1517,17 @@ export class Editor {
1418
1517
  }
1419
1518
  }
1420
1519
  }
1520
+ // Keep an open autocomplete picker in sync with the new cursor
1521
+ // position: cursor movement changes the text before the cursor, so a
1522
+ // picker computed for the old position is stale. Re-query so it
1523
+ // refreshes — or closes when the new position yields no suggestions —
1524
+ // mirroring insertCharacter()/handleBackspace(). Without this, arrowing
1525
+ // left from `/cmd ` back into the command name leaves the argument
1526
+ // picker showing against a `/cmd` prefix (and a Tab there would
1527
+ // concatenate the stale suggestion onto the partial command name).
1528
+ if (this.autocompleteState) {
1529
+ this.updateAutocomplete();
1530
+ }
1421
1531
  }
1422
1532
  /**
1423
1533
  * Scroll by a page (direction: -1 for up, 1 for down).
@@ -1444,40 +1554,10 @@ export class Editor {
1444
1554
  }
1445
1555
  return;
1446
1556
  }
1447
- const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1448
- const graphemes = [...this.segment(textBeforeCursor)];
1449
- let newCol = this.state.cursorCol;
1450
- // Skip trailing whitespace
1451
- while (graphemes.length > 0 &&
1452
- !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") &&
1453
- isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
1454
- newCol -= graphemes.pop()?.segment.length || 0;
1455
- }
1456
- if (graphemes.length > 0) {
1457
- const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
1458
- if (isPasteMarker(lastGrapheme)) {
1459
- // Paste marker is a single atomic word
1460
- newCol -= graphemes.pop()?.segment.length || 0;
1461
- }
1462
- else if (isPunctuationChar(lastGrapheme)) {
1463
- // Skip punctuation run
1464
- while (graphemes.length > 0 &&
1465
- isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
1466
- !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
1467
- newCol -= graphemes.pop()?.segment.length || 0;
1468
- }
1469
- }
1470
- else {
1471
- // Skip word run
1472
- while (graphemes.length > 0 &&
1473
- !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
1474
- !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
1475
- !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
1476
- newCol -= graphemes.pop()?.segment.length || 0;
1477
- }
1478
- }
1479
- }
1480
- this.setCursorCol(newCol);
1557
+ this.setCursorCol(findWordBackward(currentLine, this.state.cursorCol, {
1558
+ segment: (text) => this.segment(text, "word"),
1559
+ isAtomicSegment: isPasteMarker,
1560
+ }));
1481
1561
  }
1482
1562
  /**
1483
1563
  * Yank (paste) the most recent kill ring entry at cursor position.
@@ -1512,7 +1592,7 @@ export class Editor {
1512
1592
  * Insert text at cursor position (used by yank operations).
1513
1593
  */
1514
1594
  insertYankedText(text) {
1515
- this.historyIndex = -1; // Exit history browsing mode
1595
+ this.exitHistoryBrowsing();
1516
1596
  const lines = text.split("\n");
1517
1597
  if (lines.length === 1) {
1518
1598
  // Single line - insert at cursor
@@ -1584,7 +1664,7 @@ export class Editor {
1584
1664
  this.undoStack.push(this.state);
1585
1665
  }
1586
1666
  undo() {
1587
- this.historyIndex = -1; // Exit history browsing mode
1667
+ this.exitHistoryBrowsing();
1588
1668
  const snapshot = this.undoStack.pop();
1589
1669
  if (!snapshot)
1590
1670
  return;
@@ -1634,41 +1714,10 @@ export class Editor {
1634
1714
  }
1635
1715
  return;
1636
1716
  }
1637
- const textAfterCursor = currentLine.slice(this.state.cursorCol);
1638
- const segments = this.segment(textAfterCursor);
1639
- const iterator = segments[Symbol.iterator]();
1640
- let next = iterator.next();
1641
- let newCol = this.state.cursorCol;
1642
- // Skip leading whitespace
1643
- while (!next.done && !isPasteMarker(next.value.segment) && isWhitespaceChar(next.value.segment)) {
1644
- newCol += next.value.segment.length;
1645
- next = iterator.next();
1646
- }
1647
- if (!next.done) {
1648
- const firstGrapheme = next.value.segment;
1649
- if (isPasteMarker(firstGrapheme)) {
1650
- // Paste marker is a single atomic word
1651
- newCol += firstGrapheme.length;
1652
- }
1653
- else if (isPunctuationChar(firstGrapheme)) {
1654
- // Skip punctuation run
1655
- while (!next.done && isPunctuationChar(next.value.segment) && !isPasteMarker(next.value.segment)) {
1656
- newCol += next.value.segment.length;
1657
- next = iterator.next();
1658
- }
1659
- }
1660
- else {
1661
- // Skip word run
1662
- while (!next.done &&
1663
- !isWhitespaceChar(next.value.segment) &&
1664
- !isPunctuationChar(next.value.segment) &&
1665
- !isPasteMarker(next.value.segment)) {
1666
- newCol += next.value.segment.length;
1667
- next = iterator.next();
1668
- }
1669
- }
1670
- }
1671
- this.setCursorCol(newCol);
1717
+ this.setCursorCol(findWordForward(currentLine, this.state.cursorCol, {
1718
+ segment: (text) => this.segment(text, "word"),
1719
+ isAtomicSegment: isPasteMarker,
1720
+ }));
1672
1721
  }
1673
1722
  // Slash menu only allowed on the first line of the editor
1674
1723
  isSlashMenuAllowed() {
@@ -1741,9 +1790,8 @@ export class Editor {
1741
1790
  if (!this.autocompleteProvider)
1742
1791
  return;
1743
1792
  if (options.force) {
1744
- const provider = this.autocompleteProvider;
1745
- const shouldTrigger = !provider.shouldTriggerFileCompletion ||
1746
- provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1793
+ const shouldTrigger = !this.autocompleteProvider.shouldTriggerFileCompletion ||
1794
+ this.autocompleteProvider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1747
1795
  if (!shouldTrigger) {
1748
1796
  return;
1749
1797
  }
@@ -1777,14 +1825,25 @@ export class Editor {
1777
1825
  })();
1778
1826
  await this.autocompleteRequestTask;
1779
1827
  }
1828
+ setAutocompleteTriggerCharacters(triggerCharacters) {
1829
+ const next = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
1830
+ for (const character of triggerCharacters) {
1831
+ if (character.length !== 1 || character === "/" || isWhitespaceChar(character) || next.includes(character)) {
1832
+ continue;
1833
+ }
1834
+ next.push(character);
1835
+ }
1836
+ this.autocompleteTriggerCharacters = next;
1837
+ this.autocompleteTriggerPattern = buildTriggerPattern(next);
1838
+ this.autocompleteDebouncePattern = buildDebouncePattern(next);
1839
+ }
1780
1840
  getAutocompleteDebounceMs(options) {
1781
1841
  if (options.explicitTab || options.force) {
1782
1842
  return 0;
1783
1843
  }
1784
1844
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1785
1845
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1786
- const isAttachmentContext = /(?:^|[ \t])@(?:"[^"]*|[^\s]*)$/.test(textBeforeCursor);
1787
- return isAttachmentContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
1846
+ return this.autocompleteDebouncePattern.test(textBeforeCursor) ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
1788
1847
  }
1789
1848
  async runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options) {
1790
1849
  if (!this.autocompleteProvider)