@oh-my-pi/pi-tui 17.3.8 → 17.4.1

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 (34) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +23 -0
  3. package/THIRD-PARTY-NOTICES.txt +22909 -0
  4. package/dist/types/components/composer/borderless.d.ts +8 -0
  5. package/dist/types/components/composer/box.d.ts +2 -0
  6. package/dist/types/components/composer/claude.d.ts +8 -0
  7. package/dist/types/components/composer/field.d.ts +3 -0
  8. package/dist/types/components/composer/index.d.ts +9 -0
  9. package/dist/types/components/composer/pi.d.ts +2 -0
  10. package/dist/types/components/composer/rail.d.ts +3 -0
  11. package/dist/types/components/composer/registry.d.ts +12 -0
  12. package/dist/types/components/composer/rule.d.ts +7 -0
  13. package/dist/types/components/composer/types.d.ts +90 -0
  14. package/dist/types/components/editor.d.ts +10 -9
  15. package/dist/types/components/settings-list.d.ts +7 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/tui.d.ts +7 -0
  18. package/dist/types/utils.d.ts +0 -1
  19. package/package.json +6 -4
  20. package/src/components/composer/borderless.ts +37 -0
  21. package/src/components/composer/box.ts +85 -0
  22. package/src/components/composer/claude.ts +39 -0
  23. package/src/components/composer/field.ts +47 -0
  24. package/src/components/composer/index.ts +9 -0
  25. package/src/components/composer/pi.ts +37 -0
  26. package/src/components/composer/rail.ts +48 -0
  27. package/src/components/composer/registry.ts +46 -0
  28. package/src/components/composer/rule.ts +55 -0
  29. package/src/components/composer/types.ts +98 -0
  30. package/src/components/editor.ts +103 -92
  31. package/src/components/settings-list.ts +43 -11
  32. package/src/index.ts +1 -0
  33. package/src/tui.ts +84 -6
  34. package/src/utils.ts +53 -58
package/src/tui.ts CHANGED
@@ -216,6 +216,13 @@ export interface NativeScrollbackLiveRegion {
216
216
  getNativeScrollbackLiveRegionStart(): number | undefined;
217
217
  /** Keeps the mutable suffix viewport-local instead of recording frozen snapshots. */
218
218
  isNativeScrollbackLiveRegionPinned?(): boolean;
219
+ /**
220
+ * Local row where viewport pinning begins. When omitted, pinning (if
221
+ * reported) starts at {@link getNativeScrollbackLiveRegionStart}. A nested
222
+ * transcript uses this to keep an earlier unpinned live seam while still
223
+ * capping commits at a later pinned dashboard (hub wait, todo snapshot).
224
+ */
225
+ getNativeScrollbackLiveRegionPinnedStart?(): number | undefined;
219
226
  }
220
227
 
221
228
  export interface NativeScrollbackCommittedRows {
@@ -278,6 +285,13 @@ function getNativeScrollbackLiveRegionStart(component: Component): number | unde
278
285
  return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackLiveRegionStart?.();
279
286
  }
280
287
 
288
+ function getNativeScrollbackLiveRegionPinnedStart(component: Component): number | undefined {
289
+ const start = (
290
+ component as Component & Partial<NativeScrollbackLiveRegion>
291
+ ).getNativeScrollbackLiveRegionPinnedStart?.();
292
+ return start === undefined || !Number.isFinite(start) ? undefined : start;
293
+ }
294
+
281
295
  /**
282
296
  * Opt-in stability report for components that mutate their returned render
283
297
  * array in place across frames (instead of returning a fresh array per
@@ -901,6 +915,8 @@ interface FrameSegment {
901
915
  widthEpochRevision?: number;
902
916
  liveLocalStart?: number;
903
917
  liveRegionPinned: boolean;
918
+ /** Local pin start; when omitted, pinning begins at `liveLocalStart`. */
919
+ liveRegionPinnedStart?: number;
904
920
  }
905
921
 
906
922
  /** Depth-first identity search through `Container`-shaped children. */
