@mariozechner/pi-tui 0.57.1 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,9 +3,69 @@ 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,30 +98,51 @@ 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
- if (wrapOppIndex >= 0) {
43
- // Backtrack to last wrap opportunity.
104
+ if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) {
105
+ // Backtrack to last wrap opportunity (the remaining content
106
+ // plus the current grapheme still fits within maxWidth).
44
107
  chunks.push({ text: line.slice(chunkStart, wrapOppIndex), startIndex: chunkStart, endIndex: wrapOppIndex });
45
108
  chunkStart = wrapOppIndex;
46
109
  currentWidth -= wrapOppWidth;
47
110
  }
48
111
  else if (chunkStart < charIndex) {
49
- // No wrap opportunity: force-break at current position.
112
+ // No viable wrap opportunity: force-break at current position.
113
+ // This also handles the case where backtracking to a word
114
+ // boundary wouldn't help because the remaining content plus
115
+ // the current grapheme (e.g. a wide character) still exceeds
116
+ // maxWidth.
50
117
  chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex });
51
118
  chunkStart = charIndex;
52
119
  currentWidth = 0;
53
120
  }
54
121
  wrapOppIndex = -1;
55
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
+ }
56
139
  // Advance.
57
140
  currentWidth += gWidth;
58
141
  // Record wrap opportunity: whitespace followed by non-whitespace.
59
142
  // Multiple spaces join (no break between them); the break point is
60
143
  // after the last space before the next word.
61
144
  const next = segments[i + 1];
62
- if (isWs && next && !isWhitespaceChar(next.segment)) {
145
+ if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
63
146
  wrapOppIndex = next.index;
64
147
  wrapOppWidth = currentWidth;
65
148
  }
@@ -121,6 +204,14 @@ export class Editor {
121
204
  const maxVisible = options.autocompleteMaxVisible ?? 5;
122
205
  this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
123
206
  }
207
+ /** Set of currently valid paste IDs, for marker-aware segmentation. */
208
+ validPasteIds() {
209
+ return new Set(this.pastes.keys());
210
+ }
211
+ /** Segment text with paste-marker awareness, only merging markers with valid IDs. */
212
+ segment(text) {
213
+ return segmentWithMarkers(text, this.validPasteIds());
214
+ }
124
215
  getPaddingX() {
125
216
  return this.paddingX;
126
217
  }
@@ -196,7 +287,7 @@ export class Editor {
196
287
  }
197
288
  /** Internal setText that doesn't reset history state - used by navigateHistory */
198
289
  setTextInternal(text) {
199
- const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
290
+ const lines = text.split("\n");
200
291
  this.state.lines = lines.length === 0 ? [""] : lines;
201
292
  this.state.cursorLine = this.state.lines.length - 1;
202
293
  this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0);
