@oh-my-pi/pi-tui 18.4.0 → 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 (65) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/types/chat/image-loading.d.ts +24 -0
  3. package/dist/types/chat/transcript-entry.d.ts +9 -0
  4. package/dist/types/chrome/transcript-container.d.ts +24 -3
  5. package/dist/types/overlays/agents-hub.d.ts +7 -4
  6. package/dist/types/overlays/model-browser.d.ts +15 -0
  7. package/dist/types/overlays/rewind-selector.d.ts +4 -3
  8. package/dist/types/overlays/usage-dashboard.d.ts +9 -2
  9. package/dist/types/prompt/composer-attachments.d.ts +7 -0
  10. package/dist/types/prompt/composer-cache.d.ts +34 -16
  11. package/dist/types/prompt/composer.d.ts +15 -18
  12. package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
  13. package/dist/types/prompt/welcome.d.ts +5 -2
  14. package/dist/types/render/width-aware-text.d.ts +6 -0
  15. package/dist/types/status-line/component.d.ts +13 -4
  16. package/dist/types/status-line/metrics.d.ts +0 -1
  17. package/dist/types/status-line/startup.d.ts +38 -0
  18. package/dist/types/status-line/types.d.ts +0 -2
  19. package/dist/types/terminal-capabilities.d.ts +10 -6
  20. package/dist/types/terminal.d.ts +7 -0
  21. package/dist/types/theme/color.d.ts +1 -0
  22. package/dist/types/theme/session-color.d.ts +3 -3
  23. package/dist/types/theme/shimmer.d.ts +1 -1
  24. package/dist/types/theme/theme-class.d.ts +1 -1
  25. package/dist/types/tools/output-meta.d.ts +2 -1
  26. package/dist/types/tools/vibe.d.ts +4 -0
  27. package/dist/types/tui.d.ts +3 -2
  28. package/package.json +9 -9
  29. package/src/chat/assistant-message.ts +27 -21
  30. package/src/chat/image-loading.ts +81 -0
  31. package/src/chat/tool-execution.ts +53 -17
  32. package/src/chat/transcript-entry.ts +16 -0
  33. package/src/chrome/transcript-container.ts +282 -86
  34. package/src/components/editor.ts +28 -6
  35. package/src/components/image.ts +25 -9
  36. package/src/components/loader.ts +37 -17
  37. package/src/overlays/agent-hub.ts +16 -6
  38. package/src/overlays/agents-hub.ts +7 -18
  39. package/src/overlays/copy-selector.ts +8 -31
  40. package/src/overlays/model-browser.ts +106 -25
  41. package/src/overlays/model-hub.ts +49 -16
  42. package/src/overlays/rewind-selector.ts +60 -13
  43. package/src/overlays/usage-dashboard.ts +47 -6
  44. package/src/prompt/composer-attachments.ts +54 -7
  45. package/src/prompt/composer-cache.ts +217 -196
  46. package/src/prompt/composer.ts +45 -72
  47. package/src/prompt/custom-editor.ts +24 -6
  48. package/src/prompt/model-mention-autocomplete.ts +22 -10
  49. package/src/prompt/welcome.ts +31 -28
  50. package/src/render/width-aware-text.ts +9 -0
  51. package/src/status-line/component.ts +286 -182
  52. package/src/status-line/metrics.ts +3 -21
  53. package/src/status-line/segments.ts +68 -79
  54. package/src/status-line/startup.ts +181 -0
  55. package/src/status-line/types.ts +0 -2
  56. package/src/terminal-capabilities.ts +12 -6
  57. package/src/terminal.ts +75 -20
  58. package/src/theme/color.ts +15 -0
  59. package/src/theme/session-color.ts +26 -4
  60. package/src/theme/shimmer.ts +55 -76
  61. package/src/theme/theme-class.ts +29 -12
  62. package/src/tools/output-meta.ts +11 -3
  63. package/src/tools/vibe.ts +15 -2
  64. package/src/tui.ts +208 -154
  65. package/src/utils.ts +15 -0
