phi-code-tui 0.56.3 → 0.74.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -11
- package/dist/autocomplete.d.ts +18 -14
- package/dist/autocomplete.d.ts.map +1 -1
- package/dist/autocomplete.js +151 -112
- package/dist/autocomplete.js.map +1 -1
- package/dist/components/box.d.ts.map +1 -1
- package/dist/components/box.js +6 -1
- package/dist/components/box.js.map +1 -1
- package/dist/components/cancellable-loader.d.ts.map +1 -1
- package/dist/components/cancellable-loader.js +6 -7
- package/dist/components/cancellable-loader.js.map +1 -1
- package/dist/components/editor.d.ts +45 -1
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +505 -221
- package/dist/components/editor.js.map +1 -1
- package/dist/components/image.d.ts.map +1 -1
- package/dist/components/image.js +22 -7
- package/dist/components/image.js.map +1 -1
- package/dist/components/input.d.ts.map +1 -1
- package/dist/components/input.js +57 -74
- package/dist/components/input.js.map +1 -1
- package/dist/components/loader.d.ts +12 -2
- package/dist/components/loader.d.ts.map +1 -1
- package/dist/components/loader.js +36 -13
- package/dist/components/loader.js.map +1 -1
- package/dist/components/markdown.d.ts +0 -5
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +101 -114
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/select-list.d.ts +19 -1
- package/dist/components/select-list.d.ts.map +1 -1
- package/dist/components/select-list.js +82 -71
- package/dist/components/select-list.js.map +1 -1
- package/dist/components/settings-list.d.ts.map +1 -1
- package/dist/components/settings-list.js +18 -10
- package/dist/components/settings-list.js.map +1 -1
- package/dist/components/spacer.d.ts.map +1 -1
- package/dist/components/spacer.js +1 -0
- package/dist/components/spacer.js.map +1 -1
- package/dist/components/text.d.ts.map +1 -1
- package/dist/components/text.js +8 -0
- package/dist/components/text.js.map +1 -1
- package/dist/components/truncated-text.d.ts.map +1 -1
- package/dist/components/truncated-text.js +3 -0
- package/dist/components/truncated-text.js.map +1 -1
- package/dist/editor-component.d.ts.map +1 -1
- package/dist/fuzzy.d.ts.map +1 -1
- package/dist/fuzzy.js +3 -0
- package/dist/fuzzy.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/keybindings.d.ts +187 -33
- package/dist/keybindings.d.ts.map +1 -1
- package/dist/keybindings.js +156 -95
- package/dist/keybindings.js.map +1 -1
- package/dist/keys.d.ts +21 -12
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +270 -112
- package/dist/keys.js.map +1 -1
- package/dist/kill-ring.d.ts.map +1 -1
- package/dist/kill-ring.js +1 -3
- package/dist/kill-ring.js.map +1 -1
- package/dist/stdin-buffer.d.ts +2 -0
- package/dist/stdin-buffer.d.ts.map +1 -1
- package/dist/stdin-buffer.js +31 -8
- package/dist/stdin-buffer.js.map +1 -1
- package/dist/terminal-image.d.ts +17 -0
- package/dist/terminal-image.d.ts.map +1 -1
- package/dist/terminal-image.js +41 -5
- package/dist/terminal-image.js.map +1 -1
- package/dist/terminal.d.ts +4 -0
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +56 -8
- package/dist/terminal.js.map +1 -1
- package/dist/tui.d.ts +21 -5
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +234 -118
- package/dist/tui.js.map +1 -1
- package/dist/undo-stack.d.ts.map +1 -1
- package/dist/undo-stack.js +1 -3
- package/dist/undo-stack.js.map +1 -1
- package/dist/utils.d.ts +1 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +281 -81
- package/dist/utils.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,11 +1,71 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { getKeybindings } from "../keybindings.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, 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
|
}
|
|
@@ -68,42 +151,67 @@ export function wordWrapLine(line, maxWidth) {
|
|
|
68
151
|
chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length });
|
|
69
152
|
return chunks;
|
|
70
153
|
}
|
|
154
|
+
const SLASH_COMMAND_SELECT_LIST_LAYOUT = {
|
|
155
|
+
minPrimaryColumnWidth: 12,
|
|
156
|
+
maxPrimaryColumnWidth: 32,
|
|
157
|
+
};
|
|
158
|
+
const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
|
|
71
159
|
export class Editor {
|
|
160
|
+
state = {
|
|
161
|
+
lines: [""],
|
|
162
|
+
cursorLine: 0,
|
|
163
|
+
cursorCol: 0,
|
|
164
|
+
};
|
|
165
|
+
/** Focusable interface - set by TUI when focus changes */
|
|
166
|
+
focused = false;
|
|
167
|
+
tui;
|
|
168
|
+
theme;
|
|
169
|
+
paddingX = 0;
|
|
170
|
+
// Store last render width for cursor navigation
|
|
171
|
+
lastWidth = 80;
|
|
172
|
+
// Vertical scrolling support
|
|
173
|
+
scrollOffset = 0;
|
|
174
|
+
// Border color (can be changed dynamically)
|
|
175
|
+
borderColor;
|
|
176
|
+
// Autocomplete support
|
|
177
|
+
autocompleteProvider;
|
|
178
|
+
autocompleteList;
|
|
179
|
+
autocompleteState = null;
|
|
180
|
+
autocompletePrefix = "";
|
|
181
|
+
autocompleteMaxVisible = 5;
|
|
182
|
+
autocompleteAbort;
|
|
183
|
+
autocompleteDebounceTimer;
|
|
184
|
+
autocompleteRequestTask = Promise.resolve();
|
|
185
|
+
autocompleteStartToken = 0;
|
|
186
|
+
autocompleteRequestId = 0;
|
|
187
|
+
// Paste tracking for large pastes
|
|
188
|
+
pastes = new Map();
|
|
189
|
+
pasteCounter = 0;
|
|
190
|
+
// Bracketed paste mode buffering
|
|
191
|
+
pasteBuffer = "";
|
|
192
|
+
isInPaste = false;
|
|
193
|
+
// Prompt history for up/down navigation
|
|
194
|
+
history = [];
|
|
195
|
+
historyIndex = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
|
|
196
|
+
// Kill ring for Emacs-style kill/yank operations
|
|
197
|
+
killRing = new KillRing();
|
|
198
|
+
lastAction = null;
|
|
199
|
+
// Character jump mode
|
|
200
|
+
jumpMode = null;
|
|
201
|
+
// Preferred visual column for vertical cursor movement (sticky column)
|
|
202
|
+
preferredVisualCol = null;
|
|
203
|
+
// When the cursor is snapped to the start of an atomic segment, e.g. a
|
|
204
|
+
// paste marker, cursorCol no longer reflects where the cursor would have
|
|
205
|
+
// landed. This field stores the pre-snap cursorCol so that the next
|
|
206
|
+
// vertical move can resolve it to a visual column on whatever VL it belongs
|
|
207
|
+
// to.
|
|
208
|
+
snappedFromCursorCol = null;
|
|
209
|
+
// Undo support
|
|
210
|
+
undoStack = new UndoStack();
|
|
211
|
+
onSubmit;
|
|
212
|
+
onChange;
|
|
213
|
+
disableSubmit = false;
|
|
72
214
|
constructor(tui, theme, options = {}) {
|
|
73
|
-
this.state = {
|
|
74
|
-
lines: [""],
|
|
75
|
-
cursorLine: 0,
|
|
76
|
-
cursorCol: 0,
|
|
77
|
-
};
|
|
78
|
-
/** Focusable interface - set by TUI when focus changes */
|
|
79
|
-
this.focused = false;
|
|
80
|
-
this.paddingX = 0;
|
|
81
|
-
// Store last render width for cursor navigation
|
|
82
|
-
this.lastWidth = 80;
|
|
83
|
-
// Vertical scrolling support
|
|
84
|
-
this.scrollOffset = 0;
|
|
85
|
-
this.autocompleteState = null;
|
|
86
|
-
this.autocompletePrefix = "";
|
|
87
|
-
this.autocompleteMaxVisible = 5;
|
|
88
|
-
// Paste tracking for large pastes
|
|
89
|
-
this.pastes = new Map();
|
|
90
|
-
this.pasteCounter = 0;
|
|
91
|
-
// Bracketed paste mode buffering
|
|
92
|
-
this.pasteBuffer = "";
|
|
93
|
-
this.isInPaste = false;
|
|
94
|
-
// Prompt history for up/down navigation
|
|
95
|
-
this.history = [];
|
|
96
|
-
this.historyIndex = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
|
|
97
|
-
// Kill ring for Emacs-style kill/yank operations
|
|
98
|
-
this.killRing = new KillRing();
|
|
99
|
-
this.lastAction = null;
|
|
100
|
-
// Character jump mode
|
|
101
|
-
this.jumpMode = null;
|
|
102
|
-
// Preferred visual column for vertical cursor movement (sticky column)
|
|
103
|
-
this.preferredVisualCol = null;
|
|
104
|
-
// Undo support
|
|
105
|
-
this.undoStack = new UndoStack();
|
|
106
|
-
this.disableSubmit = false;
|
|
107
215
|
this.tui = tui;
|
|
108
216
|
this.theme = theme;
|
|
109
217
|
this.borderColor = theme.borderColor;
|
|
@@ -112,6 +220,14 @@ export class Editor {
|
|
|
112
220
|
const maxVisible = options.autocompleteMaxVisible ?? 5;
|
|
113
221
|
this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
|
|
114
222
|
}
|
|
223
|
+
/** Set of currently valid paste IDs, for marker-aware segmentation. */
|
|
224
|
+
validPasteIds() {
|
|
225
|
+
return new Set(this.pastes.keys());
|
|
226
|
+
}
|
|
227
|
+
/** Segment text with paste-marker awareness, only merging markers with valid IDs. */
|
|
228
|
+
segment(text) {
|
|
229
|
+
return segmentWithMarkers(text, this.validPasteIds());
|
|
230
|
+
}
|
|
115
231
|
getPaddingX() {
|
|
116
232
|
return this.paddingX;
|
|
117
233
|
}
|
|
@@ -133,6 +249,7 @@ export class Editor {
|
|
|
133
249
|
}
|
|
134
250
|
}
|
|
135
251
|
setAutocompleteProvider(provider) {
|
|
252
|
+
this.cancelAutocomplete();
|
|
136
253
|
this.autocompleteProvider = provider;
|
|
137
254
|
}
|
|
138
255
|
/**
|
|
@@ -187,7 +304,7 @@ export class Editor {
|
|
|
187
304
|
}
|
|
188
305
|
/** Internal setText that doesn't reset history state - used by navigateHistory */
|
|
189
306
|
setTextInternal(text) {
|
|
190
|
-
const lines = text.
|
|
307
|
+
const lines = text.split("\n");
|
|
191
308
|
this.state.lines = lines.length === 0 ? [""] : lines;
|
|
192
309
|
this.state.cursorLine = this.state.lines.length - 1;
|
|
193
310
|
this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0);
|
|
@@ -238,7 +355,12 @@ export class Editor {
|
|
|
238
355
|
if (this.scrollOffset > 0) {
|
|
239
356
|
const indicator = `─── ↑ ${this.scrollOffset} more `;
|
|
240
357
|
const remaining = width - visibleWidth(indicator);
|
|
241
|
-
|
|
358
|
+
if (remaining >= 0) {
|
|
359
|
+
result.push(this.borderColor(indicator + "─".repeat(remaining)));
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
result.push(this.borderColor(truncateToWidth(indicator, width)));
|
|
363
|
+
}
|
|
242
364
|
}
|
|
243
365
|
else {
|
|
244
366
|
result.push(horizontal.repeat(width));
|
|
@@ -259,7 +381,7 @@ export class Editor {
|
|
|
259
381
|
if (after.length > 0) {
|
|
260
382
|
// Cursor is on a character (grapheme) - replace it with highlighted version
|
|
261
383
|
// Get the first grapheme from 'after'
|
|
262
|
-
const afterGraphemes = [...
|
|
384
|
+
const afterGraphemes = [...this.segment(after)];
|
|
263
385
|
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
|
264
386
|
const restAfter = after.slice(firstGrapheme.length);
|
|
265
387
|
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
|
@@ -305,19 +427,20 @@ export class Editor {
|
|
|
305
427
|
return result;
|
|
306
428
|
}
|
|
307
429
|
handleInput(data) {
|
|
308
|
-
const kb =
|
|
430
|
+
const kb = getKeybindings();
|
|
309
431
|
// Handle character jump mode (awaiting next character to jump to)
|
|
310
432
|
if (this.jumpMode !== null) {
|
|
311
433
|
// Cancel if the hotkey is pressed again
|
|
312
|
-
if (kb.matches(data, "jumpForward") || kb.matches(data, "jumpBackward")) {
|
|
434
|
+
if (kb.matches(data, "tui.editor.jumpForward") || kb.matches(data, "tui.editor.jumpBackward")) {
|
|
313
435
|
this.jumpMode = null;
|
|
314
436
|
return;
|
|
315
437
|
}
|
|
316
|
-
|
|
438
|
+
const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined);
|
|
439
|
+
if (printable !== undefined) {
|
|
317
440
|
// Printable character - perform the jump
|
|
318
441
|
const direction = this.jumpMode;
|
|
319
442
|
this.jumpMode = null;
|
|
320
|
-
this.jumpToChar(
|
|
443
|
+
this.jumpToChar(printable, direction);
|
|
321
444
|
return;
|
|
322
445
|
}
|
|
323
446
|
// Control character - cancel and fall through to normal handling
|
|
@@ -348,25 +471,25 @@ export class Editor {
|
|
|
348
471
|
return;
|
|
349
472
|
}
|
|
350
473
|
// Ctrl+C - let parent handle (exit/clear)
|
|
351
|
-
if (kb.matches(data, "copy")) {
|
|
474
|
+
if (kb.matches(data, "tui.input.copy")) {
|
|
352
475
|
return;
|
|
353
476
|
}
|
|
354
477
|
// Undo
|
|
355
|
-
if (kb.matches(data, "undo")) {
|
|
478
|
+
if (kb.matches(data, "tui.editor.undo")) {
|
|
356
479
|
this.undo();
|
|
357
480
|
return;
|
|
358
481
|
}
|
|
359
482
|
// Handle autocomplete mode
|
|
360
483
|
if (this.autocompleteState && this.autocompleteList) {
|
|
361
|
-
if (kb.matches(data, "
|
|
484
|
+
if (kb.matches(data, "tui.select.cancel")) {
|
|
362
485
|
this.cancelAutocomplete();
|
|
363
486
|
return;
|
|
364
487
|
}
|
|
365
|
-
if (kb.matches(data, "
|
|
488
|
+
if (kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")) {
|
|
366
489
|
this.autocompleteList.handleInput(data);
|
|
367
490
|
return;
|
|
368
491
|
}
|
|
369
|
-
if (kb.matches(data, "tab")) {
|
|
492
|
+
if (kb.matches(data, "tui.input.tab")) {
|
|
370
493
|
const selected = this.autocompleteList.getSelectedItem();
|
|
371
494
|
if (selected && this.autocompleteProvider) {
|
|
372
495
|
this.pushUndoSnapshot();
|
|
@@ -381,7 +504,7 @@ export class Editor {
|
|
|
381
504
|
}
|
|
382
505
|
return;
|
|
383
506
|
}
|
|
384
|
-
if (kb.matches(data, "
|
|
507
|
+
if (kb.matches(data, "tui.select.confirm")) {
|
|
385
508
|
const selected = this.autocompleteList.getSelectedItem();
|
|
386
509
|
if (selected && this.autocompleteProvider) {
|
|
387
510
|
this.pushUndoSnapshot();
|
|
@@ -404,63 +527,63 @@ export class Editor {
|
|
|
404
527
|
}
|
|
405
528
|
}
|
|
406
529
|
// Tab - trigger completion
|
|
407
|
-
if (kb.matches(data, "tab") && !this.autocompleteState) {
|
|
530
|
+
if (kb.matches(data, "tui.input.tab") && !this.autocompleteState) {
|
|
408
531
|
this.handleTabCompletion();
|
|
409
532
|
return;
|
|
410
533
|
}
|
|
411
534
|
// Deletion actions
|
|
412
|
-
if (kb.matches(data, "deleteToLineEnd")) {
|
|
535
|
+
if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
|
|
413
536
|
this.deleteToEndOfLine();
|
|
414
537
|
return;
|
|
415
538
|
}
|
|
416
|
-
if (kb.matches(data, "deleteToLineStart")) {
|
|
539
|
+
if (kb.matches(data, "tui.editor.deleteToLineStart")) {
|
|
417
540
|
this.deleteToStartOfLine();
|
|
418
541
|
return;
|
|
419
542
|
}
|
|
420
|
-
if (kb.matches(data, "deleteWordBackward")) {
|
|
543
|
+
if (kb.matches(data, "tui.editor.deleteWordBackward")) {
|
|
421
544
|
this.deleteWordBackwards();
|
|
422
545
|
return;
|
|
423
546
|
}
|
|
424
|
-
if (kb.matches(data, "deleteWordForward")) {
|
|
547
|
+
if (kb.matches(data, "tui.editor.deleteWordForward")) {
|
|
425
548
|
this.deleteWordForward();
|
|
426
549
|
return;
|
|
427
550
|
}
|
|
428
|
-
if (kb.matches(data, "deleteCharBackward") || matchesKey(data, "shift+backspace")) {
|
|
551
|
+
if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
|
|
429
552
|
this.handleBackspace();
|
|
430
553
|
return;
|
|
431
554
|
}
|
|
432
|
-
if (kb.matches(data, "deleteCharForward") || matchesKey(data, "shift+delete")) {
|
|
555
|
+
if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
|
|
433
556
|
this.handleForwardDelete();
|
|
434
557
|
return;
|
|
435
558
|
}
|
|
436
559
|
// Kill ring actions
|
|
437
|
-
if (kb.matches(data, "yank")) {
|
|
560
|
+
if (kb.matches(data, "tui.editor.yank")) {
|
|
438
561
|
this.yank();
|
|
439
562
|
return;
|
|
440
563
|
}
|
|
441
|
-
if (kb.matches(data, "yankPop")) {
|
|
564
|
+
if (kb.matches(data, "tui.editor.yankPop")) {
|
|
442
565
|
this.yankPop();
|
|
443
566
|
return;
|
|
444
567
|
}
|
|
445
568
|
// Cursor movement actions
|
|
446
|
-
if (kb.matches(data, "cursorLineStart")) {
|
|
569
|
+
if (kb.matches(data, "tui.editor.cursorLineStart")) {
|
|
447
570
|
this.moveToLineStart();
|
|
448
571
|
return;
|
|
449
572
|
}
|
|
450
|
-
if (kb.matches(data, "cursorLineEnd")) {
|
|
573
|
+
if (kb.matches(data, "tui.editor.cursorLineEnd")) {
|
|
451
574
|
this.moveToLineEnd();
|
|
452
575
|
return;
|
|
453
576
|
}
|
|
454
|
-
if (kb.matches(data, "cursorWordLeft")) {
|
|
577
|
+
if (kb.matches(data, "tui.editor.cursorWordLeft")) {
|
|
455
578
|
this.moveWordBackwards();
|
|
456
579
|
return;
|
|
457
580
|
}
|
|
458
|
-
if (kb.matches(data, "cursorWordRight")) {
|
|
581
|
+
if (kb.matches(data, "tui.editor.cursorWordRight")) {
|
|
459
582
|
this.moveWordForwards();
|
|
460
583
|
return;
|
|
461
584
|
}
|
|
462
585
|
// New line
|
|
463
|
-
if (kb.matches(data, "newLine") ||
|
|
586
|
+
if (kb.matches(data, "tui.input.newLine") ||
|
|
464
587
|
(data.charCodeAt(0) === 10 && data.length > 1) ||
|
|
465
588
|
data === "\x1b\r" ||
|
|
466
589
|
data === "\x1b[13;2~" ||
|
|
@@ -475,7 +598,7 @@ export class Editor {
|
|
|
475
598
|
return;
|
|
476
599
|
}
|
|
477
600
|
// Submit (Enter)
|
|
478
|
-
if (kb.matches(data, "submit")) {
|
|
601
|
+
if (kb.matches(data, "tui.input.submit")) {
|
|
479
602
|
if (this.disableSubmit)
|
|
480
603
|
return;
|
|
481
604
|
// Workaround for terminals without Shift+Enter support:
|
|
@@ -490,7 +613,7 @@ export class Editor {
|
|
|
490
613
|
return;
|
|
491
614
|
}
|
|
492
615
|
// Arrow key navigation (with history support)
|
|
493
|
-
if (kb.matches(data, "cursorUp")) {
|
|
616
|
+
if (kb.matches(data, "tui.editor.cursorUp")) {
|
|
494
617
|
if (this.isEditorEmpty()) {
|
|
495
618
|
this.navigateHistory(-1);
|
|
496
619
|
}
|
|
@@ -506,7 +629,7 @@ export class Editor {
|
|
|
506
629
|
}
|
|
507
630
|
return;
|
|
508
631
|
}
|
|
509
|
-
if (kb.matches(data, "cursorDown")) {
|
|
632
|
+
if (kb.matches(data, "tui.editor.cursorDown")) {
|
|
510
633
|
if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
|
|
511
634
|
this.navigateHistory(1);
|
|
512
635
|
}
|
|
@@ -519,29 +642,29 @@ export class Editor {
|
|
|
519
642
|
}
|
|
520
643
|
return;
|
|
521
644
|
}
|
|
522
|
-
if (kb.matches(data, "cursorRight")) {
|
|
645
|
+
if (kb.matches(data, "tui.editor.cursorRight")) {
|
|
523
646
|
this.moveCursor(0, 1);
|
|
524
647
|
return;
|
|
525
648
|
}
|
|
526
|
-
if (kb.matches(data, "cursorLeft")) {
|
|
649
|
+
if (kb.matches(data, "tui.editor.cursorLeft")) {
|
|
527
650
|
this.moveCursor(0, -1);
|
|
528
651
|
return;
|
|
529
652
|
}
|
|
530
653
|
// Page up/down - scroll by page and move cursor
|
|
531
|
-
if (kb.matches(data, "pageUp")) {
|
|
654
|
+
if (kb.matches(data, "tui.editor.pageUp")) {
|
|
532
655
|
this.pageScroll(-1);
|
|
533
656
|
return;
|
|
534
657
|
}
|
|
535
|
-
if (kb.matches(data, "pageDown")) {
|
|
658
|
+
if (kb.matches(data, "tui.editor.pageDown")) {
|
|
536
659
|
this.pageScroll(1);
|
|
537
660
|
return;
|
|
538
661
|
}
|
|
539
662
|
// Character jump mode triggers
|
|
540
|
-
if (kb.matches(data, "jumpForward")) {
|
|
663
|
+
if (kb.matches(data, "tui.editor.jumpForward")) {
|
|
541
664
|
this.jumpMode = "forward";
|
|
542
665
|
return;
|
|
543
666
|
}
|
|
544
|
-
if (kb.matches(data, "jumpBackward")) {
|
|
667
|
+
if (kb.matches(data, "tui.editor.jumpBackward")) {
|
|
545
668
|
this.jumpMode = "backward";
|
|
546
669
|
return;
|
|
547
670
|
}
|
|
@@ -550,9 +673,9 @@ export class Editor {
|
|
|
550
673
|
this.insertCharacter(" ");
|
|
551
674
|
return;
|
|
552
675
|
}
|
|
553
|
-
const
|
|
554
|
-
if (
|
|
555
|
-
this.insertCharacter(
|
|
676
|
+
const printable = decodePrintableKey(data);
|
|
677
|
+
if (printable !== undefined) {
|
|
678
|
+
this.insertCharacter(printable);
|
|
556
679
|
return;
|
|
557
680
|
}
|
|
558
681
|
// Regular characters
|
|
@@ -594,7 +717,7 @@ export class Editor {
|
|
|
594
717
|
}
|
|
595
718
|
else {
|
|
596
719
|
// Line needs wrapping - use word-aware wrapping
|
|
597
|
-
const chunks = wordWrapLine(line, contentWidth);
|
|
720
|
+
const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]);
|
|
598
721
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
599
722
|
const chunk = chunks[chunkIndex];
|
|
600
723
|
if (!chunk)
|
|
@@ -646,17 +769,20 @@ export class Editor {
|
|
|
646
769
|
getText() {
|
|
647
770
|
return this.state.lines.join("\n");
|
|
648
771
|
}
|
|
772
|
+
expandPasteMarkers(text) {
|
|
773
|
+
let result = text;
|
|
774
|
+
for (const [pasteId, pasteContent] of this.pastes) {
|
|
775
|
+
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
|
|
776
|
+
result = result.replace(markerRegex, () => pasteContent);
|
|
777
|
+
}
|
|
778
|
+
return result;
|
|
779
|
+
}
|
|
649
780
|
/**
|
|
650
781
|
* Get text with paste markers expanded to their actual content.
|
|
651
782
|
* Use this when you need the full content (e.g., for external editor).
|
|
652
783
|
*/
|
|
653
784
|
getExpandedText() {
|
|
654
|
-
|
|
655
|
-
for (const [pasteId, pasteContent] of this.pastes) {
|
|
656
|
-
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
|
|
657
|
-
result = result.replace(markerRegex, pasteContent);
|
|
658
|
-
}
|
|
659
|
-
return result;
|
|
785
|
+
return this.expandPasteMarkers(this.state.lines.join("\n"));
|
|
660
786
|
}
|
|
661
787
|
getLines() {
|
|
662
788
|
return [...this.state.lines];
|
|
@@ -665,13 +791,15 @@ export class Editor {
|
|
|
665
791
|
return { line: this.state.cursorLine, col: this.state.cursorCol };
|
|
666
792
|
}
|
|
667
793
|
setText(text) {
|
|
794
|
+
this.cancelAutocomplete();
|
|
668
795
|
this.lastAction = null;
|
|
669
796
|
this.historyIndex = -1; // Exit history browsing mode
|
|
797
|
+
const normalized = this.normalizeText(text);
|
|
670
798
|
// Push undo snapshot if content differs (makes programmatic changes undoable)
|
|
671
|
-
if (this.getText() !==
|
|
799
|
+
if (this.getText() !== normalized) {
|
|
672
800
|
this.pushUndoSnapshot();
|
|
673
801
|
}
|
|
674
|
-
this.setTextInternal(
|
|
802
|
+
this.setTextInternal(normalized);
|
|
675
803
|
}
|
|
676
804
|
/**
|
|
677
805
|
* Insert text at the current cursor position.
|
|
@@ -681,11 +809,20 @@ export class Editor {
|
|
|
681
809
|
insertTextAtCursor(text) {
|
|
682
810
|
if (!text)
|
|
683
811
|
return;
|
|
812
|
+
this.cancelAutocomplete();
|
|
684
813
|
this.pushUndoSnapshot();
|
|
685
814
|
this.lastAction = null;
|
|
686
815
|
this.historyIndex = -1;
|
|
687
816
|
this.insertTextAtCursorInternal(text);
|
|
688
817
|
}
|
|
818
|
+
/**
|
|
819
|
+
* Normalize text for editor storage:
|
|
820
|
+
* - Normalize line endings (\r\n and \r -> \n)
|
|
821
|
+
* - Expand tabs to 4 spaces
|
|
822
|
+
*/
|
|
823
|
+
normalizeText(text) {
|
|
824
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
|
|
825
|
+
}
|
|
689
826
|
/**
|
|
690
827
|
* Internal text insertion at cursor. Handles single and multi-line text.
|
|
691
828
|
* Does not push undo snapshots or trigger autocomplete - caller is responsible.
|
|
@@ -694,8 +831,8 @@ export class Editor {
|
|
|
694
831
|
insertTextAtCursorInternal(text) {
|
|
695
832
|
if (!text)
|
|
696
833
|
return;
|
|
697
|
-
// Normalize line endings
|
|
698
|
-
const normalized =
|
|
834
|
+
// Normalize line endings and tabs
|
|
835
|
+
const normalized = this.normalizeText(text);
|
|
699
836
|
const insertedLines = normalized.split("\n");
|
|
700
837
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
701
838
|
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
@@ -754,17 +891,16 @@ export class Editor {
|
|
|
754
891
|
if (char === "/" && this.isAtStartOfMessage()) {
|
|
755
892
|
this.tryTriggerAutocomplete();
|
|
756
893
|
}
|
|
757
|
-
// Auto-trigger for
|
|
758
|
-
else if (char === "@") {
|
|
894
|
+
// Auto-trigger for symbol-based completion like @ or # at token boundaries
|
|
895
|
+
else if (char === "@" || char === "#") {
|
|
759
896
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
760
897
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
if (textBeforeCursor.length === 1 || charBeforeAt === " " || charBeforeAt === "\t") {
|
|
898
|
+
const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
|
|
899
|
+
if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
|
|
764
900
|
this.tryTriggerAutocomplete();
|
|
765
901
|
}
|
|
766
902
|
}
|
|
767
|
-
// Also auto-trigger when typing letters in a slash command context
|
|
903
|
+
// Also auto-trigger when typing letters in a slash command or symbol completion context
|
|
768
904
|
else if (/[a-zA-Z0-9.\-_]/.test(char)) {
|
|
769
905
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
770
906
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
@@ -772,8 +908,8 @@ export class Editor {
|
|
|
772
908
|
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
|
773
909
|
this.tryTriggerAutocomplete();
|
|
774
910
|
}
|
|
775
|
-
// Check if we're in
|
|
776
|
-
else if (textBeforeCursor.match(/(?:^|[\s])
|
|
911
|
+
// Check if we're in a symbol-based completion context like @ or #
|
|
912
|
+
else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
|
|
777
913
|
this.tryTriggerAutocomplete();
|
|
778
914
|
}
|
|
779
915
|
}
|
|
@@ -783,15 +919,27 @@ export class Editor {
|
|
|
783
919
|
}
|
|
784
920
|
}
|
|
785
921
|
handlePaste(pastedText) {
|
|
922
|
+
this.cancelAutocomplete();
|
|
786
923
|
this.historyIndex = -1; // Exit history browsing mode
|
|
787
924
|
this.lastAction = null;
|
|
788
925
|
this.pushUndoSnapshot();
|
|
789
|
-
//
|
|
790
|
-
|
|
791
|
-
//
|
|
792
|
-
|
|
926
|
+
// Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
|
|
927
|
+
// control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
|
|
928
|
+
// (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
|
|
929
|
+
// per-char filter below preserves newlines instead of stripping ESC and
|
|
930
|
+
// leaking the printable tail (e.g. "[106;5u") into the editor.
|
|
931
|
+
const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
|
|
932
|
+
const cp = Number(code);
|
|
933
|
+
if (cp >= 97 && cp <= 122)
|
|
934
|
+
return String.fromCharCode(cp - 96);
|
|
935
|
+
if (cp >= 65 && cp <= 90)
|
|
936
|
+
return String.fromCharCode(cp - 64);
|
|
937
|
+
return match;
|
|
938
|
+
});
|
|
939
|
+
// Clean the pasted text: normalize line endings, expand tabs
|
|
940
|
+
const cleanText = this.normalizeText(decodedText);
|
|
793
941
|
// Filter out non-printable characters except newlines
|
|
794
|
-
let filteredText =
|
|
942
|
+
let filteredText = cleanText
|
|
795
943
|
.split("")
|
|
796
944
|
.filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
|
|
797
945
|
.join("");
|
|
@@ -829,6 +977,7 @@ export class Editor {
|
|
|
829
977
|
this.insertTextAtCursorInternal(filteredText);
|
|
830
978
|
}
|
|
831
979
|
addNewLine() {
|
|
980
|
+
this.cancelAutocomplete();
|
|
832
981
|
this.historyIndex = -1; // Exit history browsing mode
|
|
833
982
|
this.lastAction = null;
|
|
834
983
|
this.pushUndoSnapshot();
|
|
@@ -850,7 +999,7 @@ export class Editor {
|
|
|
850
999
|
return false;
|
|
851
1000
|
if (!matchesKey(data, "enter"))
|
|
852
1001
|
return false;
|
|
853
|
-
const submitKeys = kb.getKeys("submit");
|
|
1002
|
+
const submitKeys = kb.getKeys("tui.input.submit");
|
|
854
1003
|
const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
|
|
855
1004
|
if (!hasShiftEnter)
|
|
856
1005
|
return false;
|
|
@@ -858,11 +1007,8 @@ export class Editor {
|
|
|
858
1007
|
return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\";
|
|
859
1008
|
}
|
|
860
1009
|
submitValue() {
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
|
|
864
|
-
result = result.replace(markerRegex, pasteContent);
|
|
865
|
-
}
|
|
1010
|
+
this.cancelAutocomplete();
|
|
1011
|
+
const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim();
|
|
866
1012
|
this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
|
867
1013
|
this.pastes.clear();
|
|
868
1014
|
this.pasteCounter = 0;
|
|
@@ -884,7 +1030,7 @@ export class Editor {
|
|
|
884
1030
|
const line = this.state.lines[this.state.cursorLine] || "";
|
|
885
1031
|
const beforeCursor = line.slice(0, this.state.cursorCol);
|
|
886
1032
|
// Find the last grapheme in the text before cursor
|
|
887
|
-
const graphemes = [...
|
|
1033
|
+
const graphemes = [...this.segment(beforeCursor)];
|
|
888
1034
|
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
889
1035
|
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
|
890
1036
|
const before = line.slice(0, this.state.cursorCol - graphemeLength);
|
|
@@ -917,8 +1063,8 @@ export class Editor {
|
|
|
917
1063
|
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
|
918
1064
|
this.tryTriggerAutocomplete();
|
|
919
1065
|
}
|
|
920
|
-
// @
|
|
921
|
-
else if (textBeforeCursor.match(/(?:^|[\s])
|
|
1066
|
+
// Symbol-based completion context like @ or #
|
|
1067
|
+
else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
|
|
922
1068
|
this.tryTriggerAutocomplete();
|
|
923
1069
|
}
|
|
924
1070
|
}
|
|
@@ -930,6 +1076,7 @@ export class Editor {
|
|
|
930
1076
|
setCursorCol(col) {
|
|
931
1077
|
this.state.cursorCol = col;
|
|
932
1078
|
this.preferredVisualCol = null;
|
|
1079
|
+
this.snappedFromCursorCol = null;
|
|
933
1080
|
}
|
|
934
1081
|
/**
|
|
935
1082
|
* Move cursor to a target visual line, applying sticky column logic.
|
|
@@ -938,22 +1085,70 @@ export class Editor {
|
|
|
938
1085
|
moveToVisualLine(visualLines, currentVisualLine, targetVisualLine) {
|
|
939
1086
|
const currentVL = visualLines[currentVisualLine];
|
|
940
1087
|
const targetVL = visualLines[targetVisualLine];
|
|
941
|
-
if (currentVL && targetVL)
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
|
|
951
|
-
// Set cursor position
|
|
952
|
-
this.state.cursorLine = targetVL.logicalLine;
|
|
953
|
-
const targetCol = targetVL.startCol + moveToVisualCol;
|
|
954
|
-
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
|
|
955
|
-
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
|
|
1088
|
+
if (!(currentVL && targetVL))
|
|
1089
|
+
return;
|
|
1090
|
+
// When the cursor was snapped to a segment start, resolve the pre-snap
|
|
1091
|
+
// position against the VL it belongs to. This gives the correct visual
|
|
1092
|
+
// column even after a resize reshuffles VLs.
|
|
1093
|
+
let currentVisualCol;
|
|
1094
|
+
if (this.snappedFromCursorCol !== null) {
|
|
1095
|
+
const vlIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
|
|
1096
|
+
currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex].startCol;
|
|
956
1097
|
}
|
|
1098
|
+
else {
|
|
1099
|
+
currentVisualCol = this.state.cursorCol - currentVL.startCol;
|
|
1100
|
+
}
|
|
1101
|
+
// For non-last segments, clamp to length-1 to stay within the segment
|
|
1102
|
+
const isLastSourceSegment = currentVisualLine === visualLines.length - 1 ||
|
|
1103
|
+
visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
|
|
1104
|
+
const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
|
|
1105
|
+
const isLastTargetSegment = targetVisualLine === visualLines.length - 1 ||
|
|
1106
|
+
visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
|
|
1107
|
+
const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
|
|
1108
|
+
const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
|
|
1109
|
+
// Set cursor position
|
|
1110
|
+
this.state.cursorLine = targetVL.logicalLine;
|
|
1111
|
+
const targetCol = targetVL.startCol + moveToVisualCol;
|
|
1112
|
+
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
|
|
1113
|
+
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
|
|
1114
|
+
// Snap cursor to atomic segment boundary (e.g. paste markers)
|
|
1115
|
+
// so the cursor never lands in the middle of a multi-grapheme unit.
|
|
1116
|
+
// Single-grapheme segments don't need snapping.
|
|
1117
|
+
const segments = [...this.segment(logicalLine)];
|
|
1118
|
+
for (const seg of segments) {
|
|
1119
|
+
if (seg.index > this.state.cursorCol)
|
|
1120
|
+
break;
|
|
1121
|
+
if (seg.segment.length <= 1)
|
|
1122
|
+
continue;
|
|
1123
|
+
if (this.state.cursorCol < seg.index + seg.segment.length) {
|
|
1124
|
+
const isContinuation = seg.index < targetVL.startCol;
|
|
1125
|
+
const isMovingDown = targetVisualLine > currentVisualLine;
|
|
1126
|
+
if (isContinuation && isMovingDown) {
|
|
1127
|
+
// The segment started on a previous visual line, and we
|
|
1128
|
+
// already visited it on the way down. Skip all remaining
|
|
1129
|
+
// continuation VLs and land on the first VL past it.
|
|
1130
|
+
const segEnd = seg.index + seg.segment.length;
|
|
1131
|
+
let next = targetVisualLine + 1;
|
|
1132
|
+
while (next < visualLines.length &&
|
|
1133
|
+
visualLines[next].logicalLine === targetVL.logicalLine &&
|
|
1134
|
+
visualLines[next].startCol < segEnd) {
|
|
1135
|
+
next++;
|
|
1136
|
+
}
|
|
1137
|
+
if (next < visualLines.length) {
|
|
1138
|
+
this.moveToVisualLine(visualLines, currentVisualLine, next);
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
// Snap to the start of the segment so it gets highlighted.
|
|
1143
|
+
// Store the pre-snap position so the next vertical move can
|
|
1144
|
+
// resolve it to the correct visual column.
|
|
1145
|
+
this.snappedFromCursorCol = this.state.cursorCol;
|
|
1146
|
+
this.state.cursorCol = seg.index;
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
// No snap occurred – we moved out of the atomic segment.
|
|
1151
|
+
this.snappedFromCursorCol = null;
|
|
957
1152
|
}
|
|
958
1153
|
/**
|
|
959
1154
|
* Compute the target visual column for vertical cursor movement.
|
|
@@ -1139,7 +1334,7 @@ export class Editor {
|
|
|
1139
1334
|
// Delete grapheme at cursor position (handles emojis, combining characters, etc.)
|
|
1140
1335
|
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
1141
1336
|
// Find the first grapheme at cursor
|
|
1142
|
-
const graphemes = [...
|
|
1337
|
+
const graphemes = [...this.segment(afterCursor)];
|
|
1143
1338
|
const firstGrapheme = graphemes[0];
|
|
1144
1339
|
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
|
1145
1340
|
const before = currentLine.slice(0, this.state.cursorCol);
|
|
@@ -1167,8 +1362,8 @@ export class Editor {
|
|
|
1167
1362
|
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
|
1168
1363
|
this.tryTriggerAutocomplete();
|
|
1169
1364
|
}
|
|
1170
|
-
// @
|
|
1171
|
-
else if (textBeforeCursor.match(/(?:^|[\s])
|
|
1365
|
+
// Symbol-based completion context like @ or #
|
|
1366
|
+
else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
|
|
1172
1367
|
this.tryTriggerAutocomplete();
|
|
1173
1368
|
}
|
|
1174
1369
|
}
|
|
@@ -1194,7 +1389,7 @@ export class Editor {
|
|
|
1194
1389
|
}
|
|
1195
1390
|
else {
|
|
1196
1391
|
// Line needs wrapping - use word-aware wrapping
|
|
1197
|
-
const chunks = wordWrapLine(line, width);
|
|
1392
|
+
const chunks = wordWrapLine(line, width, [...this.segment(line)]);
|
|
1198
1393
|
for (const chunk of chunks) {
|
|
1199
1394
|
visualLines.push({
|
|
1200
1395
|
logicalLine: i,
|
|
@@ -1207,26 +1402,29 @@ export class Editor {
|
|
|
1207
1402
|
return visualLines;
|
|
1208
1403
|
}
|
|
1209
1404
|
/**
|
|
1210
|
-
* Find the visual line index
|
|
1405
|
+
* Find the visual line index that contains the given logical position.
|
|
1211
1406
|
*/
|
|
1212
|
-
|
|
1407
|
+
findVisualLineAt(visualLines, line, col) {
|
|
1213
1408
|
for (let i = 0; i < visualLines.length; i++) {
|
|
1214
1409
|
const vl = visualLines[i];
|
|
1215
|
-
if (!vl)
|
|
1410
|
+
if (!vl || vl.logicalLine !== line)
|
|
1216
1411
|
continue;
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
return i;
|
|
1224
|
-
}
|
|
1412
|
+
const offset = col - vl.startCol;
|
|
1413
|
+
// Cursor is in this segment if it's within range. For the last
|
|
1414
|
+
// segment of a logical line, cursor can be at length (end position)
|
|
1415
|
+
const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
|
|
1416
|
+
if (offset >= 0 && (offset < vl.length || (isLastSegmentOfLine && offset === vl.length))) {
|
|
1417
|
+
return i;
|
|
1225
1418
|
}
|
|
1226
1419
|
}
|
|
1227
|
-
// Fallback: return last visual line
|
|
1228
1420
|
return visualLines.length - 1;
|
|
1229
1421
|
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Find the visual line index for the current cursor position.
|
|
1424
|
+
*/
|
|
1425
|
+
findCurrentVisualLine(visualLines) {
|
|
1426
|
+
return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol);
|
|
1427
|
+
}
|
|
1230
1428
|
moveCursor(deltaLine, deltaCol) {
|
|
1231
1429
|
this.lastAction = null;
|
|
1232
1430
|
const visualLines = this.buildVisualLineMap(this.lastWidth);
|
|
@@ -1243,7 +1441,7 @@ export class Editor {
|
|
|
1243
1441
|
// Moving right - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1244
1442
|
if (this.state.cursorCol < currentLine.length) {
|
|
1245
1443
|
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
1246
|
-
const graphemes = [...
|
|
1444
|
+
const graphemes = [...this.segment(afterCursor)];
|
|
1247
1445
|
const firstGrapheme = graphemes[0];
|
|
1248
1446
|
this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
|
|
1249
1447
|
}
|
|
@@ -1264,7 +1462,7 @@ export class Editor {
|
|
|
1264
1462
|
// Moving left - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1265
1463
|
if (this.state.cursorCol > 0) {
|
|
1266
1464
|
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1267
|
-
const graphemes = [...
|
|
1465
|
+
const graphemes = [...this.segment(beforeCursor)];
|
|
1268
1466
|
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1269
1467
|
this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
|
|
1270
1468
|
}
|
|
@@ -1303,17 +1501,25 @@ export class Editor {
|
|
|
1303
1501
|
return;
|
|
1304
1502
|
}
|
|
1305
1503
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1306
|
-
const graphemes = [...
|
|
1504
|
+
const graphemes = [...this.segment(textBeforeCursor)];
|
|
1307
1505
|
let newCol = this.state.cursorCol;
|
|
1308
1506
|
// Skip trailing whitespace
|
|
1309
|
-
while (graphemes.length > 0 &&
|
|
1507
|
+
while (graphemes.length > 0 &&
|
|
1508
|
+
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1509
|
+
isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1310
1510
|
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1311
1511
|
}
|
|
1312
1512
|
if (graphemes.length > 0) {
|
|
1313
1513
|
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
|
|
1314
|
-
if (
|
|
1514
|
+
if (isPasteMarker(lastGrapheme)) {
|
|
1515
|
+
// Paste marker is a single atomic word
|
|
1516
|
+
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1517
|
+
}
|
|
1518
|
+
else if (isPunctuationChar(lastGrapheme)) {
|
|
1315
1519
|
// Skip punctuation run
|
|
1316
|
-
while (graphemes.length > 0 &&
|
|
1520
|
+
while (graphemes.length > 0 &&
|
|
1521
|
+
isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1522
|
+
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1317
1523
|
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1318
1524
|
}
|
|
1319
1525
|
}
|
|
@@ -1321,7 +1527,8 @@ export class Editor {
|
|
|
1321
1527
|
// Skip word run
|
|
1322
1528
|
while (graphemes.length > 0 &&
|
|
1323
1529
|
!isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1324
|
-
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
|
|
1530
|
+
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1531
|
+
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1325
1532
|
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1326
1533
|
}
|
|
1327
1534
|
}
|
|
@@ -1484,27 +1691,34 @@ export class Editor {
|
|
|
1484
1691
|
return;
|
|
1485
1692
|
}
|
|
1486
1693
|
const textAfterCursor = currentLine.slice(this.state.cursorCol);
|
|
1487
|
-
const segments =
|
|
1694
|
+
const segments = this.segment(textAfterCursor);
|
|
1488
1695
|
const iterator = segments[Symbol.iterator]();
|
|
1489
1696
|
let next = iterator.next();
|
|
1490
1697
|
let newCol = this.state.cursorCol;
|
|
1491
1698
|
// Skip leading whitespace
|
|
1492
|
-
while (!next.done && isWhitespaceChar(next.value.segment)) {
|
|
1699
|
+
while (!next.done && !isPasteMarker(next.value.segment) && isWhitespaceChar(next.value.segment)) {
|
|
1493
1700
|
newCol += next.value.segment.length;
|
|
1494
1701
|
next = iterator.next();
|
|
1495
1702
|
}
|
|
1496
1703
|
if (!next.done) {
|
|
1497
1704
|
const firstGrapheme = next.value.segment;
|
|
1498
|
-
if (
|
|
1705
|
+
if (isPasteMarker(firstGrapheme)) {
|
|
1706
|
+
// Paste marker is a single atomic word
|
|
1707
|
+
newCol += firstGrapheme.length;
|
|
1708
|
+
}
|
|
1709
|
+
else if (isPunctuationChar(firstGrapheme)) {
|
|
1499
1710
|
// Skip punctuation run
|
|
1500
|
-
while (!next.done && isPunctuationChar(next.value.segment)) {
|
|
1711
|
+
while (!next.done && isPunctuationChar(next.value.segment) && !isPasteMarker(next.value.segment)) {
|
|
1501
1712
|
newCol += next.value.segment.length;
|
|
1502
1713
|
next = iterator.next();
|
|
1503
1714
|
}
|
|
1504
1715
|
}
|
|
1505
1716
|
else {
|
|
1506
1717
|
// Skip word run
|
|
1507
|
-
while (!next.done &&
|
|
1718
|
+
while (!next.done &&
|
|
1719
|
+
!isWhitespaceChar(next.value.segment) &&
|
|
1720
|
+
!isPunctuationChar(next.value.segment) &&
|
|
1721
|
+
!isPasteMarker(next.value.segment)) {
|
|
1508
1722
|
newCol += next.value.segment.length;
|
|
1509
1723
|
next = iterator.next();
|
|
1510
1724
|
}
|
|
@@ -1528,34 +1742,44 @@ export class Editor {
|
|
|
1528
1742
|
return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/");
|
|
1529
1743
|
}
|
|
1530
1744
|
// Autocomplete methods
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1745
|
+
/**
|
|
1746
|
+
* Find the best autocomplete item index for the given prefix.
|
|
1747
|
+
* Returns -1 if no match is found.
|
|
1748
|
+
*
|
|
1749
|
+
* Match priority:
|
|
1750
|
+
* 1. Exact match (prefix === item.value) -> always selected
|
|
1751
|
+
* 2. Prefix match -> first item whose value starts with prefix
|
|
1752
|
+
* 3. No match -> -1 (keep default highlight)
|
|
1753
|
+
*
|
|
1754
|
+
* Matching is case-sensitive and checks item.value only.
|
|
1755
|
+
*/
|
|
1756
|
+
getBestAutocompleteMatchIndex(items, prefix) {
|
|
1757
|
+
if (!prefix)
|
|
1758
|
+
return -1;
|
|
1759
|
+
let firstPrefixIndex = -1;
|
|
1760
|
+
for (let i = 0; i < items.length; i++) {
|
|
1761
|
+
const value = items[i].value;
|
|
1762
|
+
if (value === prefix) {
|
|
1763
|
+
return i; // Exact match always wins
|
|
1764
|
+
}
|
|
1765
|
+
if (firstPrefixIndex === -1 && value.startsWith(prefix)) {
|
|
1766
|
+
firstPrefixIndex = i;
|
|
1541
1767
|
}
|
|
1542
1768
|
}
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
}
|
|
1769
|
+
return firstPrefixIndex;
|
|
1770
|
+
}
|
|
1771
|
+
createAutocompleteList(prefix, items) {
|
|
1772
|
+
const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined;
|
|
1773
|
+
return new SelectList(items, this.autocompleteMaxVisible, this.theme.selectList, layout);
|
|
1774
|
+
}
|
|
1775
|
+
tryTriggerAutocomplete(explicitTab = false) {
|
|
1776
|
+
this.requestAutocomplete({ force: false, explicitTab });
|
|
1552
1777
|
}
|
|
1553
1778
|
handleTabCompletion() {
|
|
1554
1779
|
if (!this.autocompleteProvider)
|
|
1555
1780
|
return;
|
|
1556
1781
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1557
1782
|
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1558
|
-
// Check if we're in a slash command context
|
|
1559
1783
|
if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) {
|
|
1560
1784
|
this.handleSlashCommandCompletion();
|
|
1561
1785
|
}
|
|
@@ -1564,69 +1788,129 @@ export class Editor {
|
|
|
1564
1788
|
}
|
|
1565
1789
|
}
|
|
1566
1790
|
handleSlashCommandCompletion() {
|
|
1567
|
-
this.
|
|
1791
|
+
this.requestAutocomplete({ force: false, explicitTab: true });
|
|
1568
1792
|
}
|
|
1569
|
-
/*
|
|
1570
|
-
https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883
|
|
1571
|
-
17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19
|
|
1572
|
-
536643416/job/55932288317 havea look at .gi
|
|
1573
|
-
*/
|
|
1574
1793
|
forceFileAutocomplete(explicitTab = false) {
|
|
1794
|
+
this.requestAutocomplete({ force: true, explicitTab });
|
|
1795
|
+
}
|
|
1796
|
+
requestAutocomplete(options) {
|
|
1575
1797
|
if (!this.autocompleteProvider)
|
|
1576
1798
|
return;
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1799
|
+
if (options.force) {
|
|
1800
|
+
const shouldTrigger = !this.autocompleteProvider.shouldTriggerFileCompletion ||
|
|
1801
|
+
this.autocompleteProvider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
|
|
1802
|
+
if (!shouldTrigger) {
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
this.cancelAutocompleteRequest();
|
|
1807
|
+
const startToken = ++this.autocompleteStartToken;
|
|
1808
|
+
const debounceMs = this.getAutocompleteDebounceMs(options);
|
|
1809
|
+
if (debounceMs > 0) {
|
|
1810
|
+
this.autocompleteDebounceTimer = setTimeout(() => {
|
|
1811
|
+
this.autocompleteDebounceTimer = undefined;
|
|
1812
|
+
void this.startAutocompleteRequest(startToken, options);
|
|
1813
|
+
}, debounceMs);
|
|
1581
1814
|
return;
|
|
1582
1815
|
}
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, item, suggestions.prefix);
|
|
1591
|
-
this.state.lines = result.lines;
|
|
1592
|
-
this.state.cursorLine = result.cursorLine;
|
|
1593
|
-
this.setCursorCol(result.cursorCol);
|
|
1594
|
-
if (this.onChange)
|
|
1595
|
-
this.onChange(this.getText());
|
|
1816
|
+
void this.startAutocompleteRequest(startToken, options);
|
|
1817
|
+
}
|
|
1818
|
+
async startAutocompleteRequest(startToken, options) {
|
|
1819
|
+
const previousTask = this.autocompleteRequestTask;
|
|
1820
|
+
this.autocompleteRequestTask = (async () => {
|
|
1821
|
+
await previousTask;
|
|
1822
|
+
if (startToken !== this.autocompleteStartToken || !this.autocompleteProvider) {
|
|
1596
1823
|
return;
|
|
1597
1824
|
}
|
|
1598
|
-
|
|
1599
|
-
this.
|
|
1600
|
-
|
|
1825
|
+
const controller = new AbortController();
|
|
1826
|
+
this.autocompleteAbort = controller;
|
|
1827
|
+
const requestId = ++this.autocompleteRequestId;
|
|
1828
|
+
const snapshotText = this.getText();
|
|
1829
|
+
const snapshotLine = this.state.cursorLine;
|
|
1830
|
+
const snapshotCol = this.state.cursorCol;
|
|
1831
|
+
await this.runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options);
|
|
1832
|
+
})();
|
|
1833
|
+
await this.autocompleteRequestTask;
|
|
1834
|
+
}
|
|
1835
|
+
getAutocompleteDebounceMs(options) {
|
|
1836
|
+
if (options.explicitTab || options.force) {
|
|
1837
|
+
return 0;
|
|
1601
1838
|
}
|
|
1602
|
-
|
|
1839
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1840
|
+
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1841
|
+
const isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(textBeforeCursor);
|
|
1842
|
+
return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
|
|
1843
|
+
}
|
|
1844
|
+
async runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options) {
|
|
1845
|
+
if (!this.autocompleteProvider)
|
|
1846
|
+
return;
|
|
1847
|
+
const suggestions = await this.autocompleteProvider.getSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol, { signal: controller.signal, force: options.force });
|
|
1848
|
+
if (!this.isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol)) {
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
this.autocompleteAbort = undefined;
|
|
1852
|
+
if (!suggestions || !Array.isArray(suggestions.items) || suggestions.items.length === 0) {
|
|
1603
1853
|
this.cancelAutocomplete();
|
|
1854
|
+
this.tui.requestRender();
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
if (options.force && options.explicitTab && suggestions.items.length === 1) {
|
|
1858
|
+
const item = suggestions.items[0];
|
|
1859
|
+
this.pushUndoSnapshot();
|
|
1860
|
+
this.lastAction = null;
|
|
1861
|
+
const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, item, suggestions.prefix);
|
|
1862
|
+
this.state.lines = result.lines;
|
|
1863
|
+
this.state.cursorLine = result.cursorLine;
|
|
1864
|
+
this.setCursorCol(result.cursorCol);
|
|
1865
|
+
if (this.onChange)
|
|
1866
|
+
this.onChange(this.getText());
|
|
1867
|
+
this.tui.requestRender();
|
|
1868
|
+
return;
|
|
1604
1869
|
}
|
|
1870
|
+
this.applyAutocompleteSuggestions(suggestions, options.force ? "force" : "regular");
|
|
1871
|
+
this.tui.requestRender();
|
|
1605
1872
|
}
|
|
1606
|
-
|
|
1873
|
+
isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol) {
|
|
1874
|
+
return (!controller.signal.aborted &&
|
|
1875
|
+
requestId === this.autocompleteRequestId &&
|
|
1876
|
+
this.getText() === snapshotText &&
|
|
1877
|
+
this.state.cursorLine === snapshotLine &&
|
|
1878
|
+
this.state.cursorCol === snapshotCol);
|
|
1879
|
+
}
|
|
1880
|
+
applyAutocompleteSuggestions(suggestions, state) {
|
|
1881
|
+
this.autocompletePrefix = suggestions.prefix;
|
|
1882
|
+
this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
1883
|
+
const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
|
|
1884
|
+
if (bestMatchIndex >= 0) {
|
|
1885
|
+
this.autocompleteList.setSelectedIndex(bestMatchIndex);
|
|
1886
|
+
}
|
|
1887
|
+
this.autocompleteState = state;
|
|
1888
|
+
}
|
|
1889
|
+
cancelAutocompleteRequest() {
|
|
1890
|
+
this.autocompleteStartToken += 1;
|
|
1891
|
+
if (this.autocompleteDebounceTimer) {
|
|
1892
|
+
clearTimeout(this.autocompleteDebounceTimer);
|
|
1893
|
+
this.autocompleteDebounceTimer = undefined;
|
|
1894
|
+
}
|
|
1895
|
+
this.autocompleteAbort?.abort();
|
|
1896
|
+
this.autocompleteAbort = undefined;
|
|
1897
|
+
}
|
|
1898
|
+
clearAutocompleteUi() {
|
|
1607
1899
|
this.autocompleteState = null;
|
|
1608
1900
|
this.autocompleteList = undefined;
|
|
1609
1901
|
this.autocompletePrefix = "";
|
|
1610
1902
|
}
|
|
1903
|
+
cancelAutocomplete() {
|
|
1904
|
+
this.cancelAutocompleteRequest();
|
|
1905
|
+
this.clearAutocompleteUi();
|
|
1906
|
+
}
|
|
1611
1907
|
isShowingAutocomplete() {
|
|
1612
1908
|
return this.autocompleteState !== null;
|
|
1613
1909
|
}
|
|
1614
1910
|
updateAutocomplete() {
|
|
1615
1911
|
if (!this.autocompleteState || !this.autocompleteProvider)
|
|
1616
1912
|
return;
|
|
1617
|
-
|
|
1618
|
-
this.forceFileAutocomplete();
|
|
1619
|
-
return;
|
|
1620
|
-
}
|
|
1621
|
-
const suggestions = this.autocompleteProvider.getSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol);
|
|
1622
|
-
if (suggestions && suggestions.items.length > 0) {
|
|
1623
|
-
this.autocompletePrefix = suggestions.prefix;
|
|
1624
|
-
// Always create new SelectList to ensure update
|
|
1625
|
-
this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
|
|
1626
|
-
}
|
|
1627
|
-
else {
|
|
1628
|
-
this.cancelAutocomplete();
|
|
1629
|
-
}
|
|
1913
|
+
this.requestAutocomplete({ force: this.autocompleteState === "force", explicitTab: false });
|
|
1630
1914
|
}
|
|
1631
1915
|
}
|
|
1632
1916
|
//# sourceMappingURL=editor.js.map
|