@draht/tui 2026.3.14 → 2026.3.25-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.map +1 -1
- package/dist/autocomplete.js +49 -10
- package/dist/autocomplete.js.map +1 -1
- package/dist/components/cancellable-loader.d.ts.map +1 -1
- package/dist/components/cancellable-loader.js +3 -3
- package/dist/components/cancellable-loader.js.map +1 -1
- package/dist/components/editor.d.ts +9 -1
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +200 -71
- package/dist/components/editor.js.map +1 -1
- package/dist/components/input.d.ts.map +1 -1
- package/dist/components/input.js +19 -19
- package/dist/components/input.js.map +1 -1
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +25 -16
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/select-list.d.ts +19 -1
- package/dist/components/select-list.d.ts.map +1 -1
- package/dist/components/select-list.js +74 -67
- package/dist/components/select-list.js.map +1 -1
- package/dist/components/settings-list.d.ts.map +1 -1
- package/dist/components/settings-list.js +6 -6
- package/dist/components/settings-list.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/keybindings.d.ts +187 -33
- package/dist/keybindings.d.ts.map +1 -1
- package/dist/keybindings.js +156 -99
- package/dist/keybindings.js.map +1 -1
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +46 -7
- package/dist/keys.js.map +1 -1
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +17 -1
- package/dist/terminal.js.map +1 -1
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +15 -4
- package/dist/tui.js.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +201 -56
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,11 +1,71 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getKeybindings } from "../keybindings.js";
|
|
2
2
|
import { decodeKittyPrintable, matchesKey } from "../keys.js";
|
|
3
3
|
import { KillRing } from "../kill-ring.js";
|
|
4
4
|
import { CURSOR_MARKER } from "../tui.js";
|
|
5
5
|
import { UndoStack } from "../undo-stack.js";
|
|
6
|
-
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
|
|
6
|
+
import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.js";
|
|
7
7
|
import { SelectList } from "./select-list.js";
|
|
8
|
-
const
|
|
8
|
+
const baseSegmenter = getSegmenter();
|
|
9
|
+
/** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */
|
|
10
|
+
const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
|
|
11
|
+
/** Non-global version for single-segment testing. */
|
|
12
|
+
const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/;
|
|
13
|
+
/** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */
|
|
14
|
+
function isPasteMarker(segment) {
|
|
15
|
+
return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A segmenter that wraps Intl.Segmenter and merges graphemes that fall
|
|
19
|
+
* within paste markers into single atomic segments. This makes cursor
|
|
20
|
+
* movement, deletion, word-wrap, etc. treat paste markers as single units.
|
|
21
|
+
*
|
|
22
|
+
* Only markers whose numeric ID exists in `validIds` are merged.
|
|
23
|
+
*/
|
|
24
|
+
function segmentWithMarkers(text, validIds) {
|
|
25
|
+
// Fast path: no paste markers in the text or no valid IDs.
|
|
26
|
+
if (validIds.size === 0 || !text.includes("[paste #")) {
|
|
27
|
+
return baseSegmenter.segment(text);
|
|
28
|
+
}
|
|
29
|
+
// Find all marker spans with valid IDs.
|
|
30
|
+
const markers = [];
|
|
31
|
+
for (const m of text.matchAll(PASTE_MARKER_REGEX)) {
|
|
32
|
+
const id = Number.parseInt(m[1], 10);
|
|
33
|
+
if (!validIds.has(id))
|
|
34
|
+
continue;
|
|
35
|
+
markers.push({ start: m.index, end: m.index + m[0].length });
|
|
36
|
+
}
|
|
37
|
+
if (markers.length === 0) {
|
|
38
|
+
return baseSegmenter.segment(text);
|
|
39
|
+
}
|
|
40
|
+
// Build merged segment list.
|
|
41
|
+
const baseSegments = baseSegmenter.segment(text);
|
|
42
|
+
const result = [];
|
|
43
|
+
let markerIdx = 0;
|
|
44
|
+
for (const seg of baseSegments) {
|
|
45
|
+
// Skip past markers that are entirely before this segment.
|
|
46
|
+
while (markerIdx < markers.length && markers[markerIdx].end <= seg.index) {
|
|
47
|
+
markerIdx++;
|
|
48
|
+
}
|
|
49
|
+
const marker = markerIdx < markers.length ? markers[markerIdx] : null;
|
|
50
|
+
if (marker && seg.index >= marker.start && seg.index < marker.end) {
|
|
51
|
+
// This segment falls inside a marker.
|
|
52
|
+
// If this is the first segment of the marker, emit a merged segment.
|
|
53
|
+
if (seg.index === marker.start) {
|
|
54
|
+
const markerText = text.slice(marker.start, marker.end);
|
|
55
|
+
result.push({
|
|
56
|
+
segment: markerText,
|
|
57
|
+
index: marker.start,
|
|
58
|
+
input: text,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
// Otherwise skip (already merged into the first segment).
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
result.push(seg);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
9
69
|
/**
|
|
10
70
|
* Split a line into word-wrapped chunks.
|
|
11
71
|
* Wraps at word boundaries when possible, falling back to character-level
|
|
@@ -13,9 +73,11 @@ const segmenter = getSegmenter();
|
|
|
13
73
|
*
|
|
14
74
|
* @param line - The text line to wrap
|
|
15
75
|
* @param maxWidth - Maximum visible width per chunk
|
|
76
|
+
* @param preSegmented - Optional pre-segmented graphemes (e.g. with paste-marker awareness).
|
|
77
|
+
* When omitted the default Intl.Segmenter is used.
|
|
16
78
|
* @returns Array of chunks with text and position information
|
|
17
79
|
*/
|
|
18
|
-
export function wordWrapLine(line, maxWidth) {
|
|
80
|
+
export function wordWrapLine(line, maxWidth, preSegmented) {
|
|
19
81
|
if (!line || maxWidth <= 0) {
|
|
20
82
|
return [{ text: "", startIndex: 0, endIndex: 0 }];
|
|
21
83
|
}
|
|
@@ -24,7 +86,7 @@ export function wordWrapLine(line, maxWidth) {
|
|
|
24
86
|
return [{ text: line, startIndex: 0, endIndex: line.length }];
|
|
25
87
|
}
|
|
26
88
|
const chunks = [];
|
|
27
|
-
const segments = [...
|
|
89
|
+
const segments = preSegmented ?? [...baseSegmenter.segment(line)];
|
|
28
90
|
let currentWidth = 0;
|
|
29
91
|
let chunkStart = 0;
|
|
30
92
|
// Wrap opportunity: the position after the last whitespace before a non-whitespace
|
|
@@ -36,7 +98,7 @@ export function wordWrapLine(line, maxWidth) {
|
|
|
36
98
|
const grapheme = seg.segment;
|
|
37
99
|
const gWidth = visibleWidth(grapheme);
|
|
38
100
|
const charIndex = seg.index;
|
|
39
|
-
const isWs = isWhitespaceChar(grapheme);
|
|
101
|
+
const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme);
|
|
40
102
|
// Overflow check before advancing.
|
|
41
103
|
if (currentWidth + gWidth > maxWidth) {
|
|
42
104
|
if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) {
|
|
@@ -58,13 +120,29 @@ export function wordWrapLine(line, maxWidth) {
|
|
|
58
120
|
}
|
|
59
121
|
wrapOppIndex = -1;
|
|
60
122
|
}
|
|
123
|
+
if (gWidth > maxWidth) {
|
|
124
|
+
// Single atomic segment wider than maxWidth (e.g. paste marker
|
|
125
|
+
// in a narrow terminal). Re-wrap it at grapheme granularity.
|
|
126
|
+
// The segment remains logically atomic for cursor
|
|
127
|
+
// movement / editing — the split is purely visual for word-wrap layout.
|
|
128
|
+
const subChunks = wordWrapLine(grapheme, maxWidth);
|
|
129
|
+
for (let j = 0; j < subChunks.length - 1; j++) {
|
|
130
|
+
const sc = subChunks[j];
|
|
131
|
+
chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex });
|
|
132
|
+
}
|
|
133
|
+
const last = subChunks[subChunks.length - 1];
|
|
134
|
+
chunkStart = charIndex + last.startIndex;
|
|
135
|
+
currentWidth = visibleWidth(last.text);
|
|
136
|
+
wrapOppIndex = -1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
61
139
|
// Advance.
|
|
62
140
|
currentWidth += gWidth;
|
|
63
141
|
// Record wrap opportunity: whitespace followed by non-whitespace.
|
|
64
142
|
// Multiple spaces join (no break between them); the break point is
|
|
65
143
|
// after the last space before the next word.
|
|
66
144
|
const next = segments[i + 1];
|
|
67
|
-
if (isWs && next && !isWhitespaceChar(next.segment)) {
|
|
145
|
+
if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
|
|
68
146
|
wrapOppIndex = next.index;
|
|
69
147
|
wrapOppWidth = currentWidth;
|
|
70
148
|
}
|
|
@@ -73,6 +151,10 @@ export function wordWrapLine(line, maxWidth) {
|
|
|
73
151
|
chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length });
|
|
74
152
|
return chunks;
|
|
75
153
|
}
|
|
154
|
+
const SLASH_COMMAND_SELECT_LIST_LAYOUT = {
|
|
155
|
+
minPrimaryColumnWidth: 12,
|
|
156
|
+
maxPrimaryColumnWidth: 32,
|
|
157
|
+
};
|
|
76
158
|
export class Editor {
|
|
77
159
|
state = {
|
|
78
160
|
lines: [""],
|
|
@@ -126,6 +208,14 @@ export class Editor {
|
|
|
126
208
|
const maxVisible = options.autocompleteMaxVisible ?? 5;
|
|
127
209
|
this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
|
|
128
210
|
}
|
|
211
|
+
/** Set of currently valid paste IDs, for marker-aware segmentation. */
|
|
212
|
+
validPasteIds() {
|
|
213
|
+
return new Set(this.pastes.keys());
|
|
214
|
+
}
|
|
215
|
+
/** Segment text with paste-marker awareness, only merging markers with valid IDs. */
|
|
216
|
+
segment(text) {
|
|
217
|
+
return segmentWithMarkers(text, this.validPasteIds());
|
|
218
|
+
}
|
|
129
219
|
getPaddingX() {
|
|
130
220
|
return this.paddingX;
|
|
131
221
|
}
|
|
@@ -252,7 +342,12 @@ export class Editor {
|
|
|
252
342
|
if (this.scrollOffset > 0) {
|
|
253
343
|
const indicator = `─── ↑ ${this.scrollOffset} more `;
|
|
254
344
|
const remaining = width - visibleWidth(indicator);
|
|
255
|
-
|
|
345
|
+
if (remaining >= 0) {
|
|
346
|
+
result.push(this.borderColor(indicator + "─".repeat(remaining)));
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
result.push(this.borderColor(truncateToWidth(indicator, width)));
|
|
350
|
+
}
|
|
256
351
|
}
|
|
257
352
|
else {
|
|
258
353
|
result.push(horizontal.repeat(width));
|
|
@@ -273,7 +368,7 @@ export class Editor {
|
|
|
273
368
|
if (after.length > 0) {
|
|
274
369
|
// Cursor is on a character (grapheme) - replace it with highlighted version
|
|
275
370
|
// Get the first grapheme from 'after'
|
|
276
|
-
const afterGraphemes = [...
|
|
371
|
+
const afterGraphemes = [...this.segment(after)];
|
|
277
372
|
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
|
278
373
|
const restAfter = after.slice(firstGrapheme.length);
|
|
279
374
|
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
|
@@ -319,11 +414,11 @@ export class Editor {
|
|
|
319
414
|
return result;
|
|
320
415
|
}
|
|
321
416
|
handleInput(data) {
|
|
322
|
-
const kb =
|
|
417
|
+
const kb = getKeybindings();
|
|
323
418
|
// Handle character jump mode (awaiting next character to jump to)
|
|
324
419
|
if (this.jumpMode !== null) {
|
|
325
420
|
// Cancel if the hotkey is pressed again
|
|
326
|
-
if (kb.matches(data, "jumpForward") || kb.matches(data, "jumpBackward")) {
|
|
421
|
+
if (kb.matches(data, "tui.editor.jumpForward") || kb.matches(data, "tui.editor.jumpBackward")) {
|
|
327
422
|
this.jumpMode = null;
|
|
328
423
|
return;
|
|
329
424
|
}
|
|
@@ -362,25 +457,25 @@ export class Editor {
|
|
|
362
457
|
return;
|
|
363
458
|
}
|
|
364
459
|
// Ctrl+C - let parent handle (exit/clear)
|
|
365
|
-
if (kb.matches(data, "copy")) {
|
|
460
|
+
if (kb.matches(data, "tui.input.copy")) {
|
|
366
461
|
return;
|
|
367
462
|
}
|
|
368
463
|
// Undo
|
|
369
|
-
if (kb.matches(data, "undo")) {
|
|
464
|
+
if (kb.matches(data, "tui.editor.undo")) {
|
|
370
465
|
this.undo();
|
|
371
466
|
return;
|
|
372
467
|
}
|
|
373
468
|
// Handle autocomplete mode
|
|
374
469
|
if (this.autocompleteState && this.autocompleteList) {
|
|
375
|
-
if (kb.matches(data, "
|
|
470
|
+
if (kb.matches(data, "tui.select.cancel")) {
|
|
376
471
|
this.cancelAutocomplete();
|
|
377
472
|
return;
|
|
378
473
|
}
|
|
379
|
-
if (kb.matches(data, "
|
|
474
|
+
if (kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")) {
|
|
380
475
|
this.autocompleteList.handleInput(data);
|
|
381
476
|
return;
|
|
382
477
|
}
|
|
383
|
-
if (kb.matches(data, "tab")) {
|
|
478
|
+
if (kb.matches(data, "tui.input.tab")) {
|
|
384
479
|
const selected = this.autocompleteList.getSelectedItem();
|
|
385
480
|
if (selected && this.autocompleteProvider) {
|
|
386
481
|
const shouldChainSlashArgumentAutocomplete = this.shouldChainSlashArgumentAutocompleteOnTabSelection();
|
|
@@ -399,7 +494,7 @@ export class Editor {
|
|
|
399
494
|
}
|
|
400
495
|
return;
|
|
401
496
|
}
|
|
402
|
-
if (kb.matches(data, "
|
|
497
|
+
if (kb.matches(data, "tui.select.confirm")) {
|
|
403
498
|
const selected = this.autocompleteList.getSelectedItem();
|
|
404
499
|
if (selected && this.autocompleteProvider) {
|
|
405
500
|
this.pushUndoSnapshot();
|
|
@@ -422,63 +517,63 @@ export class Editor {
|
|
|
422
517
|
}
|
|
423
518
|
}
|
|
424
519
|
// Tab - trigger completion
|
|
425
|
-
if (kb.matches(data, "tab") && !this.autocompleteState) {
|
|
520
|
+
if (kb.matches(data, "tui.input.tab") && !this.autocompleteState) {
|
|
426
521
|
this.handleTabCompletion();
|
|
427
522
|
return;
|
|
428
523
|
}
|
|
429
524
|
// Deletion actions
|
|
430
|
-
if (kb.matches(data, "deleteToLineEnd")) {
|
|
525
|
+
if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
|
|
431
526
|
this.deleteToEndOfLine();
|
|
432
527
|
return;
|
|
433
528
|
}
|
|
434
|
-
if (kb.matches(data, "deleteToLineStart")) {
|
|
529
|
+
if (kb.matches(data, "tui.editor.deleteToLineStart")) {
|
|
435
530
|
this.deleteToStartOfLine();
|
|
436
531
|
return;
|
|
437
532
|
}
|
|
438
|
-
if (kb.matches(data, "deleteWordBackward")) {
|
|
533
|
+
if (kb.matches(data, "tui.editor.deleteWordBackward")) {
|
|
439
534
|
this.deleteWordBackwards();
|
|
440
535
|
return;
|
|
441
536
|
}
|
|
442
|
-
if (kb.matches(data, "deleteWordForward")) {
|
|
537
|
+
if (kb.matches(data, "tui.editor.deleteWordForward")) {
|
|
443
538
|
this.deleteWordForward();
|
|
444
539
|
return;
|
|
445
540
|
}
|
|
446
|
-
if (kb.matches(data, "deleteCharBackward") || matchesKey(data, "shift+backspace")) {
|
|
541
|
+
if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
|
|
447
542
|
this.handleBackspace();
|
|
448
543
|
return;
|
|
449
544
|
}
|
|
450
|
-
if (kb.matches(data, "deleteCharForward") || matchesKey(data, "shift+delete")) {
|
|
545
|
+
if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
|
|
451
546
|
this.handleForwardDelete();
|
|
452
547
|
return;
|
|
453
548
|
}
|
|
454
549
|
// Kill ring actions
|
|
455
|
-
if (kb.matches(data, "yank")) {
|
|
550
|
+
if (kb.matches(data, "tui.editor.yank")) {
|
|
456
551
|
this.yank();
|
|
457
552
|
return;
|
|
458
553
|
}
|
|
459
|
-
if (kb.matches(data, "yankPop")) {
|
|
554
|
+
if (kb.matches(data, "tui.editor.yankPop")) {
|
|
460
555
|
this.yankPop();
|
|
461
556
|
return;
|
|
462
557
|
}
|
|
463
558
|
// Cursor movement actions
|
|
464
|
-
if (kb.matches(data, "cursorLineStart")) {
|
|
559
|
+
if (kb.matches(data, "tui.editor.cursorLineStart")) {
|
|
465
560
|
this.moveToLineStart();
|
|
466
561
|
return;
|
|
467
562
|
}
|
|
468
|
-
if (kb.matches(data, "cursorLineEnd")) {
|
|
563
|
+
if (kb.matches(data, "tui.editor.cursorLineEnd")) {
|
|
469
564
|
this.moveToLineEnd();
|
|
470
565
|
return;
|
|
471
566
|
}
|
|
472
|
-
if (kb.matches(data, "cursorWordLeft")) {
|
|
567
|
+
if (kb.matches(data, "tui.editor.cursorWordLeft")) {
|
|
473
568
|
this.moveWordBackwards();
|
|
474
569
|
return;
|
|
475
570
|
}
|
|
476
|
-
if (kb.matches(data, "cursorWordRight")) {
|
|
571
|
+
if (kb.matches(data, "tui.editor.cursorWordRight")) {
|
|
477
572
|
this.moveWordForwards();
|
|
478
573
|
return;
|
|
479
574
|
}
|
|
480
575
|
// New line
|
|
481
|
-
if (kb.matches(data, "newLine") ||
|
|
576
|
+
if (kb.matches(data, "tui.input.newLine") ||
|
|
482
577
|
(data.charCodeAt(0) === 10 && data.length > 1) ||
|
|
483
578
|
data === "\x1b\r" ||
|
|
484
579
|
data === "\x1b[13;2~" ||
|
|
@@ -493,7 +588,7 @@ export class Editor {
|
|
|
493
588
|
return;
|
|
494
589
|
}
|
|
495
590
|
// Submit (Enter)
|
|
496
|
-
if (kb.matches(data, "submit")) {
|
|
591
|
+
if (kb.matches(data, "tui.input.submit")) {
|
|
497
592
|
if (this.disableSubmit)
|
|
498
593
|
return;
|
|
499
594
|
// Workaround for terminals without Shift+Enter support:
|
|
@@ -508,7 +603,7 @@ export class Editor {
|
|
|
508
603
|
return;
|
|
509
604
|
}
|
|
510
605
|
// Arrow key navigation (with history support)
|
|
511
|
-
if (kb.matches(data, "cursorUp")) {
|
|
606
|
+
if (kb.matches(data, "tui.editor.cursorUp")) {
|
|
512
607
|
if (this.isEditorEmpty()) {
|
|
513
608
|
this.navigateHistory(-1);
|
|
514
609
|
}
|
|
@@ -524,7 +619,7 @@ export class Editor {
|
|
|
524
619
|
}
|
|
525
620
|
return;
|
|
526
621
|
}
|
|
527
|
-
if (kb.matches(data, "cursorDown")) {
|
|
622
|
+
if (kb.matches(data, "tui.editor.cursorDown")) {
|
|
528
623
|
if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
|
|
529
624
|
this.navigateHistory(1);
|
|
530
625
|
}
|
|
@@ -537,29 +632,29 @@ export class Editor {
|
|
|
537
632
|
}
|
|
538
633
|
return;
|
|
539
634
|
}
|
|
540
|
-
if (kb.matches(data, "cursorRight")) {
|
|
635
|
+
if (kb.matches(data, "tui.editor.cursorRight")) {
|
|
541
636
|
this.moveCursor(0, 1);
|
|
542
637
|
return;
|
|
543
638
|
}
|
|
544
|
-
if (kb.matches(data, "cursorLeft")) {
|
|
639
|
+
if (kb.matches(data, "tui.editor.cursorLeft")) {
|
|
545
640
|
this.moveCursor(0, -1);
|
|
546
641
|
return;
|
|
547
642
|
}
|
|
548
643
|
// Page up/down - scroll by page and move cursor
|
|
549
|
-
if (kb.matches(data, "pageUp")) {
|
|
644
|
+
if (kb.matches(data, "tui.editor.pageUp")) {
|
|
550
645
|
this.pageScroll(-1);
|
|
551
646
|
return;
|
|
552
647
|
}
|
|
553
|
-
if (kb.matches(data, "pageDown")) {
|
|
648
|
+
if (kb.matches(data, "tui.editor.pageDown")) {
|
|
554
649
|
this.pageScroll(1);
|
|
555
650
|
return;
|
|
556
651
|
}
|
|
557
652
|
// Character jump mode triggers
|
|
558
|
-
if (kb.matches(data, "jumpForward")) {
|
|
653
|
+
if (kb.matches(data, "tui.editor.jumpForward")) {
|
|
559
654
|
this.jumpMode = "forward";
|
|
560
655
|
return;
|
|
561
656
|
}
|
|
562
|
-
if (kb.matches(data, "jumpBackward")) {
|
|
657
|
+
if (kb.matches(data, "tui.editor.jumpBackward")) {
|
|
563
658
|
this.jumpMode = "backward";
|
|
564
659
|
return;
|
|
565
660
|
}
|
|
@@ -612,7 +707,7 @@ export class Editor {
|
|
|
612
707
|
}
|
|
613
708
|
else {
|
|
614
709
|
// Line needs wrapping - use word-aware wrapping
|
|
615
|
-
const chunks = wordWrapLine(line, contentWidth);
|
|
710
|
+
const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]);
|
|
616
711
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
617
712
|
const chunk = chunks[chunkIndex];
|
|
618
713
|
if (!chunk)
|
|
@@ -664,17 +759,20 @@ export class Editor {
|
|
|
664
759
|
getText() {
|
|
665
760
|
return this.state.lines.join("\n");
|
|
666
761
|
}
|
|
762
|
+
expandPasteMarkers(text) {
|
|
763
|
+
let result = text;
|
|
764
|
+
for (const [pasteId, pasteContent] of this.pastes) {
|
|
765
|
+
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
|
|
766
|
+
result = result.replace(markerRegex, () => pasteContent);
|
|
767
|
+
}
|
|
768
|
+
return result;
|
|
769
|
+
}
|
|
667
770
|
/**
|
|
668
771
|
* Get text with paste markers expanded to their actual content.
|
|
669
772
|
* Use this when you need the full content (e.g., for external editor).
|
|
670
773
|
*/
|
|
671
774
|
getExpandedText() {
|
|
672
|
-
|
|
673
|
-
for (const [pasteId, pasteContent] of this.pastes) {
|
|
674
|
-
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
|
|
675
|
-
result = result.replace(markerRegex, pasteContent);
|
|
676
|
-
}
|
|
677
|
-
return result;
|
|
775
|
+
return this.expandPasteMarkers(this.state.lines.join("\n"));
|
|
678
776
|
}
|
|
679
777
|
getLines() {
|
|
680
778
|
return [...this.state.lines];
|
|
@@ -875,7 +973,7 @@ export class Editor {
|
|
|
875
973
|
return false;
|
|
876
974
|
if (!matchesKey(data, "enter"))
|
|
877
975
|
return false;
|
|
878
|
-
const submitKeys = kb.getKeys("submit");
|
|
976
|
+
const submitKeys = kb.getKeys("tui.input.submit");
|
|
879
977
|
const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
|
|
880
978
|
if (!hasShiftEnter)
|
|
881
979
|
return false;
|
|
@@ -883,11 +981,7 @@ export class Editor {
|
|
|
883
981
|
return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\";
|
|
884
982
|
}
|
|
885
983
|
submitValue() {
|
|
886
|
-
|
|
887
|
-
for (const [pasteId, pasteContent] of this.pastes) {
|
|
888
|
-
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
|
|
889
|
-
result = result.replace(markerRegex, pasteContent);
|
|
890
|
-
}
|
|
984
|
+
const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim();
|
|
891
985
|
this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
|
892
986
|
this.pastes.clear();
|
|
893
987
|
this.pasteCounter = 0;
|
|
@@ -909,7 +1003,7 @@ export class Editor {
|
|
|
909
1003
|
const line = this.state.lines[this.state.cursorLine] || "";
|
|
910
1004
|
const beforeCursor = line.slice(0, this.state.cursorCol);
|
|
911
1005
|
// Find the last grapheme in the text before cursor
|
|
912
|
-
const graphemes = [...
|
|
1006
|
+
const graphemes = [...this.segment(beforeCursor)];
|
|
913
1007
|
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
914
1008
|
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
|
915
1009
|
const before = line.slice(0, this.state.cursorCol - graphemeLength);
|
|
@@ -978,6 +1072,21 @@ export class Editor {
|
|
|
978
1072
|
const targetCol = targetVL.startCol + moveToVisualCol;
|
|
979
1073
|
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
|
|
980
1074
|
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
|
|
1075
|
+
// Snap cursor to atomic segment boundary (e.g. paste markers)
|
|
1076
|
+
// so the cursor never lands in the middle of a multi-grapheme unit.
|
|
1077
|
+
// Single-grapheme segments don't need snapping.
|
|
1078
|
+
const segments = [...this.segment(logicalLine)];
|
|
1079
|
+
for (const seg of segments) {
|
|
1080
|
+
if (seg.index > this.state.cursorCol)
|
|
1081
|
+
break;
|
|
1082
|
+
if (seg.segment.length <= 1)
|
|
1083
|
+
continue;
|
|
1084
|
+
if (this.state.cursorCol < seg.index + seg.segment.length) {
|
|
1085
|
+
// jump to the start of the segment when moving up, to the end when moving down.
|
|
1086
|
+
this.state.cursorCol = currentVisualLine > targetVisualLine ? seg.index : seg.index + seg.segment.length;
|
|
1087
|
+
break;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
981
1090
|
}
|
|
982
1091
|
}
|
|
983
1092
|
/**
|
|
@@ -1164,7 +1273,7 @@ export class Editor {
|
|
|
1164
1273
|
// Delete grapheme at cursor position (handles emojis, combining characters, etc.)
|
|
1165
1274
|
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
1166
1275
|
// Find the first grapheme at cursor
|
|
1167
|
-
const graphemes = [...
|
|
1276
|
+
const graphemes = [...this.segment(afterCursor)];
|
|
1168
1277
|
const firstGrapheme = graphemes[0];
|
|
1169
1278
|
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
|
1170
1279
|
const before = currentLine.slice(0, this.state.cursorCol);
|
|
@@ -1219,7 +1328,7 @@ export class Editor {
|
|
|
1219
1328
|
}
|
|
1220
1329
|
else {
|
|
1221
1330
|
// Line needs wrapping - use word-aware wrapping
|
|
1222
|
-
const chunks = wordWrapLine(line, width);
|
|
1331
|
+
const chunks = wordWrapLine(line, width, [...this.segment(line)]);
|
|
1223
1332
|
for (const chunk of chunks) {
|
|
1224
1333
|
visualLines.push({
|
|
1225
1334
|
logicalLine: i,
|
|
@@ -1268,7 +1377,7 @@ export class Editor {
|
|
|
1268
1377
|
// Moving right - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1269
1378
|
if (this.state.cursorCol < currentLine.length) {
|
|
1270
1379
|
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
1271
|
-
const graphemes = [...
|
|
1380
|
+
const graphemes = [...this.segment(afterCursor)];
|
|
1272
1381
|
const firstGrapheme = graphemes[0];
|
|
1273
1382
|
this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
|
|
1274
1383
|
}
|
|
@@ -1289,7 +1398,7 @@ export class Editor {
|
|
|
1289
1398
|
// Moving left - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1290
1399
|
if (this.state.cursorCol > 0) {
|
|
1291
1400
|
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1292
|
-
const graphemes = [...
|
|
1401
|
+
const graphemes = [...this.segment(beforeCursor)];
|
|
1293
1402
|
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1294
1403
|
this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
|
|
1295
1404
|
}
|
|
@@ -1328,17 +1437,25 @@ export class Editor {
|
|
|
1328
1437
|
return;
|
|
1329
1438
|
}
|
|
1330
1439
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1331
|
-
const graphemes = [...
|
|
1440
|
+
const graphemes = [...this.segment(textBeforeCursor)];
|
|
1332
1441
|
let newCol = this.state.cursorCol;
|
|
1333
1442
|
// Skip trailing whitespace
|
|
1334
|
-
while (graphemes.length > 0 &&
|
|
1443
|
+
while (graphemes.length > 0 &&
|
|
1444
|
+
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1445
|
+
isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1335
1446
|
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1336
1447
|
}
|
|
1337
1448
|
if (graphemes.length > 0) {
|
|
1338
1449
|
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
|
|
1339
|
-
if (
|
|
1450
|
+
if (isPasteMarker(lastGrapheme)) {
|
|
1451
|
+
// Paste marker is a single atomic word
|
|
1452
|
+
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1453
|
+
}
|
|
1454
|
+
else if (isPunctuationChar(lastGrapheme)) {
|
|
1340
1455
|
// Skip punctuation run
|
|
1341
|
-
while (graphemes.length > 0 &&
|
|
1456
|
+
while (graphemes.length > 0 &&
|
|
1457
|
+
isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1458
|
+
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1342
1459
|
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1343
1460
|
}
|
|
1344
1461
|
}
|
|
@@ -1346,7 +1463,8 @@ export class Editor {
|
|
|
1346
1463
|
// Skip word run
|
|
1347
1464
|
while (graphemes.length > 0 &&
|
|
1348
1465
|
!isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1349
|
-
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
|
|
1466
|
+
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1467
|
+
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1350
1468
|
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1351
1469
|
}
|
|
1352
1470
|
}
|
|
@@ -1509,27 +1627,34 @@ export class Editor {
|
|
|
1509
1627
|
return;
|
|
1510
1628
|
}
|
|
1511
1629
|
const textAfterCursor = currentLine.slice(this.state.cursorCol);
|
|
1512
|
-
const segments =
|
|
1630
|
+
const segments = this.segment(textAfterCursor);
|
|
1513
1631
|
const iterator = segments[Symbol.iterator]();
|
|
1514
1632
|
let next = iterator.next();
|
|
1515
1633
|
let newCol = this.state.cursorCol;
|
|
1516
1634
|
// Skip leading whitespace
|
|
1517
|
-
while (!next.done && isWhitespaceChar(next.value.segment)) {
|
|
1635
|
+
while (!next.done && !isPasteMarker(next.value.segment) && isWhitespaceChar(next.value.segment)) {
|
|
1518
1636
|
newCol += next.value.segment.length;
|
|
1519
1637
|
next = iterator.next();
|
|
1520
1638
|
}
|
|
1521
1639
|
if (!next.done) {
|
|
1522
1640
|
const firstGrapheme = next.value.segment;
|
|
1523
|
-
if (
|
|
1641
|
+
if (isPasteMarker(firstGrapheme)) {
|
|
1642
|
+
// Paste marker is a single atomic word
|
|
1643
|
+
newCol += firstGrapheme.length;
|
|
1644
|
+
}
|
|
1645
|
+
else if (isPunctuationChar(firstGrapheme)) {
|
|
1524
1646
|
// Skip punctuation run
|
|
1525
|
-
while (!next.done && isPunctuationChar(next.value.segment)) {
|
|
1647
|
+
while (!next.done && isPunctuationChar(next.value.segment) && !isPasteMarker(next.value.segment)) {
|
|
1526
1648
|
newCol += next.value.segment.length;
|
|
1527
1649
|
next = iterator.next();
|
|
1528
1650
|
}
|
|
1529
1651
|
}
|
|
1530
1652
|
else {
|
|
1531
1653
|
// Skip word run
|
|
1532
|
-
while (!next.done &&
|
|
1654
|
+
while (!next.done &&
|
|
1655
|
+
!isWhitespaceChar(next.value.segment) &&
|
|
1656
|
+
!isPunctuationChar(next.value.segment) &&
|
|
1657
|
+
!isPasteMarker(next.value.segment)) {
|
|
1533
1658
|
newCol += next.value.segment.length;
|
|
1534
1659
|
next = iterator.next();
|
|
1535
1660
|
}
|
|
@@ -1595,6 +1720,10 @@ export class Editor {
|
|
|
1595
1720
|
}
|
|
1596
1721
|
return firstPrefixIndex;
|
|
1597
1722
|
}
|
|
1723
|
+
createAutocompleteList(prefix, items) {
|
|
1724
|
+
const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined;
|
|
1725
|
+
return new SelectList(items, this.autocompleteMaxVisible, this.theme.selectList, layout);
|
|
1726
|
+
}
|
|
1598
1727
|
tryTriggerAutocomplete(explicitTab = false) {
|
|
1599
1728
|
if (!this.autocompleteProvider)
|
|
1600
1729
|
return;
|
|
@@ -1610,7 +1739,7 @@ export class Editor {
|
|
|
1610
1739
|
const suggestions = this.autocompleteProvider.getSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol);
|
|
1611
1740
|
if (suggestions && suggestions.items.length > 0) {
|
|
1612
1741
|
this.autocompletePrefix = suggestions.prefix;
|
|
1613
|
-
this.autocompleteList =
|
|
1742
|
+
this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
1614
1743
|
// If typed prefix exactly matches one of the suggestions, select that item
|
|
1615
1744
|
const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
|
|
1616
1745
|
if (bestMatchIndex >= 0) {
|
|
@@ -1668,7 +1797,7 @@ export class Editor {
|
|
|
1668
1797
|
return;
|
|
1669
1798
|
}
|
|
1670
1799
|
this.autocompletePrefix = suggestions.prefix;
|
|
1671
|
-
this.autocompleteList =
|
|
1800
|
+
this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
1672
1801
|
// If typed prefix exactly matches one of the suggestions, select that item
|
|
1673
1802
|
const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
|
|
1674
1803
|
if (bestMatchIndex >= 0) {
|
|
@@ -1699,7 +1828,7 @@ export class Editor {
|
|
|
1699
1828
|
if (suggestions && suggestions.items.length > 0) {
|
|
1700
1829
|
this.autocompletePrefix = suggestions.prefix;
|
|
1701
1830
|
// Always create new SelectList to ensure update
|
|
1702
|
-
this.autocompleteList =
|
|
1831
|
+
this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
1703
1832
|
// If typed prefix exactly matches one of the suggestions, select that item
|
|
1704
1833
|
const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
|
|
1705
1834
|
if (bestMatchIndex >= 0) {
|