@oh-my-pi/pi-tui 1.337.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/README.md +654 -0
- package/package.json +45 -0
- package/src/autocomplete.ts +575 -0
- package/src/components/box.ts +134 -0
- package/src/components/cancellable-loader.ts +39 -0
- package/src/components/editor.ts +1342 -0
- package/src/components/image.ts +87 -0
- package/src/components/input.ts +344 -0
- package/src/components/loader.ts +55 -0
- package/src/components/markdown.ts +646 -0
- package/src/components/select-list.ts +184 -0
- package/src/components/settings-list.ts +188 -0
- package/src/components/spacer.ts +28 -0
- package/src/components/tab-bar.ts +140 -0
- package/src/components/text.ts +106 -0
- package/src/components/truncated-text.ts +65 -0
- package/src/index.ts +91 -0
- package/src/keys.ts +560 -0
- package/src/terminal-image.ts +340 -0
- package/src/terminal.ts +163 -0
- package/src/tui.ts +353 -0
- package/src/utils.ts +712 -0
|
@@ -0,0 +1,1342 @@
|
|
|
1
|
+
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
|
|
2
|
+
import {
|
|
3
|
+
isAltBackspace,
|
|
4
|
+
isAltEnter,
|
|
5
|
+
isAltLeft,
|
|
6
|
+
isAltRight,
|
|
7
|
+
isArrowDown,
|
|
8
|
+
isArrowLeft,
|
|
9
|
+
isArrowRight,
|
|
10
|
+
isArrowUp,
|
|
11
|
+
isBackspace,
|
|
12
|
+
isCtrlA,
|
|
13
|
+
isCtrlC,
|
|
14
|
+
isCtrlE,
|
|
15
|
+
isCtrlK,
|
|
16
|
+
isCtrlLeft,
|
|
17
|
+
isCtrlRight,
|
|
18
|
+
isCtrlU,
|
|
19
|
+
isCtrlW,
|
|
20
|
+
isDelete,
|
|
21
|
+
isEnd,
|
|
22
|
+
isEnter,
|
|
23
|
+
isEscape,
|
|
24
|
+
isHome,
|
|
25
|
+
isShiftEnter,
|
|
26
|
+
isTab,
|
|
27
|
+
} from "../keys.js";
|
|
28
|
+
import type { Component } from "../tui.js";
|
|
29
|
+
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
|
|
30
|
+
import { SelectList, type SelectListTheme } from "./select-list.js";
|
|
31
|
+
|
|
32
|
+
const segmenter = getSegmenter();
|
|
33
|
+
|
|
34
|
+
interface EditorState {
|
|
35
|
+
lines: string[];
|
|
36
|
+
cursorLine: number;
|
|
37
|
+
cursorCol: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface LayoutLine {
|
|
41
|
+
text: string;
|
|
42
|
+
hasCursor: boolean;
|
|
43
|
+
cursorPos?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface EditorTheme {
|
|
47
|
+
borderColor: (str: string) => string;
|
|
48
|
+
selectList: SelectListTheme;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class Editor implements Component {
|
|
52
|
+
private state: EditorState = {
|
|
53
|
+
lines: [""],
|
|
54
|
+
cursorLine: 0,
|
|
55
|
+
cursorCol: 0,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
private theme: EditorTheme;
|
|
59
|
+
|
|
60
|
+
// Store last render width for cursor navigation
|
|
61
|
+
private lastWidth: number = 80;
|
|
62
|
+
|
|
63
|
+
// Border color (can be changed dynamically)
|
|
64
|
+
public borderColor: (str: string) => string;
|
|
65
|
+
|
|
66
|
+
// Autocomplete support
|
|
67
|
+
private autocompleteProvider?: AutocompleteProvider;
|
|
68
|
+
private autocompleteList?: SelectList;
|
|
69
|
+
private isAutocompleting: boolean = false;
|
|
70
|
+
private autocompletePrefix: string = "";
|
|
71
|
+
|
|
72
|
+
// Paste tracking for large pastes
|
|
73
|
+
private pastes: Map<number, string> = new Map();
|
|
74
|
+
private pasteCounter: number = 0;
|
|
75
|
+
|
|
76
|
+
// Bracketed paste mode buffering
|
|
77
|
+
private pasteBuffer: string = "";
|
|
78
|
+
private isInPaste: boolean = false;
|
|
79
|
+
|
|
80
|
+
// Prompt history for up/down navigation
|
|
81
|
+
private history: string[] = [];
|
|
82
|
+
private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
|
|
83
|
+
|
|
84
|
+
public onSubmit?: (text: string) => void;
|
|
85
|
+
public onChange?: (text: string) => void;
|
|
86
|
+
public disableSubmit: boolean = false;
|
|
87
|
+
|
|
88
|
+
constructor(theme: EditorTheme) {
|
|
89
|
+
this.theme = theme;
|
|
90
|
+
this.borderColor = theme.borderColor;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
94
|
+
this.autocompleteProvider = provider;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Add a prompt to history for up/down arrow navigation.
|
|
99
|
+
* Called after successful submission.
|
|
100
|
+
*/
|
|
101
|
+
addToHistory(text: string): void {
|
|
102
|
+
const trimmed = text.trim();
|
|
103
|
+
if (!trimmed) return;
|
|
104
|
+
// Don't add consecutive duplicates
|
|
105
|
+
if (this.history.length > 0 && this.history[0] === trimmed) return;
|
|
106
|
+
this.history.unshift(trimmed);
|
|
107
|
+
// Limit history size
|
|
108
|
+
if (this.history.length > 100) {
|
|
109
|
+
this.history.pop();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private isEditorEmpty(): boolean {
|
|
114
|
+
return this.state.lines.length === 1 && this.state.lines[0] === "";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private isOnFirstVisualLine(): boolean {
|
|
118
|
+
const visualLines = this.buildVisualLineMap(this.lastWidth);
|
|
119
|
+
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
|
120
|
+
return currentVisualLine === 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private isOnLastVisualLine(): boolean {
|
|
124
|
+
const visualLines = this.buildVisualLineMap(this.lastWidth);
|
|
125
|
+
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
|
126
|
+
return currentVisualLine === visualLines.length - 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private navigateHistory(direction: 1 | -1): void {
|
|
130
|
+
if (this.history.length === 0) return;
|
|
131
|
+
|
|
132
|
+
const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases
|
|
133
|
+
if (newIndex < -1 || newIndex >= this.history.length) return;
|
|
134
|
+
|
|
135
|
+
this.historyIndex = newIndex;
|
|
136
|
+
|
|
137
|
+
if (this.historyIndex === -1) {
|
|
138
|
+
// Returned to "current" state - clear editor
|
|
139
|
+
this.setTextInternal("");
|
|
140
|
+
} else {
|
|
141
|
+
this.setTextInternal(this.history[this.historyIndex] || "");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Internal setText that doesn't reset history state - used by navigateHistory */
|
|
146
|
+
private setTextInternal(text: string): void {
|
|
147
|
+
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
148
|
+
this.state.lines = lines.length === 0 ? [""] : lines;
|
|
149
|
+
this.state.cursorLine = this.state.lines.length - 1;
|
|
150
|
+
this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0;
|
|
151
|
+
|
|
152
|
+
if (this.onChange) {
|
|
153
|
+
this.onChange(this.getText());
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
invalidate(): void {
|
|
158
|
+
// No cached state to invalidate currently
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
render(width: number): string[] {
|
|
162
|
+
// Store width for cursor navigation
|
|
163
|
+
this.lastWidth = width;
|
|
164
|
+
|
|
165
|
+
const horizontal = this.borderColor("─");
|
|
166
|
+
|
|
167
|
+
// Layout the text - use full width
|
|
168
|
+
const layoutLines = this.layoutText(width);
|
|
169
|
+
|
|
170
|
+
const result: string[] = [];
|
|
171
|
+
|
|
172
|
+
// Render top border
|
|
173
|
+
result.push(horizontal.repeat(width));
|
|
174
|
+
|
|
175
|
+
// Render each layout line
|
|
176
|
+
for (const layoutLine of layoutLines) {
|
|
177
|
+
let displayText = layoutLine.text;
|
|
178
|
+
let lineVisibleWidth = visibleWidth(layoutLine.text);
|
|
179
|
+
|
|
180
|
+
// Add cursor if this line has it
|
|
181
|
+
if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
|
|
182
|
+
const before = displayText.slice(0, layoutLine.cursorPos);
|
|
183
|
+
const after = displayText.slice(layoutLine.cursorPos);
|
|
184
|
+
|
|
185
|
+
if (after.length > 0) {
|
|
186
|
+
// Cursor is on a character (grapheme) - replace it with highlighted version
|
|
187
|
+
// Get the first grapheme from 'after'
|
|
188
|
+
const afterGraphemes = [...segmenter.segment(after)];
|
|
189
|
+
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
|
190
|
+
const restAfter = after.slice(firstGrapheme.length);
|
|
191
|
+
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
|
192
|
+
displayText = before + cursor + restAfter;
|
|
193
|
+
// lineVisibleWidth stays the same - we're replacing, not adding
|
|
194
|
+
} else {
|
|
195
|
+
// Cursor is at the end - check if we have room for the space
|
|
196
|
+
if (lineVisibleWidth < width) {
|
|
197
|
+
// We have room - add highlighted space
|
|
198
|
+
const cursor = "\x1b[7m \x1b[0m";
|
|
199
|
+
displayText = before + cursor;
|
|
200
|
+
// lineVisibleWidth increases by 1 - we're adding a space
|
|
201
|
+
lineVisibleWidth = lineVisibleWidth + 1;
|
|
202
|
+
} else {
|
|
203
|
+
// Line is at full width - use reverse video on last grapheme if possible
|
|
204
|
+
// or just show cursor at the end without adding space
|
|
205
|
+
const beforeGraphemes = [...segmenter.segment(before)];
|
|
206
|
+
if (beforeGraphemes.length > 0) {
|
|
207
|
+
const lastGrapheme = beforeGraphemes[beforeGraphemes.length - 1]?.segment || "";
|
|
208
|
+
const cursor = `\x1b[7m${lastGrapheme}\x1b[0m`;
|
|
209
|
+
// Rebuild 'before' without the last grapheme
|
|
210
|
+
const beforeWithoutLast = beforeGraphemes
|
|
211
|
+
.slice(0, -1)
|
|
212
|
+
.map((g) => g.segment)
|
|
213
|
+
.join("");
|
|
214
|
+
displayText = beforeWithoutLast + cursor;
|
|
215
|
+
}
|
|
216
|
+
// lineVisibleWidth stays the same
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Calculate padding based on actual visible width
|
|
222
|
+
const padding = " ".repeat(Math.max(0, width - lineVisibleWidth));
|
|
223
|
+
|
|
224
|
+
// Render the line (no side borders, just horizontal lines above and below)
|
|
225
|
+
result.push(displayText + padding);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Render bottom border
|
|
229
|
+
result.push(horizontal.repeat(width));
|
|
230
|
+
|
|
231
|
+
// Add autocomplete list if active
|
|
232
|
+
if (this.isAutocompleting && this.autocompleteList) {
|
|
233
|
+
const autocompleteResult = this.autocompleteList.render(width);
|
|
234
|
+
result.push(...autocompleteResult);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
handleInput(data: string): void {
|
|
241
|
+
// Handle bracketed paste mode
|
|
242
|
+
// Start of paste: \x1b[200~
|
|
243
|
+
// End of paste: \x1b[201~
|
|
244
|
+
|
|
245
|
+
// Check if we're starting a bracketed paste
|
|
246
|
+
if (data.includes("\x1b[200~")) {
|
|
247
|
+
this.isInPaste = true;
|
|
248
|
+
this.pasteBuffer = "";
|
|
249
|
+
// Remove the start marker and keep the rest
|
|
250
|
+
data = data.replace("\x1b[200~", "");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// If we're in a paste, buffer the data
|
|
254
|
+
if (this.isInPaste) {
|
|
255
|
+
// Append data to buffer first (end marker could be split across chunks)
|
|
256
|
+
this.pasteBuffer += data;
|
|
257
|
+
|
|
258
|
+
// Check if the accumulated buffer contains the end marker
|
|
259
|
+
const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
|
|
260
|
+
if (endIndex !== -1) {
|
|
261
|
+
// Extract content before the end marker
|
|
262
|
+
const pasteContent = this.pasteBuffer.substring(0, endIndex);
|
|
263
|
+
|
|
264
|
+
// Process the complete paste
|
|
265
|
+
this.handlePaste(pasteContent);
|
|
266
|
+
|
|
267
|
+
// Reset paste state
|
|
268
|
+
this.isInPaste = false;
|
|
269
|
+
|
|
270
|
+
// Process any remaining data after the end marker
|
|
271
|
+
const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~
|
|
272
|
+
this.pasteBuffer = "";
|
|
273
|
+
|
|
274
|
+
if (remaining.length > 0) {
|
|
275
|
+
this.handleInput(remaining);
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
} else {
|
|
279
|
+
// Still accumulating, wait for more data
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Handle special key combinations first
|
|
285
|
+
|
|
286
|
+
// Ctrl+C - Exit (let parent handle this)
|
|
287
|
+
if (isCtrlC(data)) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Handle autocomplete special keys first (but don't block other input)
|
|
292
|
+
if (this.isAutocompleting && this.autocompleteList) {
|
|
293
|
+
// Escape - cancel autocomplete
|
|
294
|
+
if (isEscape(data)) {
|
|
295
|
+
this.cancelAutocomplete();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
// Let the autocomplete list handle navigation and selection
|
|
299
|
+
else if (isArrowUp(data) || isArrowDown(data) || isEnter(data) || isTab(data)) {
|
|
300
|
+
// Only pass arrow keys to the list, not Enter/Tab (we handle those directly)
|
|
301
|
+
if (isArrowUp(data) || isArrowDown(data)) {
|
|
302
|
+
this.autocompleteList.handleInput(data);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// If Tab was pressed, always apply the selection
|
|
307
|
+
if (isTab(data)) {
|
|
308
|
+
const selected = this.autocompleteList.getSelectedItem();
|
|
309
|
+
if (selected && this.autocompleteProvider) {
|
|
310
|
+
const result = this.autocompleteProvider.applyCompletion(
|
|
311
|
+
this.state.lines,
|
|
312
|
+
this.state.cursorLine,
|
|
313
|
+
this.state.cursorCol,
|
|
314
|
+
selected,
|
|
315
|
+
this.autocompletePrefix,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
this.state.lines = result.lines;
|
|
319
|
+
this.state.cursorLine = result.cursorLine;
|
|
320
|
+
this.state.cursorCol = result.cursorCol;
|
|
321
|
+
|
|
322
|
+
this.cancelAutocomplete();
|
|
323
|
+
|
|
324
|
+
if (this.onChange) {
|
|
325
|
+
this.onChange(this.getText());
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// If Enter was pressed on a slash command, apply completion and submit
|
|
332
|
+
if (isEnter(data) && this.autocompletePrefix.startsWith("/")) {
|
|
333
|
+
const selected = this.autocompleteList.getSelectedItem();
|
|
334
|
+
if (selected && this.autocompleteProvider) {
|
|
335
|
+
const result = this.autocompleteProvider.applyCompletion(
|
|
336
|
+
this.state.lines,
|
|
337
|
+
this.state.cursorLine,
|
|
338
|
+
this.state.cursorCol,
|
|
339
|
+
selected,
|
|
340
|
+
this.autocompletePrefix,
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
this.state.lines = result.lines;
|
|
344
|
+
this.state.cursorLine = result.cursorLine;
|
|
345
|
+
this.state.cursorCol = result.cursorCol;
|
|
346
|
+
}
|
|
347
|
+
this.cancelAutocomplete();
|
|
348
|
+
// Don't return - fall through to submission logic
|
|
349
|
+
}
|
|
350
|
+
// If Enter was pressed on a file path, apply completion
|
|
351
|
+
else if (isEnter(data)) {
|
|
352
|
+
const selected = this.autocompleteList.getSelectedItem();
|
|
353
|
+
if (selected && this.autocompleteProvider) {
|
|
354
|
+
const result = this.autocompleteProvider.applyCompletion(
|
|
355
|
+
this.state.lines,
|
|
356
|
+
this.state.cursorLine,
|
|
357
|
+
this.state.cursorCol,
|
|
358
|
+
selected,
|
|
359
|
+
this.autocompletePrefix,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
this.state.lines = result.lines;
|
|
363
|
+
this.state.cursorLine = result.cursorLine;
|
|
364
|
+
this.state.cursorCol = result.cursorCol;
|
|
365
|
+
|
|
366
|
+
this.cancelAutocomplete();
|
|
367
|
+
|
|
368
|
+
if (this.onChange) {
|
|
369
|
+
this.onChange(this.getText());
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// For other keys (like regular typing), DON'T return here
|
|
376
|
+
// Let them fall through to normal character handling
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Tab key - context-aware completion (but not when already autocompleting)
|
|
380
|
+
if (isTab(data) && !this.isAutocompleting) {
|
|
381
|
+
this.handleTabCompletion();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Continue with rest of input handling
|
|
386
|
+
// Ctrl+K - Delete to end of line
|
|
387
|
+
if (isCtrlK(data)) {
|
|
388
|
+
this.deleteToEndOfLine();
|
|
389
|
+
}
|
|
390
|
+
// Ctrl+U - Delete to start of line
|
|
391
|
+
else if (isCtrlU(data)) {
|
|
392
|
+
this.deleteToStartOfLine();
|
|
393
|
+
}
|
|
394
|
+
// Ctrl+W - Delete word backwards
|
|
395
|
+
else if (isCtrlW(data)) {
|
|
396
|
+
this.deleteWordBackwards();
|
|
397
|
+
}
|
|
398
|
+
// Option/Alt+Backspace - Delete word backwards
|
|
399
|
+
else if (isAltBackspace(data)) {
|
|
400
|
+
this.deleteWordBackwards();
|
|
401
|
+
}
|
|
402
|
+
// Ctrl+A - Move to start of line
|
|
403
|
+
else if (isCtrlA(data)) {
|
|
404
|
+
this.moveToLineStart();
|
|
405
|
+
}
|
|
406
|
+
// Ctrl+E - Move to end of line
|
|
407
|
+
else if (isCtrlE(data)) {
|
|
408
|
+
this.moveToLineEnd();
|
|
409
|
+
}
|
|
410
|
+
// New line shortcuts (but not plain LF/CR which should be submit)
|
|
411
|
+
else if (
|
|
412
|
+
(data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
|
|
413
|
+
data === "\x1b\r" || // Option+Enter in some terminals (legacy)
|
|
414
|
+
data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
|
|
415
|
+
isShiftEnter(data) || // Shift+Enter (Kitty protocol, handles lock bits)
|
|
416
|
+
isAltEnter(data) || // Alt+Enter (Kitty protocol, handles lock bits)
|
|
417
|
+
(data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
|
|
418
|
+
(data === "\n" && data.length === 1) || // Shift+Enter from iTerm2 mapping
|
|
419
|
+
data === "\\\r" // Shift+Enter in VS Code terminal
|
|
420
|
+
) {
|
|
421
|
+
// Modifier + Enter = new line
|
|
422
|
+
this.addNewLine();
|
|
423
|
+
}
|
|
424
|
+
// Plain Enter - submit (handles both legacy \r and Kitty protocol with lock bits)
|
|
425
|
+
else if (isEnter(data)) {
|
|
426
|
+
// If submit is disabled, do nothing
|
|
427
|
+
if (this.disableSubmit) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Get text and substitute paste markers with actual content
|
|
432
|
+
let result = this.state.lines.join("\n").trim();
|
|
433
|
+
|
|
434
|
+
// Replace all [paste #N +xxx lines] or [paste #N xxx chars] markers with actual paste content
|
|
435
|
+
for (const [pasteId, pasteContent] of this.pastes) {
|
|
436
|
+
// Match formats: [paste #N], [paste #N +xxx lines], or [paste #N xxx chars]
|
|
437
|
+
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
|
|
438
|
+
result = result.replace(markerRegex, pasteContent);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Reset editor and clear pastes
|
|
442
|
+
this.state = {
|
|
443
|
+
lines: [""],
|
|
444
|
+
cursorLine: 0,
|
|
445
|
+
cursorCol: 0,
|
|
446
|
+
};
|
|
447
|
+
this.pastes.clear();
|
|
448
|
+
this.pasteCounter = 0;
|
|
449
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
450
|
+
|
|
451
|
+
// Notify that editor is now empty
|
|
452
|
+
if (this.onChange) {
|
|
453
|
+
this.onChange("");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (this.onSubmit) {
|
|
457
|
+
this.onSubmit(result);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// Backspace
|
|
461
|
+
else if (isBackspace(data)) {
|
|
462
|
+
this.handleBackspace();
|
|
463
|
+
}
|
|
464
|
+
// Line navigation shortcuts (Home/End keys)
|
|
465
|
+
else if (isHome(data)) {
|
|
466
|
+
this.moveToLineStart();
|
|
467
|
+
} else if (isEnd(data)) {
|
|
468
|
+
this.moveToLineEnd();
|
|
469
|
+
}
|
|
470
|
+
// Forward delete (Fn+Backspace or Delete key)
|
|
471
|
+
else if (isDelete(data)) {
|
|
472
|
+
this.handleForwardDelete();
|
|
473
|
+
}
|
|
474
|
+
// Word navigation (Option/Alt + Arrow or Ctrl + Arrow)
|
|
475
|
+
else if (isAltLeft(data) || isCtrlLeft(data)) {
|
|
476
|
+
// Word left
|
|
477
|
+
this.moveWordBackwards();
|
|
478
|
+
} else if (isAltRight(data) || isCtrlRight(data)) {
|
|
479
|
+
// Word right
|
|
480
|
+
this.moveWordForwards();
|
|
481
|
+
}
|
|
482
|
+
// Arrow keys
|
|
483
|
+
else if (isArrowUp(data)) {
|
|
484
|
+
// Up - history navigation or cursor movement
|
|
485
|
+
if (this.isEditorEmpty()) {
|
|
486
|
+
this.navigateHistory(-1); // Start browsing history
|
|
487
|
+
} else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
|
|
488
|
+
this.navigateHistory(-1); // Navigate to older history entry
|
|
489
|
+
} else {
|
|
490
|
+
this.moveCursor(-1, 0); // Cursor movement (within text or history entry)
|
|
491
|
+
}
|
|
492
|
+
} else if (isArrowDown(data)) {
|
|
493
|
+
// Down - history navigation or cursor movement
|
|
494
|
+
if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
|
|
495
|
+
this.navigateHistory(1); // Navigate to newer history entry or clear
|
|
496
|
+
} else {
|
|
497
|
+
this.moveCursor(1, 0); // Cursor movement (within text or history entry)
|
|
498
|
+
}
|
|
499
|
+
} else if (isArrowRight(data)) {
|
|
500
|
+
// Right
|
|
501
|
+
this.moveCursor(0, 1);
|
|
502
|
+
} else if (isArrowLeft(data)) {
|
|
503
|
+
// Left
|
|
504
|
+
this.moveCursor(0, -1);
|
|
505
|
+
}
|
|
506
|
+
// Shift+Space via Kitty protocol (sends \x1b[32;2u instead of plain space)
|
|
507
|
+
else if (data === "\x1b[32;2u" || data.match(/^\x1b\[32;\d+u$/)) {
|
|
508
|
+
this.insertCharacter(" ");
|
|
509
|
+
}
|
|
510
|
+
// Regular characters (printable characters and unicode, but not control characters)
|
|
511
|
+
else if (data.charCodeAt(0) >= 32) {
|
|
512
|
+
this.insertCharacter(data);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private layoutText(contentWidth: number): LayoutLine[] {
|
|
517
|
+
const layoutLines: LayoutLine[] = [];
|
|
518
|
+
|
|
519
|
+
if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {
|
|
520
|
+
// Empty editor
|
|
521
|
+
layoutLines.push({
|
|
522
|
+
text: "",
|
|
523
|
+
hasCursor: true,
|
|
524
|
+
cursorPos: 0,
|
|
525
|
+
});
|
|
526
|
+
return layoutLines;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Process each logical line
|
|
530
|
+
for (let i = 0; i < this.state.lines.length; i++) {
|
|
531
|
+
const line = this.state.lines[i] || "";
|
|
532
|
+
const isCurrentLine = i === this.state.cursorLine;
|
|
533
|
+
const lineVisibleWidth = visibleWidth(line);
|
|
534
|
+
|
|
535
|
+
if (lineVisibleWidth <= contentWidth) {
|
|
536
|
+
// Line fits in one layout line
|
|
537
|
+
if (isCurrentLine) {
|
|
538
|
+
layoutLines.push({
|
|
539
|
+
text: line,
|
|
540
|
+
hasCursor: true,
|
|
541
|
+
cursorPos: this.state.cursorCol,
|
|
542
|
+
});
|
|
543
|
+
} else {
|
|
544
|
+
layoutLines.push({
|
|
545
|
+
text: line,
|
|
546
|
+
hasCursor: false,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
} else {
|
|
550
|
+
// Line needs wrapping - use grapheme-aware chunking
|
|
551
|
+
const chunks: { text: string; startIndex: number; endIndex: number }[] = [];
|
|
552
|
+
let currentChunk = "";
|
|
553
|
+
let currentWidth = 0;
|
|
554
|
+
let chunkStartIndex = 0;
|
|
555
|
+
let currentIndex = 0;
|
|
556
|
+
|
|
557
|
+
for (const seg of segmenter.segment(line)) {
|
|
558
|
+
const grapheme = seg.segment;
|
|
559
|
+
const graphemeWidth = visibleWidth(grapheme);
|
|
560
|
+
|
|
561
|
+
if (currentWidth + graphemeWidth > contentWidth && currentChunk !== "") {
|
|
562
|
+
// Start a new chunk
|
|
563
|
+
chunks.push({
|
|
564
|
+
text: currentChunk,
|
|
565
|
+
startIndex: chunkStartIndex,
|
|
566
|
+
endIndex: currentIndex,
|
|
567
|
+
});
|
|
568
|
+
currentChunk = grapheme;
|
|
569
|
+
currentWidth = graphemeWidth;
|
|
570
|
+
chunkStartIndex = currentIndex;
|
|
571
|
+
} else {
|
|
572
|
+
currentChunk += grapheme;
|
|
573
|
+
currentWidth += graphemeWidth;
|
|
574
|
+
}
|
|
575
|
+
currentIndex += grapheme.length;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Push the last chunk
|
|
579
|
+
if (currentChunk !== "") {
|
|
580
|
+
chunks.push({
|
|
581
|
+
text: currentChunk,
|
|
582
|
+
startIndex: chunkStartIndex,
|
|
583
|
+
endIndex: currentIndex,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
588
|
+
const chunk = chunks[chunkIndex];
|
|
589
|
+
if (!chunk) continue;
|
|
590
|
+
|
|
591
|
+
const cursorPos = this.state.cursorCol;
|
|
592
|
+
const isLastChunk = chunkIndex === chunks.length - 1;
|
|
593
|
+
// For non-last chunks, cursor at endIndex belongs to the next chunk
|
|
594
|
+
const hasCursorInChunk =
|
|
595
|
+
isCurrentLine &&
|
|
596
|
+
cursorPos >= chunk.startIndex &&
|
|
597
|
+
(isLastChunk ? cursorPos <= chunk.endIndex : cursorPos < chunk.endIndex);
|
|
598
|
+
|
|
599
|
+
if (hasCursorInChunk) {
|
|
600
|
+
layoutLines.push({
|
|
601
|
+
text: chunk.text,
|
|
602
|
+
hasCursor: true,
|
|
603
|
+
cursorPos: cursorPos - chunk.startIndex,
|
|
604
|
+
});
|
|
605
|
+
} else {
|
|
606
|
+
layoutLines.push({
|
|
607
|
+
text: chunk.text,
|
|
608
|
+
hasCursor: false,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return layoutLines;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
getText(): string {
|
|
619
|
+
return this.state.lines.join("\n");
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
getLines(): string[] {
|
|
623
|
+
return [...this.state.lines];
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
getCursor(): { line: number; col: number } {
|
|
627
|
+
return { line: this.state.cursorLine, col: this.state.cursorCol };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
setText(text: string): void {
|
|
631
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
632
|
+
this.setTextInternal(text);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Insert text at the current cursor position */
|
|
636
|
+
insertText(text: string): void {
|
|
637
|
+
this.historyIndex = -1;
|
|
638
|
+
|
|
639
|
+
const line = this.state.lines[this.state.cursorLine] || "";
|
|
640
|
+
const before = line.slice(0, this.state.cursorCol);
|
|
641
|
+
const after = line.slice(this.state.cursorCol);
|
|
642
|
+
|
|
643
|
+
this.state.lines[this.state.cursorLine] = before + text + after;
|
|
644
|
+
this.state.cursorCol += text.length;
|
|
645
|
+
|
|
646
|
+
if (this.onChange) {
|
|
647
|
+
this.onChange(this.getText());
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// All the editor methods from before...
|
|
652
|
+
private insertCharacter(char: string): void {
|
|
653
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
654
|
+
|
|
655
|
+
const line = this.state.lines[this.state.cursorLine] || "";
|
|
656
|
+
|
|
657
|
+
const before = line.slice(0, this.state.cursorCol);
|
|
658
|
+
const after = line.slice(this.state.cursorCol);
|
|
659
|
+
|
|
660
|
+
this.state.lines[this.state.cursorLine] = before + char + after;
|
|
661
|
+
this.state.cursorCol += char.length; // Fix: increment by the length of the inserted string
|
|
662
|
+
|
|
663
|
+
if (this.onChange) {
|
|
664
|
+
this.onChange(this.getText());
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Check if we should trigger or update autocomplete
|
|
668
|
+
if (!this.isAutocompleting) {
|
|
669
|
+
// Auto-trigger for "/" at the start of a line (slash commands)
|
|
670
|
+
if (char === "/" && this.isAtStartOfMessage()) {
|
|
671
|
+
this.tryTriggerAutocomplete();
|
|
672
|
+
}
|
|
673
|
+
// Auto-trigger for "@" file reference (fuzzy search)
|
|
674
|
+
else if (char === "@") {
|
|
675
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
676
|
+
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
677
|
+
// Only trigger if @ is after whitespace or at start of line
|
|
678
|
+
const charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2];
|
|
679
|
+
if (textBeforeCursor.length === 1 || charBeforeAt === " " || charBeforeAt === "\t") {
|
|
680
|
+
this.tryTriggerAutocomplete();
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
// Also auto-trigger when typing letters in a slash command context
|
|
684
|
+
else if (/[a-zA-Z0-9]/.test(char)) {
|
|
685
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
686
|
+
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
687
|
+
// Check if we're in a slash command (with or without space for arguments)
|
|
688
|
+
if (textBeforeCursor.trimStart().startsWith("/")) {
|
|
689
|
+
this.tryTriggerAutocomplete();
|
|
690
|
+
}
|
|
691
|
+
// Check if we're in an @ file reference context
|
|
692
|
+
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
|
693
|
+
this.tryTriggerAutocomplete();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
} else {
|
|
697
|
+
this.updateAutocomplete();
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private handlePaste(pastedText: string): void {
|
|
702
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
703
|
+
|
|
704
|
+
// Clean the pasted text
|
|
705
|
+
const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
706
|
+
|
|
707
|
+
// Convert tabs to spaces (4 spaces per tab)
|
|
708
|
+
const tabExpandedText = cleanText.replace(/\t/g, " ");
|
|
709
|
+
|
|
710
|
+
// Filter out non-printable characters except newlines
|
|
711
|
+
let filteredText = tabExpandedText
|
|
712
|
+
.split("")
|
|
713
|
+
.filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
|
|
714
|
+
.join("");
|
|
715
|
+
|
|
716
|
+
// If pasting a file path (starts with /, ~, or .) and the character before
|
|
717
|
+
// the cursor is a word character, prepend a space for better readability
|
|
718
|
+
if (/^[/~.]/.test(filteredText)) {
|
|
719
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
720
|
+
const charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : "";
|
|
721
|
+
if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
|
|
722
|
+
filteredText = ` ${filteredText}`;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Split into lines
|
|
727
|
+
const pastedLines = filteredText.split("\n");
|
|
728
|
+
|
|
729
|
+
// Check if this is a large paste (> 10 lines or > 1000 characters)
|
|
730
|
+
const totalChars = filteredText.length;
|
|
731
|
+
if (pastedLines.length > 10 || totalChars > 1000) {
|
|
732
|
+
// Store the paste and insert a marker
|
|
733
|
+
this.pasteCounter++;
|
|
734
|
+
const pasteId = this.pasteCounter;
|
|
735
|
+
this.pastes.set(pasteId, filteredText);
|
|
736
|
+
|
|
737
|
+
// Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]"
|
|
738
|
+
const marker =
|
|
739
|
+
pastedLines.length > 10
|
|
740
|
+
? `[paste #${pasteId} +${pastedLines.length} lines]`
|
|
741
|
+
: `[paste #${pasteId} ${totalChars} chars]`;
|
|
742
|
+
for (const char of marker) {
|
|
743
|
+
this.insertCharacter(char);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (pastedLines.length === 1) {
|
|
750
|
+
// Single line - just insert each character
|
|
751
|
+
const text = pastedLines[0] || "";
|
|
752
|
+
for (const char of text) {
|
|
753
|
+
this.insertCharacter(char);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Multi-line paste - be very careful with array manipulation
|
|
760
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
761
|
+
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
762
|
+
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
763
|
+
|
|
764
|
+
// Build the new lines array step by step
|
|
765
|
+
const newLines: string[] = [];
|
|
766
|
+
|
|
767
|
+
// Add all lines before current line
|
|
768
|
+
for (let i = 0; i < this.state.cursorLine; i++) {
|
|
769
|
+
newLines.push(this.state.lines[i] || "");
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Add the first pasted line merged with before cursor text
|
|
773
|
+
newLines.push(beforeCursor + (pastedLines[0] || ""));
|
|
774
|
+
|
|
775
|
+
// Add all middle pasted lines
|
|
776
|
+
for (let i = 1; i < pastedLines.length - 1; i++) {
|
|
777
|
+
newLines.push(pastedLines[i] || "");
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Add the last pasted line with after cursor text
|
|
781
|
+
newLines.push((pastedLines[pastedLines.length - 1] || "") + afterCursor);
|
|
782
|
+
|
|
783
|
+
// Add all lines after current line
|
|
784
|
+
for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) {
|
|
785
|
+
newLines.push(this.state.lines[i] || "");
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Replace the entire lines array
|
|
789
|
+
this.state.lines = newLines;
|
|
790
|
+
|
|
791
|
+
// Update cursor position to end of pasted content
|
|
792
|
+
this.state.cursorLine += pastedLines.length - 1;
|
|
793
|
+
this.state.cursorCol = (pastedLines[pastedLines.length - 1] || "").length;
|
|
794
|
+
|
|
795
|
+
// Notify of change
|
|
796
|
+
if (this.onChange) {
|
|
797
|
+
this.onChange(this.getText());
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private addNewLine(): void {
|
|
802
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
803
|
+
|
|
804
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
805
|
+
|
|
806
|
+
const before = currentLine.slice(0, this.state.cursorCol);
|
|
807
|
+
const after = currentLine.slice(this.state.cursorCol);
|
|
808
|
+
|
|
809
|
+
// Split current line
|
|
810
|
+
this.state.lines[this.state.cursorLine] = before;
|
|
811
|
+
this.state.lines.splice(this.state.cursorLine + 1, 0, after);
|
|
812
|
+
|
|
813
|
+
// Move cursor to start of new line
|
|
814
|
+
this.state.cursorLine++;
|
|
815
|
+
this.state.cursorCol = 0;
|
|
816
|
+
|
|
817
|
+
if (this.onChange) {
|
|
818
|
+
this.onChange(this.getText());
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
private handleBackspace(): void {
|
|
823
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
824
|
+
|
|
825
|
+
if (this.state.cursorCol > 0) {
|
|
826
|
+
// Delete grapheme before cursor (handles emojis, combining characters, etc.)
|
|
827
|
+
const line = this.state.lines[this.state.cursorLine] || "";
|
|
828
|
+
const beforeCursor = line.slice(0, this.state.cursorCol);
|
|
829
|
+
|
|
830
|
+
// Find the last grapheme in the text before cursor
|
|
831
|
+
const graphemes = [...segmenter.segment(beforeCursor)];
|
|
832
|
+
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
833
|
+
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
|
834
|
+
|
|
835
|
+
const before = line.slice(0, this.state.cursorCol - graphemeLength);
|
|
836
|
+
const after = line.slice(this.state.cursorCol);
|
|
837
|
+
|
|
838
|
+
this.state.lines[this.state.cursorLine] = before + after;
|
|
839
|
+
this.state.cursorCol -= graphemeLength;
|
|
840
|
+
} else if (this.state.cursorLine > 0) {
|
|
841
|
+
// Merge with previous line
|
|
842
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
843
|
+
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
|
844
|
+
|
|
845
|
+
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
|
|
846
|
+
this.state.lines.splice(this.state.cursorLine, 1);
|
|
847
|
+
|
|
848
|
+
this.state.cursorLine--;
|
|
849
|
+
this.state.cursorCol = previousLine.length;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (this.onChange) {
|
|
853
|
+
this.onChange(this.getText());
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Update or re-trigger autocomplete after backspace
|
|
857
|
+
if (this.isAutocompleting) {
|
|
858
|
+
this.updateAutocomplete();
|
|
859
|
+
} else {
|
|
860
|
+
// If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
|
|
861
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
862
|
+
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
863
|
+
// Slash command context
|
|
864
|
+
if (textBeforeCursor.trimStart().startsWith("/")) {
|
|
865
|
+
this.tryTriggerAutocomplete();
|
|
866
|
+
}
|
|
867
|
+
// @ file reference context
|
|
868
|
+
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
|
869
|
+
this.tryTriggerAutocomplete();
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
private moveToLineStart(): void {
|
|
875
|
+
this.state.cursorCol = 0;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
private moveToLineEnd(): void {
|
|
879
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
880
|
+
this.state.cursorCol = currentLine.length;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
private deleteToStartOfLine(): void {
|
|
884
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
885
|
+
|
|
886
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
887
|
+
|
|
888
|
+
if (this.state.cursorCol > 0) {
|
|
889
|
+
// Delete from start of line up to cursor
|
|
890
|
+
this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol);
|
|
891
|
+
this.state.cursorCol = 0;
|
|
892
|
+
} else if (this.state.cursorLine > 0) {
|
|
893
|
+
// At start of line - merge with previous line
|
|
894
|
+
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
|
895
|
+
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
|
|
896
|
+
this.state.lines.splice(this.state.cursorLine, 1);
|
|
897
|
+
this.state.cursorLine--;
|
|
898
|
+
this.state.cursorCol = previousLine.length;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (this.onChange) {
|
|
902
|
+
this.onChange(this.getText());
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
private deleteToEndOfLine(): void {
|
|
907
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
908
|
+
|
|
909
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
910
|
+
|
|
911
|
+
if (this.state.cursorCol < currentLine.length) {
|
|
912
|
+
// Delete from cursor to end of line
|
|
913
|
+
this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol);
|
|
914
|
+
} else if (this.state.cursorLine < this.state.lines.length - 1) {
|
|
915
|
+
// At end of line - merge with next line
|
|
916
|
+
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
|
917
|
+
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
|
|
918
|
+
this.state.lines.splice(this.state.cursorLine + 1, 1);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (this.onChange) {
|
|
922
|
+
this.onChange(this.getText());
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
private deleteWordBackwards(): void {
|
|
927
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
928
|
+
|
|
929
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
930
|
+
|
|
931
|
+
// If at start of line, behave like backspace at column 0 (merge with previous line)
|
|
932
|
+
if (this.state.cursorCol === 0) {
|
|
933
|
+
if (this.state.cursorLine > 0) {
|
|
934
|
+
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
|
935
|
+
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
|
|
936
|
+
this.state.lines.splice(this.state.cursorLine, 1);
|
|
937
|
+
this.state.cursorLine--;
|
|
938
|
+
this.state.cursorCol = previousLine.length;
|
|
939
|
+
}
|
|
940
|
+
} else {
|
|
941
|
+
const oldCursorCol = this.state.cursorCol;
|
|
942
|
+
this.moveWordBackwards();
|
|
943
|
+
const deleteFrom = this.state.cursorCol;
|
|
944
|
+
this.state.cursorCol = oldCursorCol;
|
|
945
|
+
|
|
946
|
+
this.state.lines[this.state.cursorLine] =
|
|
947
|
+
currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol);
|
|
948
|
+
this.state.cursorCol = deleteFrom;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (this.onChange) {
|
|
952
|
+
this.onChange(this.getText());
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
private handleForwardDelete(): void {
|
|
957
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
958
|
+
|
|
959
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
960
|
+
|
|
961
|
+
if (this.state.cursorCol < currentLine.length) {
|
|
962
|
+
// Delete grapheme at cursor position (handles emojis, combining characters, etc.)
|
|
963
|
+
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
964
|
+
|
|
965
|
+
// Find the first grapheme at cursor
|
|
966
|
+
const graphemes = [...segmenter.segment(afterCursor)];
|
|
967
|
+
const firstGrapheme = graphemes[0];
|
|
968
|
+
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
|
969
|
+
|
|
970
|
+
const before = currentLine.slice(0, this.state.cursorCol);
|
|
971
|
+
const after = currentLine.slice(this.state.cursorCol + graphemeLength);
|
|
972
|
+
this.state.lines[this.state.cursorLine] = before + after;
|
|
973
|
+
} else if (this.state.cursorLine < this.state.lines.length - 1) {
|
|
974
|
+
// At end of line - merge with next line
|
|
975
|
+
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
|
976
|
+
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
|
|
977
|
+
this.state.lines.splice(this.state.cursorLine + 1, 1);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
if (this.onChange) {
|
|
981
|
+
this.onChange(this.getText());
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Update or re-trigger autocomplete after forward delete
|
|
985
|
+
if (this.isAutocompleting) {
|
|
986
|
+
this.updateAutocomplete();
|
|
987
|
+
} else {
|
|
988
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
989
|
+
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
990
|
+
// Slash command context
|
|
991
|
+
if (textBeforeCursor.trimStart().startsWith("/")) {
|
|
992
|
+
this.tryTriggerAutocomplete();
|
|
993
|
+
}
|
|
994
|
+
// @ file reference context
|
|
995
|
+
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
|
996
|
+
this.tryTriggerAutocomplete();
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Build a mapping from visual lines to logical positions.
|
|
1003
|
+
* Returns an array where each element represents a visual line with:
|
|
1004
|
+
* - logicalLine: index into this.state.lines
|
|
1005
|
+
* - startCol: starting column in the logical line
|
|
1006
|
+
* - length: length of this visual line segment
|
|
1007
|
+
*/
|
|
1008
|
+
private buildVisualLineMap(width: number): Array<{ logicalLine: number; startCol: number; length: number }> {
|
|
1009
|
+
const visualLines: Array<{ logicalLine: number; startCol: number; length: number }> = [];
|
|
1010
|
+
|
|
1011
|
+
for (let i = 0; i < this.state.lines.length; i++) {
|
|
1012
|
+
const line = this.state.lines[i] || "";
|
|
1013
|
+
const lineVisWidth = visibleWidth(line);
|
|
1014
|
+
if (line.length === 0) {
|
|
1015
|
+
// Empty line still takes one visual line
|
|
1016
|
+
visualLines.push({ logicalLine: i, startCol: 0, length: 0 });
|
|
1017
|
+
} else if (lineVisWidth <= width) {
|
|
1018
|
+
visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
|
|
1019
|
+
} else {
|
|
1020
|
+
// Line needs wrapping - use grapheme-aware chunking
|
|
1021
|
+
let currentWidth = 0;
|
|
1022
|
+
let chunkStartIndex = 0;
|
|
1023
|
+
let currentIndex = 0;
|
|
1024
|
+
|
|
1025
|
+
for (const seg of segmenter.segment(line)) {
|
|
1026
|
+
const grapheme = seg.segment;
|
|
1027
|
+
const graphemeWidth = visibleWidth(grapheme);
|
|
1028
|
+
|
|
1029
|
+
if (currentWidth + graphemeWidth > width && currentIndex > chunkStartIndex) {
|
|
1030
|
+
// Start a new chunk
|
|
1031
|
+
visualLines.push({
|
|
1032
|
+
logicalLine: i,
|
|
1033
|
+
startCol: chunkStartIndex,
|
|
1034
|
+
length: currentIndex - chunkStartIndex,
|
|
1035
|
+
});
|
|
1036
|
+
chunkStartIndex = currentIndex;
|
|
1037
|
+
currentWidth = graphemeWidth;
|
|
1038
|
+
} else {
|
|
1039
|
+
currentWidth += graphemeWidth;
|
|
1040
|
+
}
|
|
1041
|
+
currentIndex += grapheme.length;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Push the last chunk
|
|
1045
|
+
if (currentIndex > chunkStartIndex) {
|
|
1046
|
+
visualLines.push({
|
|
1047
|
+
logicalLine: i,
|
|
1048
|
+
startCol: chunkStartIndex,
|
|
1049
|
+
length: currentIndex - chunkStartIndex,
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
return visualLines;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Find the visual line index for the current cursor position.
|
|
1060
|
+
*/
|
|
1061
|
+
private findCurrentVisualLine(
|
|
1062
|
+
visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
|
|
1063
|
+
): number {
|
|
1064
|
+
for (let i = 0; i < visualLines.length; i++) {
|
|
1065
|
+
const vl = visualLines[i];
|
|
1066
|
+
if (!vl) continue;
|
|
1067
|
+
if (vl.logicalLine === this.state.cursorLine) {
|
|
1068
|
+
const colInSegment = this.state.cursorCol - vl.startCol;
|
|
1069
|
+
// Cursor is in this segment if it's within range
|
|
1070
|
+
// For the last segment of a logical line, cursor can be at length (end position)
|
|
1071
|
+
const isLastSegmentOfLine =
|
|
1072
|
+
i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
|
|
1073
|
+
if (colInSegment >= 0 && (colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))) {
|
|
1074
|
+
return i;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
// Fallback: return last visual line
|
|
1079
|
+
return visualLines.length - 1;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
private moveCursor(deltaLine: number, deltaCol: number): void {
|
|
1083
|
+
const width = this.lastWidth;
|
|
1084
|
+
|
|
1085
|
+
if (deltaLine !== 0) {
|
|
1086
|
+
// Build visual line map for navigation
|
|
1087
|
+
const visualLines = this.buildVisualLineMap(width);
|
|
1088
|
+
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
|
1089
|
+
|
|
1090
|
+
// Calculate column position within current visual line
|
|
1091
|
+
const currentVL = visualLines[currentVisualLine];
|
|
1092
|
+
const visualCol = currentVL ? this.state.cursorCol - currentVL.startCol : 0;
|
|
1093
|
+
|
|
1094
|
+
// Move to target visual line
|
|
1095
|
+
const targetVisualLine = currentVisualLine + deltaLine;
|
|
1096
|
+
|
|
1097
|
+
if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
|
|
1098
|
+
const targetVL = visualLines[targetVisualLine];
|
|
1099
|
+
if (targetVL) {
|
|
1100
|
+
this.state.cursorLine = targetVL.logicalLine;
|
|
1101
|
+
// Try to maintain visual column position, clamped to line length
|
|
1102
|
+
const targetCol = targetVL.startCol + Math.min(visualCol, targetVL.length);
|
|
1103
|
+
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
|
|
1104
|
+
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (deltaCol !== 0) {
|
|
1110
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1111
|
+
|
|
1112
|
+
if (deltaCol > 0) {
|
|
1113
|
+
// Moving right - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1114
|
+
if (this.state.cursorCol < currentLine.length) {
|
|
1115
|
+
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
1116
|
+
const graphemes = [...segmenter.segment(afterCursor)];
|
|
1117
|
+
const firstGrapheme = graphemes[0];
|
|
1118
|
+
this.state.cursorCol += firstGrapheme ? firstGrapheme.segment.length : 1;
|
|
1119
|
+
} else if (this.state.cursorLine < this.state.lines.length - 1) {
|
|
1120
|
+
// Wrap to start of next logical line
|
|
1121
|
+
this.state.cursorLine++;
|
|
1122
|
+
this.state.cursorCol = 0;
|
|
1123
|
+
}
|
|
1124
|
+
} else {
|
|
1125
|
+
// Moving left - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1126
|
+
if (this.state.cursorCol > 0) {
|
|
1127
|
+
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1128
|
+
const graphemes = [...segmenter.segment(beforeCursor)];
|
|
1129
|
+
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1130
|
+
this.state.cursorCol -= lastGrapheme ? lastGrapheme.segment.length : 1;
|
|
1131
|
+
} else if (this.state.cursorLine > 0) {
|
|
1132
|
+
// Wrap to end of previous logical line
|
|
1133
|
+
this.state.cursorLine--;
|
|
1134
|
+
const prevLine = this.state.lines[this.state.cursorLine] || "";
|
|
1135
|
+
this.state.cursorCol = prevLine.length;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
private moveWordBackwards(): void {
|
|
1142
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1143
|
+
|
|
1144
|
+
// If at start of line, move to end of previous line
|
|
1145
|
+
if (this.state.cursorCol === 0) {
|
|
1146
|
+
if (this.state.cursorLine > 0) {
|
|
1147
|
+
this.state.cursorLine--;
|
|
1148
|
+
const prevLine = this.state.lines[this.state.cursorLine] || "";
|
|
1149
|
+
this.state.cursorCol = prevLine.length;
|
|
1150
|
+
}
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1155
|
+
const graphemes = [...segmenter.segment(textBeforeCursor)];
|
|
1156
|
+
let newCol = this.state.cursorCol;
|
|
1157
|
+
|
|
1158
|
+
// Skip trailing whitespace
|
|
1159
|
+
while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1160
|
+
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
if (graphemes.length > 0) {
|
|
1164
|
+
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
|
|
1165
|
+
if (isPunctuationChar(lastGrapheme)) {
|
|
1166
|
+
// Skip punctuation run
|
|
1167
|
+
while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1168
|
+
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1169
|
+
}
|
|
1170
|
+
} else {
|
|
1171
|
+
// Skip word run
|
|
1172
|
+
while (
|
|
1173
|
+
graphemes.length > 0 &&
|
|
1174
|
+
!isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1175
|
+
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
|
|
1176
|
+
) {
|
|
1177
|
+
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
this.state.cursorCol = newCol;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
private moveWordForwards(): void {
|
|
1186
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1187
|
+
|
|
1188
|
+
// If at end of line, move to start of next line
|
|
1189
|
+
if (this.state.cursorCol >= currentLine.length) {
|
|
1190
|
+
if (this.state.cursorLine < this.state.lines.length - 1) {
|
|
1191
|
+
this.state.cursorLine++;
|
|
1192
|
+
this.state.cursorCol = 0;
|
|
1193
|
+
}
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const textAfterCursor = currentLine.slice(this.state.cursorCol);
|
|
1198
|
+
const segments = segmenter.segment(textAfterCursor);
|
|
1199
|
+
const iterator = segments[Symbol.iterator]();
|
|
1200
|
+
let next = iterator.next();
|
|
1201
|
+
|
|
1202
|
+
// Skip leading whitespace
|
|
1203
|
+
while (!next.done && isWhitespaceChar(next.value.segment)) {
|
|
1204
|
+
this.state.cursorCol += next.value.segment.length;
|
|
1205
|
+
next = iterator.next();
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
if (!next.done) {
|
|
1209
|
+
const firstGrapheme = next.value.segment;
|
|
1210
|
+
if (isPunctuationChar(firstGrapheme)) {
|
|
1211
|
+
// Skip punctuation run
|
|
1212
|
+
while (!next.done && isPunctuationChar(next.value.segment)) {
|
|
1213
|
+
this.state.cursorCol += next.value.segment.length;
|
|
1214
|
+
next = iterator.next();
|
|
1215
|
+
}
|
|
1216
|
+
} else {
|
|
1217
|
+
// Skip word run
|
|
1218
|
+
while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
|
|
1219
|
+
this.state.cursorCol += next.value.segment.length;
|
|
1220
|
+
next = iterator.next();
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// Helper method to check if cursor is at start of message (for slash command detection)
|
|
1227
|
+
private isAtStartOfMessage(): boolean {
|
|
1228
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1229
|
+
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1230
|
+
|
|
1231
|
+
// At start if line is empty, only contains whitespace, or is just "/"
|
|
1232
|
+
return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// Autocomplete methods
|
|
1236
|
+
private tryTriggerAutocomplete(explicitTab: boolean = false): void {
|
|
1237
|
+
if (!this.autocompleteProvider) return;
|
|
1238
|
+
|
|
1239
|
+
// Check if we should trigger file completion on Tab
|
|
1240
|
+
if (explicitTab) {
|
|
1241
|
+
const provider = this.autocompleteProvider as CombinedAutocompleteProvider;
|
|
1242
|
+
const shouldTrigger =
|
|
1243
|
+
!provider.shouldTriggerFileCompletion ||
|
|
1244
|
+
provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
|
|
1245
|
+
if (!shouldTrigger) {
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const suggestions = this.autocompleteProvider.getSuggestions(
|
|
1251
|
+
this.state.lines,
|
|
1252
|
+
this.state.cursorLine,
|
|
1253
|
+
this.state.cursorCol,
|
|
1254
|
+
);
|
|
1255
|
+
|
|
1256
|
+
if (suggestions && suggestions.items.length > 0) {
|
|
1257
|
+
this.autocompletePrefix = suggestions.prefix;
|
|
1258
|
+
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
|
|
1259
|
+
this.isAutocompleting = true;
|
|
1260
|
+
} else {
|
|
1261
|
+
this.cancelAutocomplete();
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
private handleTabCompletion(): void {
|
|
1266
|
+
if (!this.autocompleteProvider) return;
|
|
1267
|
+
|
|
1268
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1269
|
+
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1270
|
+
|
|
1271
|
+
// Check if we're in a slash command context
|
|
1272
|
+
if (beforeCursor.trimStart().startsWith("/") && !beforeCursor.trimStart().includes(" ")) {
|
|
1273
|
+
this.handleSlashCommandCompletion();
|
|
1274
|
+
} else {
|
|
1275
|
+
this.forceFileAutocomplete();
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
private handleSlashCommandCompletion(): void {
|
|
1280
|
+
this.tryTriggerAutocomplete(true);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/*
|
|
1284
|
+
https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883
|
|
1285
|
+
17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19
|
|
1286
|
+
536643416/job/55932288317 havea look at .gi
|
|
1287
|
+
*/
|
|
1288
|
+
private forceFileAutocomplete(): void {
|
|
1289
|
+
if (!this.autocompleteProvider) return;
|
|
1290
|
+
|
|
1291
|
+
// Check if provider supports force file suggestions via runtime check
|
|
1292
|
+
const provider = this.autocompleteProvider as {
|
|
1293
|
+
getForceFileSuggestions?: CombinedAutocompleteProvider["getForceFileSuggestions"];
|
|
1294
|
+
};
|
|
1295
|
+
if (typeof provider.getForceFileSuggestions !== "function") {
|
|
1296
|
+
this.tryTriggerAutocomplete(true);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const suggestions = provider.getForceFileSuggestions(
|
|
1301
|
+
this.state.lines,
|
|
1302
|
+
this.state.cursorLine,
|
|
1303
|
+
this.state.cursorCol,
|
|
1304
|
+
);
|
|
1305
|
+
|
|
1306
|
+
if (suggestions && suggestions.items.length > 0) {
|
|
1307
|
+
this.autocompletePrefix = suggestions.prefix;
|
|
1308
|
+
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
|
|
1309
|
+
this.isAutocompleting = true;
|
|
1310
|
+
} else {
|
|
1311
|
+
this.cancelAutocomplete();
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
private cancelAutocomplete(): void {
|
|
1316
|
+
this.isAutocompleting = false;
|
|
1317
|
+
this.autocompleteList = undefined;
|
|
1318
|
+
this.autocompletePrefix = "";
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
public isShowingAutocomplete(): boolean {
|
|
1322
|
+
return this.isAutocompleting;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
private updateAutocomplete(): void {
|
|
1326
|
+
if (!this.isAutocompleting || !this.autocompleteProvider) return;
|
|
1327
|
+
|
|
1328
|
+
const suggestions = this.autocompleteProvider.getSuggestions(
|
|
1329
|
+
this.state.lines,
|
|
1330
|
+
this.state.cursorLine,
|
|
1331
|
+
this.state.cursorCol,
|
|
1332
|
+
);
|
|
1333
|
+
|
|
1334
|
+
if (suggestions && suggestions.items.length > 0) {
|
|
1335
|
+
this.autocompletePrefix = suggestions.prefix;
|
|
1336
|
+
// Always create new SelectList to ensure update
|
|
1337
|
+
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
|
|
1338
|
+
} else {
|
|
1339
|
+
this.cancelAutocomplete();
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|