@mariozechner/pi-tui 0.57.0 → 0.58.0

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.
@@ -3,9 +3,69 @@ import { decodeKittyPrintable, matchesKey } from "../keys.js";
3
3
  import { KillRing } from "../kill-ring.js";
4
4
  import { CURSOR_MARKER } from "../tui.js";
5
5
  import { UndoStack } from "../undo-stack.js";
6
- import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
6
+ import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.js";
7
7
  import { SelectList } from "./select-list.js";
8
- const segmenter = getSegmenter();
8
+ const baseSegmenter = getSegmenter();
9
+ /** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */
10
+ const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
11
+ /** Non-global version for single-segment testing. */
12
+ const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/;
13
+ /** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */
14
+ function isPasteMarker(segment) {
15
+ return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment);
16
+ }
17
+ /**
18
+ * A segmenter that wraps Intl.Segmenter and merges graphemes that fall
19
+ * within paste markers into single atomic segments. This makes cursor
20
+ * movement, deletion, word-wrap, etc. treat paste markers as single units.
21
+ *
22
+ * Only markers whose numeric ID exists in `validIds` are merged.
23
+ */
24
+ function segmentWithMarkers(text, validIds) {
25
+ // Fast path: no paste markers in the text or no valid IDs.
26
+ if (validIds.size === 0 || !text.includes("[paste #")) {
27
+ return baseSegmenter.segment(text);
28
+ }
29
+ // Find all marker spans with valid IDs.
30
+ const markers = [];
31
+ for (const m of text.matchAll(PASTE_MARKER_REGEX)) {
32
+ const id = Number.parseInt(m[1], 10);
33
+ if (!validIds.has(id))
34
+ continue;
35
+ markers.push({ start: m.index, end: m.index + m[0].length });
36
+ }
37
+ if (markers.length === 0) {
38
+ return baseSegmenter.segment(text);
39
+ }
40
+ // Build merged segment list.
41
+ const baseSegments = baseSegmenter.segment(text);
42
+ const result = [];
43
+ let markerIdx = 0;
44
+ for (const seg of baseSegments) {
45
+ // Skip past markers that are entirely before this segment.
46
+ while (markerIdx < markers.length && markers[markerIdx].end <= seg.index) {
47
+ markerIdx++;
48
+ }
49
+ const marker = markerIdx < markers.length ? markers[markerIdx] : null;
50
+ if (marker && seg.index >= marker.start && seg.index < marker.end) {
51
+ // This segment falls inside a marker.
52
+ // If this is the first segment of the marker, emit a merged segment.
53
+ if (seg.index === marker.start) {
54
+ const markerText = text.slice(marker.start, marker.end);
55
+ result.push({
56
+ segment: markerText,
57
+ index: marker.start,
58
+ input: text,
59
+ });
60
+ }
61
+ // Otherwise skip (already merged into the first segment).
62
+ }
63
+ else {
64
+ result.push(seg);
65
+ }
66
+ }
67
+ return result;
68
+ }
9
69
  /**
10
70
  * Split a line into word-wrapped chunks.
11
71
  * Wraps at word boundaries when possible, falling back to character-level
@@ -13,9 +73,11 @@ const segmenter = getSegmenter();
13
73
  *
14
74
  * @param line - The text line to wrap
15
75
  * @param maxWidth - Maximum visible width per chunk
76
+ * @param preSegmented - Optional pre-segmented graphemes (e.g. with paste-marker awareness).
77
+ * When omitted the default Intl.Segmenter is used.
16
78
  * @returns Array of chunks with text and position information
17
79
  */
