@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.
- package/dist/components/editor.d.ts +27 -1
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +232 -37
- package/dist/components/editor.js.map +1 -1
- package/dist/components/input.d.ts.map +1 -1
- package/dist/components/input.js +24 -41
- package/dist/components/input.js.map +1 -1
- package/dist/keybindings.d.ts +1 -1
- package/dist/keybindings.d.ts.map +1 -1
- package/dist/keybindings.js +3 -0
- package/dist/keybindings.js.map +1 -1
- package/dist/keys.d.ts +2 -7
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +113 -79
- package/dist/keys.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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 = [...
|
|
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.
|
|
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
|
-
|
|
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 = [...
|
|
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() !==
|
|
781
|
+
if (this.getText() !== normalized) {
|
|
681
782
|
this.pushUndoSnapshot();
|
|
682
783
|
}
|
|
683
|
-
this.setTextInternal(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 = [...
|
|
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 = [...
|
|
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 = [...
|
|
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 = [...
|
|
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 = [...
|
|
1437
|
+
const graphemes = [...this.segment(textBeforeCursor)];
|
|
1316
1438
|
let newCol = this.state.cursorCol;
|
|
1317
1439
|
// Skip trailing whitespace
|
|
1318
|
-
while (graphemes.length > 0 &&
|
|
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 (
|
|
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 &&
|
|
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 =
|
|
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 (
|
|
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 &&
|
|
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();
|