@draht/tui 2026.3.14 → 2026.3.25

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.
Files changed (44) hide show
  1. package/dist/autocomplete.d.ts.map +1 -1
  2. package/dist/autocomplete.js +49 -10
  3. package/dist/autocomplete.js.map +1 -1
  4. package/dist/components/cancellable-loader.d.ts.map +1 -1
  5. package/dist/components/cancellable-loader.js +3 -3
  6. package/dist/components/cancellable-loader.js.map +1 -1
  7. package/dist/components/editor.d.ts +9 -1
  8. package/dist/components/editor.d.ts.map +1 -1
  9. package/dist/components/editor.js +200 -71
  10. package/dist/components/editor.js.map +1 -1
  11. package/dist/components/input.d.ts.map +1 -1
  12. package/dist/components/input.js +19 -19
  13. package/dist/components/input.js.map +1 -1
  14. package/dist/components/markdown.d.ts.map +1 -1
  15. package/dist/components/markdown.js +25 -16
  16. package/dist/components/markdown.js.map +1 -1
  17. package/dist/components/select-list.d.ts +19 -1
  18. package/dist/components/select-list.d.ts.map +1 -1
  19. package/dist/components/select-list.js +74 -67
  20. package/dist/components/select-list.js.map +1 -1
  21. package/dist/components/settings-list.d.ts.map +1 -1
  22. package/dist/components/settings-list.js +6 -6
  23. package/dist/components/settings-list.js.map +1 -1
  24. package/dist/index.d.ts +2 -2
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/keybindings.d.ts +187 -33
  29. package/dist/keybindings.d.ts.map +1 -1
  30. package/dist/keybindings.js +156 -99
  31. package/dist/keybindings.js.map +1 -1
  32. package/dist/keys.d.ts.map +1 -1
  33. package/dist/keys.js +46 -7
  34. package/dist/keys.js.map +1 -1
  35. package/dist/terminal.d.ts.map +1 -1
  36. package/dist/terminal.js +17 -1
  37. package/dist/terminal.js.map +1 -1
  38. package/dist/tui.d.ts.map +1 -1
  39. package/dist/tui.js +15 -4
  40. package/dist/tui.js.map +1 -1
  41. package/dist/utils.d.ts.map +1 -1
  42. package/dist/utils.js +201 -56
  43. package/dist/utils.js.map +1 -1
  44. package/package.json +1 -1
@@ -1,11 +1,71 @@
1
- import { getEditorKeybindings } from "../keybindings.js";
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 segmenter = getSegmenter();
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 = [...segmenter.segment(line)];
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
- result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
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 = [...segmenter.segment(after)];
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 = getEditorKeybindings();
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, "selectCancel")) {
470
+ if (kb.matches(data, "tui.select.cancel")) {
376
471
  this.cancelAutocomplete();
377
472
  return;
378
473
  }
379
- if (kb.matches(data, "selectUp") || kb.matches(data, "selectDown")) {
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, "selectConfirm")) {
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
- let result = this.state.lines.join("\n");
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
- let result = this.state.lines.join("\n").trim();
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 = [...segmenter.segment(beforeCursor)];
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 = [...segmenter.segment(afterCursor)];
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 = [...segmenter.segment(afterCursor)];
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 = [...segmenter.segment(beforeCursor)];
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 = [...segmenter.segment(textBeforeCursor)];
1440
+ const graphemes = [...this.segment(textBeforeCursor)];
1332
1441
  let newCol = this.state.cursorCol;
1333
1442
  // Skip trailing whitespace
1334
- while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
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 (isPunctuationChar(lastGrapheme)) {
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 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
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 = segmenter.segment(textAfterCursor);
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 (isPunctuationChar(firstGrapheme)) {
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 && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
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 = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
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 = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
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 = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
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) {