package/src/tools/vibe.ts CHANGED
@@ -23,6 +23,8 @@ export interface VibeToolDetails {
23
23
  op: VibeOp;
24
24
  /** Live TV-wall snapshot of the owner's worker sessions at (or during) the call. */
25
25
  screens: VibeScreenSnapshot[];
26
+ /** Sessions the director killed, left off `screens`; shown as a count. */
27
+ hiddenKilled?: string[];
26
28
  spawned?: { id: string; cli: VibeCli; jobId: string };
27
29
  send?: VibeSendOutcome;
28
30
  wait?: {
@@ -46,6 +48,8 @@ export interface VibeScreenSnapshot {
46
48
  id: string;
47
49
  cli: VibeCli;
48
50
  state: VibeSessionState;
51
+ /** Terminated by the director (`vibe_kill`) or mode teardown; unset for workers that died on their own. */
52
+ killed?: boolean;
49
53
  model?: string;
50
54
  turns: number;
51
55
  queued: number;
@@ -373,13 +377,21 @@ export function createVibeToolRenderer(op: VibeOp) {
373
377
  return new Text(header, 0, 0);
374
378
  }
375
379
 
376
- // wait/list: the TV wall.
380
+ // wait/list: the TV wall. Director-killed sessions arrive pre-filtered
381
+ // (see `hiddenKilled`) so a long-running director's wall stays legible.
377
382
  const screens = details.screens;
383
+ const hiddenKilled = details.hiddenKilled?.length ?? 0;
384
+ const killedMeta = hiddenKilled > 0 ? [uiTheme.fg("dim", `${hiddenKilled} killed hidden`)] : [];
378
385
  if (screens.length === 0) {
379
386
  const fallback = result.content.find(part => part.type === "text")?.text ?? "no sessions";
387
+ const gist = hiddenKilled > 0 ? "no live sessions" : fallback;
380
388
  return new Text(
381
389
  renderStatusLine(
382
- { icon: "warning", title: `vibe ${op}`, meta: [uiTheme.fg("dim", frameText(fallback, 60))] },
390
+ {
391
+ icon: "warning",
392
+ title: `vibe ${op}`,
393
+ meta: [uiTheme.fg("dim", frameText(gist, 60)), ...killedMeta],
394
+ },
383
395
  uiTheme,
384
396
  ),
385
397
  0,
@@ -394,6 +406,7 @@ export function createVibeToolRenderer(op: VibeOp) {
394
406
  if (running > 0) meta.push(uiTheme.fg("accent", `${running} on air`));
395
407
  if (settledById.size > 0) meta.push(uiTheme.fg("success", `${settledById.size} settled`));
396
408
  if (details.wait?.timedOut) meta.push(uiTheme.fg("warning", "timed out"));
409
+ meta.push(...killedMeta);
397
410
  const title =
398
411
  op === "wait"
399
412
  ? waiting
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;
690
+ }
691
+ if (adjacent && start === j) {
692
+ out += `${line.slice(copiedUpto, j)}0`;
693
+ copiedUpto = j;
699
694
  }
700
- i = k;
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;
@@ -883,7 +884,8 @@ export class TUI extends Container {
883
884
  // engine paints only the modal on the alt buffer and leaves every
884
885
  // normal-screen accounting field (#previousFrameLength, #viewportTopRow, …)
885
886
  // untouched, so exiting reconciles cleanly against the terminal-restored
886
- // normal screen. #altPreviousLines is the last alt frame, for repaint-skip.
887
+ // normal screen. #altPreviousLines is the last alt frame, diffed row by row
888
+ // against the next one.
887
889
  #altActive = false;
888
890
  #mouseTracking: MouseTrackingState = "off";
889
891
  /** Product-owned probe for opt-in normal-buffer click capture (`tui.mouse`). Read every frame. */
@@ -1761,9 +1763,13 @@ export class TUI extends Container {
1761
1763
  (provider ? provider.renderFrame({ columns: width, rows: height }).viewport : this.render(width));
1762
1764
  } while (this.#imageBudget.endPass());
1763
1765
  const viewport = rendered.length > height ? rendered.slice(rendered.length - height) : Array.from(rendered);
1764
- this.#extractCursorMarkers(viewport);
1765
1766
  // The borrowed resize buffer is transient, not a streamable session paint.
1766
- 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
+ );
1767
1773
  }
1768
1774
 
1769
1775
  /**
@@ -2585,30 +2591,6 @@ export class TUI extends Container {
2585
2591
  return sliceByColumn(result, 0, totalWidth, true);
2586
2592
  }
2587
2593
 
2588
- /**
2589
- * Strip every CURSOR_MARKER from the rendered lines (markers are internal
2590
- * sentinels and must never reach the terminal) and return their positions,
2591
- * bottom-most first. Callers pick the visible one once the window top is
2592
- * known.
2593
- */
2594
- #extractCursorMarkers(lines: string[]): { row: number; col: number }[] {
2595
- const markers: { row: number; col: number }[] = [];
2596
- for (let row = lines.length - 1; row >= 0; row--) {
2597
- const line = lines[row];
2598
- let markerIndex = line.indexOf(CURSOR_MARKER);
2599
- if (markerIndex === -1) continue;
2600
- const beforeMarker = line.slice(0, markerIndex);
2601
- markers.push({ row, col: visibleWidth(beforeMarker) });
2602
- let stripped = line;
2603
- while (markerIndex !== -1) {
2604
- stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
2605
- markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
2606
- }
2607
- lines[row] = stripped;
2608
- }
2609
- return markers;
2610
- }
2611
-
2612
2594
  /**
2613
2595
  * Rewrite a Kitty direct-placement line for the viewport row it is written
2614
2596
  * at, clipping to the visible slice (see {@link encodeKittyPlacementLine})
@@ -2801,9 +2783,11 @@ export class TUI extends Container {
2801
2783
  replayViewportRows = moved;
2802
2784
  }
2803
2785
  }
2804
- const markers = this.#extractCursorMarkers(viewport);
2805
- 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.
2806
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);
2807
2791
  const rows = prepared.lines.length;
2808
2792
  // Destructive reset (session replace, /tree, explicit clear, or a settled
2809
2793
  // resize in rebuild mode): erase native history and the viewport,
@@ -2867,16 +2851,18 @@ export class TUI extends Container {
2867
2851
  this.#providerWindow.length > 0;
2868
2852
  if (diffable) {
2869
2853
  for (let index = 0; index < rows; index++) {
2870
- const previous = this.#providerPreparedRows[index];
2871
- const current = prepared.rows[index]!;
2872
2854
  if (
2873
- this.#providerWindow[index] === prepared.lines[index] &&
2874
- previous !== undefined &&
2875
- previous.widthEpoch === current.widthEpoch &&
2876
- previous.imageProtocol === current.imageProtocol
2855
+ !this.#rowNeedsRewrite(
2856
+ this.#providerWindow,
2857
+ this.#providerPreparedRows,
2858
+ prepared.lines,
2859
+ prepared.rows,
2860
+ index,
2861
+ )
2877
2862
  ) {
2878
2863
  continue;
2879
2864
  }
2865
+ const current = prepared.rows[index]!;
2880
2866
  buffer += `\x1b[${newTop + index + 1};1H${this.#lineRewriteSequence(
2881
2867
  current,
2882
2868
  width,
@@ -3145,13 +3131,23 @@ export class TUI extends Container {
3145
3131
  * Prepare one string projection plus its structured write sidecar. A prior
3146
3132
  * sidecar entry is reusable only under identical raw content, width, width
3147
3133
  * configuration, and image protocol; those are every mutable input to
3148
- * 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.
3149
3144
  */
3150
3145
  #prepareLinesArray(
3151
3146
  lines: readonly string[],
3152
3147
  width: number,
3153
3148
  previous: readonly PreparedLine[] = [],
3154
3149
  length = lines.length,
3150
+ markers?: { row: number; col: number }[],
3155
3151
  ): PreparedLines {
3156
3152
  // oxlint-disable-next-line unicorn/no-new-array -- render-frame length preallocation
3157
3153
  const prepared: string[] = new Array(length);
@@ -3159,23 +3155,61 @@ export class TUI extends Container {
3159
3155
  const rows: PreparedLine[] = new Array(length);
3160
3156
  const widthEpoch = getWidthConfigEpoch();
3161
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;
3162
3162
  for (let i = 0; i < length; i++) {
3163
- const raw = lines[i] ?? "";
3164
- const cached = previous[i];
3165
- const row =
3166
- cached !== undefined &&
3167
- cached.raw === raw &&
3168
- cached.width === width &&
3169
- cached.widthEpoch === widthEpoch &&
3170
- cached.imageProtocol === imageProtocol
3171
- ? cached
3172
- : 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);
3173
3184
  prepared[i] = row.line;
3174
3185
  rows[i] = row;
3175
3186
  }
3187
+ if (nextMemo !== undefined) {
3188
+ memo.clear();
3189
+ this.#preparedLineMemo = nextMemo;
3190
+ this.#preparedLineMemoSpare = memo;
3191
+ markers?.reverse();
3192
+ }
3176
3193
  return { lines: prepared, rows };
3177
3194
  }
3178
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
+
3179
3213
  #prepareLine(raw: string, width: number, widthEpoch: number, imageProtocol: ImageProtocol | null): PreparedLine {
3180
3214
  const safeWidth = Number.isFinite(width) ? Math.max(1, Math.trunc(width)) : 1;
3181
3215
  const maxSourceLength = Math.min(
@@ -3440,6 +3474,29 @@ export class TUI extends Container {
3440
3474
  return visibleWidth(above);
3441
3475
  }
3442
3476
 
3477
+ /**
3478
+ * Whether screen row `index` must be rewritten to turn the previously painted
3479
+ * frame into this one: its line, or the preparation it was painted with,
3480
+ * changed.
3481
+ */
3482
+ #rowNeedsRewrite(
3483
+ previousLines: readonly string[],
3484
+ previousRows: readonly PreparedLine[],
3485
+ lines: readonly string[],
3486
+ rows: readonly PreparedLine[],
3487
+ index: number,
3488
+ ): boolean {
3489
+ const previous = previousRows[index];
3490
+ const current = rows[index]!;
3491
+ return (
3492
+ previousLines[index] !== lines[index] ||
3493
+ previous === undefined ||
3494
+ previous.width !== current.width ||
3495
+ previous.widthEpoch !== current.widthEpoch ||
3496
+ previous.imageProtocol !== current.imageProtocol
3497
+ );
3498
+ }
3499
+
3443
3500
  #lineRewriteSequence(
3444
3501
  line: PreparedLine,
3445
3502
  width: number,
@@ -3543,15 +3600,16 @@ export class TUI extends Container {
3543
3600
  this.#imageBudget.beginPass(false, true);
3544
3601
  lines = this.#compositeOverlaysIntoWindow(base, width, height);
3545
3602
  } while (this.#imageBudget.endPass());
3546
- this.#extractCursorMarkers(lines);
3547
- const prepared = this.#prepareLinesArray(lines, width, this.#altPreparedRows, height);
3603
+ const prepared = this.#prepareLinesArray(lines, width, this.#altPreparedRows, height, []);
3548
3604
  this.#emitAltFrame(prepared, width, height, true);
3549
3605
  }
3550
3606
 
3551
3607
  /**
3552
- * Full per-row viewport rewrite on the alt buffer. Emits only sync-output
3553
- * brackets, a cursor home, and per-row rewrites — never ED3 or any
3554
- * native-scrollback byte. The hardware cursor stays hidden here.
3608
+ * Paint a frame on the alt buffer: only the rows that changed since the
3609
+ * previous frame, or every row when the height changed, a repaint is forced,
3610
+ * or a changed frame holds OSC 66 text before or after. Emits only
3611
+ * sync-output brackets, cursor moves, and per-row rewrites — never ED3 or
3612
+ * any native-scrollback byte. The hardware cursor stays hidden here.
3555
3613
  */
3556
3614
  #emitAltFrame(prepared: PreparedLines, width: number, height: number, notifyPaint: boolean): void {
3557
3615
  // The pass that composed this frame ran with `altScreen`, so the normal
@@ -3573,38 +3631,34 @@ export class TUI extends Container {
3573
3631
  for (const seq of imageTransmits) transmitBuffer += seq;
3574
3632
  this.terminal.write(transmitBuffer);
3575
3633
  }
3576
- // Skip an identical repaint (the modal is mostly static between
3577
- // keystrokes) — unless a forced repaint (resetDisplay,
3578
- // requestRender(true)) is pending: the redraw gesture must repair a
3579
- // corrupted modal even when our cached frame is byte-identical.
3634
+ // A forced repaint (resetDisplay, requestRender(true)) rewrites every row
3635
+ // even when the cached frame is byte-identical: the redraw gesture must
3636
+ // repair a corrupted modal. So does a changed frame with OSC 66 text in
3637
+ // it, before or after: a scaled glyph spans the rows below its own and the
3638
+ // terminal drops it when any of them is written, so those rows are not
3639
+ // independent. Otherwise rewrite only the rows that changed (a keystroke
3640
+ // in a modal touches a row or two), and skip an identical frame entirely.
3580
3641
  const force = this.#forceViewportRepaintOnNextRender;
3581
3642
  this.#forceViewportRepaintOnNextRender = false;
3582
- if (!force && this.#altPreviousLines.length === height) {
3583
- let same = true;
3584
- for (let r = 0; r < height; r++) {
3585
- const previous = this.#altPreparedRows[r];
3586
- const current = prepared.rows[r]!;
3587
- if (
3588
- prepared.lines[r] !== this.#altPreviousLines[r] ||
3589
- previous === undefined ||
3590
- previous.width !== current.width ||
3591
- previous.widthEpoch !== current.widthEpoch ||
3592
- previous.imageProtocol !== current.imageProtocol
3593
- ) {
3594
- same = false;
3595
- break;
3596
- }
3597
- }
3598
- if (same) {
3599
- this.#altPreviousLines = prepared.lines;
3600
- this.#altPreparedRows = prepared.rows;
3601
- return;
3602
- }
3603
- }
3604
- let buffer = `${this.#paintBeginSequence}\x1b[H`;
3643
+ const full =
3644
+ force ||
3645
+ this.#altPreviousLines.length !== height ||
3646
+ ((this.#altPreviousLines.some(isOsc66Line) || prepared.lines.some(isOsc66Line)) &&
3647
+ prepared.rows.some((_row, r) =>
3648
+ this.#rowNeedsRewrite(this.#altPreviousLines, this.#altPreparedRows, prepared.lines, prepared.rows, r),
3649
+ ));
3650
+ let rowsBuffer = "";
3605
3651
  for (let r = 0; r < height; r++) {
3606
- if (r > 0) buffer += "\n";
3607
- buffer += this.#lineRewriteSequence(
3652
+ if (full) {
3653
+ if (r > 0) rowsBuffer += "\n";
3654
+ } else if (
3655
+ this.#rowNeedsRewrite(this.#altPreviousLines, this.#altPreparedRows, prepared.lines, prepared.rows, r)
3656
+ ) {
3657
+ rowsBuffer += `\x1b[${r + 1};1H`;
3658
+ } else {
3659
+ continue;
3660
+ }
3661
+ rowsBuffer += this.#lineRewriteSequence(
3608
3662
  prepared.rows[r]!,
3609
3663
  width,
3610
3664
  r,
@@ -3613,10 +3667,10 @@ export class TUI extends Container {
3613
3667
  this.#osc66SpacerGlyphWidth(prepared.lines, r),
3614
3668
  );
3615
3669
  }
3616
- buffer += this.#paintEndSequence;
3617
- this.terminal.write(buffer);
3618
3670
  this.#altPreviousLines = prepared.lines;
3619
3671
  this.#altPreparedRows = prepared.rows;
3672
+ if (rowsBuffer === "") return;
3673
+ this.terminal.write(`${this.#paintBeginSequence}${full ? "\x1b[H" : ""}${rowsBuffer}${this.#paintEndSequence}`);
3620
3674
  this.#debugPaint = { lines: prepared.lines, windowTop: 0, altScreen: true };
3621
3675
  if (notifyPaint) {
3622
3676
  this.#notifyPaint({
@@ -3628,6 +3682,6 @@ export class TUI extends Container {
3628
3682
  rows: height,
3629
3683
  });
3630
3684
  }
3631
- this.#fullRedrawCount += 1;
3685
+ if (full) this.#fullRedrawCount += 1;
3632
3686
  }
3633
3687
  }
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;