18
- export function wordWrapLine(line, maxWidth) {
80
+ export function wordWrapLine(line, maxWidth, preSegmented) {
19
81
  if (!line || maxWidth <= 0) {
20
82
  return [{ text: "", startIndex: 0, endIndex: 0 }];
21
83
  }
@@ -24,7 +86,7 @@ export function wordWrapLine(line, maxWidth) {
24
86
  return [{ text: line, startIndex: 0, endIndex: line.length }];
25
87
  }
26
88
  const chunks = [];
27
- const segments = [...segmenter.segment(line)];
89
+ const segments = preSegmented ?? [...baseSegmenter.segment(line)];
28
90
  let currentWidth = 0;
29
91
  let chunkStart = 0;
30
92
  // Wrap opportunity: the position after the last whitespace before a non-whitespace
@@ -36,30 +98,51 @@ export function wordWrapLine(line, maxWidth) {
36
98
  const grapheme = seg.segment;
37
99
  const gWidth = visibleWidth(grapheme);
38
100
  const charIndex = seg.index;
39
- const isWs = isWhitespaceChar(grapheme);
101
+ const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme);
40
102
  // Overflow check before advancing.
41
103
  if (currentWidth + gWidth > maxWidth) {
42
- if (wrapOppIndex >= 0) {
43
- // Backtrack to last wrap opportunity.
104
+ if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) {
105
+ // Backtrack to last wrap opportunity (the remaining content
106
+ // plus the current grapheme still fits within maxWidth).
44
107
  chunks.push({ text: line.slice(chunkStart, wrapOppIndex), startIndex: chunkStart, endIndex: wrapOppIndex });
45
108
  chunkStart = wrapOppIndex;
46
109
  currentWidth -= wrapOppWidth;
47
110
  }
48
111
  else if (chunkStart < charIndex) {
49
- // No wrap opportunity: force-break at current position.
112
+ // No viable wrap opportunity: force-break at current position.
113
+ // This also handles the case where backtracking to a word
114
+ // boundary wouldn't help because the remaining content plus
115
+ // the current grapheme (e.g. a wide character) still exceeds
116
+ // maxWidth.
50
117
  chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex });
51
118
  chunkStart = charIndex;
52
119
  currentWidth = 0;
53
120
  }
54
121
  wrapOppIndex = -1;
55
122
  }
123
+ if (gWidth > maxWidth) {
124
+ // Single atomic segment wider than maxWidth (e.g. paste marker
125
+ // in a narrow terminal). Re-wrap it at grapheme granularity.
126
+ // The segment remains logically atomic for cursor
127
+ // movement / editing — the split is purely visual for word-wrap layout.
128
+ const subChunks = wordWrapLine(grapheme, maxWidth);
129
+ for (let j = 0; j < subChunks.length - 1; j++) {
130
+ const sc = subChunks[j];
131
+ chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex });
132
+ }
133
+ const last = subChunks[subChunks.length - 1];
134
+ chunkStart = charIndex + last.startIndex;
135
+ currentWidth = visibleWidth(last.text);
136
+ wrapOppIndex = -1;
137
+ continue;
138
+ }
56
139
  // Advance.
57
140
  currentWidth += gWidth;
58
141
  // Record wrap opportunity: whitespace followed by non-whitespace.
59
142
  // Multiple spaces join (no break between them); the break point is
60
143
  // after the last space before the next word.
61
144
  const next = segments[i + 1];
62
- if (isWs && next && !isWhitespaceChar(next.segment)) {
145
+ if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
63
146
  wrapOppIndex = next.index;
64
147
  wrapOppWidth = currentWidth;
65
148
  }
@@ -121,6 +204,14 @@ export class Editor {
121
204
  const maxVisible = options.autocompleteMaxVisible ?? 5;
122
205
  this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
123
206
  }
207
+ /** Set of currently valid paste IDs, for marker-aware segmentation. */
208
+ validPasteIds() {
209
+ return new Set(this.pastes.keys());
210
+ }
211
+ /** Segment text with paste-marker awareness, only merging markers with valid IDs. */
212
+ segment(text) {
213
+ return segmentWithMarkers(text, this.validPasteIds());
214
+ }
124
215
  getPaddingX() {
125
216
  return this.paddingX;
126
217
  }
@@ -196,7 +287,7 @@ export class Editor {
196
287
  }
197
288
  /** Internal setText that doesn't reset history state - used by navigateHistory */