@@ -1372,6 +1388,13 @@ export class TUI extends Container {
1372
1388
  #multiplexerResizeTimer: RenderTimer | undefined;
1373
1389
  #deferredForcedClearScrollback = false;
1374
1390
  #multiplexerResizeHasPendingRender = false;
1391
+ // Rows a mux pane pushed into its own scrollback on a height-only shrink
1392
+ // without the engine committing them, plus the commit seam at push time.
1393
+ // A later height grow pulls the pane's scrollback tail back into the grid:
1394
+ // the pushed rows come out first — but only while no commit has buried
1395
+ // them — and only the remainder of the pull removes committed rows.
1396
+ #muxPushedRows = 0;
1397
+ #muxPushSeam = 0;
1375
1398
  // True from the first SIGWINCH of a non-multiplexer drag until the settle
1376
1399
  // timer fires. While set, every `#doRender` short-circuits to the viewport
1377
1400
  // fast path (`#renderResizeViewport`) instead of an authoritative full
@@ -1661,12 +1684,14 @@ export class TUI extends Container {
1661
1684
  let childLines: readonly string[];
1662
1685
  let liveLocalStart: number | undefined;
1663
1686
  let liveRegionPinned = false;
1687
+ let liveRegionPinnedStart: number | undefined;
1664
1688
  let widthEpochRevision: number | undefined;
1665
1689
  let reported: number | undefined;
1666
1690
  if (reuse) {
1667
1691
  childLines = previous.lines;
1668
1692
  liveLocalStart = previous.liveLocalStart;
1669
1693
  liveRegionPinned = previous.liveRegionPinned;
1694
+ liveRegionPinnedStart = previous.liveRegionPinnedStart;
1670
1695
  widthEpochRevision = previous.widthEpochRevision;
1671
1696
  } else {
1672
1697
  // Feed the engine's committed-row claim (from the previous frame's
@@ -1697,6 +1722,15 @@ export class TUI extends Container {
1697
1722
  liveRegionPinned =
1698
1723
  (child as Component & Partial<NativeScrollbackLiveRegion>).isNativeScrollbackLiveRegionPinned?.() ===
1699
1724
  true;
1725
+ if (liveRegionPinned) {
1726
+ const pinStart = getNativeScrollbackLiveRegionPinnedStart(child);
1727
+ if (pinStart !== undefined) {
1728
+ liveRegionPinnedStart = Math.max(
1729
+ liveLocalStart,
1730
+ Math.min(childLines.length, Math.trunc(pinStart)),
1731
+ );
1732
+ }
1733
+ }
1700
1734
  }
1701
1735
  // Consume the stability report unconditionally for implementers:
1702
1736
  // reading re-bases the component's baseline to the state this
@@ -1722,7 +1756,7 @@ export class TUI extends Container {
1722
1756
  // even when an earlier unpinned seam won the topmost merge above:
1723
1757
  // its rows (a growing anchored panel) must never reach scrollback.
1724
1758
  if (liveRegionPinned && this.#nativeScrollbackPinnedBoundary === undefined) {
1725
- this.#nativeScrollbackPinnedBoundary = start;
1759
+ this.#nativeScrollbackPinnedBoundary = offset + (liveRegionPinnedStart ?? liveLocalStart);
1726
1760
  }
1727
1761
  }
1728
1762
  if (chainStable) {
@@ -1754,6 +1788,7 @@ export class TUI extends Container {
1754
1788
  widthEpochRevision,
1755
1789
  liveLocalStart,
1756
1790
  liveRegionPinned,
1791
+ liveRegionPinnedStart,
1757
1792
  };
1758
1793
  offset += childLines.length;
1759
1794
  }
@@ -3761,6 +3796,37 @@ export class TUI extends Container {
3761
3796
  !isMultiplexerSession() &&
3762
3797
  (committedRowsResynced || frameLength <= this.#committedRows);
3763
3798
  const fullPaint = firstPaint || replaceRequested || geometryRebuild || divergenceRebuild;
3799
+ // Height-only mux resizes move rows between the pane's scrollback and its
3800
+ // grid. A shrink with a full grid pushes the grid-top rows into pane
3801
+ // scrollback without a commit; a grow pulls the scrollback tail back into
3802
+ // the grid, where the forced in-place window rewrite overwrites it — gone
3803
+ // from history. The scrollback is a stack: pushed rows sit at the tail
3804
+ // only until the next commit buries them, so they shield a pull only
3805
+ // while the commit seam has not moved since the push. The unshielded
3806
+ // remainder removes committed rows — drop the commit seam by it and let
3807
+ // the next chunk re-commit them. A short frame gains blank rows instead
3808
+ // of pulling.
3809
+ if (fullPaint || widthChanged) {
3810
+ this.#muxPushedRows = 0;
3811
+ } else if (
3812
+ geometryChanged &&
3813
+ this.#previousHeight > 0 &&
3814
+ this.#previousFrameLength - this.#windowTopRow >= this.#previousHeight
3815
+ ) {
3816
+ if (this.#committedRows !== this.#muxPushSeam) this.#muxPushedRows = 0;
3817
+ if (height < this.#previousHeight) {
3818
+ this.#muxPushedRows += this.#previousHeight - height;
3819
+ this.#muxPushSeam = this.#committedRows;
3820
+ } else if (height > this.#previousHeight) {
3821
+ const pull = height - this.#previousHeight;
3822
+ const fromPushed = Math.min(pull, this.#muxPushedRows);
3823
+ this.#muxPushedRows -= fromPushed;
3824
+ const fromCommitted = Math.min(pull - fromPushed, this.#committedRows);
3825
+ this.#committedRows -= fromCommitted;
3826
+ this.#committedPrefix.length = this.#committedRows;
3827
+ this.#muxPushSeam = this.#committedRows;
3828
+ }
3829
+ }
3764
3830
  let windowTop: number;
3765
3831
  let chunkTo: number;
3766
3832
  let widthEpochAppendFrom = 0;
@@ -3828,11 +3894,23 @@ export class TUI extends Container {
3828
3894
  // the viewport. Rebase the commit seam to that exposed frame tail before
3829
3895
  // the forced rewrite; flooring at the old seam would paint only the live
3830
3896
  // suffix followed by blanks, then preserve that gap on every stream tick.
3831
- committedPrefixResliced = true;
3832
3897
  windowTop = Math.max(0, frameLength - height);
3833
3898
  chunkTo = windowTop;
3834
3899
  this.#committedRows = windowTop;
3835
- this.#committedPrefix = rawFrame.slice(0, windowTop);
3900
+ if (widthChanged) {
3901
+ // A rewrap invalidated the recorded bytes: re-base the audit prefix
3902
+ // at the new width so the accepted wrap drift does not read as a
3903
+ // violation on the next ordinary frame.
3904
+ committedPrefixResliced = true;
3905
+ this.#committedPrefix = rawFrame.slice(0, windowTop);
3906
+ } else {
3907
+ // Height-only reflow: the pane did not rewrap, so the recorded
3908
+ // bytes are still the true tape record. Truncate instead of
3909
+ // reslicing from the current frame — replacing the record with
3910
+ // live content would bless drifted frozen snapshots as verified
3911
+ // and skip their finalize-time recommit (rows lost from history).
3912
+ this.#committedPrefix.length = Math.min(this.#committedPrefix.length, windowTop);
3913
+ }
3836
3914
  } else {
3837
3915
  // Re-anchor to the frame tail, floored at the committed boundary: a
3838
3916
  // shrink (or overlay close) pulls the window back down, but never
@@ -3845,15 +3923,15 @@ export class TUI extends Container {
3845
3923
  // record; nothing that was painted may vanish. Overlays freeze
3846
3924
  // commits: composited rows must never enter history, and the hidden
3847
3925
  // gap backfills via the chunk once the overlay closes. A multiplexer
3848
- // resize also commits nothing — the pane keeps its own (old-wrap)
3849
- // history — and re-bases the audit prefix at the new width so the
3926
+ // resize also commits nothing — the pane keeps its own history — and
3927
+ // a width rewrap re-bases the audit prefix at the new width so the
3850
3928
  // accepted wrap drift does not read as a violation on the next
3851
3929
  // ordinary frame.
3852
3930
  chunkTo =
3853
3931
  hasVisibleOverlay || geometryChanged
3854
3932
  ? this.#committedRows
3855
3933
  : Math.min(windowTop, Math.max(this.#committedRows, commitCeiling));
3856
- if (geometryChanged) {
3934
+ if (widthChanged) {
3857
3935
  committedPrefixResliced = true;
3858
3936
  this.#committedPrefix = rawFrame.slice(0, this.#committedRows);
3859
3937
  }
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
  */