@jecruz/merc-tui 0.1.3 → 0.1.6

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 (77) hide show
  1. package/README.md +14 -1
  2. package/dist/autocomplete.d.ts +1 -0
  3. package/dist/autocomplete.d.ts.map +1 -1
  4. package/dist/autocomplete.js +85 -24
  5. package/dist/autocomplete.js.map +1 -1
  6. package/dist/components/box.d.ts +1 -0
  7. package/dist/components/box.d.ts.map +1 -1
  8. package/dist/components/box.js +6 -1
  9. package/dist/components/box.js.map +1 -1
  10. package/dist/components/editor.d.ts.map +1 -1
  11. package/dist/components/editor.js +80 -115
  12. package/dist/components/editor.js.map +1 -1
  13. package/dist/components/image.d.ts +12 -0
  14. package/dist/components/image.d.ts.map +1 -1
  15. package/dist/components/image.js +45 -6
  16. package/dist/components/image.js.map +1 -1
  17. package/dist/components/input.d.ts.map +1 -1
  18. package/dist/components/input.js +7 -55
  19. package/dist/components/input.js.map +1 -1
  20. package/dist/components/markdown.d.ts +1 -0
  21. package/dist/components/markdown.d.ts.map +1 -1
  22. package/dist/components/markdown.js +18 -6
  23. package/dist/components/markdown.js.map +1 -1
  24. package/dist/components/settings-list.d.ts.map +1 -1
  25. package/dist/components/settings-list.js +4 -7
  26. package/dist/components/settings-list.js.map +1 -1
  27. package/dist/components/text.d.ts +1 -0
  28. package/dist/components/text.d.ts.map +1 -1
  29. package/dist/components/text.js +11 -5
  30. package/dist/components/text.js.map +1 -1
  31. package/dist/fuzzy.d.ts.map +1 -1
  32. package/dist/fuzzy.js +23 -22
  33. package/dist/fuzzy.js.map +1 -1
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/keys.d.ts.map +1 -1
  39. package/dist/keys.js +9 -1
  40. package/dist/keys.js.map +1 -1
  41. package/dist/native-modifier-digests.d.ts +6 -0
  42. package/dist/native-modifier-digests.d.ts.map +1 -0
  43. package/dist/native-modifier-digests.js +6 -0
  44. package/dist/native-modifier-digests.js.map +1 -0
  45. package/dist/native-modifiers.d.ts +11 -0
  46. package/dist/native-modifiers.d.ts.map +1 -0
  47. package/dist/native-modifiers.js +98 -0
  48. package/dist/native-modifiers.js.map +1 -0
  49. package/dist/stdin-buffer.d.ts +25 -2
  50. package/dist/stdin-buffer.d.ts.map +1 -1
  51. package/dist/stdin-buffer.js +176 -3
  52. package/dist/stdin-buffer.js.map +1 -1
  53. package/dist/terminal-image.d.ts +10 -0
  54. package/dist/terminal-image.d.ts.map +1 -1
  55. package/dist/terminal-image.js +102 -17
  56. package/dist/terminal-image.js.map +1 -1
  57. package/dist/terminal.d.ts +17 -0
  58. package/dist/terminal.d.ts.map +1 -1
  59. package/dist/terminal.js +165 -56
  60. package/dist/terminal.js.map +1 -1
  61. package/dist/tui.d.ts +28 -7
  62. package/dist/tui.d.ts.map +1 -1
  63. package/dist/tui.js +322 -125
  64. package/dist/tui.js.map +1 -1
  65. package/dist/utils.d.ts +16 -1
  66. package/dist/utils.d.ts.map +1 -1
  67. package/dist/utils.js +115 -29
  68. package/dist/utils.js.map +1 -1
  69. package/dist/word-navigation.d.ts +25 -0
  70. package/dist/word-navigation.d.ts.map +1 -0
  71. package/dist/word-navigation.js +96 -0
  72. package/dist/word-navigation.js.map +1 -0
  73. package/native/darwin/README.md +13 -0
  74. package/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  75. package/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  76. package/native/darwin/prebuilds/manifest.json +24 -0
  77. package/package.json +4 -1
