@draht/tui 2026.5.12 → 2026.7.7-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 -3
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +234 -175
- 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 +11 -7
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +119 -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 +1 -1
- package/dist/fuzzy.d.ts.map +1 -1
- package/dist/fuzzy.js +5 -2
- package/dist/fuzzy.js.map +1 -1
- package/dist/index.d.ts +23 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/keybindings.d.ts +2 -2
- package/dist/keybindings.d.ts.map +1 -1
- package/dist/keybindings.js +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-colors.d.ts +10 -0
- package/dist/terminal-colors.d.ts.map +1 -0
- package/dist/terminal-colors.js +59 -0
- package/dist/terminal-colors.js.map +1 -0
- package/dist/terminal-image.d.ts +23 -1
- package/dist/terminal-image.d.ts.map +1 -1
- package/dist/terminal-image.js +98 -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 +51 -5
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +431 -32
- 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 +143 -43
- 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.
|
|
@@ -286,22 +319,38 @@ export class Editor {
|
|
|
286
319
|
// Capture state when first entering history browsing mode
|
|
287
320
|
if (this.historyIndex === -1 && newIndex >= 0) {
|
|
288
321
|
this.pushUndoSnapshot();
|
|
322
|
+
this.historyDraft = structuredClone(this.state);
|
|
289
323
|
}
|
|
290
324
|
this.historyIndex = newIndex;
|
|
291
325
|
if (this.historyIndex === -1) {
|
|
292
|
-
|
|
293
|
-
this.
|
|
326
|
+
const draft = this.historyDraft;
|
|
327
|
+
this.historyDraft = null;
|
|
328
|
+
if (draft) {
|
|
329
|
+
this.state = draft;
|
|
330
|
+
this.preferredVisualCol = null;
|
|
331
|
+
this.snappedFromCursorCol = null;
|
|
332
|
+
this.scrollOffset = 0;
|
|
333
|
+
if (this.onChange)
|
|
334
|
+
this.onChange(this.getText());
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
this.setTextInternal("");
|
|
338
|
+
}
|
|
294
339
|
}
|
|
295
340
|
else {
|
|
296
|
-
this.setTextInternal(this.history[this.historyIndex] || "");
|
|
341
|
+
this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end");
|
|
297
342
|
}
|
|
298
343
|
}
|
|
344
|
+
exitHistoryBrowsing() {
|
|
345
|
+
this.historyIndex = -1;
|
|
346
|
+
this.historyDraft = null;
|
|
347
|
+
}
|
|
299
348
|
/** Internal setText that doesn't reset history state - used by navigateHistory */
|
|
300
|
-
setTextInternal(text) {
|
|
349
|
+
setTextInternal(text, cursorPlacement = "end") {
|
|
301
350
|
const lines = text.split("\n");
|
|
302
351
|
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);
|
|
352
|
+
this.state.cursorLine = cursorPlacement === "start" ? 0 : this.state.lines.length - 1;
|
|
353
|
+
this.setCursorCol(cursorPlacement === "start" ? 0 : this.state.lines[this.state.cursorLine]?.length || 0);
|
|
305
354
|
// Reset scroll - render() will adjust to show cursor
|
|
306
355
|
this.scrollOffset = 0;
|
|
307
356
|
if (this.onChange) {
|
|
@@ -360,8 +409,10 @@ export class Editor {
|
|
|
360
409
|
result.push(horizontal.repeat(width));
|
|
361
410
|
}
|
|
362
411
|
// Render each visible layout line
|
|
363
|
-
// Emit hardware cursor marker
|
|
364
|
-
|
|
412
|
+
// Emit hardware cursor marker when focused so TUI can position the
|
|
413
|
+
// hardware cursor for IME candidate-window placement even while
|
|
414
|
+
// autocomplete (e.g. slash-command menu) is visible.
|
|
415
|
+
const emitCursorMarker = this.focused;
|
|
365
416
|
for (const layoutLine of visibleLines) {
|
|
366
417
|
let displayText = layoutLine.text;
|
|
367
418
|
let lineVisibleWidth = visibleWidth(layoutLine.text);
|
|
@@ -375,7 +426,7 @@ export class Editor {
|
|
|
375
426
|
if (after.length > 0) {
|
|
376
427
|
// Cursor is on a character (grapheme) - replace it with highlighted version
|
|
377
428
|
// Get the first grapheme from 'after'
|
|
378
|
-
const afterGraphemes = [...this.segment(after)];
|
|
429
|
+
const afterGraphemes = [...this.segment(after, "grapheme")];
|
|
379
430
|
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
|
380
431
|
const restAfter = after.slice(firstGrapheme.length);
|
|
381
432
|
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
|
@@ -429,11 +480,12 @@ export class Editor {
|
|
|
429
480
|
this.jumpMode = null;
|
|
430
481
|
return;
|
|
431
482
|
}
|
|
432
|
-
|
|
483
|
+
const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined);
|
|
484
|
+
if (printable !== undefined) {
|
|
433
485
|
// Printable character - perform the jump
|
|
434
486
|
const direction = this.jumpMode;
|
|
435
487
|
this.jumpMode = null;
|
|
436
|
-
this.jumpToChar(
|
|
488
|
+
this.jumpToChar(printable, direction);
|
|
437
489
|
return;
|
|
438
490
|
}
|
|
439
491
|
// Control character - cancel and fall through to normal handling
|
|
@@ -607,10 +659,8 @@ export class Editor {
|
|
|
607
659
|
}
|
|
608
660
|
// Arrow key navigation (with history support)
|
|
609
661
|
if (kb.matches(data, "tui.editor.cursorUp")) {
|
|
610
|
-
if (this.
|
|
611
|
-
this.
|
|
612
|
-
}
|
|
613
|
-
else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
|
|
662
|
+
if (this.isOnFirstVisualLine() &&
|
|
663
|
+
(this.isEditorEmpty() || this.historyIndex > -1 || this.state.cursorCol === 0)) {
|
|
614
664
|
this.navigateHistory(-1);
|
|
615
665
|
}
|
|
616
666
|
else if (this.isOnFirstVisualLine()) {
|
|
@@ -666,9 +716,9 @@ export class Editor {
|
|
|
666
716
|
this.insertCharacter(" ");
|
|
667
717
|
return;
|
|
668
718
|
}
|
|
669
|
-
const
|
|
670
|
-
if (
|
|
671
|
-
this.insertCharacter(
|
|
719
|
+
const printable = decodePrintableKey(data);
|
|
720
|
+
if (printable !== undefined) {
|
|
721
|
+
this.insertCharacter(printable);
|
|
672
722
|
return;
|
|
673
723
|
}
|
|
674
724
|
// Regular characters
|
|
@@ -710,7 +760,7 @@ export class Editor {
|
|
|
710
760
|
}
|
|
711
761
|
else {
|
|
712
762
|
// Line needs wrapping - use word-aware wrapping
|
|
713
|
-
const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]);
|
|
763
|
+
const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]);
|
|
714
764
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
715
765
|
const chunk = chunks[chunkIndex];
|
|
716
766
|
if (!chunk)
|
|
@@ -786,7 +836,7 @@ export class Editor {
|
|
|
786
836
|
setText(text) {
|
|
787
837
|
this.cancelAutocomplete();
|
|
788
838
|
this.lastAction = null;
|
|
789
|
-
this.
|
|
839
|
+
this.exitHistoryBrowsing();
|
|
790
840
|
const normalized = this.normalizeText(text);
|
|
791
841
|
// Push undo snapshot if content differs (makes programmatic changes undoable)
|
|
792
842
|
if (this.getText() !== normalized) {
|
|
@@ -805,7 +855,7 @@ export class Editor {
|
|
|
805
855
|
this.cancelAutocomplete();
|
|
806
856
|
this.pushUndoSnapshot();
|
|
807
857
|
this.lastAction = null;
|
|
808
|
-
this.
|
|
858
|
+
this.exitHistoryBrowsing();
|
|
809
859
|
this.insertTextAtCursorInternal(text);
|
|
810
860
|
}
|
|
811
861
|
/**
|
|
@@ -858,7 +908,7 @@ export class Editor {
|
|
|
858
908
|
}
|
|
859
909
|
// All the editor methods from before...
|
|
860
910
|
insertCharacter(char, skipUndoCoalescing) {
|
|
861
|
-
this.
|
|
911
|
+
this.exitHistoryBrowsing();
|
|
862
912
|
// Undo coalescing (fish-style):
|
|
863
913
|
// - Consecutive word chars coalesce into one undo unit
|
|
864
914
|
// - Space captures state before itself (so undo removes space+following word together)
|
|
@@ -884,17 +934,16 @@ export class Editor {
|
|
|
884
934
|
if (char === "/" && this.isAtStartOfMessage()) {
|
|
885
935
|
this.tryTriggerAutocomplete();
|
|
886
936
|
}
|
|
887
|
-
// Auto-trigger for
|
|
888
|
-
else if (char
|
|
937
|
+
// Auto-trigger for symbol-based completion like @, #, or provider triggers at token boundaries
|
|
938
|
+
else if (this.autocompleteTriggerCharacters.includes(char)) {
|
|
889
939
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
890
940
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
if (textBeforeCursor.length === 1 || charBeforeAt === " " || charBeforeAt === "\t") {
|
|
941
|
+
const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
|
|
942
|
+
if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
|
|
894
943
|
this.tryTriggerAutocomplete();
|
|
895
944
|
}
|
|
896
945
|
}
|
|
897
|
-
// Also auto-trigger when typing letters in a slash command context
|
|
946
|
+
// Also auto-trigger when typing letters in a slash command or symbol completion context
|
|
898
947
|
else if (/[a-zA-Z0-9.\-_]/.test(char)) {
|
|
899
948
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
900
949
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
@@ -902,8 +951,8 @@ export class Editor {
|
|
|
902
951
|
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
|
903
952
|
this.tryTriggerAutocomplete();
|
|
904
953
|
}
|
|
905
|
-
// Check if we're in
|
|
906
|
-
else if (
|
|
954
|
+
// Check if we're in a symbol-based completion context like @, #, or provider triggers
|
|
955
|
+
else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
|
|
907
956
|
this.tryTriggerAutocomplete();
|
|
908
957
|
}
|
|
909
958
|
}
|
|
@@ -914,11 +963,24 @@ export class Editor {
|
|
|
914
963
|
}
|
|
915
964
|
handlePaste(pastedText) {
|
|
916
965
|
this.cancelAutocomplete();
|
|
917
|
-
this.
|
|
966
|
+
this.exitHistoryBrowsing();
|
|
918
967
|
this.lastAction = null;
|
|
919
968
|
this.pushUndoSnapshot();
|
|
969
|
+
// Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
|
|
970
|
+
// control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
|
|
971
|
+
// (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
|
|
972
|
+
// per-char filter below preserves newlines instead of stripping ESC and
|
|
973
|
+
// leaking the printable tail (e.g. "[106;5u") into the editor.
|
|
974
|
+
const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
|
|
975
|
+
const cp = Number(code);
|
|
976
|
+
if (cp >= 97 && cp <= 122)
|
|
977
|
+
return String.fromCharCode(cp - 96);
|
|
978
|
+
if (cp >= 65 && cp <= 90)
|
|
979
|
+
return String.fromCharCode(cp - 64);
|
|
980
|
+
return match;
|
|
981
|
+
});
|
|
920
982
|
// Clean the pasted text: normalize line endings, expand tabs
|
|
921
|
-
const cleanText = this.normalizeText(
|
|
983
|
+
const cleanText = this.normalizeText(decodedText);
|
|
922
984
|
// Filter out non-printable characters except newlines
|
|
923
985
|
let filteredText = cleanText
|
|
924
986
|
.split("")
|
|
@@ -959,7 +1021,7 @@ export class Editor {
|
|
|
959
1021
|
}
|
|
960
1022
|
addNewLine() {
|
|
961
1023
|
this.cancelAutocomplete();
|
|
962
|
-
this.
|
|
1024
|
+
this.exitHistoryBrowsing();
|
|
963
1025
|
this.lastAction = null;
|
|
964
1026
|
this.pushUndoSnapshot();
|
|
965
1027
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
@@ -993,7 +1055,7 @@ export class Editor {
|
|
|
993
1055
|
this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
|
994
1056
|
this.pastes.clear();
|
|
995
1057
|
this.pasteCounter = 0;
|
|
996
|
-
this.
|
|
1058
|
+
this.exitHistoryBrowsing();
|
|
997
1059
|
this.scrollOffset = 0;
|
|
998
1060
|
this.undoStack.clear();
|
|
999
1061
|
this.lastAction = null;
|
|
@@ -1003,7 +1065,7 @@ export class Editor {
|
|
|
1003
1065
|
this.onSubmit(result);
|
|
1004
1066
|
}
|
|
1005
1067
|
handleBackspace() {
|
|
1006
|
-
this.
|
|
1068
|
+
this.exitHistoryBrowsing();
|
|
1007
1069
|
this.lastAction = null;
|
|
1008
1070
|
if (this.state.cursorCol > 0) {
|
|
1009
1071
|
this.pushUndoSnapshot();
|
|
@@ -1011,7 +1073,7 @@ export class Editor {
|
|
|
1011
1073
|
const line = this.state.lines[this.state.cursorLine] || "";
|
|
1012
1074
|
const beforeCursor = line.slice(0, this.state.cursorCol);
|
|
1013
1075
|
// Find the last grapheme in the text before cursor
|
|
1014
|
-
const graphemes = [...this.segment(beforeCursor)];
|
|
1076
|
+
const graphemes = [...this.segment(beforeCursor, "grapheme")];
|
|
1015
1077
|
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1016
1078
|
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
|
1017
1079
|
const before = line.slice(0, this.state.cursorCol - graphemeLength);
|
|
@@ -1044,8 +1106,8 @@ export class Editor {
|
|
|
1044
1106
|
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
|
1045
1107
|
this.tryTriggerAutocomplete();
|
|
1046
1108
|
}
|
|
1047
|
-
//
|
|
1048
|
-
else if (
|
|
1109
|
+
// Symbol-based completion context like @, #, or provider triggers
|
|
1110
|
+
else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
|
|
1049
1111
|
this.tryTriggerAutocomplete();
|
|
1050
1112
|
}
|
|
1051
1113
|
}
|
|
@@ -1057,6 +1119,7 @@ export class Editor {
|
|
|
1057
1119
|
setCursorCol(col) {
|
|
1058
1120
|
this.state.cursorCol = col;
|
|
1059
1121
|
this.preferredVisualCol = null;
|
|
1122
|
+
this.snappedFromCursorCol = null;
|
|
1060
1123
|
}
|
|
1061
1124
|
/**
|
|
1062
1125
|
* Move cursor to a target visual line, applying sticky column logic.
|
|
@@ -1065,37 +1128,70 @@ export class Editor {
|
|
|
1065
1128
|
moveToVisualLine(visualLines, currentVisualLine, targetVisualLine) {
|
|
1066
1129
|
const currentVL = visualLines[currentVisualLine];
|
|
1067
1130
|
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
|
-
|
|
1131
|
+
if (!(currentVL && targetVL))
|
|
1132
|
+
return;
|
|
1133
|
+
// When the cursor was snapped to a segment start, resolve the pre-snap
|
|
1134
|
+
// position against the VL it belongs to. This gives the correct visual
|
|
1135
|
+
// column even after a resize reshuffles VLs.
|
|
1136
|
+
let currentVisualCol;
|
|
1137
|
+
if (this.snappedFromCursorCol !== null) {
|
|
1138
|
+
const vlIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
|
|
1139
|
+
currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex].startCol;
|
|
1140
|
+
}
|
|
1141
|
+
else {
|
|
1142
|
+
currentVisualCol = this.state.cursorCol - currentVL.startCol;
|
|
1143
|
+
}
|
|
1144
|
+
// For non-last segments, clamp to length-1 to stay within the segment
|
|
1145
|
+
const isLastSourceSegment = currentVisualLine === visualLines.length - 1 ||
|
|
1146
|
+
visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
|
|
1147
|
+
const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
|
|
1148
|
+
const isLastTargetSegment = targetVisualLine === visualLines.length - 1 ||
|
|
1149
|
+
visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
|
|
1150
|
+
const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
|
|
1151
|
+
const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
|
|
1152
|
+
// Set cursor position
|
|
1153
|
+
this.state.cursorLine = targetVL.logicalLine;
|
|
1154
|
+
const targetCol = targetVL.startCol + moveToVisualCol;
|
|
1155
|
+
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
|
|
1156
|
+
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
|
|
1157
|
+
// Snap cursor to atomic segment boundary (e.g. paste markers)
|
|
1158
|
+
// so the cursor never lands in the middle of a multi-grapheme unit.
|
|
1159
|
+
// Single-grapheme segments don't need snapping.
|
|
1160
|
+
const segments = [...this.segment(logicalLine, "grapheme")];
|
|
1161
|
+
for (const seg of segments) {
|
|
1162
|
+
if (seg.index > this.state.cursorCol)
|
|
1163
|
+
break;
|
|
1164
|
+
if (seg.segment.length <= 1)
|
|
1165
|
+
continue;
|
|
1166
|
+
if (this.state.cursorCol < seg.index + seg.segment.length) {
|
|
1167
|
+
const isContinuation = seg.index < targetVL.startCol;
|
|
1168
|
+
const isMovingDown = targetVisualLine > currentVisualLine;
|
|
1169
|
+
if (isContinuation && isMovingDown) {
|
|
1170
|
+
// The segment started on a previous visual line, and we
|
|
1171
|
+
// already visited it on the way down. Skip all remaining
|
|
1172
|
+
// continuation VLs and land on the first VL past it.
|
|
1173
|
+
const segEnd = seg.index + seg.segment.length;
|
|
1174
|
+
let next = targetVisualLine + 1;
|
|
1175
|
+
while (next < visualLines.length &&
|
|
1176
|
+
visualLines[next].logicalLine === targetVL.logicalLine &&
|
|
1177
|
+
visualLines[next].startCol < segEnd) {
|
|
1178
|
+
next++;
|
|
1179
|
+
}
|
|
1180
|
+
if (next < visualLines.length) {
|
|
1181
|
+
this.moveToVisualLine(visualLines, currentVisualLine, next);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1096
1184
|
}
|
|
1185
|
+
// Snap to the start of the segment so it gets highlighted.
|
|
1186
|
+
// Store the pre-snap position so the next vertical move can
|
|
1187
|
+
// resolve it to the correct visual column.
|
|
1188
|
+
this.snappedFromCursorCol = this.state.cursorCol;
|
|
1189
|
+
this.state.cursorCol = seg.index;
|
|
1190
|
+
return;
|
|
1097
1191
|
}
|
|
1098
1192
|
}
|
|
1193
|
+
// No snap occurred – we moved out of the atomic segment.
|
|
1194
|
+
this.snappedFromCursorCol = null;
|
|
1099
1195
|
}
|
|
1100
1196
|
/**
|
|
1101
1197
|
* Compute the target visual column for vertical cursor movement.
|
|
@@ -1151,7 +1247,7 @@ export class Editor {
|
|
|
1151
1247
|
this.setCursorCol(currentLine.length);
|
|
1152
1248
|
}
|
|
1153
1249
|
deleteToStartOfLine() {
|
|
1154
|
-
this.
|
|
1250
|
+
this.exitHistoryBrowsing();
|
|
1155
1251
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1156
1252
|
if (this.state.cursorCol > 0) {
|
|
1157
1253
|
this.pushUndoSnapshot();
|
|
@@ -1179,7 +1275,7 @@ export class Editor {
|
|
|
1179
1275
|
}
|
|
1180
1276
|
}
|
|
1181
1277
|
deleteToEndOfLine() {
|
|
1182
|
-
this.
|
|
1278
|
+
this.exitHistoryBrowsing();
|
|
1183
1279
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1184
1280
|
if (this.state.cursorCol < currentLine.length) {
|
|
1185
1281
|
this.pushUndoSnapshot();
|
|
@@ -1204,7 +1300,7 @@ export class Editor {
|
|
|
1204
1300
|
}
|
|
1205
1301
|
}
|
|
1206
1302
|
deleteWordBackwards() {
|
|
1207
|
-
this.
|
|
1303
|
+
this.exitHistoryBrowsing();
|
|
1208
1304
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1209
1305
|
// If at start of line, behave like backspace at column 0 (merge with previous line)
|
|
1210
1306
|
if (this.state.cursorCol === 0) {
|
|
@@ -1240,7 +1336,7 @@ export class Editor {
|
|
|
1240
1336
|
}
|
|
1241
1337
|
}
|
|
1242
1338
|
deleteWordForward() {
|
|
1243
|
-
this.
|
|
1339
|
+
this.exitHistoryBrowsing();
|
|
1244
1340
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1245
1341
|
// If at end of line, merge with next line (delete the newline)
|
|
1246
1342
|
if (this.state.cursorCol >= currentLine.length) {
|
|
@@ -1273,7 +1369,7 @@ export class Editor {
|
|
|
1273
1369
|
}
|
|
1274
1370
|
}
|
|
1275
1371
|
handleForwardDelete() {
|
|
1276
|
-
this.
|
|
1372
|
+
this.exitHistoryBrowsing();
|
|
1277
1373
|
this.lastAction = null;
|
|
1278
1374
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1279
1375
|
if (this.state.cursorCol < currentLine.length) {
|
|
@@ -1281,7 +1377,7 @@ export class Editor {
|
|
|
1281
1377
|
// Delete grapheme at cursor position (handles emojis, combining characters, etc.)
|
|
1282
1378
|
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
1283
1379
|
// Find the first grapheme at cursor
|
|
1284
|
-
const graphemes = [...this.segment(afterCursor)];
|
|
1380
|
+
const graphemes = [...this.segment(afterCursor, "grapheme")];
|
|
1285
1381
|
const firstGrapheme = graphemes[0];
|
|
1286
1382
|
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
|
1287
1383
|
const before = currentLine.slice(0, this.state.cursorCol);
|
|
@@ -1309,8 +1405,8 @@ export class Editor {
|
|
|
1309
1405
|
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
|
1310
1406
|
this.tryTriggerAutocomplete();
|
|
1311
1407
|
}
|
|
1312
|
-
//
|
|
1313
|
-
else if (
|
|
1408
|
+
// Symbol-based completion context like @, #, or provider triggers
|
|
1409
|
+
else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
|
|
1314
1410
|
this.tryTriggerAutocomplete();
|
|
1315
1411
|
}
|
|
1316
1412
|
}
|
|
@@ -1336,7 +1432,7 @@ export class Editor {
|
|
|
1336
1432
|
}
|
|
1337
1433
|
else {
|
|
1338
1434
|
// Line needs wrapping - use word-aware wrapping
|
|
1339
|
-
const chunks = wordWrapLine(line, width, [...this.segment(line)]);
|
|
1435
|
+
const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]);
|
|
1340
1436
|
for (const chunk of chunks) {
|
|
1341
1437
|
visualLines.push({
|
|
1342
1438
|
logicalLine: i,
|
|
@@ -1349,26 +1445,29 @@ export class Editor {
|
|
|
1349
1445
|
return visualLines;
|
|
1350
1446
|
}
|
|
1351
1447
|
/**
|
|
1352
|
-
* Find the visual line index
|
|
1448
|
+
* Find the visual line index that contains the given logical position.
|
|
1353
1449
|
*/
|
|
1354
|
-
|
|
1450
|
+
findVisualLineAt(visualLines, line, col) {
|
|
1355
1451
|
for (let i = 0; i < visualLines.length; i++) {
|
|
1356
1452
|
const vl = visualLines[i];
|
|
1357
|
-
if (!vl)
|
|
1453
|
+
if (!vl || vl.logicalLine !== line)
|
|
1358
1454
|
continue;
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
return i;
|
|
1366
|
-
}
|
|
1455
|
+
const offset = col - vl.startCol;
|
|
1456
|
+
// Cursor is in this segment if it's within range. For the last
|
|
1457
|
+
// segment of a logical line, cursor can be at length (end position)
|
|
1458
|
+
const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
|
|
1459
|
+
if (offset >= 0 && (offset < vl.length || (isLastSegmentOfLine && offset === vl.length))) {
|
|
1460
|
+
return i;
|
|
1367
1461
|
}
|
|
1368
1462
|
}
|
|
1369
|
-
// Fallback: return last visual line
|
|
1370
1463
|
return visualLines.length - 1;
|
|
1371
1464
|
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Find the visual line index for the current cursor position.
|
|
1467
|
+
*/
|
|
1468
|
+
findCurrentVisualLine(visualLines) {
|
|
1469
|
+
return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol);
|
|
1470
|
+
}
|
|
1372
1471
|
moveCursor(deltaLine, deltaCol) {
|
|
1373
1472
|
this.lastAction = null;
|
|
1374
1473
|
const visualLines = this.buildVisualLineMap(this.lastWidth);
|
|
@@ -1385,7 +1484,7 @@ export class Editor {
|
|
|
1385
1484
|
// Moving right - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1386
1485
|
if (this.state.cursorCol < currentLine.length) {
|
|
1387
1486
|
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
1388
|
-
const graphemes = [...this.segment(afterCursor)];
|
|
1487
|
+
const graphemes = [...this.segment(afterCursor, "grapheme")];
|
|
1389
1488
|
const firstGrapheme = graphemes[0];
|
|
1390
1489
|
this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
|
|
1391
1490
|
}
|
|
@@ -1406,7 +1505,7 @@ export class Editor {
|
|
|
1406
1505
|
// Moving left - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1407
1506
|
if (this.state.cursorCol > 0) {
|
|
1408
1507
|
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1409
|
-
const graphemes = [...this.segment(beforeCursor)];
|
|
1508
|
+
const graphemes = [...this.segment(beforeCursor, "grapheme")];
|
|
1410
1509
|
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1411
1510
|
this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
|
|
1412
1511
|
}
|
|
@@ -1418,6 +1517,17 @@ export class Editor {
|
|
|
1418
1517
|
}
|
|
1419
1518
|
}
|
|
1420
1519
|
}
|
|
1520
|
+
// Keep an open autocomplete picker in sync with the new cursor
|
|
1521
|
+
// position: cursor movement changes the text before the cursor, so a
|
|
1522
|
+
// picker computed for the old position is stale. Re-query so it
|
|
1523
|
+
// refreshes — or closes when the new position yields no suggestions —
|
|
1524
|
+
// mirroring insertCharacter()/handleBackspace(). Without this, arrowing
|
|
1525
|
+
// left from `/cmd ` back into the command name leaves the argument
|
|
1526
|
+
// picker showing against a `/cmd` prefix (and a Tab there would
|
|
1527
|
+
// concatenate the stale suggestion onto the partial command name).
|
|
1528
|
+
if (this.autocompleteState) {
|
|
1529
|
+
this.updateAutocomplete();
|
|
1530
|
+
}
|
|
1421
1531
|
}
|
|
1422
1532
|
/**
|
|
1423
1533
|
* Scroll by a page (direction: -1 for up, 1 for down).
|
|
@@ -1444,40 +1554,10 @@ export class Editor {
|
|
|
1444
1554
|
}
|
|
1445
1555
|
return;
|
|
1446
1556
|
}
|
|
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);
|
|
1557
|
+
this.setCursorCol(findWordBackward(currentLine, this.state.cursorCol, {
|
|
1558
|
+
segment: (text) => this.segment(text, "word"),
|
|
1559
|
+
isAtomicSegment: isPasteMarker,
|
|
1560
|
+
}));
|
|
1481
1561
|
}
|
|
1482
1562
|
/**
|
|
1483
1563
|
* Yank (paste) the most recent kill ring entry at cursor position.
|
|
@@ -1512,7 +1592,7 @@ export class Editor {
|
|
|
1512
1592
|
* Insert text at cursor position (used by yank operations).
|
|
1513
1593
|
*/
|
|
1514
1594
|
insertYankedText(text) {
|
|
1515
|
-
this.
|
|
1595
|
+
this.exitHistoryBrowsing();
|
|
1516
1596
|
const lines = text.split("\n");
|
|
1517
1597
|
if (lines.length === 1) {
|
|
1518
1598
|
// Single line - insert at cursor
|
|
@@ -1584,7 +1664,7 @@ export class Editor {
|
|
|
1584
1664
|
this.undoStack.push(this.state);
|
|
1585
1665
|
}
|
|
1586
1666
|
undo() {
|
|
1587
|
-
this.
|
|
1667
|
+
this.exitHistoryBrowsing();
|
|
1588
1668
|
const snapshot = this.undoStack.pop();
|
|
1589
1669
|
if (!snapshot)
|
|
1590
1670
|
return;
|
|
@@ -1634,41 +1714,10 @@ export class Editor {
|
|
|
1634
1714
|
}
|
|
1635
1715
|
return;
|
|
1636
1716
|
}
|
|
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);
|
|
1717
|
+
this.setCursorCol(findWordForward(currentLine, this.state.cursorCol, {
|
|
1718
|
+
segment: (text) => this.segment(text, "word"),
|
|
1719
|
+
isAtomicSegment: isPasteMarker,
|
|
1720
|
+
}));
|
|
1672
1721
|
}
|
|
1673
1722
|
// Slash menu only allowed on the first line of the editor
|
|
1674
1723
|
isSlashMenuAllowed() {
|
|
@@ -1741,9 +1790,8 @@ export class Editor {
|
|
|
1741
1790
|
if (!this.autocompleteProvider)
|
|
1742
1791
|
return;
|
|
1743
1792
|
if (options.force) {
|
|
1744
|
-
const
|
|
1745
|
-
|
|
1746
|
-
provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
|
|
1793
|
+
const shouldTrigger = !this.autocompleteProvider.shouldTriggerFileCompletion ||
|
|
1794
|
+
this.autocompleteProvider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
|
|
1747
1795
|
if (!shouldTrigger) {
|
|
1748
1796
|
return;
|
|
1749
1797
|
}
|
|
@@ -1777,14 +1825,25 @@ export class Editor {
|
|
|
1777
1825
|
})();
|
|
1778
1826
|
await this.autocompleteRequestTask;
|
|
1779
1827
|
}
|
|
1828
|
+
setAutocompleteTriggerCharacters(triggerCharacters) {
|
|
1829
|
+
const next = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
|
|
1830
|
+
for (const character of triggerCharacters) {
|
|
1831
|
+
if (character.length !== 1 || character === "/" || isWhitespaceChar(character) || next.includes(character)) {
|
|
1832
|
+
continue;
|
|
1833
|
+
}
|
|
1834
|
+
next.push(character);
|
|
1835
|
+
}
|
|
1836
|
+
this.autocompleteTriggerCharacters = next;
|
|
1837
|
+
this.autocompleteTriggerPattern = buildTriggerPattern(next);
|
|
1838
|
+
this.autocompleteDebouncePattern = buildDebouncePattern(next);
|
|
1839
|
+
}
|
|
1780
1840
|
getAutocompleteDebounceMs(options) {
|
|
1781
1841
|
if (options.explicitTab || options.force) {
|
|
1782
1842
|
return 0;
|
|
1783
1843
|
}
|
|
1784
1844
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1785
1845
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1786
|
-
|
|
1787
|
-
return isAttachmentContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
|
|
1846
|
+
return this.autocompleteDebouncePattern.test(textBeforeCursor) ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
|
|
1788
1847
|
}
|
|
1789
1848
|
async runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options) {
|
|
1790
1849
|
if (!this.autocompleteProvider)
|