198
289
  setTextInternal(text) {
199
- const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
290
+ const lines = text.split("\n");
200
291
  this.state.lines = lines.length === 0 ? [""] : lines;
201
292
  this.state.cursorLine = this.state.lines.length - 1;
202
293
  this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0);
@@ -247,7 +338,12 @@ export class Editor {
247
338
  if (this.scrollOffset > 0) {
248
339
  const indicator = `─── ↑ ${this.scrollOffset} more `;
249
340
  const remaining = width - visibleWidth(indicator);
250
- result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
341
+ if (remaining >= 0) {
342
+ result.push(this.borderColor(indicator + "─".repeat(remaining)));
343
+ }
344
+ else {
345
+ result.push(this.borderColor(truncateToWidth(indicator, width)));
346
+ }
251
347
  }
252
348
  else {
253
349
  result.push(horizontal.repeat(width));
@@ -268,7 +364,7 @@ export class Editor {
268
364
  if (after.length > 0) {
269
365
  // Cursor is on a character (grapheme) - replace it with highlighted version
270
366
  // Get the first grapheme from 'after'
271
- const afterGraphemes = [...segmenter.segment(after)];
367
+ const afterGraphemes = [...this.segment(after)];
272
368
  const firstGrapheme = afterGraphemes[0]?.segment || "";
273
369
  const restAfter = after.slice(firstGrapheme.length);
274
370
  const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
@@ -378,6 +474,7 @@ export class Editor {
378
474
  if (kb.matches(data, "tab")) {
379
475
  const selected = this.autocompleteList.getSelectedItem();
380
476
  if (selected && this.autocompleteProvider) {
477
+ const shouldChainSlashArgumentAutocomplete = this.shouldChainSlashArgumentAutocompleteOnTabSelection();
381
478
  this.pushUndoSnapshot();
382
479
  this.lastAction = null;
383
480
  const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
@@ -387,6 +484,9 @@ export class Editor {
387
484
  this.cancelAutocomplete();
388
485
  if (this.onChange)
389
486
  this.onChange(this.getText());
487
+ if (shouldChainSlashArgumentAutocomplete && this.isBareCompletedSlashCommandAtCursor()) {
488
+ this.tryTriggerAutocomplete();
489
+ }
390
490
  }
391
491
  return;
392
492
  }
@@ -603,7 +703,7 @@ export class Editor {
603
703
  }
604
704
  else {
605
705
  // Line needs wrapping - use word-aware wrapping
606
- const chunks = wordWrapLine(line, contentWidth);
706
+ const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]);
607
707
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
608
708
  const chunk = chunks[chunkIndex];
609
709
  if (!chunk)
@@ -676,11 +776,12 @@ export class Editor {
676
776
  setText(text) {
677
777
  this.lastAction = null;
678
778
  this.historyIndex = -1; // Exit history browsing mode
779
+ const normalized = this.normalizeText(text);
679
780
  // Push undo snapshot if content differs (makes programmatic changes undoable)
680
- if (this.getText() !== text) {
781
+ if (this.getText() !== normalized) {
681
782
  this.pushUndoSnapshot();
682
783
  }
683
- this.setTextInternal(text);
784
+ this.setTextInternal(normalized);
684
785
  }
685
786
  /**
686
787
  * Insert text at the current cursor position.
@@ -695,6 +796,14 @@ export class Editor {
695
796
  this.historyIndex = -1;
696
797
  this.insertTextAtCursorInternal(text);
697
798
  }
799
+ /**
800
+ * Normalize text for editor storage:
801
+ * - Normalize line endings (\r\n and \r -> \n)
802
+ * - Expand tabs to 4 spaces
803
+ */
804
+ normalizeText(text) {
805
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
806
+ }
698
807
  /**
699
808
  * Internal text insertion at cursor. Handles single and multi-line text.
700
809
  * Does not push undo snapshots or trigger autocomplete - caller is responsible.
@@ -703,8 +812,8 @@ export class Editor {
703
812
  insertTextAtCursorInternal(text) {
704
813
  if (!text)
705
814
  return;
706
- // Normalize line endings
707
- const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
815
+ // Normalize line endings and tabs
816
+ const normalized = this.normalizeText(text);
708
817
  const insertedLines = normalized.split("\n");
709
818
  const currentLine = this.state.lines[this.state.cursorLine] || "";
710
819
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
@@ -795,12 +904,10 @@ export class Editor {
795
904
  this.historyIndex = -1; // Exit history browsing mode
796
905
  this.lastAction = null;
797
906
  this.pushUndoSnapshot();
798
- // Clean the pasted text
799
- const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
800
- // Convert tabs to spaces (4 spaces per tab)
801
- const tabExpandedText = cleanText.replace(/\t/g, " ");
907
+ // Clean the pasted text: normalize line endings, expand tabs
908
+ const cleanText = this.normalizeText(pastedText);
802
909
  // Filter out non-printable characters except newlines
803
- let filteredText = tabExpandedText
910
+ let filteredText = cleanText
804
911
  .split("")
805
912
  .filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
806
913
  .join("");
@@ -893,7 +1000,7 @@ export class Editor {
893
1000
  const line = this.state.lines[this.state.cursorLine] || "";
894
1001
  const beforeCursor = line.slice(0, this.state.cursorCol);
895
1002
  // Find the last grapheme in the text before cursor
896
- const graphemes = [...segmenter.segment(beforeCursor)];
1003
+ const graphemes = [...this.segment(beforeCursor)];
897
1004
  const lastGrapheme = graphemes[graphemes.length - 1];
898
1005
  const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
899
1006
  const before = line.slice(0, this.state.cursorCol - graphemeLength);
@@ -962,6 +1069,21 @@ export class Editor {
962
1069
  const targetCol = targetVL.startCol + moveToVisualCol;
963
1070
  const logicalLine = this.state.lines[targetVL.logicalLine] || "";
964
1071
  this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1072
+ // Snap cursor to atomic segment boundary (e.g. paste markers)
1073
+ // so the cursor never lands in the middle of a multi-grapheme unit.
1074
+ // Single-grapheme segments don't need snapping.
1075
+ const segments = [...this.segment(logicalLine)];
1076
+ for (const seg of segments) {
1077
+ if (seg.index > this.state.cursorCol)
1078
+ break;
1079
+ if (seg.segment.length <= 1)
1080
+ continue;
1081
+ if (this.state.cursorCol < seg.index + seg.segment.length) {
1082
+ // jump to the start of the segment when moving up, to the end when moving down.
1083
+ this.state.cursorCol = currentVisualLine > targetVisualLine ? seg.index : seg.index + seg.segment.length;
1084
+ break;
1085
+ }
1086
+ }
965
1087
  }
966
1088
  }
967
1089
  /**
@@ -1148,7 +1270,7 @@ export class Editor {
1148
1270
  // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
1149
1271
  const afterCursor = currentLine.slice(this.state.cursorCol);
1150
1272
  // Find the first grapheme at cursor
1151
- const graphemes = [...segmenter.segment(afterCursor)];
1273
+ const graphemes = [...this.segment(afterCursor)];
1152
1274
  const firstGrapheme = graphemes[0];
1153
1275
  const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
1154
1276
  const before = currentLine.slice(0, this.state.cursorCol);
@@ -1203,7 +1325,7 @@ export class Editor {
1203
1325
  }
1204
1326
  else {
1205
1327
  // Line needs wrapping - use word-aware wrapping
1206
- const chunks = wordWrapLine(line, width);
1328
+ const chunks = wordWrapLine(line, width, [...this.segment(line)]);
1207
1329
  for (const chunk of chunks) {
1208
1330
  visualLines.push({
1209
1331
  logicalLine: i,
@@ -1252,7 +1374,7 @@ export class Editor {
1252
1374
  // Moving right - move by one grapheme (handles emojis, combining characters, etc.)
1253
1375
  if (this.state.cursorCol < currentLine.length) {
1254
1376
  const afterCursor = currentLine.slice(this.state.cursorCol);
1255
- const graphemes = [...segmenter.segment(afterCursor)];
1377
+ const graphemes = [...this.segment(afterCursor)];
1256
1378
  const firstGrapheme = graphemes[0];
1257
1379
  this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
1258
1380
  }
@@ -1273,7 +1395,7 @@ export class Editor {
1273
1395
  // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
1274
1396
  if (this.state.cursorCol > 0) {
1275
1397
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1276
- const graphemes = [...segmenter.segment(beforeCursor)];
1398
+ const graphemes = [...this.segment(beforeCursor)];
1277
1399
  const lastGrapheme = graphemes[graphemes.length - 1];
1278
1400
  this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
1279
1401
  }
@@ -1312,17 +1434,25 @@ export class Editor {
1312
1434
  return;
1313
1435
  }
1314
1436
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1315
- const graphemes = [...segmenter.segment(textBeforeCursor)];
1437
+ const graphemes = [...this.segment(textBeforeCursor)];
1316
1438
  let newCol = this.state.cursorCol;
1317
1439
  // Skip trailing whitespace
1318
- while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
1440
+ while (graphemes.length > 0 &&
1441
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") &&
1442
+ isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
1319
1443
  newCol -= graphemes.pop()?.segment.length || 0;
1320
1444
  }
1321
1445
  if (graphemes.length > 0) {
1322
1446
  const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
1323
- if (isPunctuationChar(lastGrapheme)) {
1447
+ if (isPasteMarker(lastGrapheme)) {
1448
+ // Paste marker is a single atomic word
1449
+ newCol -= graphemes.pop()?.segment.length || 0;
1450
+ }
1451
+ else if (isPunctuationChar(lastGrapheme)) {
1324
1452
  // Skip punctuation run
1325
- while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
1453
+ while (graphemes.length > 0 &&
1454
+ isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
1455
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
1326
1456
  newCol -= graphemes.pop()?.segment.length || 0;
1327
1457
  }
1328
1458
  }
@@ -1330,7 +1460,8 @@ export class Editor {
1330
1460
  // Skip word run
1331
1461
  while (graphemes.length > 0 &&
1332
1462
  !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
1333
- !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
1463
+ !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
1464
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
1334
1465
  newCol -= graphemes.pop()?.segment.length || 0;
1335
1466
  }
1336
1467
  }
@@ -1493,27 +1624,34 @@ export class Editor {
1493
1624
  return;
1494
1625
  }
1495
1626
  const textAfterCursor = currentLine.slice(this.state.cursorCol);
1496
- const segments = segmenter.segment(textAfterCursor);
1627
+ const segments = this.segment(textAfterCursor);
1497
1628
  const iterator = segments[Symbol.iterator]();
1498
1629
  let next = iterator.next();
1499
1630
  let newCol = this.state.cursorCol;
1500
1631
  // Skip leading whitespace
1501
- while (!next.done && isWhitespaceChar(next.value.segment)) {
1632
+ while (!next.done && !isPasteMarker(next.value.segment) && isWhitespaceChar(next.value.segment)) {
1502
1633
  newCol += next.value.segment.length;
1503
1634
  next = iterator.next();
1504
1635
  }
1505
1636
  if (!next.done) {
1506
1637
  const firstGrapheme = next.value.segment;
1507
- if (isPunctuationChar(firstGrapheme)) {
1638
+ if (isPasteMarker(firstGrapheme)) {
1639
+ // Paste marker is a single atomic word
1640
+ newCol += firstGrapheme.length;
1641
+ }
1642
+ else if (isPunctuationChar(firstGrapheme)) {
1508
1643
  // Skip punctuation run
1509
- while (!next.done && isPunctuationChar(next.value.segment)) {
1644
+ while (!next.done && isPunctuationChar(next.value.segment) && !isPasteMarker(next.value.segment)) {
1510
1645
  newCol += next.value.segment.length;
1511
1646
  next = iterator.next();
1512
1647
  }
1513
1648
  }
1514
1649
  else {
1515
1650
  // Skip word run
1516
- while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
1651
+ while (!next.done &&
1652
+ !isWhitespaceChar(next.value.segment) &&
1653
+ !isPunctuationChar(next.value.segment) &&
1654
+ !isPasteMarker(next.value.segment)) {
1517
1655
  newCol += next.value.segment.length;
1518
1656
  next = iterator.next();
1519
1657
  }
@@ -1536,7 +1674,49 @@ export class Editor {
1536
1674
  isInSlashCommandContext(textBeforeCursor) {
1537
1675
  return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/");
1538
1676
  }
1677
+ shouldChainSlashArgumentAutocompleteOnTabSelection() {
1678
+ if (this.autocompleteState !== "regular") {
1679
+ return false;
1680
+ }
1681
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1682
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1683
+ return this.isInSlashCommandContext(textBeforeCursor) && !textBeforeCursor.trimStart().includes(" ");
1684
+ }
1685
+ isBareCompletedSlashCommandAtCursor() {
1686
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1687
+ if (this.state.cursorCol !== currentLine.length) {
1688
+ return false;
1689
+ }
1690
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol).trimStart();
1691
+ return /^\/\S+ $/.test(textBeforeCursor);
1692
+ }
1539
1693
  // Autocomplete methods
1694
+ /**
1695
+ * Find the best autocomplete item index for the given prefix.
1696
+ * Returns -1 if no match is found.
1697
+ *
1698
+ * Match priority:
1699
+ * 1. Exact match (prefix === item.value) -> always selected
1700
+ * 2. Prefix match -> first item whose value starts with prefix
1701
+ * 3. No match -> -1 (keep default highlight)
1702
+ *
1703
+ * Matching is case-sensitive and checks item.value only.
1704
+ */
1705
+ getBestAutocompleteMatchIndex(items, prefix) {
1706
+ if (!prefix)
1707
+ return -1;
1708
+ let firstPrefixIndex = -1;
1709
+ for (let i = 0; i < items.length; i++) {
1710
+ const value = items[i].value;
1711
+ if (value === prefix) {
1712
+ return i; // Exact match always wins
1713
+ }
1714
+ if (firstPrefixIndex === -1 && value.startsWith(prefix)) {
1715
+ firstPrefixIndex = i;
1716
+ }
1717
+ }
1718
+ return firstPrefixIndex;
1719
+ }
1540
1720
  tryTriggerAutocomplete(explicitTab = false) {
1541
1721
  if (!this.autocompleteProvider)
1542
1722
  return;
@@ -1553,6 +1733,11 @@ export class Editor {
1553
1733
  if (suggestions && suggestions.items.length > 0) {
1554
1734
  this.autocompletePrefix = suggestions.prefix;
1555
1735
  this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1736
+ // If typed prefix exactly matches one of the suggestions, select that item
1737
+ const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
1738
+ if (bestMatchIndex >= 0) {
1739
+ this.autocompleteList.setSelectedIndex(bestMatchIndex);
1740
+ }
1556
1741
  this.autocompleteState = "regular";
1557
1742
  }
1558
1743
  else {
@@ -1606,6 +1791,11 @@ export class Editor {
1606
1791
  }
1607
1792
  this.autocompletePrefix = suggestions.prefix;
1608
1793
  this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1794
+ // If typed prefix exactly matches one of the suggestions, select that item
1795
+ const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
1796
+ if (bestMatchIndex >= 0) {
1797
+ this.autocompleteList.setSelectedIndex(bestMatchIndex);
1798
+ }
1609
1799
  this.autocompleteState = "force";
1610
1800
  }
1611
1801
  else {
@@ -1632,6 +1822,11 @@ export class Editor {
1632
1822
  this.autocompletePrefix = suggestions.prefix;
1633
1823
  // Always create new SelectList to ensure update
1634
1824
  this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1825
+ // If typed prefix exactly matches one of the suggestions, select that item
1826
+ const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
1827
+ if (bestMatchIndex >= 0) {
1828
+ this.autocompleteList.setSelectedIndex(bestMatchIndex);
1829
+ }
1635
1830
  }
1636
1831
  else {
1637
1832
  this.cancelAutocomplete();