@oh-my-pi/pi-tui 17.3.7 → 17.4.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/types/components/composer/borderless.d.ts +8 -0
  3. package/dist/types/components/composer/box.d.ts +2 -0
  4. package/dist/types/components/composer/claude.d.ts +8 -0
  5. package/dist/types/components/composer/field.d.ts +3 -0
  6. package/dist/types/components/composer/index.d.ts +9 -0
  7. package/dist/types/components/composer/pi.d.ts +2 -0
  8. package/dist/types/components/composer/rail.d.ts +3 -0
  9. package/dist/types/components/composer/registry.d.ts +12 -0
  10. package/dist/types/components/composer/rule.d.ts +7 -0
  11. package/dist/types/components/composer/types.d.ts +90 -0
  12. package/dist/types/components/editor.d.ts +10 -9
  13. package/dist/types/components/settings-list.d.ts +7 -1
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/terminal-capabilities.d.ts +7 -0
  16. package/dist/types/utils.d.ts +0 -1
  17. package/package.json +3 -3
  18. package/src/components/composer/borderless.ts +37 -0
  19. package/src/components/composer/box.ts +85 -0
  20. package/src/components/composer/claude.ts +39 -0
  21. package/src/components/composer/field.ts +47 -0
  22. package/src/components/composer/index.ts +9 -0
  23. package/src/components/composer/pi.ts +37 -0
  24. package/src/components/composer/rail.ts +48 -0
  25. package/src/components/composer/registry.ts +46 -0
  26. package/src/components/composer/rule.ts +55 -0
  27. package/src/components/composer/types.ts +98 -0
  28. package/src/components/editor.ts +115 -99
  29. package/src/components/settings-list.ts +43 -11
  30. package/src/index.ts +1 -0
  31. package/src/terminal-capabilities.ts +10 -0
  32. package/src/tui.ts +53 -56
  33. package/src/utils.ts +53 -58