@@ -3,9 +3,11 @@ 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 { autocompleteBoundaryRegex, autocompleteSeparatorRegex, cjkBreakRegex, getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, sliceByColumn, 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,22 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT = {
156
167
  maxPrimaryColumnWidth: 32,
157
168
  };
158
169
  const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
170
+ // Unquoted completions end at whitespace or CJK punctuation; quoted paths may contain either.
171
+ const unquotedAutocompleteSuffixRegex = new RegExp(`(?:(?!${autocompleteSeparatorRegex.source}).)*`, "u");
172
+ // Trigger tokens may be wrapped in prose, e.g. "(@src/foo" or "`@src/foo".
173
+ const autocompleteTokenStartSource = `${autocompleteBoundaryRegex.source}[([{<\`]*`;
174
+ const symbolTriggerPattern = new RegExp(`${autocompleteTokenStartSource}(?:@"[^"]*|[@#]${unquotedAutocompleteSuffixRegex.source})$`, "u");
175
+ const symbolDebouncePattern = new RegExp(`${autocompleteTokenStartSource}(?:@(?:"[^"]*|${unquotedAutocompleteSuffixRegex.source})|#${unquotedAutocompleteSuffixRegex.source})$`, "u");
176
+ function createScrollBorder(direction, hiddenLineCount, width) {
177
+ const availableWidth = Math.max(0, width);
178
+ const indicator = `─── ${direction} ${hiddenLineCount} more `;
179
+ const remaining = availableWidth - visibleWidth(indicator);
180
+ if (remaining >= 0)
181
+ return indicator + "─".repeat(remaining);
182
+ const ellipsis = "...".slice(0, availableWidth);
183
+ const indicatorWidth = availableWidth - visibleWidth(ellipsis);
184
+ return sliceByColumn(indicator, 0, indicatorWidth, true) + ellipsis;
185
+ }
159
186
  export class Editor {
160
187
  state = {
161
188
  lines: [""],
@@ -226,8 +253,8 @@ export class Editor {
226
253
  return new Set(this.pastes.keys());
227
254
  }
228
255
  /** Segment text with paste-marker awareness, only merging markers with valid IDs. */
229
- segment(text) {
230
- return segmentWithMarkers(text, this.validPasteIds());
256
+ segment(text, mode) {
257
+ return segmentWithMarkers(text, mode === "word" ? wordSegmenter : graphemeSegmenter, this.validPasteIds());
231
258
  }
232
259
  getPaddingX() {
233
260
  return this.paddingX;
@@ -370,14 +397,8 @@ export class Editor {
370
397
  const rightPadding = leftPadding;
371
398
  // Render top border (with scroll indicator if scrolled down)
372
399
  if (this.scrollOffset > 0) {
373
- const indicator = `─── ↑ ${this.scrollOffset} more `;
374
- const remaining = width - visibleWidth(indicator);
375
- if (remaining >= 0) {
376
- result.push(this.borderColor(indicator + "─".repeat(remaining)));
377
- }
378
- else {
379
- result.push(this.borderColor(truncateToWidth(indicator, width)));
380
- }
400
+ const border = createScrollBorder("↑", this.scrollOffset, width);
401
+ result.push(this.borderColor(border));
381
402
  }
382
403
  else {
383
404
  result.push(horizontal.repeat(width));
@@ -400,7 +421,7 @@ export class Editor {
400
421
  if (after.length > 0) {
401
422
  // Cursor is on a character (grapheme) - replace it with highlighted version
402
423
  // Get the first grapheme from 'after'
403
- const afterGraphemes = [...this.segment(after)];
424
+ const afterGraphemes = [...this.segment(after, "grapheme")];
404
425
  const firstGrapheme = afterGraphemes[0]?.segment || "";
405
426
  const restAfter = after.slice(firstGrapheme.length);
406
427
  const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
@@ -427,9 +448,8 @@ export class Editor {
427
448
  // Render bottom border (with scroll indicator if more content below)
428
449
  const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length);
429
450
  if (linesBelow > 0) {
430
- const indicator = `─── ↓ ${linesBelow} more `;
431
- const remaining = width - visibleWidth(indicator);
432
- result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
451
+ const border = createScrollBorder("↓", linesBelow, width);
452
+ result.push(this.borderColor(border));
433
453
  }
434
454
  else {
435
455
  result.push(horizontal.repeat(width));
@@ -734,7 +754,7 @@ export class Editor {
734
754
  }
735
755
  else {
736
756
  // Line needs wrapping - use word-aware wrapping
737
- const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]);
757
+ const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]);
738
758
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
739
759
  const chunk = chunks[chunkIndex];
740
760
  if (!chunk)
@@ -811,13 +831,13 @@ export class Editor {
811
831
  this.cancelAutocomplete();
812
832
  this.lastAction = null;
813
833
  this.exitHistoryBrowsing();
814
- this.pastes.clear();
815
- this.pasteCounter = 0;
816
834
  const normalized = this.normalizeText(text);
817
835
  // Push undo snapshot if content differs (makes programmatic changes undoable)
818
836
  if (this.getText() !== normalized) {
819
837
  this.pushUndoSnapshot();
820
838
  }
839
+ this.pastes.clear();
840
+ this.pasteCounter = 0;
821
841
  this.setTextInternal(normalized);
822
842
  }
823
843
  /**
@@ -914,13 +934,12 @@ export class Editor {
914
934
  else if (char === "@" || char === "#") {
915
935
  const currentLine = this.state.lines[this.state.cursorLine] || "";
916
936
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
917
- const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
918
- if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
937
+ if (symbolTriggerPattern.test(textBeforeCursor)) {
919
938
  this.tryTriggerAutocomplete();
920
939
  }
921
940
  }
922
941
  // Also auto-trigger when typing letters in a slash command or symbol completion context
923
- else if (/[a-zA-Z0-9.\-_]/.test(char)) {
942
+ else if (/[a-zA-Z0-9.\-_]/.test(char) || cjkBreakRegex.test(char)) {
924
943
  const currentLine = this.state.lines[this.state.cursorLine] || "";
925
944
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
926
945
  // Check if we're in a slash command (with or without space for arguments)
@@ -928,7 +947,7 @@ export class Editor {
928
947
  this.tryTriggerAutocomplete();
929
948
  }
930
949
  // Check if we're in a symbol-based completion context like @ or #
931
- else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
950
+ else if (symbolTriggerPattern.test(textBeforeCursor)) {
932
951
  this.tryTriggerAutocomplete();
933
952
  }
934
953
  }
@@ -1049,7 +1068,7 @@ export class Editor {
1049
1068
  let line = this.state.lines[this.state.cursorLine] || "";
1050
1069
  const beforeCursor = line.slice(0, this.state.cursorCol);
1051
1070
  // Find the last grapheme in the text before cursor
1052
- const graphemes = [...this.segment(beforeCursor)];
1071
+ const graphemes = [...this.segment(beforeCursor, "grapheme")];
1053
1072
  const lastGrapheme = graphemes[graphemes.length - 1];
1054
1073
  const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
1055
1074
  const isPastedSegment = PASTE_MARKER_SINGLE.exec(lastGrapheme.segment);
@@ -1057,15 +1076,20 @@ export class Editor {
1057
1076
  const targetId = Number(isPastedSegment[1]);
1058
1077
  this.pastes.delete(targetId);
1059
1078
  this.pasteCounter--;
1060
- // Renumber higher paste IDs so the marker registry stays dense.
1079
+ // Shift registry entries down in ascending id order, independent
1080
+ // of marker order in the text ([paste #3] becomes [paste #2] when
1081
+ // [paste #1] is removed).
1082
+ const higherIds = [...this.pastes.keys()].filter((id) => id > targetId).sort((a, b) => a - b);
1083
+ for (const id of higherIds) {
1084
+ this.pastes.set(id - 1, this.pastes.get(id));
1085
+ this.pastes.delete(id);
1086
+ }
1087
+ // Renumber markers with ids greater than the removed one.
1061
1088
  this.state.lines = this.state.lines.map((line) => line.replace(PASTE_MARKER_REGEX, (fullMatch, idGroup, suffixGroup) => {
1062
- const id = Number(idGroup);
1063
- if (id <= targetId)
1089
+ const x = Number(idGroup);
1090
+ if (x <= targetId)
1064
1091
  return fullMatch;
1065
- const newText = `[paste #${id - 1}${suffixGroup}]`;
1066
- this.pastes.set(id - 1, this.pastes.get(id) ?? newText);
1067
- this.pastes.delete(id);
1068
- return newText;
1092
+ return `[paste #${x - 1}${suffixGroup}]`;
1069
1093
  }));
1070
1094
  }
1071
1095
  line = this.state.lines[this.state.cursorLine] || "";
@@ -1100,7 +1124,7 @@ export class Editor {
1100
1124
  this.tryTriggerAutocomplete();
1101
1125
  }
1102
1126
  // Symbol-based completion context like @ or #
1103
- else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
1127
+ else if (symbolTriggerPattern.test(textBeforeCursor)) {
1104
1128
  this.tryTriggerAutocomplete();
1105
1129
  }
1106
1130
  }
@@ -1150,7 +1174,7 @@ export class Editor {
1150
1174
  // Snap cursor to atomic segment boundary (e.g. paste markers)
1151
1175
  // so the cursor never lands in the middle of a multi-grapheme unit.
1152
1176
  // Single-grapheme segments don't need snapping.
1153
- const segments = [...this.segment(logicalLine)];
1177
+ const segments = [...this.segment(logicalLine, "grapheme")];
1154
1178
  for (const seg of segments) {
1155
1179
  if (seg.index > this.state.cursorCol)
1156
1180
  break;
@@ -1370,7 +1394,7 @@ export class Editor {
1370
1394
  // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
1371
1395
  const afterCursor = currentLine.slice(this.state.cursorCol);
1372
1396
  // Find the first grapheme at cursor
1373
- const graphemes = [...this.segment(afterCursor)];
1397
+ const graphemes = [...this.segment(afterCursor, "grapheme")];
1374
1398
  const firstGrapheme = graphemes[0];
1375
1399
  const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
1376
1400
  const before = currentLine.slice(0, this.state.cursorCol);
@@ -1399,7 +1423,7 @@ export class Editor {
1399
1423
  this.tryTriggerAutocomplete();
1400
1424
  }
1401
1425
  // Symbol-based completion context like @ or #
1402
- else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
1426
+ else if (symbolTriggerPattern.test(textBeforeCursor)) {
1403
1427
  this.tryTriggerAutocomplete();
1404
1428
  }
1405
1429
  }
@@ -1425,7 +1449,7 @@ export class Editor {
1425
1449
  }
1426
1450
  else {
1427
1451
  // Line needs wrapping - use word-aware wrapping
1428
- const chunks = wordWrapLine(line, width, [...this.segment(line)]);
1452
+ const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]);
1429
1453
  for (const chunk of chunks) {
1430
1454
  visualLines.push({
1431
1455
  logicalLine: i,
@@ -1477,7 +1501,7 @@ export class Editor {
1477
1501
  // Moving right - move by one grapheme (handles emojis, combining characters, etc.)
1478
1502
  if (this.state.cursorCol < currentLine.length) {
1479
1503
  const afterCursor = currentLine.slice(this.state.cursorCol);
1480
- const graphemes = [...this.segment(afterCursor)];
1504
+ const graphemes = [...this.segment(afterCursor, "grapheme")];
1481
1505
  const firstGrapheme = graphemes[0];
1482
1506
  this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
1483
1507
  }
@@ -1498,7 +1522,7 @@ export class Editor {
1498
1522
  // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
1499
1523
  if (this.state.cursorCol > 0) {
1500
1524
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1501
- const graphemes = [...this.segment(beforeCursor)];
1525
+ const graphemes = [...this.segment(beforeCursor, "grapheme")];
1502
1526
  const lastGrapheme = graphemes[graphemes.length - 1];
1503
1527
  this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
1504
1528
  }
@@ -1547,40 +1571,10 @@ export class Editor {
1547
1571
  }
1548
1572
  return;
1549
1573
  }
1550
- const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1551
- const graphemes = [...this.segment(textBeforeCursor)];
1552
- let newCol = this.state.cursorCol;
1553
- // Skip trailing whitespace
1554
- while (graphemes.length > 0 &&
1555
- !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") &&
1556
- isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
1557
- newCol -= graphemes.pop()?.segment.length || 0;
1558
- }
1559
- if (graphemes.length > 0) {
1560
- const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
1561
- if (isPasteMarker(lastGrapheme)) {
1562
- // Paste marker is a single atomic word
1563
- newCol -= graphemes.pop()?.segment.length || 0;
1564
- }
1565
- else if (isPunctuationChar(lastGrapheme)) {
1566
- // Skip punctuation run
1567
- while (graphemes.length > 0 &&
1568
- isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
1569
- !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
1570
- newCol -= graphemes.pop()?.segment.length || 0;
1571
- }
1572
- }
1573
- else {
1574
- // Skip word run
1575
- while (graphemes.length > 0 &&
1576
- !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
1577
- !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
1578
- !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
1579
- newCol -= graphemes.pop()?.segment.length || 0;
1580
- }
1581
- }
1582
- }
1583
- this.setCursorCol(newCol);
1574
+ this.setCursorCol(findWordBackward(currentLine, this.state.cursorCol, {
1575
+ segment: (text) => this.segment(text, "word"),
1576
+ isAtomicSegment: isPasteMarker,
1577
+ }));
1584
1578
  }
1585
1579
  /**
1586
1580
  * Yank (paste) the most recent kill ring entry at cursor position.
@@ -1684,14 +1678,16 @@ export class Editor {
1684
1678
  }
1685
1679
  }
1686
1680
  pushUndoSnapshot() {
1687
- this.undoStack.push(this.state);
1681
+ this.undoStack.push({ state: this.state, pastes: this.pastes, pasteCounter: this.pasteCounter });
1688
1682
  }
1689
1683
  undo() {
1690
1684
  this.exitHistoryBrowsing();
1691
1685
  const snapshot = this.undoStack.pop();
1692
1686
  if (!snapshot)
1693
1687
  return;
1694
- Object.assign(this.state, snapshot);
1688
+ Object.assign(this.state, snapshot.state);
1689
+ this.pastes = snapshot.pastes;
1690
+ this.pasteCounter = snapshot.pasteCounter;
1695
1691
  this.lastAction = null;
1696
1692
  this.preferredVisualCol = null;
1697
1693
  if (this.onChange) {
@@ -1737,41 +1733,10 @@ export class Editor {
1737
1733
  }
1738
1734
  return;
1739
1735
  }
1740
- const textAfterCursor = currentLine.slice(this.state.cursorCol);
1741
- const segments = this.segment(textAfterCursor);
1742
- const iterator = segments[Symbol.iterator]();
1743
- let next = iterator.next();
1744
- let newCol = this.state.cursorCol;
1745
- // Skip leading whitespace
1746
- while (!next.done && !isPasteMarker(next.value.segment) && isWhitespaceChar(next.value.segment)) {
1747
- newCol += next.value.segment.length;
1748
- next = iterator.next();
1749
- }
1750
- if (!next.done) {
1751
- const firstGrapheme = next.value.segment;
1752
- if (isPasteMarker(firstGrapheme)) {
1753
- // Paste marker is a single atomic word
1754
- newCol += firstGrapheme.length;
1755
- }
1756
- else if (isPunctuationChar(firstGrapheme)) {
1757
- // Skip punctuation run
1758
- while (!next.done && isPunctuationChar(next.value.segment) && !isPasteMarker(next.value.segment)) {
1759
- newCol += next.value.segment.length;
1760
- next = iterator.next();
1761
- }
1762
- }
1763
- else {
1764
- // Skip word run
1765
- while (!next.done &&
1766
- !isWhitespaceChar(next.value.segment) &&
1767
- !isPunctuationChar(next.value.segment) &&
1768
- !isPasteMarker(next.value.segment)) {
1769
- newCol += next.value.segment.length;
1770
- next = iterator.next();
1771
- }
1772
- }
1773
- }
1774
- this.setCursorCol(newCol);
1736
+ this.setCursorCol(findWordForward(currentLine, this.state.cursorCol, {
1737
+ segment: (text) => this.segment(text, "word"),
1738
+ isAtomicSegment: isPasteMarker,
1739
+ }));
1775
1740
  }
1776
1741
  // Slash menu only allowed on the first line of the editor
1777
1742
  isSlashMenuAllowed() {
@@ -1885,7 +1850,7 @@ export class Editor {
1885
1850
  }
1886
1851
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1887
1852
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1888
- const isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(textBeforeCursor);
1853
+ const isSymbolAutocompleteContext = symbolDebouncePattern.test(textBeforeCursor);
1889
1854
  return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
1890
1855
  }
1891
1856
  async runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options) {