@draht/tui 2026.5.12 → 2026.6.11

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 (91) 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 -4
  12. package/dist/components/editor.d.ts.map +1 -1
  13. package/dist/components/editor.js +233 -178
  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 +8 -7
  28. package/dist/components/markdown.d.ts.map +1 -1
  29. package/dist/components/markdown.js +87 -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.map +1 -1
  50. package/dist/fuzzy.js +3 -0
  51. package/dist/fuzzy.js.map +1 -1
  52. package/dist/index.d.ts +22 -22
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +1 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/keybindings.d.ts +1 -1
  57. package/dist/keybindings.d.ts.map +1 -1
  58. package/dist/keybindings.js.map +1 -1
  59. package/dist/keys.d.ts +19 -5
  60. package/dist/keys.d.ts.map +1 -1
  61. package/dist/keys.js +82 -33
  62. package/dist/keys.js.map +1 -1
  63. package/dist/native-modifiers.d.ts +3 -0
  64. package/dist/native-modifiers.d.ts.map +1 -0
  65. package/dist/native-modifiers.js +53 -0
  66. package/dist/native-modifiers.js.map +1 -0
  67. package/dist/stdin-buffer.d.ts +2 -0
  68. package/dist/stdin-buffer.d.ts.map +1 -1
  69. package/dist/stdin-buffer.js +48 -4
  70. package/dist/stdin-buffer.js.map +1 -1
  71. package/dist/terminal-image.d.ts +23 -1
  72. package/dist/terminal-image.d.ts.map +1 -1
  73. package/dist/terminal-image.js +94 -16
  74. package/dist/terminal-image.js.map +1 -1
  75. package/dist/terminal.d.ts +36 -10
  76. package/dist/terminal.d.ts.map +1 -1
  77. package/dist/terminal.js +212 -64
  78. package/dist/terminal.js.map +1 -1
  79. package/dist/tui.d.ts +25 -5
  80. package/dist/tui.d.ts.map +1 -1
  81. package/dist/tui.js +245 -30
  82. package/dist/tui.js.map +1 -1
  83. package/dist/utils.d.ts +8 -1
  84. package/dist/utils.d.ts.map +1 -1
  85. package/dist/utils.js +142 -42
  86. package/dist/utils.js.map +1 -1
  87. package/dist/word-navigation.d.ts +25 -0
  88. package/dist/word-navigation.d.ts.map +1 -0
  89. package/dist/word-navigation.js +96 -0
  90. package/dist/word-navigation.js.map +1 -0
  91. 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.
@@ -263,9 +296,6 @@ export class Editor {
263
296
  this.history.pop();
264
297
  }
265
298
  }