@@ -20,6 +20,8 @@ export interface SettingItem {
20
20
  label: string;
21
21
  /** Optional description shown when selected */
22
22
  description?: string;
23
+ /** Optional risk note shown in warning styling above the description, with a glyph on the row. */
24
+ warning?: string;
23
25
  /** Current value to display (right side) */
24
26
  currentValue: string;
25
27
  /** If provided, Enter/Space cycles through these values */
@@ -36,6 +38,10 @@ export interface SettingsListTheme {
36
38
  label: (text: string, selected: boolean, changed: boolean) => string;
37
39
  value: (text: string, selected: boolean, changed: boolean) => string;
38
40
  description: (text: string) => string;
41
+ /** Style for risk notes and the row warning glyph. Falls back to `description` when omitted. */
42
+ warning?: (text: string) => string;
43
+ /** Glyph marking rows that carry a `warning`. Omitted hides the row marker. */
44
+ warningMark?: string;
39
45
  cursor: string;
40
46
  hint: (text: string) => string;
41
47
  /** Style for section heading rows (dimmed when outside the active section). Falls back to `hint` when omitted. */
@@ -78,12 +84,15 @@ export interface SettingsListOptions {
78
84
  sidebarWidth?: number;
79
85
  }
80
86
 
81
- /** Searchable text for a setting item: label, id, value, description, and cycle values. */
87
+ /** Searchable text for a setting item: label, id, value, description, warning, and cycle values. */
82
88
  export function getSettingItemFilterText(item: SettingItem): string {
83
89
  let text = `${item.label} ${item.id} ${item.currentValue}`;
84
90
  if (item.description) {
85
91
  text += ` ${item.description}`;
86
92
  }
93
+ if (item.warning) {
94
+ text += ` ${item.warning}`;
95
+ }
87
96
  if (item.values) {
88
97
  text += ` ${item.values.join(" ")}`;
89
98
  }
@@ -477,6 +486,11 @@ export class SettingsList implements Component {
477
486
  return this.#padLines(this.#renderMainList(width));
478
487
  }
479
488
 
489
+ /** Warning glyph suffix for a row that carries a risk note, or "" when none applies. */
490
+ #warningMark(item: SettingItem): string {
491
+ return item.warning && this.#theme.warningMark ? ` ${this.#theme.warningMark}` : "";
492
+ }
493
+
480
494
  #renderItemRow(
481
495
  item: SettingItem,
482
496
  index: number,
@@ -495,7 +509,9 @@ export class SettingsList implements Component {
495
509
  const isSelected = index === this.#selectedIndex && !this.#sectionFocus;
496
510
  const prefix = isSelected ? this.#theme.cursor : " ";
497
511
  const prefixWidth = visibleWidth(prefix);
498
- const labelPadded = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
512
+ const mark = this.#warningMark(item);
513
+ const labelPlain = item.label + mark;
514
+ const labelPad = padding(Math.max(0, maxLabelWidth - visibleWidth(labelPlain)));
499
515
  const separator = " ";
500
516
  const valueMaxWidth = rowWidth - prefixWidth - maxLabelWidth - visibleWidth(separator) - 2;
501
517
  const valuePlain = truncateToWidth(String(item.currentValue ?? ""), valueMaxWidth, Ellipsis.Omit);
@@ -504,11 +520,13 @@ export class SettingsList implements Component {
504
520
  // under one dim wash so inner label/value colors don't fight it.
505
521
  if (dimmed && !isSelected) {
506
522
  const text = this.#theme.hint(
507
- truncateToWidth(` ${labelPadded}${separator}${valuePlain}`, Math.max(0, rowWidth)),
523
+ truncateToWidth(` ${labelPlain}${labelPad}${separator}${valuePlain}`, Math.max(0, rowWidth)),
508
524
  );
509
525
  return hovered && this.#theme.hovered ? this.#theme.hovered(text) : text;
510
526
  }
511
- const labelText = this.#theme.label(labelPadded, isSelected, item.changed === true);
527
+ const warningStyle = this.#theme.warning ?? this.#theme.description;
528
+ const labelText =
529
+ this.#theme.label(item.label, isSelected, item.changed === true) + (mark ? warningStyle(mark) : "") + labelPad;
512
530
  const valueText = this.#theme.value(valuePlain, isSelected, item.changed === true);
513
531
  const text = truncateToWidth(prefix + labelText + separator + valueText, Math.max(0, rowWidth));
514
532
  // Pointer hover paints a band behind the whole row, distinct from the
@@ -550,7 +568,9 @@ export class SettingsList implements Component {
550
568
  0,
551
569
  Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#filteredItems.length - viewportHeight),
552
570
  );
553
- const labelWidths = this.#filteredItems.filter(item => !item.heading).map(item => visibleWidth(item.label));
571
+ const labelWidths = this.#filteredItems
572
+ .filter(item => !item.heading)
573
+ .map(item => visibleWidth(item.label + this.#warningMark(item)));
554
574
  const maxLabelWidth = Math.min(30, labelWidths.length > 0 ? Math.max(...labelWidths) : 0);
555
575
  const itemRowsOverflow = this.#filteredItems.length > viewportHeight;
556
576
  const itemRowWidth = Math.max(0, width - (itemRowsOverflow ? 1 : 0));
@@ -589,15 +609,25 @@ export class SettingsList implements Component {
589
609
 
590
610
  // Description area: 1 blank + exactly 3 rows, clamped with an ellipsis,
591
611
  // so moving between items with/without descriptions never shifts rows.
612
+ // The risk note leads so it survives the clamp when both are present.
592
613
  lines.push("");
593
614
  const selectedItem = this.#filteredItems[this.#selectedIndex];
594
615
  const descLines: string[] = [];
595
- if (selectedItem?.description && !selectedItem.heading) {
596
- const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
597
- for (const line of wrappedDesc.slice(0, 3)) {
598
- descLines.push(this.#theme.description(` ${line}`));
616
+ if (selectedItem && !selectedItem.heading) {
617
+ if (selectedItem.warning) {
618
+ const warningStyle = this.#theme.warning ?? this.#theme.description;
619
+ const mark = this.#theme.warningMark ? `${this.#theme.warningMark} ` : "";
620
+ for (const line of wrapTextWithAnsi(`${mark}${selectedItem.warning}`, width - 4)) {
621
+ descLines.push(warningStyle(` ${line}`));
622
+ }
623
+ }
624
+ if (selectedItem.description) {
625
+ for (const line of wrapTextWithAnsi(selectedItem.description, width - 4)) {
626
+ descLines.push(this.#theme.description(` ${line}`));
627
+ }
599
628
  }
600
- if (wrappedDesc.length > 3) {
629
+ if (descLines.length > 3) {
630
+ descLines.splice(3);
601
631
  descLines[2] = truncateToWidth(`${descLines[2]}…`, width);
602
632
  }
603
633
  }
@@ -659,7 +689,9 @@ export class SettingsList implements Component {
659
689
  Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#filteredItems.length - viewportHeight),
660
690
  );
661
691
  // Label column width spans all items so the layout stays stable across sections.
662
- const labelWidths = this.#filteredItems.filter(item => !item.heading).map(item => visibleWidth(item.label));
692
+ const labelWidths = this.#filteredItems
693
+ .filter(item => !item.heading)
694
+ .map(item => visibleWidth(item.label + this.#warningMark(item)));
663
695
  const maxLabelWidth = Math.min(30, labelWidths.length > 0 ? Math.max(...labelWidths) : 0);
664
696
  const overflow = this.#filteredItems.length > viewportHeight;
665
697
  const rowWidth = Math.max(0, paneWidth - (overflow ? 1 : 0));
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./autocomplete";
5
5
  // Components
6
6
  export * from "./components/box";
7
7
  export * from "./components/cancellable-loader";
8
+ export * from "./components/composer";
8
9
  export * from "./components/editor";
9
10
  export * from "./components/image";
10
11
  export * from "./components/input";
@@ -223,6 +223,16 @@ function getForcedImageProtocol(): ImageProtocol | null | undefined {
223
223
  return null;
224
224
  }
225
225
 
226
+ /**
227
+ * Whether `PI_FORCE_IMAGE_PROTOCOL` pins the image protocol, including its
228
+ * `off`/`none` kill switch. A runtime capability probe must not override an
229
+ * explicit user choice: a forced protocol is already applied to {@link TERMINAL},
230
+ * and a forced "off" leaves `imageProtocol` null on purpose.
231
+ */
232
+ export function isImageProtocolForced(): boolean {
233
+ return getForcedImageProtocol() !== undefined;
234
+ }
235
+
226
236
  function parseMajorMinorVersion(versionRaw?: string): { major: number; minor: number } | null {
227
237
  if (!versionRaw) return null;
228
238
  const match = /^(\d+)\.(\d+)/u.exec(versionRaw.trim());
package/src/tui.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  encodeKittyDeletePlacement,
29
29
  encodeKittyPlacementLine,
30
30
  ImageProtocol,
31
+ isImageProtocolForced,
31
32
  isInsideTerminalMultiplexer,
32
33
  parseKittyDirectPlacementLine,
33
34
  setCellDimensions,
@@ -1249,7 +1250,6 @@ export class TUI extends Container {
1249
1250
  #hardwareCursorState: HardwareCursorState | null = null;
1250
1251
  #hardwareCursorVisibilityKnown = false;
1251
1252
  #hardwareCursorVisible = false;
1252
- #sixelProbePendingDa = false;
1253
1253
  #sixelProbePendingGraphics = false;
1254
1254
  #sixelProbeBuffer = "";
1255
1255
  #sixelProbeTimeout?: NodeJS.Timeout;
@@ -1332,6 +1332,12 @@ export class TUI extends Container {
1332
1332
  #previousWindow: string[] = [];
1333
1333
  #nativeScrollbackLiveRegionStart: number | undefined;
1334
1334
  #nativeScrollbackLiveRegionPinned = false;
1335
+ // Start row of the topmost live region that pinned itself. The topmost seam
1336
+ // governs the exactness boundary and the frame-wide pin policy, but a pinned
1337
+ // region BELOW an unpinned seam (an anchored HUD/panel under a streaming
1338
+ // transcript) still must never commit its rows to native scrollback. This is
1339
+ // the ceiling no commit may cross, independent of the topmost seam's policy.
1340
+ #nativeScrollbackPinnedBoundary: number | undefined;
1335
1341
  #fullRedrawCount = 0;
1336
1342
  // Caps how many inline images render as live graphics; older ones fall back
1337
1343
  // to text via a purge + full redraw. Cap is configured by the host app.
@@ -1625,6 +1631,7 @@ export class TUI extends Container {
1625
1631
  width = Math.max(1, width);
1626
1632
  this.#nativeScrollbackLiveRegionStart = undefined;
1627
1633
  this.#nativeScrollbackLiveRegionPinned = false;
1634
+ this.#nativeScrollbackPinnedBoundary = undefined;
1628
1635
  const children = this.children;
1629
1636
  const previousSegments = this.#frameSegments;
1630
1637
  const segments: FrameSegment[] = new Array(children.length);
@@ -1705,9 +1712,18 @@ export class TUI extends Container {
1705
1712
  // transcript) must never overwrite it — moving the boundary down
1706
1713
  // would commit the earlier child's still-mutable rows as stale
1707
1714
  // history.
1708
- if (liveLocalStart !== undefined && this.#nativeScrollbackLiveRegionStart === undefined) {
1709
- this.#nativeScrollbackLiveRegionStart = offset + liveLocalStart;
1710
- this.#nativeScrollbackLiveRegionPinned = liveRegionPinned;
1715
+ if (liveLocalStart !== undefined) {
1716
+ const start = offset + liveLocalStart;
1717
+ if (this.#nativeScrollbackLiveRegionStart === undefined) {
1718
+ this.#nativeScrollbackLiveRegionStart = start;
1719
+ this.#nativeScrollbackLiveRegionPinned = liveRegionPinned;
1720
+ }
1721
+ // A pinned region anywhere in the frame caps commits at its start,
1722
+ // even when an earlier unpinned seam won the topmost merge above:
1723
+ // its rows (a growing anchored panel) must never reach scrollback.
1724
+ if (liveRegionPinned && this.#nativeScrollbackPinnedBoundary === undefined) {
1725
+ this.#nativeScrollbackPinnedBoundary = start;
1726
+ }
1711
1727
  }
1712
1728
  if (chainStable) {
1713
1729
  if (previous !== undefined && previous.component === child && previous.start === offset) {
@@ -2200,19 +2216,21 @@ export class TUI extends Container {
2200
2216
  }
2201
2217
 
2202
2218
  #querySixelSupport(): void {
2219
+ // A statically known protocol (Kitty/iTerm2 terminals) or an explicit
2220
+ // PI_FORCE_IMAGE_PROTOCOL choice — including its `off` kill switch — wins
2221
+ // over the probe.
2203
2222
  if (TERMINAL.imageProtocol) return;
2204
- // win32 native or WSL under Windows Terminal — both are ConPTY-hosted and
2205
- // reach the same WT graphics negotiation. WSL reports process.platform
2206
- // "linux", so a bare win32 check silently skips the probe there (#6009).
2207
- if (!isConPTYHosted()) return;
2208
- if (!Bun.env.WT_SESSION) return;
2223
+ if (isImageProtocolForced()) return;
2209
2224
  if (!process.stdin.isTTY || !process.stdout.isTTY) return;
2210
2225
 
2211
2226
  this.#clearSixelProbeState();
2212
- this.#sixelProbePendingDa = true;
2213
2227
  this.#sixelProbePendingGraphics = true;
2214
2228
  this.#sixelProbeUnsubscribe = this.addInputListener(data => this.#handleSixelProbeInput(data));
2215
- this.terminal.write("\x1b[c");
2229
+ // XTSMGRAPHICS item 2 reports the terminal's maximum SIXEL geometry. DA1
2230
+ // attribute 4 advertises SIXEL as well, but ProcessTerminal swallows every
2231
+ // `CSI ? … c` reply for the whole session so a late one cannot leak into the
2232
+ // composer (#8542): those bytes never reach an input listener, so this probe
2233
+ // cannot read them.
2216
2234
  this.terminal.write("\x1b[?2;1;0S");
2217
2235
  this.#sixelProbeTimeout = setTimeout(() => {
2218
2236
  this.#finishSixelProbe(false);
@@ -2220,7 +2238,7 @@ export class TUI extends Container {
2220
2238
  }
2221
2239
 
2222
2240
  #handleSixelProbeInput(data: string): InputListenerResult {
2223
- if (!this.#sixelProbePendingDa && !this.#sixelProbePendingGraphics) {
2241
+ if (!this.#sixelProbePendingGraphics) {
2224
2242
  return undefined;
2225
2243
  }
2226
2244
 
@@ -2229,47 +2247,24 @@ export class TUI extends Container {
2229
2247
  let probeOutcome: boolean | null = null;
2230
2248
 
2231
2249
  while (this.#sixelProbeBuffer.length > 0) {
2232
- const daMatch = this.#sixelProbeBuffer.match(/\x1b\[\?([0-9;]+)c/u);
2233
2250
  const graphicsMatch = this.#sixelProbeBuffer.match(/\x1b\[\?2;(\d+);([0-9;]+)S/u);
2251
+ if (!graphicsMatch || graphicsMatch.index === undefined) break;
2234
2252
 
2235
- if (!daMatch && !graphicsMatch) break;
2236
-
2237
- const daIndex = daMatch?.index ?? Number.POSITIVE_INFINITY;
2238
- const graphicsIndex = graphicsMatch?.index ?? Number.POSITIVE_INFINITY;
2239
- const useDa = daIndex <= graphicsIndex;
2240
- const match = useDa ? daMatch : graphicsMatch;
2241
- if (!match || match.index === undefined) break;
2242
-
2243
- passthrough += this.#sixelProbeBuffer.slice(0, match.index);
2244
- this.#sixelProbeBuffer = this.#sixelProbeBuffer.slice(match.index + match[0].length);
2245
-
2246
- if (useDa && this.#sixelProbePendingDa) {
2247
- this.#sixelProbePendingDa = false;
2248
- const attributes = (match[1] ?? "")
2249
- .split(";")
2250
- .map(value => Number.parseInt(value, 10))
2251
- .filter(value => Number.isFinite(value));
2252
- const hasSixelAttribute = attributes.includes(4);
2253
- if (hasSixelAttribute) {
2254
- this.#sixelProbePendingGraphics = false;
2255
- probeOutcome = true;
2256
- } else if (!this.#sixelProbePendingGraphics) {
2257
- probeOutcome = false;
2258
- }
2259
- } else if (!useDa && this.#sixelProbePendingGraphics) {
2253
+ passthrough += this.#sixelProbeBuffer.slice(0, graphicsMatch.index);
2254
+ this.#sixelProbeBuffer = this.#sixelProbeBuffer.slice(graphicsMatch.index + graphicsMatch[0].length);
2255
+
2256
+ if (this.#sixelProbePendingGraphics) {
2260
2257
  this.#sixelProbePendingGraphics = false;
2261
- const status = Number.parseInt(match[1] ?? "", 10);
2262
- const supportsSixel = !Number.isNaN(status) && status !== 0;
2263
- if (supportsSixel) {
2264
- this.#sixelProbePendingDa = false;
2265
- probeOutcome = true;
2266
- } else if (!this.#sixelProbePendingDa) {
2267
- probeOutcome = false;
2268
- }
2258
+ // Reply shape `CSI ? 2 ; Ps ; Pv S`: per xterm ctlseqs Ps is the status
2259
+ // (0 = success, 1..3 = error/failure) and Pv the maximum SIXEL geometry,
2260
+ // which a terminal without SIXEL reports as zero.
2261
+ const status = Number.parseInt(graphicsMatch[1] ?? "", 10);
2262
+ const hasGeometry = (graphicsMatch[2] ?? "").split(";").some(part => Number.parseInt(part, 10) > 0);
2263
+ probeOutcome = status === 0 && hasGeometry;
2269
2264
  }
2270
2265
  }
2271
2266
 
2272
- if (this.#sixelProbePendingDa || this.#sixelProbePendingGraphics) {
2267
+ if (this.#sixelProbePendingGraphics) {
2273
2268
  const partialStart = this.#getSixelProbePartialStart(this.#sixelProbeBuffer);
2274
2269
  if (partialStart >= 0) {
2275
2270
  passthrough += this.#sixelProbeBuffer.slice(0, partialStart);
@@ -2313,7 +2308,6 @@ export class TUI extends Container {
2313
2308
  this.#sixelProbeUnsubscribe();
2314
2309
  this.#sixelProbeUnsubscribe = undefined;
2315
2310
  }
2316
- this.#sixelProbePendingDa = false;
2317
2311
  this.#sixelProbePendingGraphics = false;
2318
2312
  this.#sixelProbeBuffer = "";
2319
2313
  }
@@ -3551,6 +3545,11 @@ export class TUI extends Container {
3551
3545
  // reports no seam (shell semantics).
3552
3546
  const frameLength = rawFrame.length;
3553
3547
  const finalBoundary = Math.max(0, Math.min(frameLength, liveRegionStart ?? frameLength));
3548
+ // No commit may cross into a pinned region, even one below an unpinned
3549
+ // topmost seam (an anchored HUD/panel under a streaming transcript). The
3550
+ // topmost seam still governs exactness (finalBoundary); this ceiling only
3551
+ // bars a growing pinned region's scrolled-off rows from native scrollback.
3552
+ const commitCeiling = this.#nativeScrollbackPinnedBoundary ?? frameLength;
3554
3553
 
3555
3554
  // 2. Transition state captured before any emitter runs.
3556
3555
  let prevWindowTop = this.#windowTopRow;
@@ -3769,7 +3768,7 @@ export class TUI extends Container {
3769
3768
  if (fullPaint) {
3770
3769
  committedPrefixResliced = true;
3771
3770
  windowTop = Math.max(0, frameLength - height);
3772
- chunkTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop;
3771
+ chunkTo = Math.min(windowTop, commitCeiling);
3773
3772
  } else if (widthEpochReset) {
3774
3773
  // A terminal width change ends the physical-row coordinate epoch.
3775
3774
  // Resolve the last emitted logical source boundary at the new width;
@@ -3789,7 +3788,7 @@ export class TUI extends Container {
3789
3788
  hasVisibleOverlay || widthEpochCurrentRows === undefined
3790
3789
  ? hasVisibleOverlay
3791
3790
  ? widthEpochAppendFrom
3792
- : Math.max(widthEpochAppendFrom, liveRegionPinned ? finalBoundary : frameLength)
3791
+ : Math.max(widthEpochAppendFrom, commitCeiling)
3793
3792
  : Math.max(widthEpochAppendFrom, widthEpochCurrentRows);
3794
3793
  } else if (this.#widthEpochBaselineRows !== undefined) {
3795
3794
  // Only rows physically appended after the width epoch may drive the
@@ -3800,7 +3799,7 @@ export class TUI extends Container {
3800
3799
  windowTop = Math.max(0, frameLength - height);
3801
3800
  chunkTo = this.#committedRows;
3802
3801
  widthEpochAppendFrom = this.#widthEpochBaselineRows;
3803
- const appendBoundary = liveRegionPinned ? finalBoundary : frameLength;
3802
+ const appendBoundary = commitCeiling;
3804
3803
  widthEpochAppendTo = hasVisibleOverlay ? widthEpochAppendFrom : Math.max(widthEpochAppendFrom, appendBoundary);
3805
3804
  } else if (
3806
3805
  frameLength <= this.#committedRows ||
@@ -3821,7 +3820,7 @@ export class TUI extends Container {
3821
3820
  // "duplication, never loss" is the ED3-unsafe fallback contract.
3822
3821
  committedPrefixResliced = true;
3823
3822
  windowTop = Math.max(0, frameLength - height);
3824
- chunkTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop;
3823
+ chunkTo = Math.min(windowTop, commitCeiling);
3825
3824
  this.#committedRows = chunkTo;
3826
3825
  this.#committedPrefix = rawFrame.slice(0, chunkTo);
3827
3826
  } else if (geometryChanged && Math.max(0, frameLength - height) < this.#committedRows) {
@@ -3853,9 +3852,7 @@ export class TUI extends Container {
3853
3852
  chunkTo =
3854
3853
  hasVisibleOverlay || geometryChanged
3855
3854
  ? this.#committedRows
3856
- : liveRegionPinned
3857
- ? Math.min(windowTop, Math.max(this.#committedRows, finalBoundary))
3858
- : windowTop;
3855
+ : Math.min(windowTop, Math.max(this.#committedRows, commitCeiling));
3859
3856
  if (geometryChanged) {
3860
3857
  committedPrefixResliced = true;
3861
3858
  this.#committedPrefix = rawFrame.slice(0, this.#committedRows);
@@ -3965,7 +3962,7 @@ export class TUI extends Container {
3965
3962
  let commitTo: number;
3966
3963
  if (replayUnresolvedWidthEpoch) {
3967
3964
  commitFrom = 0;
3968
- commitTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop;
3965
+ commitTo = Math.min(windowTop, commitCeiling);
3969
3966
  scrollRows = commitTo;
3970
3967
  } else if (logicalAppend && !logicalPrefixAppend) {
3971
3968
  const sourceWindowTop = Math.max(0, widthEpochSourceBoundary - height);
package/src/utils.ts CHANGED
@@ -225,9 +225,7 @@ export function getSegmenter(): Intl.Segmenter {
225
225
  // added back so width matches the native truncate/slice/wrap helpers.
226
226
  const OSC66_SPAN_REGEX = /\x1b\]66;([^;]*);([\s\S]*?)(?:\x07|\x1b\\)/g;
227
227
  const OSC66_PREFIX = "\x1b]66;";
228
- const ESC = "\x1b";
229
- const TAB = "\t";
230
- const LONG_WIDTH_FAST_PATH_MIN = 128;
228
+ const PRINTABLE_ASCII_REGEX = /^[\u0020-\u007e]*$/;
231
229
 
232
230
  // Pin Bun.stringWidth semantics to the native width engine and guard against Bun
233
231
  // default drift: strip ANSI/OSC (don't count escape bytes) and treat
@@ -243,8 +241,6 @@ const STRING_WIDTH_OPTS = { countAnsiEscapeCodes: false, ambiguousIsNarrow: true
243
241
  // `setHangulCompatibilityJamoWidth`; mirror the same correction here so the TS
244
242
  // width stays in parity with the native truncate/slice/wrap model — and so the
245
243
  // hardware cursor column lands on the actual glyph during Korean IME input.
246
- const HANGUL_COMPAT_JAMO_REGEX = /[\u3131-\u318e]/;
247
- const HANGUL_COMPAT_JAMO_GLOBAL_REGEX = /[\u3131-\u318e]/g;
248
244
  const HANGUL_FILLER_CODE_POINT = 0x3164;
249
245
  // `Bun.stringWidth` counts every code point in the Compatibility Jamo block as
250
246
  // 2 cells (even the U+3164 filler that `unicode-width` treats as zero-width).
@@ -274,19 +270,28 @@ function hangulCompatibilityJamoTargetWidth(): 1 | 2 | null {
274
270
  // crates/pi-natives/src/text.rs, including the rule that the zero-width filler
275
271
  // (U+3164) is never widened past the narrow correction (a wide terminal still
276
272
  // renders it at its Unicode width of 0).
277
- function correctHangulCompatibilityJamoWidth(width: number, str: string): number {
278
- if (!HANGUL_COMPAT_JAMO_REGEX.test(str)) return width;
273
+ function correctHangulCompatibilityJamoWidth(
274
+ width: number,
275
+ compatibilityJamoCount: number,
276
+ fillerCount: number,
277
+ ): number {
278
+ if (compatibilityJamoCount === 0) return width;
279
279
  const target = hangulCompatibilityJamoTargetWidth();
280
- let corrected = width;
281
- HANGUL_COMPAT_JAMO_GLOBAL_REGEX.lastIndex = 0;
282
- for (let m = HANGUL_COMPAT_JAMO_GLOBAL_REGEX.exec(str); m !== null; m = HANGUL_COMPAT_JAMO_GLOBAL_REGEX.exec(str)) {
283
- const unicodeWidth = m[0].codePointAt(0) === HANGUL_FILLER_CODE_POINT ? 0 : 2;
284
- const finalWidth = target === null || (unicodeWidth === 0 && target > 1) ? unicodeWidth : target;
285
- corrected += finalWidth - HANGUL_COMPAT_JAMO_BUN_WIDTH;
286
- }
287
- return corrected;
280
+ return target === 1 ? width - compatibilityJamoCount : width - fillerCount * HANGUL_COMPAT_JAMO_BUN_WIDTH;
288
281
  }
289
282
 
283
+ // Terminal redraws re-measure the same visible lines every frame, usually as
284
+ // the same string objects (JSC caches their hashes, so repeat lookups are
285
+ // O(1) — cheaper than even the ASCII fast scan). Strings longer than the
286
+ // length gate skip the cache entirely: hashing them costs as much as measuring
287
+ // them, and retaining them would pin large render buffers. Worst-case
288
+ // retention is MAX * MAX_LEN UTF-16 units (~2 MiB); cleared when the width
289
+ // configuration epoch changes.
290
+ const VISIBLE_WIDTH_CACHE_MAX = 2048;
291
+ const VISIBLE_WIDTH_CACHE_MAX_LEN = 512;
292
+ const visibleWidthCache = new Map<string, number>();
293
+ let visibleWidthCacheEpoch = widthConfigEpoch;
294
+
290
295
  /**
291
296
  * Visible width of a string in terminal columns, excluding ANSI/OSC escapes.
292
297
  *
@@ -296,65 +301,51 @@ function correctHangulCompatibilityJamoWidth(width: number, str: string): number
296
301
  */
297
302
  export function visibleWidth(str: string): number {
298
303
  if (!str) return 0;
299
-
300
- // Long non-escape text is faster through Bun's native scanner than through
301
- // a JS printable-ASCII prepass. Escape-bearing strings stay on the scanner
302
- // below so CSI/OSC-heavy render output can still bail out at the first ESC.
303
- if (str.length >= LONG_WIDTH_FAST_PATH_MIN && !str.includes(ESC)) {
304
- let width = Bun.stringWidth(str, STRING_WIDTH_OPTS);
305
- let tabCount = 0;
306
- for (let tabIndex = str.indexOf(TAB); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) {
307
- tabCount++;
304
+ const cacheable = str.length <= VISIBLE_WIDTH_CACHE_MAX_LEN;
305
+ if (cacheable) {
306
+ if (visibleWidthCacheEpoch !== widthConfigEpoch) {
307
+ visibleWidthCache.clear();
308
+ visibleWidthCacheEpoch = widthConfigEpoch;
308
309
  }
309
- if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH;
310
- return correctHangulCompatibilityJamoWidth(width, str);
310
+ const cached = visibleWidthCache.get(str);
311
+ if (cached !== undefined) return cached;
311
312
  }
312
313
 
313
- let tabCount = 0;
314
- let i = 0;
315
- for (; i < str.length; i++) {
316
- const code = str.charCodeAt(i);
317
- if (code < 0x20 || code > 0x7e) {
318
- if (code === 0x09) {
319
- tabCount++;
320
- continue;
321
- }
322
- break;
314
+ // This regex compiles to a native ASCII scan, cheaper than Bun's width
315
+ // scanner for the overwhelmingly common source-code path.
316
+ if (PRINTABLE_ASCII_REGEX.test(str)) {
317
+ if (cacheable) {
318
+ if (visibleWidthCache.size >= VISIBLE_WIDTH_CACHE_MAX) visibleWidthCache.clear();
319
+ visibleWidthCache.set(str, str.length);
323
320
  }
324
- }
325
- if (i === str.length) {
326
- return tabCount === 0 ? str.length : str.length + tabCount * (DEFAULT_TAB_WIDTH - 1);
321
+ return str.length;
327
322
  }
328
323
 
329
- if (tabCount === 0) {
330
- let tabIndex = str.indexOf(TAB, i + 1);
331
- if (tabIndex !== -1) {
332
- tabCount = 1;
333
- for (tabIndex = str.indexOf(TAB, tabIndex + 1); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) {
334
- tabCount++;
335
- }
336
- }
337
- } else {
338
- for (let tabIndex = str.indexOf(TAB, i + 1); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) {
324
+ let tabCount = 0;
325
+ let compatibilityJamoCount = 0;
326
+ let fillerCount = 0;
327
+ let hasEsc = false;
328
+ for (let i = 0; i < str.length; i++) {
329
+ const code = str.charCodeAt(i);
330
+ if (code === 0x09) {
339
331
  tabCount++;
332
+ } else if (code === 0x1b) {
333
+ hasEsc = true;
334
+ } else if (code >= 0x3131 && code <= 0x318e) {
335
+ compatibilityJamoCount++;
336
+ if (code === HANGUL_FILLER_CODE_POINT) fillerCount++;
340
337
  }
341
338
  }
342
339
 
343
- // `Bun.stringWidth` is a JSC builtin (no per-call N-API number box, unlike
344
- // the native scanner that traps under Bun 1.3.x GC/N-API load). It strips
345
- // CSI/OSC to zero cells and shares the native engine's UAX#11 width tables.
346
340
  let width = Bun.stringWidth(str, STRING_WIDTH_OPTS);
347
341
  if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH;
348
342
 
349
- // OSC 66: add back each stripped span as `scale * (explicit w ?? payload
350
- // width)`. Matched rather than replaced to avoid reallocating the string.
351
- if (str.includes(OSC66_PREFIX, i)) {
343
+ if (hasEsc && str.includes(OSC66_PREFIX)) {
352
344
  OSC66_SPAN_REGEX.lastIndex = 0;
353
345
  for (let m = OSC66_SPAN_REGEX.exec(str); m !== null; m = OSC66_SPAN_REGEX.exec(str)) {
354
346
  let scale = 1;
355
347
  let explicit: number | undefined;
356
348
  for (const part of m[1].split(":")) {
357
- // metadata keys are single chars, e.g. `s=2`, `w=5`
358
349
  if (part.indexOf("=") !== 1) continue;
359
350
  const value = Number.parseInt(part.slice(2), 10);
360
351
  if (!Number.isFinite(value)) continue;
@@ -368,11 +359,15 @@ export function visibleWidth(str: string): number {
368
359
  }
369
360
  }
370
361
 
371
- return correctHangulCompatibilityJamoWidth(width, str);
362
+ width = correctHangulCompatibilityJamoWidth(width, compatibilityJamoCount, fillerCount);
363
+ if (cacheable) {
364
+ if (visibleWidthCache.size >= VISIBLE_WIDTH_CACHE_MAX) visibleWidthCache.clear();
365
+ visibleWidthCache.set(str, width);
366
+ }
367
+ return width;
372
368
  }
373
369
 
374
370
  /**
375
- * True when a row carries a Kitty OSC 66 text-sizing span (`\x1b]66;…`).
376
371
  * Scaled spans must bypass wrapping/padding and, when scaled up, reserve the
377
372
  * terminal rows their multicell glyphs flow into.
378
373
  */