@draht/tui 2026.5.12 → 2026.6.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/autocomplete.d.ts +5 -1
- package/dist/autocomplete.d.ts.map +1 -1
- package/dist/autocomplete.js +16 -7
- package/dist/autocomplete.js.map +1 -1
- package/dist/components/box.d.ts +1 -1
- package/dist/components/box.d.ts.map +1 -1
- package/dist/components/box.js.map +1 -1
- package/dist/components/cancellable-loader.d.ts +1 -1
- package/dist/components/cancellable-loader.d.ts.map +1 -1
- package/dist/components/cancellable-loader.js.map +1 -1
- package/dist/components/editor.d.ts +14 -4
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +233 -178
- package/dist/components/editor.js.map +1 -1
- package/dist/components/image.d.ts +2 -2
- package/dist/components/image.d.ts.map +1 -1
- package/dist/components/image.js +31 -11
- package/dist/components/image.js.map +1 -1
- package/dist/components/input.d.ts +1 -1
- package/dist/components/input.d.ts.map +1 -1
- package/dist/components/input.js +7 -55
- package/dist/components/input.js.map +1 -1
- package/dist/components/loader.d.ts +17 -7
- package/dist/components/loader.d.ts.map +1 -1
- package/dist/components/loader.js +34 -14
- package/dist/components/loader.js.map +1 -1
- package/dist/components/markdown.d.ts +8 -7
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +87 -102
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/select-list.d.ts +1 -1
- package/dist/components/select-list.d.ts.map +1 -1
- package/dist/components/select-list.js.map +1 -1
- package/dist/components/settings-list.d.ts +1 -1
- package/dist/components/settings-list.d.ts.map +1 -1
- package/dist/components/settings-list.js.map +1 -1
- package/dist/components/spacer.d.ts +1 -1
- package/dist/components/spacer.d.ts.map +1 -1
- package/dist/components/spacer.js.map +1 -1
- package/dist/components/text.d.ts +1 -1
- package/dist/components/text.d.ts.map +1 -1
- package/dist/components/text.js.map +1 -1
- package/dist/components/truncated-text.d.ts +1 -1
- package/dist/components/truncated-text.d.ts.map +1 -1
- package/dist/components/truncated-text.js.map +1 -1
- package/dist/editor-component.d.ts +2 -2
- package/dist/editor-component.d.ts.map +1 -1
- package/dist/editor-component.js.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 +22 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/keybindings.d.ts +1 -1
- package/dist/keybindings.d.ts.map +1 -1
- package/dist/keybindings.js.map +1 -1
- package/dist/keys.d.ts +19 -5
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +82 -33
- package/dist/keys.js.map +1 -1
- package/dist/native-modifiers.d.ts +3 -0
- package/dist/native-modifiers.d.ts.map +1 -0
- package/dist/native-modifiers.js +53 -0
- package/dist/native-modifiers.js.map +1 -0
- package/dist/stdin-buffer.d.ts +2 -0
- package/dist/stdin-buffer.d.ts.map +1 -1
- package/dist/stdin-buffer.js +48 -4
- package/dist/stdin-buffer.js.map +1 -1
- package/dist/terminal-image.d.ts +23 -1
- package/dist/terminal-image.d.ts.map +1 -1
- package/dist/terminal-image.js +94 -16
- package/dist/terminal-image.js.map +1 -1
- package/dist/terminal.d.ts +36 -10
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +212 -64
- package/dist/terminal.js.map +1 -1
- package/dist/tui.d.ts +25 -5
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +245 -30
- package/dist/tui.js.map +1 -1
- package/dist/utils.d.ts +8 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +142 -42
- package/dist/utils.js.map +1 -1
- package/dist/word-navigation.d.ts +25 -0
- package/dist/word-navigation.d.ts.map +1 -0
- package/dist/word-navigation.js +96 -0
- package/dist/word-navigation.js.map +1 -0
- package/package.json +1 -1
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { getKeybindings } from "../keybindings.js";
|
|
2
|
-
import {
|
|
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 {
|
|
6
|
+
import { cjkBreakRegex, getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, truncateToWidth, visibleWidth, } from "../utils.js";
|
|
7
|
+
import { findWordBackward, findWordForward } from "../word-navigation.js";
|
|
7
8
|
import { SelectList } from "./select-list.js";
|
|
8
|
-
const
|
|
9
|
+
const graphemeSegmenter = getGraphemeSegmenter();
|
|
10
|
+
const wordSegmenter = getWordSegmenter();
|
|
9
11
|
/** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */
|
|
10
12
|
const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
|
|
11
13
|
/** Non-global version for single-segment testing. */
|
|
@@ -21,7 +23,7 @@ function isPasteMarker(segment) {
|
|
|
21
23
|
*
|
|
22
24
|
* Only markers whose numeric ID exists in `validIds` are merged.
|
|
23
25
|
*/
|
|
24
|
-
function segmentWithMarkers(text, validIds) {
|
|
26
|
+
function segmentWithMarkers(text, baseSegmenter, validIds) {
|
|
25
27
|
// Fast path: no paste markers in the text or no valid IDs.
|
|
26
28
|
if (validIds.size === 0 || !text.includes("[paste #")) {
|
|
27
29
|
return baseSegmenter.segment(text);
|
|
@@ -86,7 +88,7 @@ export function wordWrapLine(line, maxWidth, preSegmented) {
|
|
|
86
88
|
return [{ text: line, startIndex: 0, endIndex: line.length }];
|
|
87
89
|
}
|
|
88
90
|
const chunks = [];
|
|
89
|
-
const segments = preSegmented ?? [...
|
|
91
|
+
const segments = preSegmented ?? [...graphemeSegmenter.segment(line)];
|
|
90
92
|
let currentWidth = 0;
|
|
91
93
|
let chunkStart = 0;
|
|
92
94
|
// Wrap opportunity: the position after the last whitespace before a non-whitespace
|
|
@@ -138,14 +140,23 @@ export function wordWrapLine(line, maxWidth, preSegmented) {
|
|
|
138
140
|
}
|
|
139
141
|
// Advance.
|
|
140
142
|
currentWidth += gWidth;
|
|
141
|
-
// Record wrap opportunity: whitespace followed by non-whitespace
|
|
142
|
-
//
|
|
143
|
-
//
|
|
143
|
+
// Record wrap opportunity: whitespace followed by non-whitespace
|
|
144
|
+
// (multiple spaces join; the break point is after the last space),
|
|
145
|
+
// or at a boundary where either side is CJK (CJK allows breaking
|
|
146
|
+
// between any adjacent characters).
|
|
144
147
|
const next = segments[i + 1];
|
|
145
148
|
if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
|
|
146
149
|
wrapOppIndex = next.index;
|
|
147
150
|
wrapOppWidth = currentWidth;
|
|
148
151
|
}
|
|
152
|
+
else if (!isWs && next && !isWhitespaceChar(next.segment)) {
|
|
153
|
+
const isCjk = !isPasteMarker(grapheme) && cjkBreakRegex.test(grapheme);
|
|
154
|
+
const nextIsCjk = !isPasteMarker(next.segment) && cjkBreakRegex.test(next.segment);
|
|
155
|
+
if (isCjk || nextIsCjk) {
|
|
156
|
+
wrapOppIndex = next.index;
|
|
157
|
+
wrapOppWidth = currentWidth;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
149
160
|
}
|
|
150
161
|
// Push final chunk.
|
|
151
162
|
chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length });
|
|
@@ -156,6 +167,17 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT = {
|
|
|
156
167
|
maxPrimaryColumnWidth: 32,
|
|
157
168
|
};
|
|
158
169
|
const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
|
|
170
|
+
const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"];
|
|
171
|
+
function escapeCharacterClass(value) {
|
|
172
|
+
return value.replace(/[\\^$.*+?()[\]{}|-]/g, "\\$&");
|
|
173
|
+
}
|
|
174
|
+
function buildTriggerPattern(triggerCharacters) {
|
|
175
|
+
return new RegExp(`(?:^|[\\s])[${triggerCharacters.map(escapeCharacterClass).join("")}][^\\s]*$`);
|
|
176
|
+
}
|
|
177
|
+
function buildDebouncePattern(triggerCharacters) {
|
|
178
|
+
const escapedWithoutAt = triggerCharacters.filter((character) => character !== "@").map(escapeCharacterClass);
|
|
179
|
+
return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`);
|
|
180
|
+
}
|
|
159
181
|
export class Editor {
|
|
160
182
|
state = {
|
|
161
183
|
lines: [""],
|
|
@@ -175,6 +197,9 @@ export class Editor {
|
|
|
175
197
|
borderColor;
|
|
176
198
|
// Autocomplete support
|
|
177
199
|
autocompleteProvider;
|
|
200
|
+
autocompleteTriggerCharacters = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
|
|
201
|
+
autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters);
|
|
202
|
+
autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters);
|
|
178
203
|
autocompleteList;
|
|
179
204
|
autocompleteState = null;
|
|
180
205
|
autocompletePrefix = "";
|
|
@@ -193,6 +218,7 @@ export class Editor {
|
|
|
193
218
|
// Prompt history for up/down navigation
|
|
194
219
|
history = [];
|
|
195
220
|
historyIndex = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
|
|
221
|
+
historyDraft = null;
|
|
196
222
|
// Kill ring for Emacs-style kill/yank operations
|
|
197
223
|
killRing = new KillRing();
|
|
198
224
|
lastAction = null;
|
|
@@ -200,6 +226,12 @@ export class Editor {
|
|
|
200
226
|
jumpMode = null;
|
|
201
227
|
// Preferred visual column for vertical cursor movement (sticky column)
|
|
202
228
|
preferredVisualCol = null;
|
|
229
|
+
// When the cursor is snapped to the start of an atomic segment, e.g. a
|
|
230
|
+
// paste marker, cursorCol no longer reflects where the cursor would have
|
|
231
|
+
// landed. This field stores the pre-snap cursorCol so that the next
|
|
232
|
+
// vertical move can resolve it to a visual column on whatever VL it belongs
|
|
233
|
+
// to.
|
|
234
|
+
snappedFromCursorCol = null;
|
|
203
235
|
// Undo support
|
|
204
236
|
undoStack = new UndoStack();
|
|
205
237
|
onSubmit;
|
|
@@ -219,8 +251,8 @@ export class Editor {
|
|
|
219
251
|
return new Set(this.pastes.keys());
|
|
220
252
|
}
|
|
221
253
|
/** Segment text with paste-marker awareness, only merging markers with valid IDs. */
|
|
222
|
-
segment(text) {
|
|
223
|
-
return segmentWithMarkers(text, this.validPasteIds());
|
|
254
|
+
segment(text, mode) {
|
|
255
|
+
return segmentWithMarkers(text, mode === "word" ? wordSegmenter : graphemeSegmenter, this.validPasteIds());
|
|
224
256
|
}
|
|
225
257
|
getPaddingX() {
|
|
226
258
|
return this.paddingX;
|
|
@@ -245,6 +277,7 @@ export class Editor {
|
|
|
245
277
|
setAutocompleteProvider(provider) {
|
|
246
278
|
this.cancelAutocomplete();
|
|
247
279
|
this.autocompleteProvider = provider;
|
|
280
|
+
this.setAutocompleteTriggerCharacters(provider.triggerCharacters ?? []);
|
|
248
281
|
}
|
|
249
282
|
/**
|
|
250
283
|
* Add a prompt to history for up/down arrow navigation.
|
|
@@ -263,9 +296,6 @@ export class Editor {
|
|
|
263
296
|
this.history.pop();
|
|
264
297
|
}
|
|
265
298
|
}
|
|
266
|
-
isEditorEmpty() {
|
|
267
|
-
return this.state.lines.length === 1 && this.state.lines[0] === "";
|
|
268
|
-
}
|
|
269
299
|
isOnFirstVisualLine() {
|
|
270
300
|
const visualLines = this.buildVisualLineMap(this.lastWidth);
|
|
271
301
|
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
|
@@ -286,22 +316,38 @@ export class Editor {
|
|
|
286
316
|
// Capture state when first entering history browsing mode
|
|
287
317
|
if (this.historyIndex === -1 && newIndex >= 0) {
|
|
288
318
|
this.pushUndoSnapshot();
|
|
319
|
+
this.historyDraft = structuredClone(this.state);
|
|
289
320
|
}
|
|
290
321
|
this.historyIndex = newIndex;
|
|
291
322
|
if (this.historyIndex === -1) {
|
|
292
|
-
|
|
293
|
-
this.
|
|
323
|
+
const draft = this.historyDraft;
|
|
324
|
+
this.historyDraft = null;
|
|
325
|
+
if (draft) {
|
|
326
|
+
this.state = draft;
|
|
327
|
+
this.preferredVisualCol = null;
|
|
328
|
+
this.snappedFromCursorCol = null;
|
|
329
|
+
this.scrollOffset = 0;
|
|
330
|
+
if (this.onChange)
|
|
331
|
+
this.onChange(this.getText());
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
this.setTextInternal("");
|
|
335
|
+
}
|
|
294
336
|
}
|
|
295
337
|
else {
|
|
296
|
-
this.setTextInternal(this.history[this.historyIndex] || "");
|
|
338
|
+
this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end");
|
|
297
339
|
}
|
|
298
340
|
}
|
|
341
|
+
exitHistoryBrowsing() {
|
|
342
|
+
this.historyIndex = -1;
|
|
343
|
+
this.historyDraft = null;
|
|
344
|
+
}
|
|
299
345
|
/** Internal setText that doesn't reset history state - used by navigateHistory */
|
|
300
|
-
setTextInternal(text) {
|
|
346
|
+
setTextInternal(text, cursorPlacement = "end") {
|
|
301
347
|
const lines = text.split("\n");
|
|
302
348
|
this.state.lines = lines.length === 0 ? [""] : lines;
|
|
303
|
-
this.state.cursorLine = this.state.lines.length - 1;
|
|
304
|
-
this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0);
|
|
349
|
+
this.state.cursorLine = cursorPlacement === "start" ? 0 : this.state.lines.length - 1;
|
|
350
|
+
this.setCursorCol(cursorPlacement === "start" ? 0 : this.state.lines[this.state.cursorLine]?.length || 0);
|
|
305
351
|
// Reset scroll - render() will adjust to show cursor
|
|
306
352
|
this.scrollOffset = 0;
|
|
307
353
|
if (this.onChange) {
|
|
@@ -360,8 +406,10 @@ export class Editor {
|
|
|
360
406
|
result.push(horizontal.repeat(width));
|
|
361
407
|
}
|
|
362
408
|
// Render each visible layout line
|
|
363
|
-
// Emit hardware cursor marker
|
|
364
|
-
|
|
409
|
+
// Emit hardware cursor marker when focused so TUI can position the
|
|
410
|
+
// hardware cursor for IME candidate-window placement even while
|
|
411
|
+
// autocomplete (e.g. slash-command menu) is visible.
|
|
412
|
+
const emitCursorMarker = this.focused;
|
|
365
413
|
for (const layoutLine of visibleLines) {
|
|
366
414
|
let displayText = layoutLine.text;
|
|
367
415
|
let lineVisibleWidth = visibleWidth(layoutLine.text);
|
|
@@ -375,7 +423,7 @@ export class Editor {
|
|
|
375
423
|
if (after.length > 0) {
|
|
376
424
|
// Cursor is on a character (grapheme) - replace it with highlighted version
|
|
377
425
|
// Get the first grapheme from 'after'
|
|
378
|
-
const afterGraphemes = [...this.segment(after)];
|
|
426
|
+
const afterGraphemes = [...this.segment(after, "grapheme")];
|
|
379
427
|
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
|
380
428
|
const restAfter = after.slice(firstGrapheme.length);
|
|
381
429
|
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
|
@@ -429,11 +477,12 @@ export class Editor {
|
|
|
429
477
|
this.jumpMode = null;
|
|
430
478
|
return;
|
|
431
479
|
}
|
|
432
|
-
|
|
480
|
+
const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined);
|
|
481
|
+
if (printable !== undefined) {
|
|
433
482
|
// Printable character - perform the jump
|
|
434
483
|
const direction = this.jumpMode;
|
|
435
484
|
this.jumpMode = null;
|
|
436
|
-
this.jumpToChar(
|
|
485
|
+
this.jumpToChar(printable, direction);
|
|
437
486
|
return;
|
|
438
487
|
}
|
|
439
488
|
// Control character - cancel and fall through to normal handling
|
|
@@ -607,10 +656,7 @@ export class Editor {
|
|
|
607
656
|
}
|
|
608
657
|
// Arrow key navigation (with history support)
|
|
609
658
|
if (kb.matches(data, "tui.editor.cursorUp")) {
|
|
610
|
-
if (this.
|
|
611
|
-
this.navigateHistory(-1);
|
|
612
|
-
}
|
|
613
|
-
else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
|
|
659
|
+
if (this.isOnFirstVisualLine() && this.history.length > 0) {
|
|
614
660
|
this.navigateHistory(-1);
|
|
615
661
|
}
|
|
616
662
|
else if (this.isOnFirstVisualLine()) {
|
|
@@ -666,9 +712,9 @@ export class Editor {
|
|
|
666
712
|
this.insertCharacter(" ");
|
|
667
713
|
return;
|
|
668
714
|
}
|
|
669
|
-
const
|
|
670
|
-
if (
|
|
671
|
-
this.insertCharacter(
|
|
715
|
+
const printable = decodePrintableKey(data);
|
|
716
|
+
if (printable !== undefined) {
|
|
717
|
+
this.insertCharacter(printable);
|
|
672
718
|
return;
|
|
673
719
|
}
|
|
674
720
|
// Regular characters
|
|
@@ -710,7 +756,7 @@ export class Editor {
|
|
|
710
756
|
}
|
|
711
757
|
else {
|
|
712
758
|
// Line needs wrapping - use word-aware wrapping
|
|
713
|
-
const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]);
|
|
759
|
+
const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]);
|
|
714
760
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
715
761
|
const chunk = chunks[chunkIndex];
|
|
716
762
|
if (!chunk)
|
|
@@ -786,7 +832,7 @@ export class Editor {
|
|
|
786
832
|
setText(text) {
|
|
787
833
|
this.cancelAutocomplete();
|
|
788
834
|
this.lastAction = null;
|
|
789
|
-
this.
|
|
835
|
+
this.exitHistoryBrowsing();
|
|
790
836
|
const normalized = this.normalizeText(text);
|
|
791
837
|
// Push undo snapshot if content differs (makes programmatic changes undoable)
|
|
792
838
|
if (this.getText() !== normalized) {
|
|
@@ -805,7 +851,7 @@ export class Editor {
|
|
|
805
851
|
this.cancelAutocomplete();
|
|
806
852
|
this.pushUndoSnapshot();
|
|
807
853
|
this.lastAction = null;
|
|
808
|
-
this.
|
|
854
|
+
this.exitHistoryBrowsing();
|
|
809
855
|
this.insertTextAtCursorInternal(text);
|
|
810
856
|
}
|
|
811
857
|
/**
|
|
@@ -858,7 +904,7 @@ export class Editor {
|
|
|
858
904
|
}
|
|
859
905
|
// All the editor methods from before...
|
|
860
906
|
insertCharacter(char, skipUndoCoalescing) {
|
|
861
|
-
this.
|
|
907
|
+
this.exitHistoryBrowsing();
|
|
862
908
|
// Undo coalescing (fish-style):
|
|
863
909
|
// - Consecutive word chars coalesce into one undo unit
|
|
864
910
|
// - Space captures state before itself (so undo removes space+following word together)
|
|
@@ -884,17 +930,16 @@ export class Editor {
|
|
|
884
930
|
if (char === "/" && this.isAtStartOfMessage()) {
|
|
885
931
|
this.tryTriggerAutocomplete();
|
|
886
932
|
}
|
|
887
|
-
// Auto-trigger for
|
|
888
|
-
else if (char
|
|
933
|
+
// Auto-trigger for symbol-based completion like @, #, or provider triggers at token boundaries
|
|
934
|
+
else if (this.autocompleteTriggerCharacters.includes(char)) {
|
|
889
935
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
890
936
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
if (textBeforeCursor.length === 1 || charBeforeAt === " " || charBeforeAt === "\t") {
|
|
937
|
+
const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
|
|
938
|
+
if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
|
|
894
939
|
this.tryTriggerAutocomplete();
|
|
895
940
|
}
|
|
896
941
|
}
|
|
897
|
-
// Also auto-trigger when typing letters in a slash command context
|
|
942
|
+
// Also auto-trigger when typing letters in a slash command or symbol completion context
|
|
898
943
|
else if (/[a-zA-Z0-9.\-_]/.test(char)) {
|
|
899
944
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
900
945
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
@@ -902,8 +947,8 @@ export class Editor {
|
|
|
902
947
|
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
|
903
948
|
this.tryTriggerAutocomplete();
|
|
904
949
|
}
|
|
905
|
-
// Check if we're in
|
|
906
|
-
else if (
|
|
950
|
+
// Check if we're in a symbol-based completion context like @, #, or provider triggers
|
|
951
|
+
else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
|
|
907
952
|
this.tryTriggerAutocomplete();
|
|
908
953
|
}
|
|
909
954
|
}
|
|
@@ -914,11 +959,24 @@ export class Editor {
|
|
|
914
959
|
}
|
|
915
960
|
handlePaste(pastedText) {
|
|
916
961
|
this.cancelAutocomplete();
|
|
917
|
-
this.
|
|
962
|
+
this.exitHistoryBrowsing();
|
|
918
963
|
this.lastAction = null;
|
|
919
964
|
this.pushUndoSnapshot();
|
|
965
|
+
// Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
|
|
966
|
+
// control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
|
|
967
|
+
// (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
|
|
968
|
+
// per-char filter below preserves newlines instead of stripping ESC and
|
|
969
|
+
// leaking the printable tail (e.g. "[106;5u") into the editor.
|
|
970
|
+
const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
|
|
971
|
+
const cp = Number(code);
|
|
972
|
+
if (cp >= 97 && cp <= 122)
|
|
973
|
+
return String.fromCharCode(cp - 96);
|
|
974
|
+
if (cp >= 65 && cp <= 90)
|
|
975
|
+
return String.fromCharCode(cp - 64);
|
|
976
|
+
return match;
|
|
977
|
+
});
|
|
920
978
|
// Clean the pasted text: normalize line endings, expand tabs
|
|
921
|
-
const cleanText = this.normalizeText(
|
|
979
|
+
const cleanText = this.normalizeText(decodedText);
|
|
922
980
|
// Filter out non-printable characters except newlines
|
|
923
981
|
let filteredText = cleanText
|
|
924
982
|
.split("")
|
|
@@ -959,7 +1017,7 @@ export class Editor {
|
|
|
959
1017
|
}
|
|
960
1018
|
addNewLine() {
|
|
961
1019
|
this.cancelAutocomplete();
|
|
962
|
-
this.
|
|
1020
|
+
this.exitHistoryBrowsing();
|
|
963
1021
|
this.lastAction = null;
|
|
964
1022
|
this.pushUndoSnapshot();
|
|
965
1023
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
@@ -993,7 +1051,7 @@ export class Editor {
|
|
|
993
1051
|
this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
|
994
1052
|
this.pastes.clear();
|
|
995
1053
|
this.pasteCounter = 0;
|
|
996
|
-
this.
|
|
1054
|
+
this.exitHistoryBrowsing();
|
|
997
1055
|
this.scrollOffset = 0;
|
|
998
1056
|
this.undoStack.clear();
|
|
999
1057
|
this.lastAction = null;
|
|
@@ -1003,7 +1061,7 @@ export class Editor {
|
|
|
1003
1061
|
this.onSubmit(result);
|
|
1004
1062
|
}
|
|
1005
1063
|
handleBackspace() {
|
|
1006
|
-
this.
|
|
1064
|
+
this.exitHistoryBrowsing();
|
|
1007
1065
|
this.lastAction = null;
|
|
1008
1066
|
if (this.state.cursorCol > 0) {
|
|
1009
1067
|
this.pushUndoSnapshot();
|
|
@@ -1011,7 +1069,7 @@ export class Editor {
|
|
|
1011
1069
|
const line = this.state.lines[this.state.cursorLine] || "";
|
|
1012
1070
|
const beforeCursor = line.slice(0, this.state.cursorCol);
|
|
1013
1071
|
// Find the last grapheme in the text before cursor
|
|
1014
|
-
const graphemes = [...this.segment(beforeCursor)];
|
|
1072
|
+
const graphemes = [...this.segment(beforeCursor, "grapheme")];
|
|
1015
1073
|
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1016
1074
|
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
|
1017
1075
|
const before = line.slice(0, this.state.cursorCol - graphemeLength);
|
|
@@ -1044,8 +1102,8 @@ export class Editor {
|
|
|
1044
1102
|
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
|
1045
1103
|
this.tryTriggerAutocomplete();
|
|
1046
1104
|
}
|
|
1047
|
-
//
|
|
1048
|
-
else if (
|
|
1105
|
+
// Symbol-based completion context like @, #, or provider triggers
|
|
1106
|
+
else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
|
|
1049
1107
|
this.tryTriggerAutocomplete();
|
|
1050
1108
|
}
|
|
1051
1109
|
}
|
|
@@ -1057,6 +1115,7 @@ export class Editor {
|
|
|
1057
1115
|
setCursorCol(col) {
|
|
1058
1116
|
this.state.cursorCol = col;
|
|
1059
1117
|
this.preferredVisualCol = null;
|
|
1118
|
+
this.snappedFromCursorCol = null;
|
|
1060
1119
|
}
|
|
1061
1120
|
/**
|
|
1062
1121
|
* Move cursor to a target visual line, applying sticky column logic.
|
|
@@ -1065,37 +1124,70 @@ export class Editor {
|
|
|
1065
1124
|
moveToVisualLine(visualLines, currentVisualLine, targetVisualLine) {
|
|
1066
1125
|
const currentVL = visualLines[currentVisualLine];
|
|
1067
1126
|
const targetVL = visualLines[targetVisualLine];
|
|
1068
|
-
if (currentVL && targetVL)
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
this.state.
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1127
|
+
if (!(currentVL && targetVL))
|
|
1128
|
+
return;
|
|
1129
|
+
// When the cursor was snapped to a segment start, resolve the pre-snap
|
|
1130
|
+
// position against the VL it belongs to. This gives the correct visual
|
|
1131
|
+
// column even after a resize reshuffles VLs.
|
|
1132
|
+
let currentVisualCol;
|
|
1133
|
+
if (this.snappedFromCursorCol !== null) {
|
|
1134
|
+
const vlIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
|
|
1135
|
+
currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex].startCol;
|
|
1136
|
+
}
|
|
1137
|
+
else {
|
|
1138
|
+
currentVisualCol = this.state.cursorCol - currentVL.startCol;
|
|
1139
|
+
}
|
|
1140
|
+
// For non-last segments, clamp to length-1 to stay within the segment
|
|
1141
|
+
const isLastSourceSegment = currentVisualLine === visualLines.length - 1 ||
|
|
1142
|
+
visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
|
|
1143
|
+
const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
|
|
1144
|
+
const isLastTargetSegment = targetVisualLine === visualLines.length - 1 ||
|
|
1145
|
+
visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
|
|
1146
|
+
const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
|
|
1147
|
+
const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
|
|
1148
|
+
// Set cursor position
|
|
1149
|
+
this.state.cursorLine = targetVL.logicalLine;
|
|
1150
|
+
const targetCol = targetVL.startCol + moveToVisualCol;
|
|
1151
|
+
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
|
|
1152
|
+
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
|
|
1153
|
+
// Snap cursor to atomic segment boundary (e.g. paste markers)
|
|
1154
|
+
// so the cursor never lands in the middle of a multi-grapheme unit.
|
|
1155
|
+
// Single-grapheme segments don't need snapping.
|
|
1156
|
+
const segments = [...this.segment(logicalLine, "grapheme")];
|
|
1157
|
+
for (const seg of segments) {
|
|
1158
|
+
if (seg.index > this.state.cursorCol)
|
|
1159
|
+
break;
|
|
1160
|
+
if (seg.segment.length <= 1)
|
|
1161
|
+
continue;
|
|
1162
|
+
if (this.state.cursorCol < seg.index + seg.segment.length) {
|
|
1163
|
+
const isContinuation = seg.index < targetVL.startCol;
|
|
1164
|
+
const isMovingDown = targetVisualLine > currentVisualLine;
|
|
1165
|
+
if (isContinuation && isMovingDown) {
|
|
1166
|
+
// The segment started on a previous visual line, and we
|
|
1167
|
+
// already visited it on the way down. Skip all remaining
|
|
1168
|
+
// continuation VLs and land on the first VL past it.
|
|
1169
|
+
const segEnd = seg.index + seg.segment.length;
|
|
1170
|
+
let next = targetVisualLine + 1;
|
|
1171
|
+
while (next < visualLines.length &&
|
|
1172
|
+
visualLines[next].logicalLine === targetVL.logicalLine &&
|
|
1173
|
+
visualLines[next].startCol < segEnd) {
|
|
1174
|
+
next++;
|
|
1175
|
+
}
|
|
1176
|
+
if (next < visualLines.length) {
|
|
1177
|
+
this.moveToVisualLine(visualLines, currentVisualLine, next);
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1096
1180
|
}
|
|
1181
|
+
// Snap to the start of the segment so it gets highlighted.
|
|
1182
|
+
// Store the pre-snap position so the next vertical move can
|
|
1183
|
+
// resolve it to the correct visual column.
|
|
1184
|
+
this.snappedFromCursorCol = this.state.cursorCol;
|
|
1185
|
+
this.state.cursorCol = seg.index;
|
|
1186
|
+
return;
|
|
1097
1187
|
}
|
|
1098
1188
|
}
|
|
1189
|
+
// No snap occurred – we moved out of the atomic segment.
|
|
1190
|
+
this.snappedFromCursorCol = null;
|
|
1099
1191
|
}
|
|
1100
1192
|
/**
|
|
1101
1193
|
* Compute the target visual column for vertical cursor movement.
|
|
@@ -1151,7 +1243,7 @@ export class Editor {
|
|
|
1151
1243
|
this.setCursorCol(currentLine.length);
|
|
1152
1244
|
}
|
|
1153
1245
|
deleteToStartOfLine() {
|
|
1154
|
-
this.
|
|
1246
|
+
this.exitHistoryBrowsing();
|
|
1155
1247
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1156
1248
|
if (this.state.cursorCol > 0) {
|
|
1157
1249
|
this.pushUndoSnapshot();
|
|
@@ -1179,7 +1271,7 @@ export class Editor {
|
|
|
1179
1271
|
}
|
|
1180
1272
|
}
|
|
1181
1273
|
deleteToEndOfLine() {
|
|
1182
|
-
this.
|
|
1274
|
+
this.exitHistoryBrowsing();
|
|
1183
1275
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1184
1276
|
if (this.state.cursorCol < currentLine.length) {
|
|
1185
1277
|
this.pushUndoSnapshot();
|
|
@@ -1204,7 +1296,7 @@ export class Editor {
|
|
|
1204
1296
|
}
|
|
1205
1297
|
}
|
|
1206
1298
|
deleteWordBackwards() {
|
|
1207
|
-
this.
|
|
1299
|
+
this.exitHistoryBrowsing();
|
|
1208
1300
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1209
1301
|
// If at start of line, behave like backspace at column 0 (merge with previous line)
|
|
1210
1302
|
if (this.state.cursorCol === 0) {
|
|
@@ -1240,7 +1332,7 @@ export class Editor {
|
|
|
1240
1332
|
}
|
|
1241
1333
|
}
|
|
1242
1334
|
deleteWordForward() {
|
|
1243
|
-
this.
|
|
1335
|
+
this.exitHistoryBrowsing();
|
|
1244
1336
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1245
1337
|
// If at end of line, merge with next line (delete the newline)
|
|
1246
1338
|
if (this.state.cursorCol >= currentLine.length) {
|
|
@@ -1273,7 +1365,7 @@ export class Editor {
|
|
|
1273
1365
|
}
|
|
1274
1366
|
}
|
|
1275
1367
|
handleForwardDelete() {
|
|
1276
|
-
this.
|
|
1368
|
+
this.exitHistoryBrowsing();
|
|
1277
1369
|
this.lastAction = null;
|
|
1278
1370
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1279
1371
|
if (this.state.cursorCol < currentLine.length) {
|
|
@@ -1281,7 +1373,7 @@ export class Editor {
|
|
|
1281
1373
|
// Delete grapheme at cursor position (handles emojis, combining characters, etc.)
|
|
1282
1374
|
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
1283
1375
|
// Find the first grapheme at cursor
|
|
1284
|
-
const graphemes = [...this.segment(afterCursor)];
|
|
1376
|
+
const graphemes = [...this.segment(afterCursor, "grapheme")];
|
|
1285
1377
|
const firstGrapheme = graphemes[0];
|
|
1286
1378
|
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
|
1287
1379
|
const before = currentLine.slice(0, this.state.cursorCol);
|
|
@@ -1309,8 +1401,8 @@ export class Editor {
|
|
|
1309
1401
|
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
|
1310
1402
|
this.tryTriggerAutocomplete();
|
|
1311
1403
|
}
|
|
1312
|
-
//
|
|
1313
|
-
else if (
|
|
1404
|
+
// Symbol-based completion context like @, #, or provider triggers
|
|
1405
|
+
else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
|
|
1314
1406
|
this.tryTriggerAutocomplete();
|
|
1315
1407
|
}
|
|
1316
1408
|
}
|
|
@@ -1336,7 +1428,7 @@ export class Editor {
|
|
|
1336
1428
|
}
|
|
1337
1429
|
else {
|
|
1338
1430
|
// Line needs wrapping - use word-aware wrapping
|
|
1339
|
-
const chunks = wordWrapLine(line, width, [...this.segment(line)]);
|
|
1431
|
+
const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]);
|
|
1340
1432
|
for (const chunk of chunks) {
|
|
1341
1433
|
visualLines.push({
|
|
1342
1434
|
logicalLine: i,
|
|
@@ -1349,26 +1441,29 @@ export class Editor {
|
|
|
1349
1441
|
return visualLines;
|
|
1350
1442
|
}
|
|
1351
1443
|
/**
|
|
1352
|
-
* Find the visual line index
|
|
1444
|
+
* Find the visual line index that contains the given logical position.
|
|
1353
1445
|
*/
|
|
1354
|
-
|
|
1446
|
+
findVisualLineAt(visualLines, line, col) {
|
|
1355
1447
|
for (let i = 0; i < visualLines.length; i++) {
|
|
1356
1448
|
const vl = visualLines[i];
|
|
1357
|
-
if (!vl)
|
|
1449
|
+
if (!vl || vl.logicalLine !== line)
|
|
1358
1450
|
continue;
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
return i;
|
|
1366
|
-
}
|
|
1451
|
+
const offset = col - vl.startCol;
|
|
1452
|
+
// Cursor is in this segment if it's within range. For the last
|
|
1453
|
+
// segment of a logical line, cursor can be at length (end position)
|
|
1454
|
+
const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
|
|
1455
|
+
if (offset >= 0 && (offset < vl.length || (isLastSegmentOfLine && offset === vl.length))) {
|
|
1456
|
+
return i;
|
|
1367
1457
|
}
|
|
1368
1458
|
}
|
|
1369
|
-
// Fallback: return last visual line
|
|
1370
1459
|
return visualLines.length - 1;
|
|
1371
1460
|
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Find the visual line index for the current cursor position.
|
|
1463
|
+
*/
|
|
1464
|
+
findCurrentVisualLine(visualLines) {
|
|
1465
|
+
return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol);
|
|
1466
|
+
}
|
|
1372
1467
|
moveCursor(deltaLine, deltaCol) {
|
|
1373
1468
|
this.lastAction = null;
|
|
1374
1469
|
const visualLines = this.buildVisualLineMap(this.lastWidth);
|
|
@@ -1385,7 +1480,7 @@ export class Editor {
|
|
|
1385
1480
|
// Moving right - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1386
1481
|
if (this.state.cursorCol < currentLine.length) {
|
|
1387
1482
|
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
1388
|
-
const graphemes = [...this.segment(afterCursor)];
|
|
1483
|
+
const graphemes = [...this.segment(afterCursor, "grapheme")];
|
|
1389
1484
|
const firstGrapheme = graphemes[0];
|
|
1390
1485
|
this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
|
|
1391
1486
|
}
|
|
@@ -1406,7 +1501,7 @@ export class Editor {
|
|
|
1406
1501
|
// Moving left - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1407
1502
|
if (this.state.cursorCol > 0) {
|
|
1408
1503
|
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1409
|
-
const graphemes = [...this.segment(beforeCursor)];
|
|
1504
|
+
const graphemes = [...this.segment(beforeCursor, "grapheme")];
|
|
1410
1505
|
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1411
1506
|
this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
|
|
1412
1507
|
}
|
|
@@ -1418,6 +1513,17 @@ export class Editor {
|
|
|
1418
1513
|
}
|
|
1419
1514
|
}
|
|
1420
1515
|
}
|
|
1516
|
+
// Keep an open autocomplete picker in sync with the new cursor
|
|
1517
|
+
// position: cursor movement changes the text before the cursor, so a
|
|
1518
|
+
// picker computed for the old position is stale. Re-query so it
|
|
1519
|
+
// refreshes — or closes when the new position yields no suggestions —
|
|
1520
|
+
// mirroring insertCharacter()/handleBackspace(). Without this, arrowing
|
|
1521
|
+
// left from `/cmd ` back into the command name leaves the argument
|
|
1522
|
+
// picker showing against a `/cmd` prefix (and a Tab there would
|
|
1523
|
+
// concatenate the stale suggestion onto the partial command name).
|
|
1524
|
+
if (this.autocompleteState) {
|
|
1525
|
+
this.updateAutocomplete();
|
|
1526
|
+
}
|
|
1421
1527
|
}
|
|
1422
1528
|
/**
|
|
1423
1529
|
* Scroll by a page (direction: -1 for up, 1 for down).
|
|
@@ -1444,40 +1550,10 @@ export class Editor {
|
|
|
1444
1550
|
}
|
|
1445
1551
|
return;
|
|
1446
1552
|
}
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
while (graphemes.length > 0 &&
|
|
1452
|
-
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1453
|
-
isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1454
|
-
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1455
|
-
}
|
|
1456
|
-
if (graphemes.length > 0) {
|
|
1457
|
-
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
|
|
1458
|
-
if (isPasteMarker(lastGrapheme)) {
|
|
1459
|
-
// Paste marker is a single atomic word
|
|
1460
|
-
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1461
|
-
}
|
|
1462
|
-
else if (isPunctuationChar(lastGrapheme)) {
|
|
1463
|
-
// Skip punctuation run
|
|
1464
|
-
while (graphemes.length > 0 &&
|
|
1465
|
-
isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1466
|
-
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1467
|
-
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
else {
|
|
1471
|
-
// Skip word run
|
|
1472
|
-
while (graphemes.length > 0 &&
|
|
1473
|
-
!isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1474
|
-
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1475
|
-
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1476
|
-
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1477
|
-
}
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
this.setCursorCol(newCol);
|
|
1553
|
+
this.setCursorCol(findWordBackward(currentLine, this.state.cursorCol, {
|
|
1554
|
+
segment: (text) => this.segment(text, "word"),
|
|
1555
|
+
isAtomicSegment: isPasteMarker,
|
|
1556
|
+
}));
|
|
1481
1557
|
}
|
|
1482
1558
|
/**
|
|
1483
1559
|
* Yank (paste) the most recent kill ring entry at cursor position.
|
|
@@ -1512,7 +1588,7 @@ export class Editor {
|
|
|
1512
1588
|
* Insert text at cursor position (used by yank operations).
|
|
1513
1589
|
*/
|
|
1514
1590
|
insertYankedText(text) {
|
|
1515
|
-
this.
|
|
1591
|
+
this.exitHistoryBrowsing();
|
|
1516
1592
|
const lines = text.split("\n");
|
|
1517
1593
|
if (lines.length === 1) {
|
|
1518
1594
|
// Single line - insert at cursor
|
|
@@ -1584,7 +1660,7 @@ export class Editor {
|
|
|
1584
1660
|
this.undoStack.push(this.state);
|
|
1585
1661
|
}
|
|
1586
1662
|
undo() {
|
|
1587
|
-
this.
|
|
1663
|
+
this.exitHistoryBrowsing();
|
|
1588
1664
|
const snapshot = this.undoStack.pop();
|
|
1589
1665
|
if (!snapshot)
|
|
1590
1666
|
return;
|
|
@@ -1634,41 +1710,10 @@ export class Editor {
|
|
|
1634
1710
|
}
|
|
1635
1711
|
return;
|
|
1636
1712
|
}
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
let newCol = this.state.cursorCol;
|
|
1642
|
-
// Skip leading whitespace
|
|
1643
|
-
while (!next.done && !isPasteMarker(next.value.segment) && isWhitespaceChar(next.value.segment)) {
|
|
1644
|
-
newCol += next.value.segment.length;
|
|
1645
|
-
next = iterator.next();
|
|
1646
|
-
}
|
|
1647
|
-
if (!next.done) {
|
|
1648
|
-
const firstGrapheme = next.value.segment;
|
|
1649
|
-
if (isPasteMarker(firstGrapheme)) {
|
|
1650
|
-
// Paste marker is a single atomic word
|
|
1651
|
-
newCol += firstGrapheme.length;
|
|
1652
|
-
}
|
|
1653
|
-
else if (isPunctuationChar(firstGrapheme)) {
|
|
1654
|
-
// Skip punctuation run
|
|
1655
|
-
while (!next.done && isPunctuationChar(next.value.segment) && !isPasteMarker(next.value.segment)) {
|
|
1656
|
-
newCol += next.value.segment.length;
|
|
1657
|
-
next = iterator.next();
|
|
1658
|
-
}
|
|
1659
|
-
}
|
|
1660
|
-
else {
|
|
1661
|
-
// Skip word run
|
|
1662
|
-
while (!next.done &&
|
|
1663
|
-
!isWhitespaceChar(next.value.segment) &&
|
|
1664
|
-
!isPunctuationChar(next.value.segment) &&
|
|
1665
|
-
!isPasteMarker(next.value.segment)) {
|
|
1666
|
-
newCol += next.value.segment.length;
|
|
1667
|
-
next = iterator.next();
|
|
1668
|
-
}
|
|
1669
|
-
}
|
|
1670
|
-
}
|
|
1671
|
-
this.setCursorCol(newCol);
|
|
1713
|
+
this.setCursorCol(findWordForward(currentLine, this.state.cursorCol, {
|
|
1714
|
+
segment: (text) => this.segment(text, "word"),
|
|
1715
|
+
isAtomicSegment: isPasteMarker,
|
|
1716
|
+
}));
|
|
1672
1717
|
}
|
|
1673
1718
|
// Slash menu only allowed on the first line of the editor
|
|
1674
1719
|
isSlashMenuAllowed() {
|
|
@@ -1741,9 +1786,8 @@ export class Editor {
|
|
|
1741
1786
|
if (!this.autocompleteProvider)
|
|
1742
1787
|
return;
|
|
1743
1788
|
if (options.force) {
|
|
1744
|
-
const
|
|
1745
|
-
|
|
1746
|
-
provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
|
|
1789
|
+
const shouldTrigger = !this.autocompleteProvider.shouldTriggerFileCompletion ||
|
|
1790
|
+
this.autocompleteProvider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
|
|
1747
1791
|
if (!shouldTrigger) {
|
|
1748
1792
|
return;
|
|
1749
1793
|
}
|
|
@@ -1777,14 +1821,25 @@ export class Editor {
|
|
|
1777
1821
|
})();
|
|
1778
1822
|
await this.autocompleteRequestTask;
|
|
1779
1823
|
}
|
|
1824
|
+
setAutocompleteTriggerCharacters(triggerCharacters) {
|
|
1825
|
+
const next = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
|
|
1826
|
+
for (const character of triggerCharacters) {
|
|
1827
|
+
if (character.length !== 1 || character === "/" || isWhitespaceChar(character) || next.includes(character)) {
|
|
1828
|
+
continue;
|
|
1829
|
+
}
|
|
1830
|
+
next.push(character);
|
|
1831
|
+
}
|
|
1832
|
+
this.autocompleteTriggerCharacters = next;
|
|
1833
|
+
this.autocompleteTriggerPattern = buildTriggerPattern(next);
|
|
1834
|
+
this.autocompleteDebouncePattern = buildDebouncePattern(next);
|
|
1835
|
+
}
|
|
1780
1836
|
getAutocompleteDebounceMs(options) {
|
|
1781
1837
|
if (options.explicitTab || options.force) {
|
|
1782
1838
|
return 0;
|
|
1783
1839
|
}
|
|
1784
1840
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1785
1841
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1786
|
-
|
|
1787
|
-
return isAttachmentContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
|
|
1842
|
+
return this.autocompleteDebouncePattern.test(textBeforeCursor) ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
|
|
1788
1843
|
}
|
|
1789
1844
|
async runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options) {
|
|
1790
1845
|
if (!this.autocompleteProvider)
|