266
- isEditorEmpty() {
267
- return this.state.lines.length === 1 && this.state.lines[0] === "";
268
- }
269
299
  isOnFirstVisualLine() {
270
300
  const visualLines = this.buildVisualLineMap(this.lastWidth);
271
301
  const currentVisualLine = this.findCurrentVisualLine(visualLines);
@@ -286,22 +316,38 @@ export class Editor {
286
316
  // Capture state when first entering history browsing mode
287
317
  if (this.historyIndex === -1 && newIndex >= 0) {
288
318
  this.pushUndoSnapshot();
319
+ this.historyDraft = structuredClone(this.state);
289
320
  }
290
321
  this.historyIndex = newIndex;
291
322
  if (this.historyIndex === -1) {
292
- // Returned to "current" state - clear editor
293
- this.setTextInternal("");
323
+ const draft = this.historyDraft;
324
+ this.historyDraft = null;
325
+ if (draft) {
326
+ this.state = draft;
327
+ this.preferredVisualCol = null;
328
+ this.snappedFromCursorCol = null;
329
+ this.scrollOffset = 0;
330
+ if (this.onChange)
331
+ this.onChange(this.getText());
332
+ }
333
+ else {
334
+ this.setTextInternal("");
335
+ }
294
336
  }
295
337
  else {
296
- this.setTextInternal(this.history[this.historyIndex] || "");
338
+ this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end");
297
339
  }
298
340
  }
341
+ exitHistoryBrowsing() {
342
+ this.historyIndex = -1;
343
+ this.historyDraft = null;
344
+ }
299
345
  /** Internal setText that doesn't reset history state - used by navigateHistory */
300
- setTextInternal(text) {
346
+ setTextInternal(text, cursorPlacement = "end") {
301
347
  const lines = text.split("\n");
302
348
  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);
349
+ this.state.cursorLine = cursorPlacement === "start" ? 0 : this.state.lines.length - 1;
350
+ this.setCursorCol(cursorPlacement === "start" ? 0 : this.state.lines[this.state.cursorLine]?.length || 0);
305
351
  // Reset scroll - render() will adjust to show cursor
306
352
  this.scrollOffset = 0;
307
353
  if (this.onChange) {
@@ -360,8 +406,10 @@ export class Editor {
360
406
  result.push(horizontal.repeat(width));
361
407
  }
362
408
  // Render each visible layout line
363
- // Emit hardware cursor marker only when focused and not showing autocomplete
364
- const emitCursorMarker = this.focused && !this.autocompleteState;
409
+ // Emit hardware cursor marker when focused so TUI can position the
410
+ // hardware cursor for IME candidate-window placement even while
411
+ // autocomplete (e.g. slash-command menu) is visible.
412
+ const emitCursorMarker = this.focused;
365
413
  for (const layoutLine of visibleLines) {
366
414
  let displayText = layoutLine.text;
367
415
  let lineVisibleWidth = visibleWidth(layoutLine.text);
@@ -375,7 +423,7 @@ export class Editor {
375
423
  if (after.length > 0) {
376
424
  // Cursor is on a character (grapheme) - replace it with highlighted version
377
425
  // Get the first grapheme from 'after'
378
- const afterGraphemes = [...this.segment(after)];
426
+ const afterGraphemes = [...this.segment(after, "grapheme")];
379
427
  const firstGrapheme = afterGraphemes[0]?.segment || "";
380
428
  const restAfter = after.slice(firstGrapheme.length);
381
429
  const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
@@ -429,11 +477,12 @@ export class Editor {
429
477
  this.jumpMode = null;
430
478
  return;
431
479
  }
432
- if (data.charCodeAt(0) >= 32) {
480
+ const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined);
481
+ if (printable !== undefined) {
433
482
  // Printable character - perform the jump
434
483
  const direction = this.jumpMode;
435
484
  this.jumpMode = null;
436
- this.jumpToChar(data, direction);
485
+ this.jumpToChar(printable, direction);
437
486
  return;
438
487
  }
439
488
  // Control character - cancel and fall through to normal handling
@@ -607,10 +656,7 @@ export class Editor {
607
656
  }
608
657
  // Arrow key navigation (with history support)
609
658
  if (kb.matches(data, "tui.editor.cursorUp")) {
610
- if (this.isEditorEmpty()) {
611
- this.navigateHistory(-1);
612
- }
613
- else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
659
+ if (this.isOnFirstVisualLine() && this.history.length > 0) {
614
660
  this.navigateHistory(-1);
615
661
  }
616
662
  else if (this.isOnFirstVisualLine()) {
@@ -666,9 +712,9 @@ export class Editor {
666
712
  this.insertCharacter(" ");
667
713
  return;
668
714
  }
669
- const kittyPrintable = decodeKittyPrintable(data);
670
- if (kittyPrintable !== undefined) {
671
- this.insertCharacter(kittyPrintable);
715
+ const printable = decodePrintableKey(data);
716
+ if (printable !== undefined) {
717
+ this.insertCharacter(printable);
672
718
  return;
673
719
  }
674
720
  // Regular characters
@@ -710,7 +756,7 @@ export class Editor {
710
756
  }
711
757
  else {
712
758
  // Line needs wrapping - use word-aware wrapping
713
- const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]);
759
+ const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]);
714
760
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
715
761
  const chunk = chunks[chunkIndex];
716
762
  if (!chunk)
@@ -786,7 +832,7 @@ export class Editor {
786
832
  setText(text) {
787
833
  this.cancelAutocomplete();
788
834
  this.lastAction = null;
789
- this.historyIndex = -1; // Exit history browsing mode
835
+ this.exitHistoryBrowsing();
790
836
  const normalized = this.normalizeText(text);
791
837
  // Push undo snapshot if content differs (makes programmatic changes undoable)
792
838
  if (this.getText() !== normalized) {
@@ -805,7 +851,7 @@ export class Editor {
805
851
  this.cancelAutocomplete();
806
852
  this.pushUndoSnapshot();
807
853
  this.lastAction = null;
808
- this.historyIndex = -1;
854
+ this.exitHistoryBrowsing();
809
855
  this.insertTextAtCursorInternal(text);
810
856
  }
811
857
  /**
@@ -858,7 +904,7 @@ export class Editor {
858
904
  }
859
905
  // All the editor methods from before...
860
906
  insertCharacter(char, skipUndoCoalescing) {
861
- this.historyIndex = -1; // Exit history browsing mode
907
+ this.exitHistoryBrowsing();
862
908
  // Undo coalescing (fish-style):
863
909
  // - Consecutive word chars coalesce into one undo unit
864
910
  // - Space captures state before itself (so undo removes space+following word together)
@@ -884,17 +930,16 @@ export class Editor {
884
930
  if (char === "/" && this.isAtStartOfMessage()) {
885
931
  this.tryTriggerAutocomplete();
886
932
  }
887
- // Auto-trigger for "@" file reference (fuzzy search)
888
- else if (char === "@") {
933
+ // Auto-trigger for symbol-based completion like @, #, or provider triggers at token boundaries
934
+ else if (this.autocompleteTriggerCharacters.includes(char)) {
889
935
  const currentLine = this.state.lines[this.state.cursorLine] || "";
890
936
  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") {
937
+ const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
938
+ if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
894
939
  this.tryTriggerAutocomplete();
895
940
  }
896
941
  }
897
- // Also auto-trigger when typing letters in a slash command context
942
+ // Also auto-trigger when typing letters in a slash command or symbol completion context
898
943
  else if (/[a-zA-Z0-9.\-_]/.test(char)) {
899
944
  const currentLine = this.state.lines[this.state.cursorLine] || "";
900
945
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
@@ -902,8 +947,8 @@ export class Editor {
902
947
  if (this.isInSlashCommandContext(textBeforeCursor)) {
903
948
  this.tryTriggerAutocomplete();
904
949
  }
905
- // Check if we're in an @ file reference context
906
- else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
950
+ // Check if we're in a symbol-based completion context like @, #, or provider triggers
951
+ else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
907
952
  this.tryTriggerAutocomplete();
908
953
  }
909
954
  }
@@ -914,11 +959,24 @@ export class Editor {
914
959
  }
915
960
  handlePaste(pastedText) {
916
961
  this.cancelAutocomplete();
917
- this.historyIndex = -1; // Exit history browsing mode
962
+ this.exitHistoryBrowsing();
918
963
  this.lastAction = null;
919
964
  this.pushUndoSnapshot();
965
+ // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
966
+ // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
967
+ // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
968
+ // per-char filter below preserves newlines instead of stripping ESC and
969
+ // leaking the printable tail (e.g. "[106;5u") into the editor.
970
+ const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
971
+ const cp = Number(code);
972
+ if (cp >= 97 && cp <= 122)
973
+ return String.fromCharCode(cp - 96);
974
+ if (cp >= 65 && cp <= 90)
975
+ return String.fromCharCode(cp - 64);
976
+ return match;
977
+ });
920
978
  // Clean the pasted text: normalize line endings, expand tabs
921
- const cleanText = this.normalizeText(pastedText);
979
+ const cleanText = this.normalizeText(decodedText);
922
980
  // Filter out non-printable characters except newlines
923
981
  let filteredText = cleanText
924
982
  .split("")
@@ -959,7 +1017,7 @@ export class Editor {
959
1017
  }
960
1018
  addNewLine() {
961
1019
  this.cancelAutocomplete();
962
- this.historyIndex = -1; // Exit history browsing mode
1020
+ this.exitHistoryBrowsing();
963
1021
  this.lastAction = null;
964
1022
  this.pushUndoSnapshot();
965
1023
  const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -993,7 +1051,7 @@ export class Editor {
993
1051
  this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
994
1052
  this.pastes.clear();
995
1053
  this.pasteCounter = 0;
996
- this.historyIndex = -1;
1054
+ this.exitHistoryBrowsing();
997
1055
  this.scrollOffset = 0;
998
1056
  this.undoStack.clear();
999
1057
  this.lastAction = null;
@@ -1003,7 +1061,7 @@ export class Editor {
1003
1061
  this.onSubmit(result);
1004
1062
  }
1005
1063
  handleBackspace() {
1006
- this.historyIndex = -1; // Exit history browsing mode
1064
+ this.exitHistoryBrowsing();
1007
1065
  this.lastAction = null;
1008
1066
  if (this.state.cursorCol > 0) {
1009
1067
  this.pushUndoSnapshot();
@@ -1011,7 +1069,7 @@ export class Editor {
1011
1069
  const line = this.state.lines[this.state.cursorLine] || "";
1012
1070
  const beforeCursor = line.slice(0, this.state.cursorCol);
1013
1071
  // Find the last grapheme in the text before cursor
1014
- const graphemes = [...this.segment(beforeCursor)];
1072
+ const graphemes = [...this.segment(beforeCursor, "grapheme")];
1015
1073
  const lastGrapheme = graphemes[graphemes.length - 1];
1016
1074
  const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
1017
1075
  const before = line.slice(0, this.state.cursorCol - graphemeLength);
@@ -1044,8 +1102,8 @@ export class Editor {
1044
1102
  if (this.isInSlashCommandContext(textBeforeCursor)) {
1045
1103
  this.tryTriggerAutocomplete();
1046
1104
  }
1047
- // @ file reference context
1048
- else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
1105
+ // Symbol-based completion context like @, #, or provider triggers
1106
+ else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
1049
1107
  this.tryTriggerAutocomplete();
1050
1108
  }
1051
1109
  }
@@ -1057,6 +1115,7 @@ export class Editor {
1057
1115
  setCursorCol(col) {
1058
1116
  this.state.cursorCol = col;
1059
1117
  this.preferredVisualCol = null;
1118
+ this.snappedFromCursorCol = null;
1060
1119
  }
1061
1120
  /**
1062
1121
  * Move cursor to a target visual line, applying sticky column logic.
@@ -1065,37 +1124,70 @@ export class Editor {
1065
1124
  moveToVisualLine(visualLines, currentVisualLine, targetVisualLine) {
1066
1125
  const currentVL = visualLines[currentVisualLine];
1067
1126
  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;
1127
+ if (!(currentVL && targetVL))
1128
+ return;
1129
+ // When the cursor was snapped to a segment start, resolve the pre-snap
1130
+ // position against the VL it belongs to. This gives the correct visual
1131
+ // column even after a resize reshuffles VLs.
1132
+ let currentVisualCol;
1133
+ if (this.snappedFromCursorCol !== null) {
1134
+ const vlIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
1135
+ currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex].startCol;
1136
+ }
1137
+ else {
1138
+ currentVisualCol = this.state.cursorCol - currentVL.startCol;
1139
+ }
1140
+ // For non-last segments, clamp to length-1 to stay within the segment
1141
+ const isLastSourceSegment = currentVisualLine === visualLines.length - 1 ||
1142
+ visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
1143
+ const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
1144
+ const isLastTargetSegment = targetVisualLine === visualLines.length - 1 ||
1145
+ visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
1146
+ const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
1147
+ const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
1148
+ // Set cursor position
1149
+ this.state.cursorLine = targetVL.logicalLine;
1150
+ const targetCol = targetVL.startCol + moveToVisualCol;
1151
+ const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1152
+ this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1153
+ // Snap cursor to atomic segment boundary (e.g. paste markers)
1154
+ // so the cursor never lands in the middle of a multi-grapheme unit.
1155
+ // Single-grapheme segments don't need snapping.
1156
+ const segments = [...this.segment(logicalLine, "grapheme")];
1157
+ for (const seg of segments) {
1158
+ if (seg.index > this.state.cursorCol)
1159
+ break;
1160
+ if (seg.segment.length <= 1)
1161
+ continue;
1162
+ if (this.state.cursorCol < seg.index + seg.segment.length) {
1163
+ const isContinuation = seg.index < targetVL.startCol;
1164
+ const isMovingDown = targetVisualLine > currentVisualLine;
1165
+ if (isContinuation && isMovingDown) {
1166
+ // The segment started on a previous visual line, and we
1167
+ // already visited it on the way down. Skip all remaining
1168
+ // continuation VLs and land on the first VL past it.
1169
+ const segEnd = seg.index + seg.segment.length;
1170
+ let next = targetVisualLine + 1;
1171
+ while (next < visualLines.length &&
1172
+ visualLines[next].logicalLine === targetVL.logicalLine &&
1173
+ visualLines[next].startCol < segEnd) {
1174
+ next++;
1175
+ }
1176
+ if (next < visualLines.length) {
1177
+ this.moveToVisualLine(visualLines, currentVisualLine, next);
1178
+ return;
1179
+ }
1096
1180
  }
1181
+ // Snap to the start of the segment so it gets highlighted.
1182
+ // Store the pre-snap position so the next vertical move can
1183
+ // resolve it to the correct visual column.
1184
+ this.snappedFromCursorCol = this.state.cursorCol;
1185
+ this.state.cursorCol = seg.index;
1186
+ return;
1097
1187
  }
1098
1188
  }
1189
+ // No snap occurred – we moved out of the atomic segment.
1190
+ this.snappedFromCursorCol = null;
1099
1191
  }
1100
1192
  /**
1101
1193
  * Compute the target visual column for vertical cursor movement.
@@ -1151,7 +1243,7 @@ export class Editor {
1151
1243
  this.setCursorCol(currentLine.length);
1152
1244
  }
1153
1245
  deleteToStartOfLine() {
1154
- this.historyIndex = -1; // Exit history browsing mode
1246
+ this.exitHistoryBrowsing();
1155
1247
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1156
1248
  if (this.state.cursorCol > 0) {
1157
1249
  this.pushUndoSnapshot();
@@ -1179,7 +1271,7 @@ export class Editor {
1179
1271
  }
1180
1272
  }
1181
1273
  deleteToEndOfLine() {
1182
- this.historyIndex = -1; // Exit history browsing mode
1274
+ this.exitHistoryBrowsing();
1183
1275
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1184
1276
  if (this.state.cursorCol < currentLine.length) {
1185
1277
  this.pushUndoSnapshot();
@@ -1204,7 +1296,7 @@ export class Editor {
1204
1296
  }
1205
1297
  }
1206
1298
  deleteWordBackwards() {
1207
- this.historyIndex = -1; // Exit history browsing mode
1299
+ this.exitHistoryBrowsing();
1208
1300
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1209
1301
  // If at start of line, behave like backspace at column 0 (merge with previous line)
1210
1302
  if (this.state.cursorCol === 0) {
@@ -1240,7 +1332,7 @@ export class Editor {
1240
1332
  }
1241
1333
  }
1242
1334
  deleteWordForward() {
1243
- this.historyIndex = -1; // Exit history browsing mode
1335
+ this.exitHistoryBrowsing();
1244
1336
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1245
1337
  // If at end of line, merge with next line (delete the newline)
1246
1338
  if (this.state.cursorCol >= currentLine.length) {
@@ -1273,7 +1365,7 @@ export class Editor {
1273
1365
  }
1274
1366
  }
1275
1367
  handleForwardDelete() {
1276
- this.historyIndex = -1; // Exit history browsing mode
1368
+ this.exitHistoryBrowsing();
1277
1369
  this.lastAction = null;
1278
1370
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1279
1371
  if (this.state.cursorCol < currentLine.length) {
@@ -1281,7 +1373,7 @@ export class Editor {
1281
1373
  // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
1282
1374
  const afterCursor = currentLine.slice(this.state.cursorCol);
1283
1375
  // Find the first grapheme at cursor
1284
- const graphemes = [...this.segment(afterCursor)];
1376
+ const graphemes = [...this.segment(afterCursor, "grapheme")];
1285
1377
  const firstGrapheme = graphemes[0];
1286
1378
  const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
1287
1379
  const before = currentLine.slice(0, this.state.cursorCol);
@@ -1309,8 +1401,8 @@ export class Editor {
1309
1401
  if (this.isInSlashCommandContext(textBeforeCursor)) {
1310
1402
  this.tryTriggerAutocomplete();
1311
1403
  }
1312
- // @ file reference context
1313
- else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
1404
+ // Symbol-based completion context like @, #, or provider triggers
1405
+ else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
1314
1406
  this.tryTriggerAutocomplete();
1315
1407
  }
1316
1408
  }
@@ -1336,7 +1428,7 @@ export class Editor {
1336
1428
  }
1337
1429
  else {
1338
1430
  // Line needs wrapping - use word-aware wrapping
1339
- const chunks = wordWrapLine(line, width, [...this.segment(line)]);
1431
+ const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]);
1340
1432
  for (const chunk of chunks) {
1341
1433
  visualLines.push({
1342
1434
  logicalLine: i,
@@ -1349,26 +1441,29 @@ export class Editor {
1349
1441
  return visualLines;
1350
1442
  }
1351
1443
  /**
1352
- * Find the visual line index for the current cursor position.
1444
+ * Find the visual line index that contains the given logical position.
1353
1445
  */
1354
- findCurrentVisualLine(visualLines) {
1446
+ findVisualLineAt(visualLines, line, col) {
1355
1447
  for (let i = 0; i < visualLines.length; i++) {
1356
1448
  const vl = visualLines[i];
1357
- if (!vl)
1449
+ if (!vl || vl.logicalLine !== line)
1358
1450
  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
- }
1451
+ const offset = col - vl.startCol;
1452
+ // Cursor is in this segment if it's within range. For the last
1453
+ // segment of a logical line, cursor can be at length (end position)
1454
+ const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
1455
+ if (offset >= 0 && (offset < vl.length || (isLastSegmentOfLine && offset === vl.length))) {
1456
+ return i;
1367
1457
  }
1368
1458
  }
1369
- // Fallback: return last visual line
1370
1459
  return visualLines.length - 1;
1371
1460
  }
1461
+ /**
1462
+ * Find the visual line index for the current cursor position.
1463
+ */
1464
+ findCurrentVisualLine(visualLines) {
1465
+ return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol);
1466
+ }
1372
1467
  moveCursor(deltaLine, deltaCol) {
1373
1468
  this.lastAction = null;
1374
1469
  const visualLines = this.buildVisualLineMap(this.lastWidth);
@@ -1385,7 +1480,7 @@ export class Editor {
1385
1480
  // Moving right - move by one grapheme (handles emojis, combining characters, etc.)
1386
1481
  if (this.state.cursorCol < currentLine.length) {
1387
1482
  const afterCursor = currentLine.slice(this.state.cursorCol);
1388
- const graphemes = [...this.segment(afterCursor)];
1483
+ const graphemes = [...this.segment(afterCursor, "grapheme")];
1389
1484
  const firstGrapheme = graphemes[0];
1390
1485
  this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
1391
1486
  }
@@ -1406,7 +1501,7 @@ export class Editor {
1406
1501
  // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
1407
1502
  if (this.state.cursorCol > 0) {
1408
1503
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1409
- const graphemes = [...this.segment(beforeCursor)];
1504
+ const graphemes = [...this.segment(beforeCursor, "grapheme")];
1410
1505
  const lastGrapheme = graphemes[graphemes.length - 1];
1411
1506
  this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
1412
1507
  }
@@ -1418,6 +1513,17 @@ export class Editor {
1418
1513
  }
1419
1514
  }
1420
1515
  }
1516
+ // Keep an open autocomplete picker in sync with the new cursor
1517
+ // position: cursor movement changes the text before the cursor, so a
1518
+ // picker computed for the old position is stale. Re-query so it
1519
+ // refreshes — or closes when the new position yields no suggestions —
1520
+ // mirroring insertCharacter()/handleBackspace(). Without this, arrowing
1521
+ // left from `/cmd ` back into the command name leaves the argument
1522
+ // picker showing against a `/cmd` prefix (and a Tab there would
1523
+ // concatenate the stale suggestion onto the partial command name).
1524
+ if (this.autocompleteState) {
1525
+ this.updateAutocomplete();
1526
+ }
1421
1527
  }
1422
1528
  /**
1423
1529
  * Scroll by a page (direction: -1 for up, 1 for down).
@@ -1444,40 +1550,10 @@ export class Editor {
1444
1550
  }
1445
1551
  return;
1446
1552
  }
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);
1553
+ this.setCursorCol(findWordBackward(currentLine, this.state.cursorCol, {
1554
+ segment: (text) => this.segment(text, "word"),
1555
+ isAtomicSegment: isPasteMarker,
1556
+ }));
1481
1557
  }
1482
1558
  /**
1483
1559
  * Yank (paste) the most recent kill ring entry at cursor position.
@@ -1512,7 +1588,7 @@ export class Editor {
1512
1588
  * Insert text at cursor position (used by yank operations).
1513
1589
  */
1514
1590
  insertYankedText(text) {
1515
- this.historyIndex = -1; // Exit history browsing mode
1591
+ this.exitHistoryBrowsing();
1516
1592
  const lines = text.split("\n");
1517
1593
  if (lines.length === 1) {
1518
1594
  // Single line - insert at cursor
@@ -1584,7 +1660,7 @@ export class Editor {
1584
1660
  this.undoStack.push(this.state);
1585
1661
  }
1586
1662
  undo() {
1587
- this.historyIndex = -1; // Exit history browsing mode
1663
+ this.exitHistoryBrowsing();
1588
1664
  const snapshot = this.undoStack.pop();
1589
1665
  if (!snapshot)
1590
1666
  return;
@@ -1634,41 +1710,10 @@ export class Editor {
1634
1710
  }
1635
1711
  return;
1636
1712
  }
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);
1713
+ this.setCursorCol(findWordForward(currentLine, this.state.cursorCol, {
1714
+ segment: (text) => this.segment(text, "word"),
1715
+ isAtomicSegment: isPasteMarker,
1716
+ }));
1672
1717
  }
1673
1718
  // Slash menu only allowed on the first line of the editor
1674
1719
  isSlashMenuAllowed() {
@@ -1741,9 +1786,8 @@ export class Editor {
1741
1786
  if (!this.autocompleteProvider)
1742
1787
  return;
1743
1788
  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);
1789
+ const shouldTrigger = !this.autocompleteProvider.shouldTriggerFileCompletion ||
1790
+ this.autocompleteProvider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1747
1791
  if (!shouldTrigger) {
1748
1792
  return;
1749
1793
  }
@@ -1777,14 +1821,25 @@ export class Editor {
1777
1821
  })();
1778
1822
  await this.autocompleteRequestTask;
1779
1823
  }
1824
+ setAutocompleteTriggerCharacters(triggerCharacters) {
1825
+ const next = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
1826
+ for (const character of triggerCharacters) {
1827
+ if (character.length !== 1 || character === "/" || isWhitespaceChar(character) || next.includes(character)) {
1828
+ continue;
1829
+ }
1830
+ next.push(character);
1831
+ }
1832
+ this.autocompleteTriggerCharacters = next;
1833
+ this.autocompleteTriggerPattern = buildTriggerPattern(next);
1834
+ this.autocompleteDebouncePattern = buildDebouncePattern(next);
1835
+ }
1780
1836
  getAutocompleteDebounceMs(options) {
1781
1837
  if (options.explicitTab || options.force) {
1782
1838
  return 0;
1783
1839
  }
1784
1840
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1785
1841
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1786
- const isAttachmentContext = /(?:^|[ \t])@(?:"[^"]*|[^\s]*)$/.test(textBeforeCursor);
1787
- return isAttachmentContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
1842
+ return this.autocompleteDebouncePattern.test(textBeforeCursor) ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
1788
1843
  }
1789
1844
  async runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options) {
1790
1845
  if (!this.autocompleteProvider)