@@ -247,7 +338,12 @@ export class Editor {
247
338
  if (this.scrollOffset > 0) {
248
339
  const indicator = `─── ↑ ${this.scrollOffset} more `;
249
340
  const remaining = width - visibleWidth(indicator);
250
- result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
341
+ if (remaining >= 0) {
342
+ result.push(this.borderColor(indicator + "─".repeat(remaining)));
343
+ }
344
+ else {
345
+ result.push(this.borderColor(truncateToWidth(indicator, width)));
346
+ }
251
347
  }
252
348
  else {
253
349
  result.push(horizontal.repeat(width));
@@ -268,7 +364,7 @@ export class Editor {
268
364
  if (after.length > 0) {
269
365
  // Cursor is on a character (grapheme) - replace it with highlighted version
270
366
  // Get the first grapheme from 'after'
271
- const afterGraphemes = [...segmenter.segment(after)];
367
+ const afterGraphemes = [...this.segment(after)];
272
368
  const firstGrapheme = afterGraphemes[0]?.segment || "";
273
369
  const restAfter = after.slice(firstGrapheme.length);
274
370
  const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
@@ -607,7 +703,7 @@ export class Editor {
607
703
  }
608
704
  else {
609
705
  // Line needs wrapping - use word-aware wrapping
610
- const chunks = wordWrapLine(line, contentWidth);
706
+ const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]);
611
707
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
612
708
  const chunk = chunks[chunkIndex];
613
709
  if (!chunk)
@@ -680,11 +776,12 @@ export class Editor {
680
776
  setText(text) {
681
777
  this.lastAction = null;
682
778
  this.historyIndex = -1; // Exit history browsing mode
779
+ const normalized = this.normalizeText(text);
683
780
  // Push undo snapshot if content differs (makes programmatic changes undoable)
684
- if (this.getText() !== text) {
781
+ if (this.getText() !== normalized) {
685
782
  this.pushUndoSnapshot();
686
783
  }
687
- this.setTextInternal(text);
784
+ this.setTextInternal(normalized);
688
785
  }
689
786
  /**
690
787
  * Insert text at the current cursor position.
@@ -699,6 +796,14 @@ export class Editor {
699
796
  this.historyIndex = -1;
700
797
  this.insertTextAtCursorInternal(text);
701
798
  }
799
+ /**
800
+ * Normalize text for editor storage:
801
+ * - Normalize line endings (\r\n and \r -> \n)
802
+ * - Expand tabs to 4 spaces
803
+ */
804
+ normalizeText(text) {
805
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
806
+ }
702
807
  /**
703
808
  * Internal text insertion at cursor. Handles single and multi-line text.
704
809
  * Does not push undo snapshots or trigger autocomplete - caller is responsible.
@@ -707,8 +812,8 @@ export class Editor {
707
812
  insertTextAtCursorInternal(text) {
708
813
  if (!text)
709
814
  return;
710
- // Normalize line endings
711
- const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
815
+ // Normalize line endings and tabs
816
+ const normalized = this.normalizeText(text);
712
817
  const insertedLines = normalized.split("\n");
713
818
  const currentLine = this.state.lines[this.state.cursorLine] || "";
714
819
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
@@ -799,12 +904,10 @@ export class Editor {
799
904
  this.historyIndex = -1; // Exit history browsing mode
800
905
  this.lastAction = null;
801
906
  this.pushUndoSnapshot();
802
- // Clean the pasted text
803
- const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
804
- // Convert tabs to spaces (4 spaces per tab)
805
- const tabExpandedText = cleanText.replace(/\t/g, " ");
907
+ // Clean the pasted text: normalize line endings, expand tabs
908
+ const cleanText = this.normalizeText(pastedText);
806
909
  // Filter out non-printable characters except newlines
807
- let filteredText = tabExpandedText
910
+ let filteredText = cleanText
808
911
  .split("")
809
912
  .filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
810
913
  .join("");
@@ -897,7 +1000,7 @@ export class Editor {
897
1000
  const line = this.state.lines[this.state.cursorLine] || "";
898
1001
  const beforeCursor = line.slice(0, this.state.cursorCol);
899
1002
  // Find the last grapheme in the text before cursor
900
- const graphemes = [...segmenter.segment(beforeCursor)];
1003
+ const graphemes = [...this.segment(beforeCursor)];
901
1004
  const lastGrapheme = graphemes[graphemes.length - 1];
902
1005
  const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
903
1006
  const before = line.slice(0, this.state.cursorCol - graphemeLength);
@@ -966,6 +1069,21 @@ export class Editor {
966
1069
  const targetCol = targetVL.startCol + moveToVisualCol;
967
1070
  const logicalLine = this.state.lines[targetVL.logicalLine] || "";
968
1071
  this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1072
+ // Snap cursor to atomic segment boundary (e.g. paste markers)
1073
+ // so the cursor never lands in the middle of a multi-grapheme unit.
1074
+ // Single-grapheme segments don't need snapping.
1075
+ const segments = [...this.segment(logicalLine)];
1076
+ for (const seg of segments) {
1077
+ if (seg.index > this.state.cursorCol)
1078
+ break;
1079
+ if (seg.segment.length <= 1)
1080
+ continue;
1081
+ if (this.state.cursorCol < seg.index + seg.segment.length) {
1082
+ // jump to the start of the segment when moving up, to the end when moving down.
1083
+ this.state.cursorCol = currentVisualLine > targetVisualLine ? seg.index : seg.index + seg.segment.length;
1084
+ break;
1085
+ }
1086
+ }
969
1087
  }
970
1088
  }
971
1089
  /**
@@ -1152,7 +1270,7 @@ export class Editor {
1152
1270
  // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
1153
1271
  const afterCursor = currentLine.slice(this.state.cursorCol);
1154
1272
  // Find the first grapheme at cursor
1155
- const graphemes = [...segmenter.segment(afterCursor)];
1273
+ const graphemes = [...this.segment(afterCursor)];
1156
1274
  const firstGrapheme = graphemes[0];
1157
1275
  const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
1158
1276
  const before = currentLine.slice(0, this.state.cursorCol);
@@ -1207,7 +1325,7 @@ export class Editor {
1207
1325
  }
1208
1326
  else {
1209
1327
  // Line needs wrapping - use word-aware wrapping
1210
- const chunks = wordWrapLine(line, width);
1328
+ const chunks = wordWrapLine(line, width, [...this.segment(line)]);
1211
1329
  for (const chunk of chunks) {
1212
1330
  visualLines.push({
1213
1331
  logicalLine: i,
@@ -1256,7 +1374,7 @@ export class Editor {
1256
1374
  // Moving right - move by one grapheme (handles emojis, combining characters, etc.)
1257
1375
  if (this.state.cursorCol < currentLine.length) {
1258
1376
  const afterCursor = currentLine.slice(this.state.cursorCol);
1259
- const graphemes = [...segmenter.segment(afterCursor)];
1377
+ const graphemes = [...this.segment(afterCursor)];
1260
1378
  const firstGrapheme = graphemes[0];
1261
1379
  this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
1262
1380
  }
@@ -1277,7 +1395,7 @@ export class Editor {
1277
1395
  // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
1278
1396
  if (this.state.cursorCol > 0) {
1279
1397
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1280
- const graphemes = [...segmenter.segment(beforeCursor)];
1398
+ const graphemes = [...this.segment(beforeCursor)];
1281
1399
  const lastGrapheme = graphemes[graphemes.length - 1];
1282
1400
  this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
1283
1401
  }
@@ -1316,17 +1434,25 @@ export class Editor {
1316
1434
  return;
1317
1435
  }
1318
1436
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1319
- const graphemes = [...segmenter.segment(textBeforeCursor)];
1437
+ const graphemes = [...this.segment(textBeforeCursor)];
1320
1438
  let newCol = this.state.cursorCol;
1321
1439
  // Skip trailing whitespace
1322
- while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
1440
+ while (graphemes.length > 0 &&
1441
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") &&
1442
+ isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
1323
1443
  newCol -= graphemes.pop()?.segment.length || 0;
1324
1444
  }
1325
1445
  if (graphemes.length > 0) {
1326
1446
  const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
1327
- if (isPunctuationChar(lastGrapheme)) {
1447
+ if (isPasteMarker(lastGrapheme)) {
1448
+ // Paste marker is a single atomic word
1449
+ newCol -= graphemes.pop()?.segment.length || 0;
1450
+ }
1451
+ else if (isPunctuationChar(lastGrapheme)) {
1328
1452
  // Skip punctuation run
1329
- while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
1453
+ while (graphemes.length > 0 &&
1454
+ isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
1455
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
1330
1456
  newCol -= graphemes.pop()?.segment.length || 0;
1331
1457
  }
1332
1458
  }
@@ -1334,7 +1460,8 @@ export class Editor {
1334
1460
  // Skip word run
1335
1461
  while (graphemes.length > 0 &&
1336
1462
  !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
1337
- !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
1463
+ !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
1464
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
1338
1465
  newCol -= graphemes.pop()?.segment.length || 0;
1339
1466
  }
1340
1467
  }
@@ -1497,27 +1624,34 @@ export class Editor {
1497
1624
  return;
1498
1625
  }
1499
1626
  const textAfterCursor = currentLine.slice(this.state.cursorCol);
1500
- const segments = segmenter.segment(textAfterCursor);
1627
+ const segments = this.segment(textAfterCursor);
1501
1628
  const iterator = segments[Symbol.iterator]();
1502
1629
  let next = iterator.next();
1503
1630
  let newCol = this.state.cursorCol;
1504
1631
  // Skip leading whitespace
1505
- while (!next.done && isWhitespaceChar(next.value.segment)) {
1632
+ while (!next.done && !isPasteMarker(next.value.segment) && isWhitespaceChar(next.value.segment)) {
1506
1633
  newCol += next.value.segment.length;
1507
1634
  next = iterator.next();
1508
1635
  }
1509
1636
  if (!next.done) {
1510
1637
  const firstGrapheme = next.value.segment;
1511
- if (isPunctuationChar(firstGrapheme)) {
1638
+ if (isPasteMarker(firstGrapheme)) {
1639
+ // Paste marker is a single atomic word
1640
+ newCol += firstGrapheme.length;
1641
+ }
1642
+ else if (isPunctuationChar(firstGrapheme)) {
1512
1643
  // Skip punctuation run
1513
- while (!next.done && isPunctuationChar(next.value.segment)) {
1644
+ while (!next.done && isPunctuationChar(next.value.segment) && !isPasteMarker(next.value.segment)) {
1514
1645
  newCol += next.value.segment.length;
1515
1646
  next = iterator.next();
1516
1647
  }
1517
1648
  }
1518
1649
  else {
1519
1650
  // Skip word run
1520
- while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
1651
+ while (!next.done &&
1652
+ !isWhitespaceChar(next.value.segment) &&
1653
+ !isPunctuationChar(next.value.segment) &&
1654
+ !isPasteMarker(next.value.segment)) {
1521
1655
  newCol += next.value.segment.length;
1522
1656
  next = iterator.next();
1523
1657
  }