@oh-my-pi/pi-tui 15.10.4 → 15.10.5

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/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.10.5] - 2026-06-08
6
+
7
+ ### Added
8
+
9
+ - Added `atomicTokenPattern` to `Editor`: when set to a global regex matching placeholder tokens such as `[Image #1, 800x600]` or `[Paste #2, +30 lines]`, a single backspace or forward-delete landing anywhere on a token removes the whole token instead of corrupting it into stray text.
10
+
11
+ ### Changed
12
+
13
+ - Changed the large-paste placeholder label from `[paste #N +X lines]`/`[paste #N Y chars]` to `[Paste #N, +X lines]`/`[Paste #N, Y chars]`.
14
+
15
+ ### Fixed
16
+
17
+ - Fixed pasting large text lagging the prompt for hundreds of milliseconds before the `[paste #N …]` placeholder appeared. `StdinBuffer` assembled bracketed pastes by re-concatenating and re-scanning the entire accumulated buffer on every incoming stdin chunk (`#pasteBuffer += chunk; indexOf(END)`), which is O(n²) in the paste size and dominates when the terminal/PTY delivers the paste in many small reads (SSH, tmux, slow hosts) — a 1 MB paste at 1 KB chunks cost ~33 ms and 5 MB ~740 ms. Chunks are now collected in an array and joined once when the end marker arrives, with a short overlap tail carried across chunk boundaries so a marker split between two reads is still detected without rescanning, making assembly O(n) (~1 ms for 5 MB). The `Editor` paste cleaner also dropped its `split("").filter().join("")` per-code-unit array allocation in favor of a single control-character regex pass (~20× faster on large pastes).
18
+
5
19
  ## [15.10.4] - 2026-06-08
6
20
 
7
21
  ### Fixed
@@ -37,6 +37,12 @@ export declare class Editor implements Component, Focusable {
37
37
  decorateText: ((text: string) => string) | undefined;
38
38
  borderColor: (str: string) => string;
39
39
  onAutocompleteUpdate?: () => void;
40
+ /** Optional pattern matching atomic placeholder tokens (e.g. `[Image #1, 800x600]` or
41
+ * `[Paste #2, +30 lines]`) that the editor treats as indivisible: a backspace or forward-delete
42
+ * landing on any character of a token removes the whole token instead of corrupting it into
43
+ * stray text. MUST be a global regex; the editor recompiles a private copy so its `lastIndex`
44
+ * is never shared with the caller. */
45
+ atomicTokenPattern: RegExp | undefined;
40
46
  onSubmit?: (text: string) => void;
41
47
  onAltEnter?: (text: string) => void;
42
48
  onChange?: (text: string) => void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "15.10.4",
4
+ "version": "15.10.5",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "15.10.4",
41
- "@oh-my-pi/pi-utils": "15.10.4",
40
+ "@oh-my-pi/pi-natives": "15.10.5",
41
+ "@oh-my-pi/pi-utils": "15.10.5",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.4"
44
44
  },
