@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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +23 -0
- package/THIRD-PARTY-NOTICES.txt +22909 -0
- package/dist/types/components/editor.d.ts +9 -0
- package/dist/types/tui.d.ts +7 -0
- package/package.json +6 -4
- package/src/components/editor.ts +45 -6
- package/src/tui.ts +84 -6
|
@@ -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).
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -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.
|
|
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": "
|
|
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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "17.4.
|
|
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": {
|
package/src/components/editor.ts
CHANGED
|
@@ -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
|
-
|
|
1757
|
-
for (const
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
3849
|
-
//
|
|
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 (
|
|
3934
|
+
if (widthChanged) {
|
|
3857
3935
|
committedPrefixResliced = true;
|
|
3858
3936
|
this.#committedPrefix = rawFrame.slice(0, this.#committedRows);
|
|
3859
3937
|
}
|