@oh-my-pi/pi-tui 18.4.1 → 18.4.2

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 (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/types/chrome/transcript-container.d.ts +15 -1
  3. package/dist/types/overlays/model-browser.d.ts +15 -0
  4. package/dist/types/overlays/rewind-selector.d.ts +2 -1
  5. package/dist/types/prompt/composer-attachments.d.ts +7 -0
  6. package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
  7. package/dist/types/render/width-aware-text.d.ts +6 -0
  8. package/dist/types/terminal.d.ts +7 -0
  9. package/dist/types/theme/color.d.ts +1 -0
  10. package/dist/types/theme/session-color.d.ts +3 -3
  11. package/dist/types/theme/shimmer.d.ts +1 -1
  12. package/dist/types/theme/theme-class.d.ts +1 -1
  13. package/dist/types/tui.d.ts +3 -2
  14. package/package.json +9 -9
  15. package/src/chat/tool-execution.ts +24 -4
  16. package/src/chrome/transcript-container.ts +76 -14
  17. package/src/components/editor.ts +28 -6
  18. package/src/components/image.ts +15 -7
  19. package/src/components/loader.ts +37 -17
  20. package/src/overlays/agent-hub.ts +16 -6
  21. package/src/overlays/copy-selector.ts +5 -0
  22. package/src/overlays/model-browser.ts +106 -25
  23. package/src/overlays/rewind-selector.ts +10 -2
  24. package/src/prompt/composer-attachments.ts +54 -7
  25. package/src/prompt/composer.ts +5 -2
  26. package/src/prompt/custom-editor.ts +24 -6
  27. package/src/prompt/model-mention-autocomplete.ts +22 -10
  28. package/src/render/width-aware-text.ts +9 -0
  29. package/src/status-line/component.ts +213 -132
  30. package/src/status-line/segments.ts +34 -29
  31. package/src/terminal.ts +37 -11
  32. package/src/theme/color.ts +15 -0
  33. package/src/theme/session-color.ts +26 -4
  34. package/src/theme/shimmer.ts +55 -76
  35. package/src/theme/theme-class.ts +29 -12
  36. package/src/tui.ts +142 -112
  37. package/src/utils.ts +15 -0
package/src/tui.ts CHANGED
@@ -604,100 +604,95 @@ function isSgrParamByte(c: number): boolean {
604
604
  // standalone fg-red), changing the rendered color. The self-delimiting colon
605
605
  // form (`38:2::r:g:b`) is unambiguous — its tokens never equal a bare `38`, so
606
606
  // the scan treats it as a complete unit and merging stays safe.
607
- function endsWithIncompleteExtendedColor(params: string): boolean {
608
- const t = params.split(";");
609
- let i = 0;
610
- while (i < t.length) {
611
- const tok = t[i];
612
- if (tok === "38" || tok === "48" || tok === "58") {
613
- const mode = t[i + 1];
614
- if (mode === undefined) return true; // introducer with no mode
615
- if (mode === "2") {
616
- if (i + 4 >= t.length) return true; // missing r/g/b
617
- i += 5;
618
- continue;
619
- }
620
- if (mode === "5") {
621
- if (i + 2 >= t.length) return true; // missing index
622
- i += 3;
623
- continue;
624
- }
607
+ function endsWithIncompleteExtendedColor(line: string, start: number, end: number): boolean {
608
+ let tokenStart = start;
609
+ let needsMode = false;
610
+ let valuesRemaining = 0;
611
+ for (let i = start; i <= end; i++) {
612
+ if (i !== end && line.charCodeAt(i) !== CC_SEMI) continue;
613
+ const tokenLength = i - tokenStart;
614
+ const first = line.charCodeAt(tokenStart);
615
+ if (valuesRemaining > 0) {
616
+ valuesRemaining--;
617
+ } else if (needsMode && tokenLength === 1 && (first === 0x32 || first === 0x35)) {
618
+ valuesRemaining = first === 0x32 ? 3 : 1;
619
+ needsMode = false;
620
+ } else {
621
+ // An unrecognized mode is also a standalone token: `38;48;5`
622
+ // still ends in an incomplete background palette spec.
623
+ needsMode = tokenLength === 2 && first >= 0x33 && first <= 0x35 && line.charCodeAt(tokenStart + 1) === 0x38;
625
624
  }
626
- i += 1;
625
+ tokenStart = i + 1;
627
626
  }
628
- return false;
627
+ return needsMode || valuesRemaining > 0;
629
628
  }
630
629
 
631
630
  /**
632
631
  * Merge runs of byte-adjacent SGR sequences (`CSI [0-9;:]* m`) into one. Only
633
632
  * CSI-SGR sequences are touched; text, cursor moves, OSC, hyperlinks and image
634
- * payloads pass through verbatim. Returns the original reference when nothing
635
- * merges, so SGR-light lines incur only a single `indexOf` scan.
633
+ * payloads pass through verbatim. Isolated sequences require no parameter
634
+ * slices or arrays; output is built only at merged boundaries or empty resets
635
+ * that need normalization within an adjacent run.
636
636
  */
637
637
  export function coalesceAdjacentSgr(line: string): string {
638
- if (!SGR_COALESCE_ENABLED || line.indexOf("\x1b[") === -1) return line;
638
+ if (!SGR_COALESCE_ENABLED) return line;
639
639
  const n = line.length;
640
640
  let out = "";
641
641
  let copiedUpto = 0;
642
- let i = 0;
643
- while (i < n) {
644
- if (line.charCodeAt(i) !== CC_ESC || line.charCodeAt(i + 1) !== CC_BRACKET) {
645
- i++;
646
- continue;
647
- }
642
+ let i = line.indexOf("\x1b[");
643
+ while (i !== -1) {
648
644
  // Scan a candidate SGR sequence: ESC [ <params> m.
649
- let j = i + 2;
650
- while (j < n && isSgrParamByte(line.charCodeAt(j))) j++;
645
+ let start = i + 2;
646
+ let j = start;
647
+ let groupTokens = 1;
648
+ while (j < n && isSgrParamByte(line.charCodeAt(j))) {
649
+ const cc = line.charCodeAt(j++);
650
+ if (cc === CC_SEMI || cc === CC_COLON) groupTokens++;
651
+ }
651
652
  if (j >= n || line.charCodeAt(j) !== CC_M) {
652
653
  // Not an SGR (e.g. cursor move); leave it in the pending region.
653
- i = j;
654
+ i = line.indexOf("\x1b[", j);
654
655
  continue;
655
656
  }
656
- // Collect the run of adjacent SGR sequences starting here.
657
- const params: string[] = [line.slice(i + 2, j)];
657
+
658
+ let adjacent = false;
658
659
  let k = j + 1;
659
660
  while (k < n && line.charCodeAt(k) === CC_ESC && line.charCodeAt(k + 1) === CC_BRACKET) {
660
- let p = k + 2;
661
- while (p < n && isSgrParamByte(line.charCodeAt(p))) p++;
661
+ const nextStart = k + 2;
662
+ let p = nextStart;
663
+ let tokens = 1;
664
+ while (p < n && isSgrParamByte(line.charCodeAt(p))) {
665
+ const cc = line.charCodeAt(p++);
666
+ if (cc === CC_SEMI || cc === CC_COLON) tokens++;
667
+ }
662
668
  if (p >= n || line.charCodeAt(p) !== CC_M) break;
663
- params.push(line.slice(k + 2, p));
664
- k = p + 1;
665
- }
666
- if (params.length > 1) {
667
- out += line.slice(copiedUpto, i);
668
- // Emit the merged run, but flush the current group before appending a
669
- // list when (a) the previous list ended mid extended-color, so the
670
- // next code cannot be absorbed as its missing channel/index, or (b)
671
- // the token count would exceed MERGE_TOKEN_CAP. SGR params apply
672
- // left-to-right regardless of how they are grouped across adjacent
673
- // CSIs, so a capped/guarded split stays behavior-preserving — while a
674
- // single unbounded merge would overflow a terminal's CSI parameter
675
- // buffer (xterm.js caps at 32 and silently truncates the rest,
676
- // corrupting colors). Empty params (`CSI m`) mean a full reset;
677
- // normalize to `0` so the merged list stays unambiguous.
678
- let group = "";
679
- let groupTokens = 0;
680
- let groupOpenSafe = true;
681
- for (let q = 0; q < params.length; q++) {
682
- const norm = params[q]!.length === 0 ? "0" : params[q]!;
683
- let tk = 1;
684
- for (let z = 0; z < norm.length; z++) {
685
- const cc = norm.charCodeAt(z);
686
- if (cc === CC_SEMI || cc === CC_COLON) tk++;
687
- }
688
- if (groupTokens > 0 && (!groupOpenSafe || groupTokens + tk > MERGE_TOKEN_CAP)) {
689
- out += `\x1b[${group}m`;
690
- group = "";
691
- groupTokens = 0;
669
+ adjacent = true;
670
+
671
+ // Keep the boundary if the preceding list could absorb a missing
672
+ // channel/index, or if merging would overflow the parameter cap.
673
+ // Otherwise replace only `m ESC [` with `;`, keeping the source
674
+ // untouched until a boundary actually merges. Empty lists in an
675
+ // adjacent run normalize to `0`, including at a guarded boundary.
676
+ if (groupTokens + tokens <= MERGE_TOKEN_CAP && !endsWithIncompleteExtendedColor(line, start, j)) {
677
+ out += line.slice(copiedUpto, j) + (start === j ? "0;" : ";");
678
+ copiedUpto = nextStart;
679
+ groupTokens += tokens;
680
+ } else {
681
+ if (start === j) {
682
+ out += `${line.slice(copiedUpto, j)}0`;
683
+ copiedUpto = j;
692
684
  }
693
- group += group.length === 0 ? norm : `;${norm}`;
694
- groupTokens += tk;
695
- groupOpenSafe = !endsWithIncompleteExtendedColor(norm);
685
+ groupTokens = tokens;
696
686
  }
697
- if (group.length > 0) out += `\x1b[${group}m`;
698
- copiedUpto = k;
687
+ start = nextStart;
688
+ j = p;
689
+ k = p + 1;
699
690
  }
700
- i = k;
691
+ if (adjacent && start === j) {
692
+ out += `${line.slice(copiedUpto, j)}0`;
693
+ copiedUpto = j;
694
+ }
695
+ i = line.indexOf("\x1b[", k);
701
696
  }
702
697
  if (copiedUpto === 0) return line;
703
698
  return out + line.slice(copiedUpto);
@@ -776,6 +771,12 @@ export class TUI extends Container {
776
771
  // the established differential comparison and resize accounting.
777
772
  #providerWindow: string[] = [];
778
773
  #providerPreparedRows: PreparedLine[] = [];
774
+ // Rows of the last marker-stripping prepare pass keyed by their stripped
775
+ // raw line, so rows that only moved (a scroll or a history commit shifts
776
+ // every row under the positional sidecar) reuse their preparation. Swapped
777
+ // with the spare each pass, which bounds the memo to one frame of rows.
778
+ #preparedLineMemo = new Map<string, PreparedLine>();
779
+ #preparedLineMemoSpare = new Map<string, PreparedLine>();
779
780
  #previousFrameLength = 0;
780
781
  #previousWidth = 0;
781
782
  #previousHeight = 0;
@@ -1762,9 +1763,13 @@ export class TUI extends Container {
1762
1763
  (provider ? provider.renderFrame({ columns: width, rows: height }).viewport : this.render(width));
1763
1764
  } while (this.#imageBudget.endPass());
1764
1765
  const viewport = rendered.length > height ? rendered.slice(rendered.length - height) : Array.from(rendered);
1765
- this.#extractCursorMarkers(viewport);
1766
1766
  // The borrowed resize buffer is transient, not a streamable session paint.
1767
- this.#emitAltFrame(this.#prepareLinesArray(viewport, width, this.#altPreparedRows, height), width, height, false);
1767
+ this.#emitAltFrame(
1768
+ this.#prepareLinesArray(viewport, width, this.#altPreparedRows, height, []),
1769
+ width,
1770
+ height,
1771
+ false,
1772
+ );
1768
1773
  }
1769
1774
 
1770
1775
  /**
@@ -2586,30 +2591,6 @@ export class TUI extends Container {
2586
2591
  return sliceByColumn(result, 0, totalWidth, true);
2587
2592
  }
2588
2593
 
2589
- /**
2590
- * Strip every CURSOR_MARKER from the rendered lines (markers are internal
2591
- * sentinels and must never reach the terminal) and return their positions,
2592
- * bottom-most first. Callers pick the visible one once the window top is
2593
- * known.
2594
- */
2595
- #extractCursorMarkers(lines: string[]): { row: number; col: number }[] {
2596
- const markers: { row: number; col: number }[] = [];
2597
- for (let row = lines.length - 1; row >= 0; row--) {
2598
- const line = lines[row];
2599
- let markerIndex = line.indexOf(CURSOR_MARKER);
2600
- if (markerIndex === -1) continue;
2601
- const beforeMarker = line.slice(0, markerIndex);
2602
- markers.push({ row, col: visibleWidth(beforeMarker) });
2603
- let stripped = line;
2604
- while (markerIndex !== -1) {
2605
- stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
2606
- markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
2607
- }
2608
- lines[row] = stripped;
2609
- }
2610
- return markers;
2611
- }
2612
-
2613
2594
  /**
2614
2595
  * Rewrite a Kitty direct-placement line for the viewport row it is written
2615
2596
  * at, clipping to the visible slice (see {@link encodeKittyPlacementLine})
@@ -2802,9 +2783,11 @@ export class TUI extends Container {
2802
2783
  replayViewportRows = moved;
2803
2784
  }
2804
2785
  }
2805
- const markers = this.#extractCursorMarkers(viewport);
2806
- const prepared = this.#prepareLinesArray(viewport, width, this.#providerPreparedRows);
2786
+ // History first: it reuses the previous viewport's rows by content, and
2787
+ // the viewport pass replaces that memo with its own rows.
2807
2788
  const preparedHistory = this.#prepareLinesArray(historyRows, width);
2789
+ const markers: { row: number; col: number }[] = [];
2790
+ const prepared = this.#prepareLinesArray(viewport, width, this.#providerPreparedRows, viewport.length, markers);
2808
2791
  const rows = prepared.lines.length;
2809
2792
  // Destructive reset (session replace, /tree, explicit clear, or a settled
2810
2793
  // resize in rebuild mode): erase native history and the viewport,
@@ -3148,13 +3131,23 @@ export class TUI extends Container {
3148
3131
  * Prepare one string projection plus its structured write sidecar. A prior
3149
3132
  * sidecar entry is reusable only under identical raw content, width, width
3150
3133
  * configuration, and image protocol; those are every mutable input to
3151
- * normalization, fitting, classification, and terminal coalescing.
3134
+ * normalization, fitting, classification, and terminal coalescing. The same
3135
+ * row at the same index is checked first, then the previous stripping pass's
3136
+ * rows by content, so a scrolled row is not re-prepared.
3137
+ *
3138
+ * With `markers`, every CURSOR_MARKER is stripped (markers are internal
3139
+ * sentinels and must never reach the terminal) and its position recorded,
3140
+ * bottom-most first; callers pick the visible one once the window top is
3141
+ * known. Without it (history rows) lines are prepared verbatim. Every
3142
+ * reusable entry holds a stripped raw line, so a row that matches one
3143
+ * carries no marker and skips the marker scan.
3152
3144
  */
3153
3145
  #prepareLinesArray(
3154
3146
  lines: readonly string[],
3155
3147
  width: number,
3156
3148
  previous: readonly PreparedLine[] = [],
3157
3149
  length = lines.length,
3150
+ markers?: { row: number; col: number }[],
3158
3151
  ): PreparedLines {
3159
3152
  // oxlint-disable-next-line unicorn/no-new-array -- render-frame length preallocation
3160
3153
  const prepared: string[] = new Array(length);
@@ -3162,23 +3155,61 @@ export class TUI extends Container {
3162
3155
  const rows: PreparedLine[] = new Array(length);
3163
3156
  const widthEpoch = getWidthConfigEpoch();
3164
3157
  const imageProtocol = TERMINAL.imageProtocol;
3158
+ const memo = this.#preparedLineMemo;
3159
+ // Only stripping passes index their rows: a verbatim history row may keep
3160
+ // a marker, and reusing it for a viewport row would leak that marker.
3161
+ const nextMemo = markers === undefined ? undefined : this.#preparedLineMemoSpare;
3165
3162
  for (let i = 0; i < length; i++) {
3166
- const raw = lines[i] ?? "";
3167
- const cached = previous[i];
3168
- const row =
3169
- cached !== undefined &&
3170
- cached.raw === raw &&
3171
- cached.width === width &&
3172
- cached.widthEpoch === widthEpoch &&
3173
- cached.imageProtocol === imageProtocol
3174
- ? cached
3175
- : this.#prepareLine(raw, width, widthEpoch, imageProtocol);
3163
+ const source = lines[i] ?? "";
3164
+ let row = this.#reusablePreparedLine(previous[i], memo, source, width, widthEpoch, imageProtocol);
3165
+ if (row === undefined) {
3166
+ let raw = source;
3167
+ if (markers !== undefined) {
3168
+ let markerIndex = source.indexOf(CURSOR_MARKER);
3169
+ if (markerIndex !== -1) {
3170
+ markers.push({ row: i, col: visibleWidth(source.slice(0, markerIndex)) });
3171
+ // Resume the search just before the splice so a marker that the
3172
+ // removal itself joins together is stripped too; reusable rows
3173
+ // must stay marker-free.
3174
+ while (markerIndex !== -1) {
3175
+ raw = raw.slice(0, markerIndex) + raw.slice(markerIndex + CURSOR_MARKER.length);
3176
+ markerIndex = raw.indexOf(CURSOR_MARKER, Math.max(0, markerIndex - CURSOR_MARKER.length + 1));
3177
+ }
3178
+ row = this.#reusablePreparedLine(previous[i], memo, raw, width, widthEpoch, imageProtocol);
3179
+ }
3180
+ }
3181
+ row ??= this.#prepareLine(raw, width, widthEpoch, imageProtocol);
3182
+ }
3183
+ nextMemo?.set(row.raw, row);
3176
3184
  prepared[i] = row.line;
3177
3185
  rows[i] = row;
3178
3186
  }
3187
+ if (nextMemo !== undefined) {
3188
+ memo.clear();
3189
+ this.#preparedLineMemo = nextMemo;
3190
+ this.#preparedLineMemoSpare = memo;
3191
+ markers?.reverse();
3192
+ }
3179
3193
  return { lines: prepared, rows };
3180
3194
  }
3181
3195
 
3196
+ #reusablePreparedLine(
3197
+ positional: PreparedLine | undefined,
3198
+ memo: ReadonlyMap<string, PreparedLine>,
3199
+ raw: string,
3200
+ width: number,
3201
+ widthEpoch: number,
3202
+ imageProtocol: ImageProtocol | null,
3203
+ ): PreparedLine | undefined {
3204
+ const cached = positional !== undefined && positional.raw === raw ? positional : memo.get(raw);
3205
+ return cached !== undefined &&
3206
+ cached.width === width &&
3207
+ cached.widthEpoch === widthEpoch &&
3208
+ cached.imageProtocol === imageProtocol
3209
+ ? cached
3210
+ : undefined;
3211
+ }
3212
+
3182
3213
  #prepareLine(raw: string, width: number, widthEpoch: number, imageProtocol: ImageProtocol | null): PreparedLine {
3183
3214
  const safeWidth = Number.isFinite(width) ? Math.max(1, Math.trunc(width)) : 1;
3184
3215
  const maxSourceLength = Math.min(
@@ -3569,8 +3600,7 @@ export class TUI extends Container {
3569
3600
  this.#imageBudget.beginPass(false, true);
3570
3601
  lines = this.#compositeOverlaysIntoWindow(base, width, height);
3571
3602
  } while (this.#imageBudget.endPass());
3572
- this.#extractCursorMarkers(lines);
3573
- const prepared = this.#prepareLinesArray(lines, width, this.#altPreparedRows, height);
3603
+ const prepared = this.#prepareLinesArray(lines, width, this.#altPreparedRows, height, []);
3574
3604
  this.#emitAltFrame(prepared, width, height, true);
3575
3605
  }
3576
3606
 
package/src/utils.ts CHANGED
@@ -241,6 +241,9 @@ const OSC66_PREFIX = "\x1b]66;";
241
241
  const APC_SPAN_REGEX = /\x1b_[\s\S]*?(?:\x07|\x1b\\)/g;
242
242
  const APC_PREFIX = "\x1b_";
243
243
  const PRINTABLE_ASCII_REGEX = /^[\u0020-\u007e]*$/;
244
+ // Keep native escape parsing: a JS SGR parser costs more than Bun's scanner.
245
+ // Test Jamo separately from the ASCII correction markers to keep scans cheap.
246
+ const HANGUL_COMPAT_JAMO_REGEX = /[\u3131-\u318e]/;
244
247
 
245
248
  // Pin Bun.stringWidth semantics to the native width engine and guard against Bun
246
249
  // default drift: strip ANSI/OSC (don't count escape bytes) and treat
@@ -337,6 +340,18 @@ export function visibleWidth(str: string): number {
337
340
  return str.length;
338
341
  }
339
342
 
343
+ // The extra gate pays for itself on long uncached lines, not short cache
344
+ // misses. APC and OSC 66 still need their existing payload corrections.
345
+ if (
346
+ !cacheable &&
347
+ !str.includes("\t") &&
348
+ !HANGUL_COMPAT_JAMO_REGEX.test(str) &&
349
+ !str.includes(APC_PREFIX) &&
350
+ !str.includes(OSC66_PREFIX)
351
+ ) {
352
+ return Bun.stringWidth(str, STRING_WIDTH_OPTS);
353
+ }
354
+
340
355
  let tabCount = 0;
341
356
  let compatibilityJamoCount = 0;
342
357
  let fillerCount = 0;