@@ -368,6 +368,15 @@ export class Editor implements Component, Focusable {
368
368
  #pastes: Map<number, string> = new Map();
369
369
  #pasteCounter: number = 0;
370
370
 
371
+ /** Optional pattern matching atomic placeholder tokens (e.g. `[Image #1, 800x600]` or
372
+ * `[Paste #2, +30 lines]`) that the editor treats as indivisible: a backspace or forward-delete
373
+ * landing on any character of a token removes the whole token instead of corrupting it into
374
+ * stray text. MUST be a global regex; the editor recompiles a private copy so its `lastIndex`
375
+ * is never shared with the caller. */
376
+ atomicTokenPattern: RegExp | undefined;
377
+ #atomicTokenSource: string | undefined;
378
+ #atomicTokenRe: RegExp | undefined;
379
+
371
380
  // Bracketed paste mode buffering
372
381
  #pasteHandler = new BracketedPasteHandler();
373
382
 
@@ -1389,7 +1398,7 @@ export class Editor implements Component, Focusable {
1389
1398
  #expandPasteMarkers(text: string): string {
1390
1399
  let result = text;
1391
1400
  for (const [pasteId, pasteContent] of this.#pastes) {
1392
- const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
1401
+ const markerRegex = new RegExp(`\\[Paste #${pasteId}(?:, (?:\\+\\d+ lines|\\d+ chars))?\\]`, "g");
1393
1402
  result = result.replace(markerRegex, () => pasteContent);
1394
1403
  }
1395
1404
  return result;
@@ -1627,11 +1636,10 @@ export class Editor implements Component, Focusable {
1627
1636
  // Convert tabs to spaces (4 spaces per tab)
1628
1637
  const tabExpandedText = cleanText.replace(/\t/g, " ");
1629
1638
 
1630
- // Filter out non-printable characters except newlines
1631
- let filteredText = tabExpandedText
1632
- .split("")
1633
- .filter(char => char === "\n" || char.charCodeAt(0) >= 32)
1634
- .join("");
1639
+ // Strip control characters except newline (tabs already expanded above,
1640
+ // CRs already normalized). Single regex pass instead of split/filter/join
1641
+ // to avoid allocating a per-code-unit array for large pastes.
1642
+ let filteredText = tabExpandedText.replace(/[\x00-\x09\x0B-\x1F]/g, "");
1635
1643
 
1636
1644
  // If pasting a file path (starts with /, ~, or .) and the character before
1637
1645
  // the cursor is a word character, prepend a space for better readability
@@ -1654,11 +1662,11 @@ export class Editor implements Component, Focusable {
1654
1662
  const pasteId = this.#pasteCounter;
1655
1663
  this.#pastes.set(pasteId, filteredText);
1656
1664
 
1657
- // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]"
1665
+ // Insert marker like "[Paste #1, +123 lines]" or "[Paste #1, 1234 chars]"
1658
1666
  const marker =
1659
1667
  pastedLines.length > 10
1660
- ? `[paste #${pasteId} +${pastedLines.length} lines]`
1661
- : `[paste #${pasteId} ${totalChars} chars]`;
1668
+ ? `[Paste #${pasteId}, +${pastedLines.length} lines]`
1669
+ : `[Paste #${pasteId}, ${totalChars} chars]`;
1662
1670
  this.#insertTextAtCursor(marker);
1663
1671
 
1664
1672
  return;
@@ -1727,26 +1735,72 @@ export class Editor implements Component, Focusable {
1727
1735
  if (this.onSubmit) this.onSubmit(result);
1728
1736
  }
1729
1737
 
1738
+ /** Resolve the compiled, global copy of `atomicTokenPattern`, rebuilt only when the source changes. */
1739
+ #getAtomicTokenRe(): RegExp | undefined {
1740
+ const pattern = this.atomicTokenPattern;
1741
+ if (pattern === undefined) {
1742
+ this.#atomicTokenSource = undefined;
1743
+ this.#atomicTokenRe = undefined;
1744
+ return undefined;
1745
+ }
1746
+ if (pattern.source !== this.#atomicTokenSource) {
1747
+ this.#atomicTokenSource = pattern.source;
1748
+ this.#atomicTokenRe = new RegExp(
1749
+ pattern.source,
1750
+ pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
1751
+ );
1752
+ }
1753
+ return this.#atomicTokenRe;
1754
+ }
1755
+
1756
+ /** Find an atomic token on `line` whose span contains column `col` (`start <= col < end`). */
1757
+ #atomicTokenAt(line: string, col: number): { start: number; end: number } | undefined {
1758
+ const re = this.#getAtomicTokenRe();
1759
+ if (re === undefined) return undefined;
1760
+ re.lastIndex = 0;
1761
+ for (;;) {
1762
+ const match = re.exec(line);
1763
+ if (match === null) break;
1764
+ if (match[0].length === 0) {
1765
+ re.lastIndex = match.index + 1;
1766
+ continue;
1767
+ }
1768
+ const start = match.index;
1769
+ const end = start + match[0].length;
1770
+ if (col < start) break;
1771
+ if (col < end) return { start, end };
1772
+ }
1773
+ return undefined;
1774
+ }
1775
+
1730
1776
  #handleBackspace(): void {
1731
1777
  this.#historyIndex = -1; // Exit history browsing mode
1732
1778
  this.#resetKillSequence();
1733
1779
  this.#recordUndoState();
1734
1780
 
1735
1781
  if (this.#state.cursorCol > 0) {
1736
- // Delete grapheme before cursor (handles emojis, combining characters, etc.)
1737
1782
  const line = this.#state.lines[this.#state.cursorLine] || "";
1738
- const beforeCursor = line.slice(0, this.#state.cursorCol);
1783
+ // An atomic placeholder token (image/paste marker) deletes as a unit, so a single
1784
+ // backspace never leaves a half-eaten `[Paste #1, +30 lines` behind as stray text.
1785
+ const token = this.#atomicTokenAt(line, this.#state.cursorCol - 1);
1786
+ if (token !== undefined) {
1787
+ this.#state.lines[this.#state.cursorLine] = line.slice(0, token.start) + line.slice(token.end);
1788
+ this.#setCursorCol(token.start);
1789
+ } else {
1790
+ // Delete grapheme before cursor (handles emojis, combining characters, etc.)
1791
+ const beforeCursor = line.slice(0, this.#state.cursorCol);
1739
1792
 
1740
- // Find the last grapheme in the text before cursor
1741
- const graphemes = [...segmenter.segment(beforeCursor)];
1742
- const lastGrapheme = graphemes[graphemes.length - 1];
1743
- const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
1793
+ // Find the last grapheme in the text before cursor
1794
+ const graphemes = [...segmenter.segment(beforeCursor)];
1795
+ const lastGrapheme = graphemes[graphemes.length - 1];
1796
+ const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
1744
1797
 
1745
- const before = line.slice(0, this.#state.cursorCol - graphemeLength);
1746
- const after = line.slice(this.#state.cursorCol);
1798
+ const before = line.slice(0, this.#state.cursorCol - graphemeLength);
1799
+ const after = line.slice(this.#state.cursorCol);
1747
1800
 
1748
- this.#state.lines[this.#state.cursorLine] = before + after;
1749
- this.#setCursorCol(this.#state.cursorCol - graphemeLength);
1801
+ this.#state.lines[this.#state.cursorLine] = before + after;
1802
+ this.#setCursorCol(this.#state.cursorCol - graphemeLength);
1803
+ }
1750
1804
  } else if (this.#state.cursorLine > 0) {
1751
1805
  // Merge with previous line
1752
1806
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
@@ -2218,17 +2272,25 @@ export class Editor implements Component, Focusable {
2218
2272
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2219
2273
 
2220
2274
  if (this.#state.cursorCol < currentLine.length) {
2221
- // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
2222
- const afterCursor = currentLine.slice(this.#state.cursorCol);
2275
+ // An atomic placeholder token (image/paste marker) deletes as a unit.
2276
+ const token = this.#atomicTokenAt(currentLine, this.#state.cursorCol);
2277
+ if (token !== undefined) {
2278
+ this.#state.lines[this.#state.cursorLine] =
2279
+ currentLine.slice(0, token.start) + currentLine.slice(token.end);
2280
+ this.#setCursorCol(token.start);
2281
+ } else {
2282
+ // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
2283
+ const afterCursor = currentLine.slice(this.#state.cursorCol);
2223
2284
 
2224
- // Find the first grapheme at cursor
2225
- const graphemes = [...segmenter.segment(afterCursor)];
2226
- const firstGrapheme = graphemes[0];
2227
- const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
2285
+ // Find the first grapheme at cursor
2286
+ const graphemes = [...segmenter.segment(afterCursor)];
2287
+ const firstGrapheme = graphemes[0];
2288
+ const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
2228
2289
 
2229
- const before = currentLine.slice(0, this.#state.cursorCol);
2230
- const after = currentLine.slice(this.#state.cursorCol + graphemeLength);
2231
- this.#state.lines[this.#state.cursorLine] = before + after;
2290
+ const before = currentLine.slice(0, this.#state.cursorCol);
2291
+ const after = currentLine.slice(this.#state.cursorCol + graphemeLength);
2292
+ this.#state.lines[this.#state.cursorLine] = before + after;
2293
+ }
2232
2294
  } else if (this.#state.cursorLine < this.#state.lines.length - 1) {
2233
2295
  // At end of line - merge with next line
2234
2296
  const nextLine = this.#state.lines[this.#state.cursorLine + 1] || "";
@@ -265,7 +265,8 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
265
265
  #timeout?: NodeJS.Timeout;
266
266
  readonly #timeoutMs: number;
267
267
  #pasteMode: boolean = false;
268
- #pasteBuffer: string = "";
268
+ #pasteChunks: string[] = [];
269
+ #pasteOverlap: string = "";
269
270
  #pendingKittyPrintableCodepoint: number | undefined;
270
271
 
271
272
  constructor(options: StdinBufferOptions = {}) {
@@ -302,24 +303,9 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
302
303
  this.#buffer += str;
303
304
 
304
305
  if (this.#pasteMode) {
305
- this.#pasteBuffer += this.#buffer;
306
+ const chunk = this.#buffer;
306
307
  this.#buffer = "";
307
-
308
- const endIndex = this.#pasteBuffer.indexOf(BRACKETED_PASTE_END);
309
- if (endIndex !== -1) {
310
- const pastedContent = this.#pasteBuffer.slice(0, endIndex);
311
- const remaining = this.#pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
312
-
313
- this.#pasteMode = false;
314
- this.#pasteBuffer = "";
315
- this.#pendingKittyPrintableCodepoint = undefined;
316
-
317
- this.emit("paste", pastedContent);
318
-
319
- if (remaining.length > 0) {
320
- this.process(remaining);
321
- }
322
- }
308
+ this.#consumePasteChunk(chunk);
323
309
  return;
324
310
  }
325
311
 
@@ -335,25 +321,12 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
335
321
 
336
322
  this.#pendingKittyPrintableCodepoint = undefined;
337
323
  this.#buffer = this.#buffer.slice(startIndex + BRACKETED_PASTE_START.length);
338
- this.#pasteMode = true;
339
- this.#pasteBuffer = this.#buffer;
324
+ const firstChunk = this.#buffer;
340
325
  this.#buffer = "";
341
-
342
- const endIndex = this.#pasteBuffer.indexOf(BRACKETED_PASTE_END);
343
- if (endIndex !== -1) {
344
- const pastedContent = this.#pasteBuffer.slice(0, endIndex);
345
- const remaining = this.#pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
346
-
347
- this.#pasteMode = false;
348
- this.#pasteBuffer = "";
349
- this.#pendingKittyPrintableCodepoint = undefined;
350
-
351
- this.emit("paste", pastedContent);
352
-
353
- if (remaining.length > 0) {
354
- this.process(remaining);
355
- }
356
- }
326
+ this.#pasteMode = true;
327
+ this.#pasteChunks = [];
328
+ this.#pasteOverlap = "";
329
+ this.#consumePasteChunk(firstChunk);
357
330
  return;
358
331
  }
359
332
 
@@ -375,6 +348,42 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
375
348
  }
376
349
  }
377
350
 
351
+ /**
352
+ * Consume one chunk of paste-mode input. Chunks are accumulated in an array
353
+ * and only joined once the end marker arrives, so a large paste delivered in
354
+ * many small terminal reads stays O(total) instead of the O(total^2) cost of
355
+ * re-concatenating and rescanning the whole buffer on every chunk. A short
356
+ * overlap tail (end-marker length - 1) is carried across chunk boundaries so
357
+ * a marker split between two reads is still detected without rescanning.
358
+ */
359
+ #consumePasteChunk(chunk: string): void {
360
+ const probe = this.#pasteOverlap + chunk;
361
+ if (probe.indexOf(BRACKETED_PASTE_END) === -1) {
362
+ this.#pasteChunks.push(chunk);
363
+ const keep = BRACKETED_PASTE_END.length - 1;
364
+ this.#pasteOverlap = probe.length > keep ? probe.slice(probe.length - keep) : probe;
365
+ return;
366
+ }
367
+
368
+ // End marker arrived: join once and split at its first occurrence,
369
+ // matching the prior indexOf-from-start semantics exactly.
370
+ const flat = this.#pasteChunks.length > 0 ? `${this.#pasteChunks.join("")}${chunk}` : chunk;
371
+ const endIndex = flat.indexOf(BRACKETED_PASTE_END);
372
+ const pastedContent = flat.slice(0, endIndex);
373
+ const remaining = flat.slice(endIndex + BRACKETED_PASTE_END.length);
374
+
375
+ this.#pasteMode = false;
376
+ this.#pasteChunks = [];
377
+ this.#pasteOverlap = "";
378
+ this.#pendingKittyPrintableCodepoint = undefined;
379
+
380
+ this.emit("paste", pastedContent);
381
+
382
+ if (remaining.length > 0) {
383
+ this.process(remaining);
384
+ }
385
+ }
386
+
378
387
  #emitDataSequence(sequence: string): void {
379
388
  const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
380
389
  if (rawCodepoint !== undefined && rawCodepoint === this.#pendingKittyPrintableCodepoint) {
@@ -409,7 +418,8 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
409
418
  }
410
419
  this.#buffer = "";
411
420
  this.#pasteMode = false;
412
- this.#pasteBuffer = "";
421
+ this.#pasteChunks = [];
422
+ this.#pasteOverlap = "";
413
423
  this.#pendingKittyPrintableCodepoint = undefined;
414
424
  }
415
425