@oh-my-pi/pi-tui 17.4.0 → 17.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.
@@ -120,6 +120,15 @@ export declare class Editor implements Component, Focusable {
120
120
  /** Whether the buffer text equals `value`, without `getText()`'s full join —
121
121
  * O(1) for the hot per-keystroke probes against short single-line values. */
122
122
  textEquals(value: string): boolean;
123
+ /** Register `label` as a collapsed atom expanding to `expansion` on submit, without inserting
124
+ * it — for hosts that re-collapse restored draft text via {@link setText}. */
125
+ registerAtom(label: string, expansion: string): void;
126
+ /** Insert `label` (plus a trailing space) at the cursor and register it as an atom expanding
127
+ * to `expansion` on submit. Pair with {@link atomicTokenPattern} so the label deletes as a
128
+ * unit. */
129
+ insertAtom(label: string, expansion: string): void;
130
+ /** Drop every registered atom expansion (draft cleared or replaced by the host). */
131
+ clearAtoms(): void;
123
132
  /**
124
133
  * Get text with paste markers expanded to their actual content.
125
134
  * Use this when you need the full content (e.g., for external editor).
@@ -98,6 +98,13 @@ export interface NativeScrollbackLiveRegion {
98
98
  getNativeScrollbackLiveRegionStart(): number | undefined;
99
99
  /** Keeps the mutable suffix viewport-local instead of recording frozen snapshots. */
100
100
  isNativeScrollbackLiveRegionPinned?(): boolean;
101
+ /**
102
+ * Local row where viewport pinning begins. When omitted, pinning (if
103
+ * reported) starts at {@link getNativeScrollbackLiveRegionStart}. A nested
104
+ * transcript uses this to keep an earlier unpinned live seam while still
105
+ * capping commits at a later pinned dashboard (hub wait, todo snapshot).
106
+ */
107
+ getNativeScrollbackLiveRegionPinnedStart?(): number | undefined;
101
108
  }
102
109
  export interface NativeScrollbackCommittedRows {
103
110
  setNativeScrollbackCommittedRows(rows: number): void;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "17.4.0",
4
+ "version": "17.4.2",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
- "author": "Can Boluk",
7
+ "author": "Stencil Labs, Inc.",
8
8
  "contributors": [
9
9
  "Mario Zechner"
10
10
  ],
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "17.4.0",
41
- "@oh-my-pi/pi-utils": "17.4.0"
40
+ "@oh-my-pi/pi-natives": "17.4.2",
41
+ "@oh-my-pi/pi-utils": "17.4.2"
42
42
  },
43
43
  "devDependencies": {
44
44
  "ghostty-web": "^0.4.0"
@@ -50,6 +50,8 @@
50
50
  "src",
51
51
  "README.md",
52
52
  "CHANGELOG.md",
53
+ "LICENSE",
54
+ "THIRD-PARTY-NOTICES.txt",
53
55
  "dist/types"
54
56
  ],
55
57
  "exports": {
@@ -484,6 +484,9 @@ export class Editor implements Component, Focusable {
484
484
  #pastes: Map<number, string> = new Map();
485
485
  #pasteCounter: number = 0;
486
486
 
487
+ // Host-registered atomic chip tokens: exact buffer label → expansion emitted on submit.
488
+ #atoms: Map<string, string> = new Map();
489
+
487
490
  /** Optional pattern matching atomic placeholder tokens (e.g. `[Image #1, 800x600]` or
488
491
  * `[Paste #2, +30 lines]`) that the editor treats as indivisible: a backspace or forward-delete
489
492
  * landing on any character of a token removes the whole token instead of corrupting it into
@@ -1752,13 +1755,48 @@ export class Editor implements Component, Focusable {
1752
1755
  return this.getText() === value;
1753
1756
  }
1754
1757
 
1758
+ /** Expand collapsed markers — `[Paste #N]` tokens and registered atom labels — into their
1759
+ * stored content. Single pass so replaced content is never rescanned (a pasted body that
1760
+ * happens to contain another token's label must survive verbatim). Longer atom labels are
1761
+ * tried first so `#1` never shadows `#10`. */
1755
1762
  #expandPasteMarkers(text: string): string {
1756
- let result = text;
1757
- for (const [pasteId, pasteContent] of this.#pastes) {
1758
- const markerRegex = new RegExp(`\\[Paste #${pasteId}(?:, (?:\\+\\d+ lines|\\d+ chars))?\\]`, "g");
1759
- result = result.replace(markerRegex, () => pasteContent);
1760
- }
1761
- return result;
1763
+ const sources: string[] = [];
1764
+ for (const pasteId of this.#pastes.keys()) {
1765
+ sources.push(`\\[Paste #${pasteId}(?:, (?:\\+\\d+ lines|\\d+ chars))?\\]`);
1766
+ }
1767
+ const labels = [...this.#atoms.keys()].sort((a, b) => b.length - a.length);
1768
+ for (const label of labels) sources.push(RegExp.escape(label));
1769
+ if (sources.length === 0) return text;
1770
+ const markerRegex = new RegExp(sources.join("|"), "g");
1771
+ return text.replace(markerRegex, match => {
1772
+ const paste = /^\[Paste #(\d+)/.exec(match);
1773
+ if (paste) return this.#pastes.get(Number(paste[1])) ?? match;
1774
+ return this.#atoms.get(match) ?? match;
1775
+ });
1776
+ }
1777
+
1778
+ /** Register `label` as a collapsed atom expanding to `expansion` on submit, without inserting
1779
+ * it — for hosts that re-collapse restored draft text via {@link setText}. */
1780
+ registerAtom(label: string, expansion: string): void {
1781
+ this.#atoms.set(label, expansion);
1782
+ }
1783
+
1784
+ /** Insert `label` (plus a trailing space) at the cursor and register it as an atom expanding
1785
+ * to `expansion` on submit. Pair with {@link atomicTokenPattern} so the label deletes as a
1786
+ * unit. */
1787
+ insertAtom(label: string, expansion: string): void {
1788
+ this.#historyIndex = -1;
1789
+ this.#resetKillSequence();
1790
+ this.#recordUndoState();
1791
+ this.registerAtom(label, expansion);
1792
+ this.#withUndoSuspended(() => {
1793
+ this.#insertTextAtCursor(`${label} `);
1794
+ });
1795
+ }
1796
+
1797
+ /** Drop every registered atom expansion (draft cleared or replaced by the host). */
1798
+ clearAtoms(): void {
1799
+ this.#atoms.clear();
1762
1800
  }
1763
1801
 
1764
1802
  /**
@@ -2213,6 +2251,7 @@ export class Editor implements Component, Focusable {
2213
2251
  this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 };
2214
2252
  this.#pastes.clear();
2215
2253
  this.#pasteCounter = 0;
2254
+ this.#atoms.clear();
2216
2255
  this.#historyIndex = -1;
2217
2256
  this.#scrollOffset = 0;
2218
2257
  this.#undoStack.length = 0;
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
  }