@oh-my-pi/pi-tui 16.3.4 → 16.3.6
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 +22 -0
- package/dist/types/components/markdown.d.ts +10 -0
- package/dist/types/tui.d.ts +34 -68
- package/package.json +3 -3
- package/src/components/markdown.ts +58 -5
- package/src/tui.ts +191 -390
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.3.6] - 2026-07-04
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `Markdown.getLastRenderSettledRows()`: the rendered frozen-token-prefix row count of the most recent streaming render, exposed (hard-monotone per text lineage) for native-scrollback commit gating. Frozen-prefix code blocks now syntax-highlight during streaming renders so settled rows stay byte-stable across finalize.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Rewrote the native-scrollback commit law around a visual-record model: whatever scrolls above the viewport enters history exactly once, in order — nothing painted ever vanishes. `NativeScrollbackLiveRegion` is now a single method (`getNativeScrollbackLiveRegionStart`) reporting an exactness boundary: rows below it commit as exact audited bytes; rows above it commit as frozen visual snapshots that are audit-exempt while their source stays live and strict-verified exactly once when the boundary passes them (a divergence recommits the final content below the frozen fragment — duplication, never loss). `getNativeScrollbackCommitSafeEnd`/`getNativeScrollbackSnapshotSafeEnd` and the heuristic promotion machinery they fed are removed.
|
|
14
|
+
- Simplified committed-prefix auditing to one hard-verified mark deriving three zones (verified/newly-final/frozen); a frame that shrinks below the committed row count re-bases at the first divergence against the recorded prefix so a collapsing live suffix never re-shows or re-commits already-recorded rows.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Fixed live tool/eval preview boxes spraying duplicated stale copies into native scrollback mid-run: a still-live block's scrolled rows are now frozen visual snapshots that never re-anchor while it runs; at most one repair happens at finalize.
|
|
19
|
+
- Fixed streamed content vanishing above the viewport mid-turn: scrolled-off rows always reach native scrollback (as exact bytes for declared-final content, as visual snapshots otherwise) instead of being deferred invisibly.
|
|
20
|
+
|
|
21
|
+
## [16.3.5] - 2026-07-04
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Fixed the modifyOtherKeys keyboard fallback enabling on unknown SSH terminals, avoiding broken Shift input in iOS SSH clients such as Redock ([#4325](https://github.com/can1357/oh-my-pi/issues/4325)).
|
|
26
|
+
|
|
5
27
|
## [16.3.3] - 2026-07-02
|
|
6
28
|
|
|
7
29
|
### Fixed
|
|
@@ -55,6 +55,16 @@ export declare class Markdown implements Component {
|
|
|
55
55
|
invalidate(): void;
|
|
56
56
|
get transientRenderCache(): boolean;
|
|
57
57
|
set transientRenderCache(value: boolean);
|
|
58
|
+
/**
|
|
59
|
+
* Rows at the top of the most recent render() (top padding + rendered
|
|
60
|
+
* frozen-token prefix) whose bytes are settled: byte-stable at this
|
|
61
|
+
* width/theme for as long as the text keeps growing append-only. Hosts
|
|
62
|
+
* feed this to transcript commit gating (see the coding agent's
|
|
63
|
+
* `FinalizableBlock.getTranscriptBlockSettledRows`). 0 outside streaming
|
|
64
|
+
* (`transientRenderCache`) mode, after a text rewind (re-earned on the new
|
|
65
|
+
* lineage), and on cache-served non-streaming renders.
|
|
66
|
+
*/
|
|
67
|
+
getLastRenderSettledRows(): number;
|
|
58
68
|
render(width: number): readonly string[];
|
|
59
69
|
}
|
|
60
70
|
/**
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -76,51 +76,23 @@ export interface OverlayFocusOwner {
|
|
|
76
76
|
ownsOverlayFocusTarget(component: Component): boolean;
|
|
77
77
|
}
|
|
78
78
|
/**
|
|
79
|
-
* Component seam for append-only native-scrollback commits. A component
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
79
|
+
* Component seam for append-only native-scrollback commits. A component whose
|
|
80
|
+
* rendered rows can still change reports, after each render, the local line
|
|
81
|
+
* index where that mutable suffix begins. Rows above the boundary are declared
|
|
82
|
+
* FINAL — byte-stable at the current width for the component's lifetime — and
|
|
83
|
+
* commit to native scrollback as exact, audited content. Rows at/after the
|
|
84
|
+
* boundary repaint in place inside the visible window; when they scroll above
|
|
85
|
+
* the window top they still commit — the tape records what was on screen —
|
|
86
|
+
* but as frozen visual snapshots that are permanently audit-exempt: later
|
|
87
|
+
* re-layout of their source never re-anchors or recommits them. A root that
|
|
88
|
+
* reports no seam commits everything that scrolls as final (shell semantics).
|
|
85
89
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* Rows in `[liveRegionStart, commitSafeEnd)` may commit even though they are
|
|
90
|
-
* technically live, because they will never change. Without it, a single live
|
|
91
|
-
* block that alone overflows the window would hold its scrolled-off head out
|
|
92
|
-
* of history until it finalizes. Volatile live blocks (tool previews that
|
|
93
|
-
* collapse) omit it. Defaults to `liveRegionStart` when absent; a root that
|
|
94
|
-
* reports no seam at all commits everything that scrolls (shell semantics).
|
|
95
|
-
* `getNativeScrollbackSnapshotSafeEnd` optionally reports a still deeper
|
|
96
|
-
* boundary: the line index up to which the live region is *durable* — its rows
|
|
97
|
-
* may still change bytes later (a streaming markdown table re-aligning its
|
|
98
|
-
* columns every row), but their CURRENT snapshot is permanent content, so
|
|
99
|
-
* dropping them when they scroll above the window is forbidden. Unlike
|
|
100
|
-
* `commitSafeEnd` (byte-stable: offered rows are asserted never to re-layout and
|
|
101
|
-
* stay under the committed-prefix audit), rows committed under the snapshot end
|
|
102
|
-
* are audit-EXEMPT once they pass the window top — the engine appends their
|
|
103
|
-
* scroll-off snapshot and never recommits them, so later layout drift becomes a
|
|
104
|
-
* frozen stale row in history (duplication never loss) instead of either a
|
|
105
|
-
* dropped row or an audit re-anchor spray. Provisional live blocks (collapsing
|
|
106
|
-
* tool/edit previews whose head is a throwaway tail window) omit it. Defaults to
|
|
107
|
-
* `commitSafeEnd ?? liveRegionStart` when absent.
|
|
108
|
-
* `getNativeScrollbackOfferSafeEnd` optionally reports the deepest prefix row
|
|
109
|
-
* that may physically enter native scrollback while still remaining audited.
|
|
110
|
-
* This is for finalized lower siblings under a live block: the rows may scroll
|
|
111
|
-
* off, but a later live-block insertion above them must trigger repair instead
|
|
112
|
-
* of becoming durable audit-exempt history.
|
|
113
|
-
*
|
|
114
|
-
* When several root children report a seam in the same frame, the topmost
|
|
115
|
-
* one (and its commit-safe / snapshot-safe extension) defines the boundary:
|
|
116
|
-
* commits are prefix-only, so everything below the first seam is already
|
|
117
|
-
* excluded.
|
|
90
|
+
* When several root children report a seam in the same frame, the topmost one
|
|
91
|
+
* defines the boundary: exactness is prefix-only, so everything below the
|
|
92
|
+
* first seam is already excluded.
|
|
118
93
|
*/
|
|
119
94
|
export interface NativeScrollbackLiveRegion {
|
|
120
95
|
getNativeScrollbackLiveRegionStart(): number | undefined;
|
|
121
|
-
getNativeScrollbackCommitSafeEnd?(): number | undefined;
|
|
122
|
-
getNativeScrollbackSnapshotSafeEnd?(): number | undefined;
|
|
123
|
-
getNativeScrollbackOfferSafeEnd?(): number | undefined;
|
|
124
96
|
}
|
|
125
97
|
export interface NativeScrollbackCommittedRows {
|
|
126
98
|
setNativeScrollbackCommittedRows(rows: number): void;
|
|
@@ -300,40 +272,34 @@ export declare function coalesceAdjacentSgr(line: string): string;
|
|
|
300
272
|
* re-anchor the commit index when it does not. Returns the resync row index,
|
|
301
273
|
* or -1 when no resync is needed.
|
|
302
274
|
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
275
|
+
* Zones (verifiedTo ≤ finalTo ≤ prefix.length):
|
|
276
|
+
* [0, verifiedTo) VERIFIED exact rows — sampled with tolerance.
|
|
277
|
+
* [verifiedTo, finalTo) NEWLY-FINAL rows — frozen visual snapshots whose
|
|
278
|
+
* source just became declared-final (the block finalized / a barrier
|
|
279
|
+
* cleared). Hard-scanned in FULL with no tolerance: any content change
|
|
280
|
+
* (a pending header settling, a preview replaced by its result, a tail
|
|
281
|
+
* shifting up after a barrier removal) re-anchors so the final content
|
|
282
|
+
* recommits below the frozen snapshot — duplication, never loss —
|
|
283
|
+
* instead of being committed nowhere and painted nowhere.
|
|
284
|
+
* [finalTo, prefix.length) FROZEN visual snapshots of still-live rows —
|
|
285
|
+
* exempt: their drift is expected (a collapsing preview, a ticking
|
|
286
|
+
* progress tree) and must never spray re-anchors mid-run.
|
|
311
287
|
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* between the two mutation classes — an in-place edit/restyle of a committed
|
|
321
|
-
* row disturbs only the touched rows (alignment below intact; the stale copy
|
|
322
|
-
* in history is the long-accepted artifact), while an insertion/deletion
|
|
323
|
-
* shifts EVERY row below it. So up to 8 non-blank rows within the last 24
|
|
324
|
-
* audited rows are compared SGR-stripped (theme changes stay quiet),
|
|
325
|
-
* tolerating a SINGLE non-hard mismatch (a legitimate one-row edit): aligned ⇒
|
|
326
|
-
* no resync; misaligned ⇒ resync at the first non-equivalent audited row. The
|
|
327
|
-
* tolerance keeps both an offscreen still-live barrier (a ticking spinner) and
|
|
328
|
-
* a no-seam in-place row edit from spraying duplicate snapshots every frame;
|
|
329
|
-
* the hard scan above is what forbids it from swallowing a finalized row.
|
|
288
|
+
* The verified zone's sampled check exploits the asymmetry between the two
|
|
289
|
+
* mutation classes: an in-place edit/restyle disturbs only the touched rows
|
|
290
|
+
* (alignment below stays intact; the stale copy in history is the accepted
|
|
291
|
+
* artifact), while an insertion/deletion shifts EVERY row below it. Up to 8
|
|
292
|
+
* non-blank rows within the last 24 verified rows are compared SGR-stripped
|
|
293
|
+
* (theme changes stay quiet), tolerating a SINGLE mismatch. The tolerance is
|
|
294
|
+
* load-bearing for roots that report NO seam: an animated row already in
|
|
295
|
+
* history would otherwise re-anchor on every glyph tick.
|
|
330
296
|
*
|
|
331
297
|
* Highly repetitive tails (identical filler rows) can mask a shift in the tail
|
|
332
298
|
* sample, in which case the skipped rows are content-identical to the committed
|
|
333
299
|
* ones — observationally harmless. Exported for the render-stress harness, whose
|
|
334
300
|
* shadow commit ledger must mirror the engine's law exactly.
|
|
335
301
|
*/
|
|
336
|
-
export declare function findCommittedPrefixResync(frame: readonly string[], prefix: readonly string[],
|
|
302
|
+
export declare function findCommittedPrefixResync(frame: readonly string[], prefix: readonly string[], verifiedTo?: number, finalTo?: number): number;
|
|
337
303
|
/**
|
|
338
304
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
339
305
|
*/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "16.3.
|
|
4
|
+
"version": "16.3.6",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "16.3.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.3.
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.3.6",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.3.6",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.5"
|
|
44
44
|
},
|
|
@@ -817,6 +817,21 @@ export class Markdown implements Component {
|
|
|
817
817
|
#streamPrefixText?: string;
|
|
818
818
|
#streamPrefixTokens?: Token[];
|
|
819
819
|
#streamPrefixLineCache?: StreamPrefixLineCache;
|
|
820
|
+
// Rows of the most recent render() that are settled — top padding plus the
|
|
821
|
+
// rendered frozen token prefix — exposed via getLastRenderSettledRows()
|
|
822
|
+
// for native-scrollback commit gating.
|
|
823
|
+
#lastRenderSettledRows = 0;
|
|
824
|
+
// Frozen-prefix text backing the last non-zero settled exposure. Settled
|
|
825
|
+
// rows are declared final downstream, so a render whose frozen text no
|
|
826
|
+
// longer extends this prefix (a rewind / wholesale rewrite) resets the
|
|
827
|
+
// exposure to 0 and re-earns it — the exposure is hard-monotone within a
|
|
828
|
+
// text lineage.
|
|
829
|
+
#settledExposedText?: string;
|
|
830
|
+
// True while #renderStreamingContentLines renders the frozen token range:
|
|
831
|
+
// frozen code blocks highlight even in transient mode so their bytes match
|
|
832
|
+
// the finalized render (they render once into the prefix line cache, so
|
|
833
|
+
// the FFI cost is amortized); the volatile tail stays unhighlighted.
|
|
834
|
+
#renderingFrozenPrefix = false;
|
|
820
835
|
|
|
821
836
|
#ignoreTight = false;
|
|
822
837
|
|
|
@@ -857,6 +872,7 @@ export class Markdown implements Component {
|
|
|
857
872
|
this.#streamPrefixText = undefined;
|
|
858
873
|
this.#streamPrefixTokens = undefined;
|
|
859
874
|
this.#streamPrefixLineCache = undefined;
|
|
875
|
+
this.#settledExposedText = undefined;
|
|
860
876
|
}
|
|
861
877
|
this.invalidate();
|
|
862
878
|
return true;
|
|
@@ -878,6 +894,19 @@ export class Markdown implements Component {
|
|
|
878
894
|
this.invalidate();
|
|
879
895
|
}
|
|
880
896
|
|
|
897
|
+
/**
|
|
898
|
+
* Rows at the top of the most recent render() (top padding + rendered
|
|
899
|
+
* frozen-token prefix) whose bytes are settled: byte-stable at this
|
|
900
|
+
* width/theme for as long as the text keeps growing append-only. Hosts
|
|
901
|
+
* feed this to transcript commit gating (see the coding agent's
|
|
902
|
+
* `FinalizableBlock.getTranscriptBlockSettledRows`). 0 outside streaming
|
|
903
|
+
* (`transientRenderCache`) mode, after a text rewind (re-earned on the new
|
|
904
|
+
* lineage), and on cache-served non-streaming renders.
|
|
905
|
+
*/
|
|
906
|
+
getLastRenderSettledRows(): number {
|
|
907
|
+
return this.#lastRenderSettledRows;
|
|
908
|
+
}
|
|
909
|
+
|
|
881
910
|
// Lex `text` into block tokens, reusing the frozen stable prefix when the text
|
|
882
911
|
// only grew (the streaming path). Falls back to a full lex whenever the prefix
|
|
883
912
|
// is no longer a prefix (non-append edit), the text carries reference-link
|
|
@@ -965,6 +994,10 @@ export class Markdown implements Component {
|
|
|
965
994
|
return this.#cachedLines;
|
|
966
995
|
}
|
|
967
996
|
|
|
997
|
+
// Recomputed below by the streaming path; every other path (cache-served,
|
|
998
|
+
// empty text, non-streaming full render) exposes no settled rows.
|
|
999
|
+
this.#lastRenderSettledRows = 0;
|
|
1000
|
+
|
|
968
1001
|
// Calculate available width for content (subtract horizontal padding)
|
|
969
1002
|
const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
|
|
970
1003
|
const contentWidth = Math.max(1, width - paddingX * 2);
|
|
@@ -1076,9 +1109,16 @@ export class Markdown implements Component {
|
|
|
1076
1109
|
}
|
|
1077
1110
|
|
|
1078
1111
|
if (renderedUntil < frozenTokenCount) {
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1112
|
+
// Frozen tokens render with full fidelity (syntax highlighting on)
|
|
1113
|
+
// so these cached rows byte-match the finalized render.
|
|
1114
|
+
this.#renderingFrozenPrefix = true;
|
|
1115
|
+
try {
|
|
1116
|
+
contentLines.push(
|
|
1117
|
+
...this.#renderContentLines(tokens, renderedUntil, frozenTokenCount, contentWidth, signature),
|
|
1118
|
+
);
|
|
1119
|
+
} finally {
|
|
1120
|
+
this.#renderingFrozenPrefix = false;
|
|
1121
|
+
}
|
|
1082
1122
|
renderedUntil = frozenTokenCount;
|
|
1083
1123
|
}
|
|
1084
1124
|
|
|
@@ -1089,6 +1129,19 @@ export class Markdown implements Component {
|
|
|
1089
1129
|
lines: contentLines.slice(),
|
|
1090
1130
|
};
|
|
1091
1131
|
|
|
1132
|
+
// Settled exposure (hard-monotone): these rows are declared final to
|
|
1133
|
+
// the host, so expose them only while the frozen text still extends
|
|
1134
|
+
// the previously exposed prefix; a rewind resets to 0 and re-earns on
|
|
1135
|
+
// the rewritten lineage.
|
|
1136
|
+
if (contentLines.length > 0) {
|
|
1137
|
+
if (this.#settledExposedText === undefined || frozenText.startsWith(this.#settledExposedText)) {
|
|
1138
|
+
this.#settledExposedText = frozenText;
|
|
1139
|
+
this.#lastRenderSettledRows = signature.paddingY + contentLines.length;
|
|
1140
|
+
} else {
|
|
1141
|
+
this.#settledExposedText = undefined;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1092
1145
|
if (renderedUntil < tokens.length) {
|
|
1093
1146
|
contentLines.push(...this.#renderContentLines(tokens, renderedUntil, tokens.length, contentWidth, signature));
|
|
1094
1147
|
}
|
|
@@ -1361,7 +1414,7 @@ export class Markdown implements Component {
|
|
|
1361
1414
|
|
|
1362
1415
|
const codeIndent = padding(this.#codeBlockIndent);
|
|
1363
1416
|
lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
|
|
1364
|
-
if (this.#theme.highlightCode && !this.transientRenderCache) {
|
|
1417
|
+
if (this.#theme.highlightCode && (!this.transientRenderCache || this.#renderingFrozenPrefix)) {
|
|
1365
1418
|
const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
|
|
1366
1419
|
for (const hlLine of highlightedLines) {
|
|
1367
1420
|
lines.push(`${codeIndent}${hlLine}`);
|
|
@@ -1751,7 +1804,7 @@ export class Markdown implements Component {
|
|
|
1751
1804
|
// Code block in list item
|
|
1752
1805
|
const codeIndent = padding(this.#codeBlockIndent);
|
|
1753
1806
|
lines.push({ text: this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`), nested: false });
|
|
1754
|
-
if (this.#theme.highlightCode && !this.transientRenderCache) {
|
|
1807
|
+
if (this.#theme.highlightCode && (!this.transientRenderCache || this.#renderingFrozenPrefix)) {
|
|
1755
1808
|
const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
|
|
1756
1809
|
for (const hlLine of highlightedLines) {
|
|
1757
1810
|
lines.push({ text: `${codeIndent}${hlLine}`, nested: false });
|
package/src/tui.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Minimal TUI implementation with differential rendering.
|
|
3
3
|
*
|
|
4
4
|
* Append-only render contract: rows committed to native scrollback are
|
|
5
|
-
* immutable
|
|
6
|
-
* history exactly once, in order
|
|
7
|
-
*
|
|
5
|
+
* immutable — the tape is the terminal's visual record. Whatever scrolls
|
|
6
|
+
* above the window enters history exactly once, in order: as exact-final
|
|
7
|
+
* bytes when the component seam (`NativeScrollbackLiveRegion`) declared them
|
|
8
|
+
* final, else as a frozen snapshot of what was on screen. ED3 (`CSI 3 J`) is
|
|
8
9
|
* emitted only for gesture-driven replays (session replace, resize,
|
|
9
10
|
* resetDisplay) where snapping the viewport is acceptable. The engine never
|
|
10
11
|
* probes or guesses the terminal's scroll position, and the hot path clamps
|
|
@@ -183,51 +184,23 @@ export interface OverlayFocusOwner {
|
|
|
183
184
|
}
|
|
184
185
|
|
|
185
186
|
/**
|
|
186
|
-
* Component seam for append-only native-scrollback commits. A component
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
187
|
+
* Component seam for append-only native-scrollback commits. A component whose
|
|
188
|
+
* rendered rows can still change reports, after each render, the local line
|
|
189
|
+
* index where that mutable suffix begins. Rows above the boundary are declared
|
|
190
|
+
* FINAL — byte-stable at the current width for the component's lifetime — and
|
|
191
|
+
* commit to native scrollback as exact, audited content. Rows at/after the
|
|
192
|
+
* boundary repaint in place inside the visible window; when they scroll above
|
|
193
|
+
* the window top they still commit — the tape records what was on screen —
|
|
194
|
+
* but as frozen visual snapshots that are permanently audit-exempt: later
|
|
195
|
+
* re-layout of their source never re-anchors or recommits them. A root that
|
|
196
|
+
* reports no seam commits everything that scrolls as final (shell semantics).
|
|
192
197
|
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* Rows in `[liveRegionStart, commitSafeEnd)` may commit even though they are
|
|
197
|
-
* technically live, because they will never change. Without it, a single live
|
|
198
|
-
* block that alone overflows the window would hold its scrolled-off head out
|
|
199
|
-
* of history until it finalizes. Volatile live blocks (tool previews that
|
|
200
|
-
* collapse) omit it. Defaults to `liveRegionStart` when absent; a root that
|
|
201
|
-
* reports no seam at all commits everything that scrolls (shell semantics).
|
|
202
|
-
* `getNativeScrollbackSnapshotSafeEnd` optionally reports a still deeper
|
|
203
|
-
* boundary: the line index up to which the live region is *durable* — its rows
|
|
204
|
-
* may still change bytes later (a streaming markdown table re-aligning its
|
|
205
|
-
* columns every row), but their CURRENT snapshot is permanent content, so
|
|
206
|
-
* dropping them when they scroll above the window is forbidden. Unlike
|
|
207
|
-
* `commitSafeEnd` (byte-stable: offered rows are asserted never to re-layout and
|
|
208
|
-
* stay under the committed-prefix audit), rows committed under the snapshot end
|
|
209
|
-
* are audit-EXEMPT once they pass the window top — the engine appends their
|
|
210
|
-
* scroll-off snapshot and never recommits them, so later layout drift becomes a
|
|
211
|
-
* frozen stale row in history (duplication never loss) instead of either a
|
|
212
|
-
* dropped row or an audit re-anchor spray. Provisional live blocks (collapsing
|
|
213
|
-
* tool/edit previews whose head is a throwaway tail window) omit it. Defaults to
|
|
214
|
-
* `commitSafeEnd ?? liveRegionStart` when absent.
|
|
215
|
-
* `getNativeScrollbackOfferSafeEnd` optionally reports the deepest prefix row
|
|
216
|
-
* that may physically enter native scrollback while still remaining audited.
|
|
217
|
-
* This is for finalized lower siblings under a live block: the rows may scroll
|
|
218
|
-
* off, but a later live-block insertion above them must trigger repair instead
|
|
219
|
-
* of becoming durable audit-exempt history.
|
|
220
|
-
*
|
|
221
|
-
* When several root children report a seam in the same frame, the topmost
|
|
222
|
-
* one (and its commit-safe / snapshot-safe extension) defines the boundary:
|
|
223
|
-
* commits are prefix-only, so everything below the first seam is already
|
|
224
|
-
* excluded.
|
|
198
|
+
* When several root children report a seam in the same frame, the topmost one
|
|
199
|
+
* defines the boundary: exactness is prefix-only, so everything below the
|
|
200
|
+
* first seam is already excluded.
|
|
225
201
|
*/
|
|
226
202
|
export interface NativeScrollbackLiveRegion {
|
|
227
203
|
getNativeScrollbackLiveRegionStart(): number | undefined;
|
|
228
|
-
getNativeScrollbackCommitSafeEnd?(): number | undefined;
|
|
229
|
-
getNativeScrollbackSnapshotSafeEnd?(): number | undefined;
|
|
230
|
-
getNativeScrollbackOfferSafeEnd?(): number | undefined;
|
|
231
204
|
}
|
|
232
205
|
|
|
233
206
|
export interface NativeScrollbackCommittedRows {
|
|
@@ -249,18 +222,6 @@ function getNativeScrollbackLiveRegionStart(component: Component): number | unde
|
|
|
249
222
|
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackLiveRegionStart?.();
|
|
250
223
|
}
|
|
251
224
|
|
|
252
|
-
function getNativeScrollbackCommitSafeEnd(component: Component): number | undefined {
|
|
253
|
-
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackCommitSafeEnd?.();
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function getNativeScrollbackSnapshotSafeEnd(component: Component): number | undefined {
|
|
257
|
-
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackSnapshotSafeEnd?.();
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function getNativeScrollbackOfferSafeEnd(component: Component): number | undefined {
|
|
261
|
-
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackOfferSafeEnd?.();
|
|
262
|
-
}
|
|
263
|
-
|
|
264
225
|
/**
|
|
265
226
|
* Opt-in stability report for components that mutate their returned render
|
|
266
227
|
* array in place across frames (instead of returning a fresh array per
|
|
@@ -627,7 +588,7 @@ interface CursorControlResult extends HardwareCursorUpdate {
|
|
|
627
588
|
* One root child's contribution to the composed frame: the array reference its
|
|
628
589
|
* render() returned, the frame row it starts at, the row count recorded at
|
|
629
590
|
* compose time (in-place mutators keep the reference but may change length),
|
|
630
|
-
* and the child-local seam
|
|
591
|
+
* and the child-local seam report captured at render time — replayed verbatim
|
|
631
592
|
* when a component-scoped frame reuses this segment without re-rendering.
|
|
632
593
|
*/
|
|
633
594
|
interface FrameSegment {
|
|
@@ -636,9 +597,6 @@ interface FrameSegment {
|
|
|
636
597
|
start: number;
|
|
637
598
|
rowCount: number;
|
|
638
599
|
liveLocalStart?: number;
|
|
639
|
-
commitLocalEnd?: number;
|
|
640
|
-
snapshotLocalEnd?: number;
|
|
641
|
-
offerLocalEnd?: number;
|
|
642
600
|
}
|
|
643
601
|
|
|
644
602
|
/** Depth-first identity search through `Container`-shaped children. */
|
|
@@ -814,33 +772,27 @@ const RESYNC_TAIL_SAMPLES = 8;
|
|
|
814
772
|
* re-anchor the commit index when it does not. Returns the resync row index,
|
|
815
773
|
* or -1 when no resync is needed.
|
|
816
774
|
*
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
775
|
+
* Zones (verifiedTo ≤ finalTo ≤ prefix.length):
|
|
776
|
+
* [0, verifiedTo) VERIFIED exact rows — sampled with tolerance.
|
|
777
|
+
* [verifiedTo, finalTo) NEWLY-FINAL rows — frozen visual snapshots whose
|
|
778
|
+
* source just became declared-final (the block finalized / a barrier
|
|
779
|
+
* cleared). Hard-scanned in FULL with no tolerance: any content change
|
|
780
|
+
* (a pending header settling, a preview replaced by its result, a tail
|
|
781
|
+
* shifting up after a barrier removal) re-anchors so the final content
|
|
782
|
+
* recommits below the frozen snapshot — duplication, never loss —
|
|
783
|
+
* instead of being committed nowhere and painted nowhere.
|
|
784
|
+
* [finalTo, prefix.length) FROZEN visual snapshots of still-live rows —
|
|
785
|
+
* exempt: their drift is expected (a collapsing preview, a ticking
|
|
786
|
+
* progress tree) and must never spray re-anchors mid-run.
|
|
823
787
|
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
* never loss) instead of being committed nowhere and painted nowhere.
|
|
833
|
-
* 2. Tail sample (only when the hard scan is clean): exploits the asymmetry
|
|
834
|
-
* between the two mutation classes — an in-place edit/restyle of a committed
|
|
835
|
-
* row disturbs only the touched rows (alignment below intact; the stale copy
|
|
836
|
-
* in history is the long-accepted artifact), while an insertion/deletion
|
|
837
|
-
* shifts EVERY row below it. So up to 8 non-blank rows within the last 24
|
|
838
|
-
* audited rows are compared SGR-stripped (theme changes stay quiet),
|
|
839
|
-
* tolerating a SINGLE non-hard mismatch (a legitimate one-row edit): aligned ⇒
|
|
840
|
-
* no resync; misaligned ⇒ resync at the first non-equivalent audited row. The
|
|
841
|
-
* tolerance keeps both an offscreen still-live barrier (a ticking spinner) and
|
|
842
|
-
* a no-seam in-place row edit from spraying duplicate snapshots every frame;
|
|
843
|
-
* the hard scan above is what forbids it from swallowing a finalized row.
|
|
788
|
+
* The verified zone's sampled check exploits the asymmetry between the two
|
|
789
|
+
* mutation classes: an in-place edit/restyle disturbs only the touched rows
|
|
790
|
+
* (alignment below stays intact; the stale copy in history is the accepted
|
|
791
|
+
* artifact), while an insertion/deletion shifts EVERY row below it. Up to 8
|
|
792
|
+
* non-blank rows within the last 24 verified rows are compared SGR-stripped
|
|
793
|
+
* (theme changes stay quiet), tolerating a SINGLE mismatch. The tolerance is
|
|
794
|
+
* load-bearing for roots that report NO seam: an animated row already in
|
|
795
|
+
* history would otherwise re-anchor on every glyph tick.
|
|
844
796
|
*
|
|
845
797
|
* Highly repetitive tails (identical filler rows) can mask a shift in the tail
|
|
846
798
|
* sample, in which case the skipped rows are content-identical to the committed
|
|
@@ -850,39 +802,30 @@ const RESYNC_TAIL_SAMPLES = 8;
|
|
|
850
802
|
export function findCommittedPrefixResync(
|
|
851
803
|
frame: readonly string[],
|
|
852
804
|
prefix: readonly string[],
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
exemptTo: number = exemptFrom,
|
|
856
|
-
permanentEnd = 0,
|
|
805
|
+
verifiedTo: number = prefix.length,
|
|
806
|
+
finalTo: number = verifiedTo,
|
|
857
807
|
): number {
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
const audited = (i: number): boolean => i < exFrom || i >= exTo;
|
|
865
|
-
if (frame.length >= committed) {
|
|
866
|
-
// 1. Hard scan: forced-overflow rows now asserted permanent. Full scan, no
|
|
867
|
-
// tolerance — a finalized row that changed must re-anchor.
|
|
868
|
-
const hardEnd = Math.min(committed, Math.max(0, Math.trunc(permanentEnd)));
|
|
808
|
+
const verified = Math.min(prefix.length, Math.max(0, Math.trunc(verifiedTo)));
|
|
809
|
+
const hardEnd = Math.min(prefix.length, Math.max(verified, Math.trunc(finalTo)));
|
|
810
|
+
if (hardEnd === 0) return -1;
|
|
811
|
+
if (frame.length >= hardEnd) {
|
|
812
|
+
// 1. Hard scan: frozen snapshots whose source just became final. Full
|
|
813
|
+
// scan, no tolerance — a finalized row that changed must re-anchor.
|
|
869
814
|
let hardMismatch = false;
|
|
870
|
-
for (let i =
|
|
815
|
+
for (let i = verified; i < hardEnd; i++) {
|
|
871
816
|
if (!rowsEquivalent(frame[i]!, prefix[i]!)) {
|
|
872
817
|
hardMismatch = true;
|
|
873
818
|
break;
|
|
874
819
|
}
|
|
875
820
|
}
|
|
876
821
|
if (!hardMismatch) {
|
|
877
|
-
// 2. Tail sample
|
|
878
|
-
//
|
|
822
|
+
// 2. Tail sample over the verified zone (only when the hard scan is
|
|
823
|
+
// clean): walk up from its end until LOOKBACK rows or SAMPLES
|
|
824
|
+
// non-blank comparisons.
|
|
879
825
|
let samples = 0;
|
|
880
826
|
let mismatches = 0;
|
|
881
|
-
let
|
|
882
|
-
|
|
883
|
-
const idx = committed - j;
|
|
884
|
-
if (!audited(idx)) continue;
|
|
885
|
-
scanned++;
|
|
827
|
+
for (let j = 1; j <= verified && j <= RESYNC_TAIL_LOOKBACK && samples < RESYNC_TAIL_SAMPLES; j++) {
|
|
828
|
+
const idx = verified - j;
|
|
886
829
|
const row = frame[idx]!;
|
|
887
830
|
const old = prefix[idx]!;
|
|
888
831
|
if (row === old) {
|
|
@@ -893,18 +836,18 @@ export function findCommittedPrefixResync(
|
|
|
893
836
|
samples++;
|
|
894
837
|
if (!rowsEquivalent(row, old)) mismatches++;
|
|
895
838
|
}
|
|
896
|
-
// No signal (all-blank
|
|
839
|
+
// No signal (all-blank tail) or at most one edited row: aligned.
|
|
897
840
|
if (samples === 0 || mismatches <= 1) return -1;
|
|
898
841
|
}
|
|
899
842
|
}
|
|
900
|
-
// Misaligned (hard mismatch, tail-sample shift, or the frame no longer
|
|
901
|
-
// the
|
|
902
|
-
|
|
843
|
+
// Misaligned (hard mismatch, tail-sample shift, or the frame no longer
|
|
844
|
+
// covers the checked zones): re-anchor at the first row whose content
|
|
845
|
+
// changed.
|
|
846
|
+
const limit = Math.min(hardEnd, frame.length);
|
|
903
847
|
for (let i = 0; i < limit; i++) {
|
|
904
|
-
if (!audited(i)) continue;
|
|
905
848
|
if (!rowsEquivalent(frame[i]!, prefix[i]!)) return i;
|
|
906
849
|
}
|
|
907
|
-
return limit <
|
|
850
|
+
return limit < hardEnd ? limit : -1;
|
|
908
851
|
}
|
|
909
852
|
|
|
910
853
|
/**
|
|
@@ -1010,37 +953,31 @@ export class TUI extends Container {
|
|
|
1010
953
|
#cursorEndSequence = this.#synchronizedOutputEnabled ? CURSOR_END : CURSOR_END_NO_SYNC;
|
|
1011
954
|
// Rows of the current frame physically committed to the terminal tape
|
|
1012
955
|
// (native scrollback or scrolled past the window top). Immutable by
|
|
1013
|
-
// contract: the engine never rewrites them
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
956
|
+
// contract: the engine never rewrites them. Rows below
|
|
957
|
+
// #committedPrefixAuditRows entered as exact-final bytes (the component
|
|
958
|
+
// seam declared them); rows at/after it are frozen visual snapshots that
|
|
959
|
+
// scrolled off the window top while still live.
|
|
1016
960
|
#committedRows = 0;
|
|
1017
961
|
// Raw rows mirroring [0, #committedRows) — the engine's claim of what it
|
|
1018
|
-
// committed
|
|
1019
|
-
//
|
|
1020
|
-
// #auditCommittedPrefix). Holds
|
|
1021
|
-
// the audit is a pointer walk
|
|
962
|
+
// committed. The audited prefix [0, #committedPrefixAuditRows) is checked
|
|
963
|
+
// each ordinary frame against the current render to detect components
|
|
964
|
+
// re-laying-out declared-final content (see #auditCommittedPrefix). Holds
|
|
965
|
+
// references to component-cached strings, so the audit is a pointer walk
|
|
966
|
+
// in the common case.
|
|
1022
967
|
#committedPrefix: string[] = [];
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
//
|
|
1034
|
-
//
|
|
1035
|
-
// barrier later shifts/finalizes/removes, instead of stranding a stale
|
|
1036
|
-
// prefix that silently drops the rows beneath it.
|
|
1037
|
-
// Marks re-base on a wholesale re-slice (full paint / shrink / geometry) and
|
|
1038
|
-
// otherwise advance per the persistence rules in #updateCommittedAuditRows.
|
|
1039
|
-
// #auditCommittedPrefix audits [0, committedRows) skipping the exempt window
|
|
1040
|
-
// [auditRows, durableRows).
|
|
968
|
+
// Rows of the committed prefix that were HARD-VERIFIED as exact-final
|
|
969
|
+
// bytes (committed below the exactness boundary, or frozen snapshots that
|
|
970
|
+
// passed the one-time strict scan when the boundary rose past them). Rows
|
|
971
|
+
// in [#committedPrefixAuditRows, #committedRows) are frozen visual
|
|
972
|
+
// snapshots of still-live content — the terminal's record of what was on
|
|
973
|
+
// screen when it scrolled off — and are audit-exempt while their source
|
|
974
|
+
// remains live, so a collapsing preview never sprays re-anchors mid-run.
|
|
975
|
+
// When the exactness boundary rises past them (the block finalized), they
|
|
976
|
+
// are strict-scanned exactly once: unchanged rows join the verified zone,
|
|
977
|
+
// a divergence re-anchors so the final content recommits below the frozen
|
|
978
|
+
// snapshot (duplication, never loss). Re-based on full paints / shrinks /
|
|
979
|
+
// geometry frames.
|
|
1041
980
|
#committedPrefixAuditRows = 0;
|
|
1042
|
-
#committedPrefixDurableRows = 0;
|
|
1043
|
-
#committedPrefixOfferRows = 0;
|
|
1044
981
|
// Frame row currently mapped to screen row 0. Monotonic between full
|
|
1045
982
|
// paints: a shrink never re-exposes scrolled-off rows (they cannot be
|
|
1046
983
|
// un-scrolled without rewriting history); live rows repaint at fixed
|
|
@@ -1049,9 +986,6 @@ export class TUI extends Container {
|
|
|
1049
986
|
// Exactly what is painted on the screen rows (post-composite, prepared).
|
|
1050
987
|
#previousWindow: string[] = [];
|
|
1051
988
|
#nativeScrollbackLiveRegionStart: number | undefined;
|
|
1052
|
-
#nativeScrollbackCommitSafeEnd: number | undefined;
|
|
1053
|
-
#nativeScrollbackSnapshotSafeEnd: number | undefined;
|
|
1054
|
-
#nativeScrollbackOfferSafeEnd: number | undefined;
|
|
1055
989
|
#fullRedrawCount = 0;
|
|
1056
990
|
// Caps how many inline images render as live graphics; older ones fall back
|
|
1057
991
|
// to text via a purge + full redraw. Cap is configured by the host app.
|
|
@@ -1169,9 +1103,6 @@ export class TUI extends Container {
|
|
|
1169
1103
|
override render(width: number): readonly string[] {
|
|
1170
1104
|
width = Math.max(1, width);
|
|
1171
1105
|
this.#nativeScrollbackLiveRegionStart = undefined;
|
|
1172
|
-
this.#nativeScrollbackCommitSafeEnd = undefined;
|
|
1173
|
-
this.#nativeScrollbackSnapshotSafeEnd = undefined;
|
|
1174
|
-
this.#nativeScrollbackOfferSafeEnd = undefined;
|
|
1175
1106
|
const children = this.children;
|
|
1176
1107
|
const previousSegments = this.#frameSegments;
|
|
1177
1108
|
const segments: FrameSegment[] = new Array(children.length);
|
|
@@ -1192,16 +1123,10 @@ export class TUI extends Container {
|
|
|
1192
1123
|
partialRoots !== null && previous !== undefined && previous.component === child && !partialRoots.has(child);
|
|
1193
1124
|
let childLines: readonly string[];
|
|
1194
1125
|
let liveLocalStart: number | undefined;
|
|
1195
|
-
let commitLocalEnd: number | undefined;
|
|
1196
|
-
let snapshotLocalEnd: number | undefined;
|
|
1197
|
-
let offerLocalEnd: number | undefined;
|
|
1198
1126
|
let reported: number | undefined;
|
|
1199
1127
|
if (reuse) {
|
|
1200
1128
|
childLines = previous.lines;
|
|
1201
1129
|
liveLocalStart = previous.liveLocalStart;
|
|
1202
|
-
commitLocalEnd = previous.commitLocalEnd;
|
|
1203
|
-
snapshotLocalEnd = previous.snapshotLocalEnd;
|
|
1204
|
-
offerLocalEnd = previous.offerLocalEnd;
|
|
1205
1130
|
} else {
|
|
1206
1131
|
// Feed the engine's committed-row claim (from the previous frame's
|
|
1207
1132
|
// emit) before rendering so the child can skip re-deriving blocks
|
|
@@ -1214,29 +1139,6 @@ export class TUI extends Container {
|
|
|
1214
1139
|
liveLocalStart = Number.isFinite(liveRegionStart)
|
|
1215
1140
|
? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart)))
|
|
1216
1141
|
: childLines.length;
|
|
1217
|
-
const commitSafeEnd = getNativeScrollbackCommitSafeEnd(child);
|
|
1218
|
-
if (commitSafeEnd !== undefined) {
|
|
1219
|
-
commitLocalEnd = Number.isFinite(commitSafeEnd)
|
|
1220
|
-
? Math.max(liveLocalStart, Math.min(childLines.length, Math.trunc(commitSafeEnd)))
|
|
1221
|
-
: childLines.length;
|
|
1222
|
-
}
|
|
1223
|
-
// Durable snapshot end: clamped at/above the byte-stable end (or
|
|
1224
|
-
// the live-region start when none) so a child can never report a
|
|
1225
|
-
// shallower durable boundary than its byte-stable one.
|
|
1226
|
-
const snapshotSafeEnd = getNativeScrollbackSnapshotSafeEnd(child);
|
|
1227
|
-
if (snapshotSafeEnd !== undefined) {
|
|
1228
|
-
const snapshotFloor = commitLocalEnd ?? liveLocalStart;
|
|
1229
|
-
snapshotLocalEnd = Number.isFinite(snapshotSafeEnd)
|
|
1230
|
-
? Math.max(snapshotFloor, Math.min(childLines.length, Math.trunc(snapshotSafeEnd)))
|
|
1231
|
-
: childLines.length;
|
|
1232
|
-
}
|
|
1233
|
-
const offerSafeEnd = getNativeScrollbackOfferSafeEnd(child);
|
|
1234
|
-
if (offerSafeEnd !== undefined) {
|
|
1235
|
-
const offerFloor = snapshotLocalEnd ?? commitLocalEnd ?? liveLocalStart;
|
|
1236
|
-
offerLocalEnd = Number.isFinite(offerSafeEnd)
|
|
1237
|
-
? Math.max(offerFloor, Math.min(childLines.length, Math.trunc(offerSafeEnd)))
|
|
1238
|
-
: childLines.length;
|
|
1239
|
-
}
|
|
1240
1142
|
}
|
|
1241
1143
|
// Consume the stability report unconditionally for implementers:
|
|
1242
1144
|
// reading re-bases the component's baseline to the state this
|
|
@@ -1247,22 +1149,13 @@ export class TUI extends Container {
|
|
|
1247
1149
|
reported = getRenderStablePrefixRows(child);
|
|
1248
1150
|
}
|
|
1249
1151
|
// Topmost seam wins. Commits are prefix-only: the first child that
|
|
1250
|
-
// reports a live region
|
|
1251
|
-
//
|
|
1252
|
-
//
|
|
1253
|
-
//
|
|
1254
|
-
//
|
|
1152
|
+
// reports a live region already bounds everything below it, so a
|
|
1153
|
+
// lower sibling's seam (e.g. a status loader under a streaming
|
|
1154
|
+
// transcript) must never overwrite it — moving the boundary down
|
|
1155
|
+
// would commit the earlier child's still-mutable rows as stale
|
|
1156
|
+
// history.
|
|
1255
1157
|
if (liveLocalStart !== undefined && this.#nativeScrollbackLiveRegionStart === undefined) {
|
|
1256
1158
|
this.#nativeScrollbackLiveRegionStart = offset + liveLocalStart;
|
|
1257
|
-
if (commitLocalEnd !== undefined) {
|
|
1258
|
-
this.#nativeScrollbackCommitSafeEnd = offset + commitLocalEnd;
|
|
1259
|
-
}
|
|
1260
|
-
if (snapshotLocalEnd !== undefined) {
|
|
1261
|
-
this.#nativeScrollbackSnapshotSafeEnd = offset + snapshotLocalEnd;
|
|
1262
|
-
}
|
|
1263
|
-
if (offerLocalEnd !== undefined) {
|
|
1264
|
-
this.#nativeScrollbackOfferSafeEnd = offset + offerLocalEnd;
|
|
1265
|
-
}
|
|
1266
1159
|
}
|
|
1267
1160
|
if (chainStable) {
|
|
1268
1161
|
if (previous !== undefined && previous.component === child && previous.start === offset) {
|
|
@@ -1291,9 +1184,6 @@ export class TUI extends Container {
|
|
|
1291
1184
|
start: offset,
|
|
1292
1185
|
rowCount: childLines.length,
|
|
1293
1186
|
liveLocalStart,
|
|
1294
|
-
commitLocalEnd,
|
|
1295
|
-
snapshotLocalEnd,
|
|
1296
|
-
offerLocalEnd,
|
|
1297
1187
|
};
|
|
1298
1188
|
offset += childLines.length;
|
|
1299
1189
|
}
|
|
@@ -2692,37 +2582,15 @@ export class TUI extends Container {
|
|
|
2692
2582
|
// known. Ascending by frame row.
|
|
2693
2583
|
const cursorMarkers = this.#frameCursorMarkers;
|
|
2694
2584
|
const liveRegionStart = this.#nativeScrollbackLiveRegionStart;
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
//
|
|
2700
|
-
//
|
|
2701
|
-
//
|
|
2702
|
-
// The commit floor is windowTop in every non-frozen path (see chunkTo), so
|
|
2703
|
-
// whatever scrolls above the window is committed — never committed nowhere
|
|
2704
|
-
// AND painted nowhere (the loss bug). The boundaries no longer gate the
|
|
2705
|
-
// commit; they define audit and repair spans. byteStableBoundary: rows below
|
|
2706
|
-
// it are byte-stable (never re-layout), audited. durableBoundary: rows in
|
|
2707
|
-
// [byteStableBoundary, durableBoundary) are durable — permanent on scroll-off
|
|
2708
|
-
// but may drift in place (a streaming table re-aligning), committed
|
|
2709
|
-
// audit-EXEMPT. offerBoundary: rows in [durableBoundary, offerBoundary)
|
|
2710
|
-
// were explicitly allowed to enter native scrollback while remaining
|
|
2711
|
-
// audited; if they later shift, the stale physical history is repaired by a
|
|
2712
|
-
// destructive replay instead of duplicate recommit. Rows at/beyond
|
|
2713
|
-
// offerBoundary committed only because they scrolled above the window (a
|
|
2714
|
-
// commit-unstable barrier over a long tail) are forced-overflow rows:
|
|
2715
|
-
// audited, so a later shift/finalize/removal re-anchors (duplication, never
|
|
2716
|
-
// loss) instead of stranding a stale prefix. Built on the finalized prefix
|
|
2717
|
-
// (live-region start); the whole frame when the root reports no seam (shell
|
|
2718
|
-
// semantics: whatever scrolls is final).
|
|
2585
|
+
|
|
2586
|
+
// Exactness boundary (used by the audit-zone math below). Rows below it
|
|
2587
|
+
// are declared FINAL by the component seam: when they commit, they enter
|
|
2588
|
+
// the audited zone (byte-exact, repairable on violation). Rows above it
|
|
2589
|
+
// that scroll off the window commit as frozen visual snapshots (see
|
|
2590
|
+
// #committedPrefixAuditRows). The whole frame is final when the root
|
|
2591
|
+
// reports no seam (shell semantics).
|
|
2719
2592
|
const frameLength = rawFrame.length;
|
|
2720
|
-
const
|
|
2721
|
-
const durableBoundary = Math.max(
|
|
2722
|
-
byteStableBoundary,
|
|
2723
|
-
Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary),
|
|
2724
|
-
);
|
|
2725
|
-
const offerBoundary = Math.max(durableBoundary, Math.min(frameLength, offerSafeEnd ?? durableBoundary));
|
|
2593
|
+
const finalBoundary = Math.max(0, Math.min(frameLength, liveRegionStart ?? frameLength));
|
|
2726
2594
|
|
|
2727
2595
|
// 2. Transition state captured before any emitter runs.
|
|
2728
2596
|
const prevWindowTop = this.#windowTopRow;
|
|
@@ -2739,66 +2607,67 @@ export class TUI extends Container {
|
|
|
2739
2607
|
(resizeEventOccurred && this.#previousHeight > 0);
|
|
2740
2608
|
const geometryChanged = widthChanged || heightChanged;
|
|
2741
2609
|
|
|
2742
|
-
// Committed-prefix audit
|
|
2743
|
-
//
|
|
2744
|
-
//
|
|
2745
|
-
//
|
|
2746
|
-
//
|
|
2747
|
-
//
|
|
2748
|
-
//
|
|
2749
|
-
//
|
|
2750
|
-
//
|
|
2751
|
-
//
|
|
2752
|
-
//
|
|
2753
|
-
// that provably did not change since the last (aligned) frame cannot
|
|
2754
|
-
// have diverged.
|
|
2610
|
+
// Committed-prefix audit. Rows below the audit mark are hard-verified
|
|
2611
|
+
// exact bytes; rows between the mark and the current exactness boundary
|
|
2612
|
+
// are frozen snapshots whose source JUST became final and must be
|
|
2613
|
+
// verified once (a pending header settling, a barrier clearing above a
|
|
2614
|
+
// shifted tail); rows past the boundary are still-live frozen snapshots,
|
|
2615
|
+
// exempt so a collapsing preview can never spray re-anchors mid-run. A
|
|
2616
|
+
// divergence re-anchors and recommits — duplication, never loss —
|
|
2617
|
+
// instead of silently skipping rows (committed nowhere, painted
|
|
2618
|
+
// nowhere). Skipped on geometry frames (a rewrap legitimately reflows
|
|
2619
|
+
// every row), and skipped when the composed frame's stable prefix
|
|
2620
|
+
// covers every verified row and no rows newly became final.
|
|
2755
2621
|
let committedRowsResynced = false;
|
|
2756
|
-
|
|
2757
|
-
//
|
|
2758
|
-
//
|
|
2759
|
-
//
|
|
2760
|
-
//
|
|
2761
|
-
//
|
|
2762
|
-
//
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
// (committedPrefixOfferRows < min(committed, offerBoundary)): a
|
|
2767
|
-
// finalized sibling under a live block asks for destructive-replay
|
|
2768
|
-
// repair, so a single-row shift there must be caught even if
|
|
2769
|
-
// tail-sample tolerance would otherwise skip it.
|
|
2770
|
-
// The hard scan in findCommittedPrefixResync covers [durableRows,
|
|
2771
|
-
// hardAuditEnd) in full (no tail-sample miss).
|
|
2772
|
-
const auditUpper =
|
|
2773
|
-
this.#committedPrefixDurableRows < this.#committedRows ? this.#committedRows : this.#committedPrefixAuditRows;
|
|
2774
|
-
const durableHardEnd = Math.min(this.#committedRows, durableBoundary);
|
|
2775
|
-
const offerHardEnd = Math.min(this.#committedRows, offerBoundary);
|
|
2776
|
-
const hardAuditEnd = Math.max(durableHardEnd, offerHardEnd);
|
|
2777
|
-
const needHardAudit =
|
|
2778
|
-
this.#committedPrefixDurableRows < durableHardEnd || this.#committedPrefixOfferRows < offerHardEnd;
|
|
2779
|
-
let repairOfferedScrollback = false;
|
|
2622
|
+
const newlyFinalEnd = Math.min(this.#committedRows, finalBoundary);
|
|
2623
|
+
// The exactness boundary can RETREAT (a markdown rewind, a mermaid fence
|
|
2624
|
+
// appearing, a fast-path reset re-opening a block): rows verified under
|
|
2625
|
+
// the old boundary have a live source again. Demote them to frozen
|
|
2626
|
+
// snapshots instead of auditing content that is expected to change —
|
|
2627
|
+
// their committed bytes stay as the visual record, and the next boundary
|
|
2628
|
+
// rise strict-verifies them once like any other frozen row.
|
|
2629
|
+
if (this.#committedPrefixAuditRows > newlyFinalEnd) {
|
|
2630
|
+
this.#committedPrefixAuditRows = newlyFinalEnd;
|
|
2631
|
+
}
|
|
2780
2632
|
const auditRan =
|
|
2781
2633
|
this.#hasEverRendered &&
|
|
2782
2634
|
!geometryChanged &&
|
|
2783
2635
|
!this.#clearScrollbackOnNextRender &&
|
|
2784
|
-
(this.#renderStablePrefixRows <
|
|
2636
|
+
(this.#renderStablePrefixRows < this.#committedPrefixAuditRows ||
|
|
2637
|
+
newlyFinalEnd > this.#committedPrefixAuditRows);
|
|
2785
2638
|
if (auditRan) {
|
|
2786
2639
|
const committedRowsBeforeAudit = this.#committedRows;
|
|
2787
|
-
|
|
2788
|
-
const durableRowsBeforeAudit = this.#committedPrefixDurableRows;
|
|
2789
|
-
this.#auditCommittedPrefix(rawFrame, hardAuditEnd);
|
|
2640
|
+
this.#auditCommittedPrefix(rawFrame, newlyFinalEnd);
|
|
2790
2641
|
committedRowsResynced = this.#committedRows !== committedRowsBeforeAudit;
|
|
2791
|
-
repairOfferedScrollback =
|
|
2792
|
-
offeredRowsBeforeAudit > durableRowsBeforeAudit &&
|
|
2793
|
-
committedRowsResynced &&
|
|
2794
|
-
this.#committedRows < offeredRowsBeforeAudit;
|
|
2795
2642
|
}
|
|
2796
|
-
//
|
|
2797
|
-
//
|
|
2643
|
+
// A frame that shrank below the committed row count collapsed content
|
|
2644
|
+
// that was already recorded (a live suffix collapsing on abort/result).
|
|
2645
|
+
// Re-base the commit index at the first divergence against the recorded
|
|
2646
|
+
// prefix — frozen snapshots included; a collapse is precisely when the
|
|
2647
|
+
// record and the frame part ways — so the surviving exact prefix stays
|
|
2648
|
+
// recognized and is never re-shown or re-committed. Only genuinely new
|
|
2649
|
+
// content repaints below it.
|
|
2650
|
+
if (!geometryChanged && !this.#clearScrollbackOnNextRender && frameLength < this.#committedRows) {
|
|
2651
|
+
const limit = Math.min(this.#committedRows, frameLength);
|
|
2652
|
+
let diverged = limit;
|
|
2653
|
+
for (let i = 0; i < limit; i++) {
|
|
2654
|
+
if (!rowsEquivalent(rawFrame[i]!, this.#committedPrefix[i]!)) {
|
|
2655
|
+
diverged = i;
|
|
2656
|
+
break;
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
if (diverged < this.#committedRows) {
|
|
2660
|
+
this.#committedRows = diverged;
|
|
2661
|
+
this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, diverged);
|
|
2662
|
+
this.#committedPrefix.length = diverged;
|
|
2663
|
+
committedRowsResynced = true;
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
// Committed-prefix state this frame's commit math extends from
|
|
2667
|
+
// (post-audit): drives the audit-mark advance after the emit.
|
|
2798
2668
|
const preCommitRows = this.#committedRows;
|
|
2799
|
-
const
|
|
2800
|
-
|
|
2801
|
-
const preCommitOfferRows = this.#committedPrefixOfferRows;
|
|
2669
|
+
const preAuditRows = this.#committedPrefixAuditRows;
|
|
2670
|
+
let committedPrefixResliced = false;
|
|
2802
2671
|
|
|
2803
2672
|
// 3. Window and commit math (lengths only; content prepared below).
|
|
2804
2673
|
let hasVisibleOverlay = false;
|
|
@@ -2816,12 +2685,11 @@ export class TUI extends Container {
|
|
|
2816
2685
|
// place, because an ED3 rewrap is unsafe (pane scrollback / alt-screen
|
|
2817
2686
|
// feedback loop), so committed history keeps its old wrap.
|
|
2818
2687
|
const firstPaint = !this.#hasEverRendered;
|
|
2819
|
-
const replaceRequested = this.#clearScrollbackOnNextRender
|
|
2688
|
+
const replaceRequested = this.#clearScrollbackOnNextRender;
|
|
2820
2689
|
const geometryRebuild = geometryChanged && !resizeRepaintsInPlace();
|
|
2821
2690
|
const fullPaint = firstPaint || replaceRequested || geometryRebuild;
|
|
2822
2691
|
let windowTop: number;
|
|
2823
2692
|
let chunkTo: number;
|
|
2824
|
-
let committedPrefixResliced = false;
|
|
2825
2693
|
if (fullPaint) {
|
|
2826
2694
|
committedPrefixResliced = true;
|
|
2827
2695
|
windowTop = Math.max(0, frameLength - height);
|
|
@@ -2842,9 +2710,9 @@ export class TUI extends Container {
|
|
|
2842
2710
|
// stale committed copy stays in native history; duplicating a few rows
|
|
2843
2711
|
// is preferable to a live editor gap and matches the existing
|
|
2844
2712
|
// "duplication, never loss" resync contract.
|
|
2713
|
+
committedPrefixResliced = true;
|
|
2845
2714
|
windowTop = Math.max(0, frameLength - height);
|
|
2846
2715
|
chunkTo = windowTop;
|
|
2847
|
-
committedPrefixResliced = true;
|
|
2848
2716
|
this.#committedRows = chunkTo;
|
|
2849
2717
|
this.#committedPrefix = rawFrame.slice(0, chunkTo);
|
|
2850
2718
|
} else {
|
|
@@ -2855,12 +2723,14 @@ export class TUI extends Container {
|
|
|
2855
2723
|
// multiplexer resize the pane reflowed its own history; committed
|
|
2856
2724
|
// rows keep their old wrap there, same as any shell output.
|
|
2857
2725
|
windowTop = Math.max(this.#committedRows, frameLength - height, 0);
|
|
2858
|
-
//
|
|
2859
|
-
//
|
|
2860
|
-
//
|
|
2861
|
-
//
|
|
2862
|
-
//
|
|
2863
|
-
//
|
|
2726
|
+
// Whatever scrolls above the window commits — the tape is the visual
|
|
2727
|
+
// record; nothing that was painted may vanish. Overlays freeze
|
|
2728
|
+
// commits: composited rows must never enter history, and the hidden
|
|
2729
|
+
// gap backfills via the chunk once the overlay closes. A multiplexer
|
|
2730
|
+
// resize also commits nothing — the pane keeps its own (old-wrap)
|
|
2731
|
+
// history — and re-bases the audit prefix at the new width so the
|
|
2732
|
+
// accepted wrap drift does not read as a violation on the next
|
|
2733
|
+
// ordinary frame.
|
|
2864
2734
|
chunkTo = hasVisibleOverlay || geometryChanged ? this.#committedRows : windowTop;
|
|
2865
2735
|
if (geometryChanged) {
|
|
2866
2736
|
committedPrefixResliced = true;
|
|
@@ -2923,17 +2793,7 @@ export class TUI extends Container {
|
|
|
2923
2793
|
cursorTrackingLineCount,
|
|
2924
2794
|
});
|
|
2925
2795
|
this.#committedPrefix = rawFrame.slice(0, chunkTo);
|
|
2926
|
-
this.#
|
|
2927
|
-
true,
|
|
2928
|
-
preCommitRows,
|
|
2929
|
-
preCommitAuditRows,
|
|
2930
|
-
preCommitDurableRows,
|
|
2931
|
-
preCommitOfferRows,
|
|
2932
|
-
byteStableBoundary,
|
|
2933
|
-
durableBoundary,
|
|
2934
|
-
offerBoundary,
|
|
2935
|
-
false,
|
|
2936
|
-
);
|
|
2796
|
+
this.#committedPrefixAuditRows = Math.min(chunkTo, finalBoundary);
|
|
2937
2797
|
this.#clearScrollbackOnNextRender = false;
|
|
2938
2798
|
this.#hasEverRendered = true;
|
|
2939
2799
|
if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle();
|
|
@@ -2954,42 +2814,32 @@ export class TUI extends Container {
|
|
|
2954
2814
|
for (let i = this.#committedPrefix.length; i < chunkTo; i++) {
|
|
2955
2815
|
this.#committedPrefix.push(rawFrame[i] ?? "");
|
|
2956
2816
|
}
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
auditRan,
|
|
2967
|
-
);
|
|
2817
|
+
// Audit-mark advance. A re-slice re-bases it outright. Otherwise it may
|
|
2818
|
+
// advance to the exactness boundary only when this frame verified the
|
|
2819
|
+
// newly-final span (auditRan hard-scans it) or no such span existed —
|
|
2820
|
+
// rows committed this frame below the boundary are fresh exact bytes.
|
|
2821
|
+
if (committedPrefixResliced || auditRan || preAuditRows >= Math.min(preCommitRows, finalBoundary)) {
|
|
2822
|
+
this.#committedPrefixAuditRows = Math.min(this.#committedRows, finalBoundary);
|
|
2823
|
+
} else {
|
|
2824
|
+
this.#committedPrefixAuditRows = Math.min(preAuditRows, this.#committedRows);
|
|
2825
|
+
}
|
|
2968
2826
|
}
|
|
2969
2827
|
|
|
2970
2828
|
/**
|
|
2971
|
-
* Detect committed-prefix violations
|
|
2972
|
-
*
|
|
2973
|
-
*
|
|
2974
|
-
*
|
|
2975
|
-
* history was always
|
|
2829
|
+
* Detect committed-prefix violations (see {@link findCommittedPrefixResync}
|
|
2830
|
+
* for the zone semantics) and re-anchor the commit index at the first moved
|
|
2831
|
+
* row, so subsequent rows recommit instead of being skipped: the stale copy
|
|
2832
|
+
* stays in history — duplication, never loss. Pure in-place restyles keep
|
|
2833
|
+
* their alignment and are left alone (stale styling in history was always
|
|
2834
|
+
* the accepted artifact).
|
|
2976
2835
|
*/
|
|
2977
|
-
#auditCommittedPrefix(rawFrame: readonly string[],
|
|
2836
|
+
#auditCommittedPrefix(rawFrame: readonly string[], newlyFinalEnd: number): void {
|
|
2978
2837
|
const prefix = this.#committedPrefix;
|
|
2979
2838
|
if (prefix.length === 0) return;
|
|
2980
|
-
const resyncTo = findCommittedPrefixResync(
|
|
2981
|
-
rawFrame,
|
|
2982
|
-
prefix,
|
|
2983
|
-
prefix.length,
|
|
2984
|
-
this.#committedPrefixAuditRows,
|
|
2985
|
-
this.#committedPrefixDurableRows,
|
|
2986
|
-
permanentEnd,
|
|
2987
|
-
);
|
|
2839
|
+
const resyncTo = findCommittedPrefixResync(rawFrame, prefix, this.#committedPrefixAuditRows, newlyFinalEnd);
|
|
2988
2840
|
if (resyncTo < 0) return;
|
|
2989
2841
|
this.#committedRows = resyncTo;
|
|
2990
2842
|
this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, resyncTo);
|
|
2991
|
-
this.#committedPrefixDurableRows = Math.min(this.#committedPrefixDurableRows, resyncTo);
|
|
2992
|
-
this.#committedPrefixOfferRows = Math.min(this.#committedPrefixOfferRows, resyncTo);
|
|
2993
2843
|
prefix.length = resyncTo;
|
|
2994
2844
|
if ($flag("PI_DEBUG_REDRAW")) {
|
|
2995
2845
|
const msg = `[${new Date().toISOString()}] commit resync: committed prefix diverged at row ${resyncTo}; recommitting\n`;
|
|
@@ -2997,59 +2847,6 @@ export class TUI extends Container {
|
|
|
2997
2847
|
}
|
|
2998
2848
|
}
|
|
2999
2849
|
|
|
3000
|
-
/**
|
|
3001
|
-
* Recompute the audit-rows / durable-rows marks after a commit (see the
|
|
3002
|
-
* #committedPrefixAuditRows field doc for the three audit zones).
|
|
3003
|
-
*
|
|
3004
|
-
* auditRows tracks the byte-stable boundary; durableRows the durable snapshot
|
|
3005
|
-
* boundary; offerRows the deepest explicit audited-offer boundary. A wholesale
|
|
3006
|
-
* re-slice (full paint / shrink / geometry) re-bases each mark from the
|
|
3007
|
-
* current frame. An incremental extend keeps a mark once a row past it has
|
|
3008
|
-
* committed (mark < committed): a later RISE in a boundary (a table finalizing)
|
|
3009
|
-
* must neither pull already-committed stale snapshots back under the
|
|
3010
|
-
* byte-stable cap nor retroactively exempt forced-overflow rows already
|
|
3011
|
-
* audited. durableRows is floored at auditRows; offerRows is floored at
|
|
3012
|
-
* durableRows.
|
|
3013
|
-
*/
|
|
3014
|
-
#updateCommittedAuditRows(
|
|
3015
|
-
resliced: boolean,
|
|
3016
|
-
preCommittedRows: number,
|
|
3017
|
-
preAuditRows: number,
|
|
3018
|
-
preDurableRows: number,
|
|
3019
|
-
preOfferRows: number,
|
|
3020
|
-
byteStableBoundary: number,
|
|
3021
|
-
durableBoundary: number,
|
|
3022
|
-
offerBoundary: number,
|
|
3023
|
-
hardAudited: boolean,
|
|
3024
|
-
): void {
|
|
3025
|
-
const committed = this.#committedRows;
|
|
3026
|
-
const auditRows =
|
|
3027
|
-
resliced || preAuditRows >= preCommittedRows
|
|
3028
|
-
? Math.min(committed, byteStableBoundary)
|
|
3029
|
-
: Math.min(preAuditRows, committed);
|
|
3030
|
-
// durableRows also advances when a hard audit ran this frame: the resync's
|
|
3031
|
-
// full hard scan verified the forced suffix [durableRows, min(committed,
|
|
3032
|
-
// durableBoundary)) (re-anchoring on any divergence), so those rows are now
|
|
3033
|
-
// proven durable and may leave the audited set — otherwise the durable-rise
|
|
3034
|
-
// gate would re-fire the full scan every frame (and spray on later drift).
|
|
3035
|
-
const durableRows =
|
|
3036
|
-
resliced || preDurableRows >= preCommittedRows || hardAudited
|
|
3037
|
-
? Math.min(committed, durableBoundary)
|
|
3038
|
-
: Math.min(preDurableRows, committed);
|
|
3039
|
-
// offerRows may EXTEND to include forced-overflow rows within a
|
|
3040
|
-
// newly-visible offerBoundary — unlike auditRows/durableRows, retroactive
|
|
3041
|
-
// promotion is safe: both offered and forced-overflow zones stay
|
|
3042
|
-
// audited; offered only switches divergence repair from tolerant recommit
|
|
3043
|
-
// to destructive replay (stronger, not weaker). A dropped offerBoundary
|
|
3044
|
-
// still keeps the durability rule via `preOfferRows` (never demote
|
|
3045
|
-
// already-offered rows to forced-overflow).
|
|
3046
|
-
const offerCap = Math.min(committed, offerBoundary);
|
|
3047
|
-
const offerRows = resliced ? offerCap : Math.max(Math.min(preOfferRows, committed), offerCap);
|
|
3048
|
-
this.#committedPrefixAuditRows = auditRows;
|
|
3049
|
-
this.#committedPrefixDurableRows = Math.max(auditRows, durableRows);
|
|
3050
|
-
this.#committedPrefixOfferRows = Math.max(this.#committedPrefixDurableRows, offerRows);
|
|
3051
|
-
}
|
|
3052
|
-
|
|
3053
2850
|
/**
|
|
3054
2851
|
* Prepare the composed frame for emission, in place. Rows below
|
|
3055
2852
|
* `#preparedValidRows` are already prepared against the current frame (the
|
|
@@ -3800,17 +3597,20 @@ export class TUI extends Container {
|
|
|
3800
3597
|
}
|
|
3801
3598
|
}
|
|
3802
3599
|
|
|
3803
|
-
// In-window diff: nothing commits.
|
|
3804
|
-
//
|
|
3805
|
-
//
|
|
3806
|
-
//
|
|
3807
|
-
// row
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
if (
|
|
3600
|
+
// In-window diff: nothing commits. Rewrite in place when the window slid
|
|
3601
|
+
// without a commit — an overlay visible (composited rows must never enter
|
|
3602
|
+
// history), a commit-frozen geometry frame, or the window pulling back
|
|
3603
|
+
// down after a shrink. Overlay cursor-only frames can also leave the
|
|
3604
|
+
// tracked row behind the physical cursor; a relative partial rewrite from
|
|
3605
|
+
// that stale origin can CRLF on the bottom row and scroll native history
|
|
3606
|
+
// without appending to the commit tape, so overlays always take the
|
|
3607
|
+
// top-clamped full rewrite.
|
|
3608
|
+
const inPlaceRewrite = repaintVirtualScrollInPlace || scroll !== 0;
|
|
3609
|
+
if (chunkLength === 0) {
|
|
3610
|
+
if (forceWindowRewrite || inPlaceRewrite) this.#fullRedrawCount += 1;
|
|
3611
|
+
let firstChanged = forceWindowRewrite || inPlaceRewrite ? 0 : -1;
|
|
3612
|
+
let lastChanged = forceWindowRewrite || inPlaceRewrite ? height - 1 : -1;
|
|
3613
|
+
if (!forceWindowRewrite && !inPlaceRewrite) {
|
|
3814
3614
|
const comparable = previousWindow.length === height;
|
|
3815
3615
|
for (let r = 0; r < height; r++) {
|
|
3816
3616
|
if (comparable && (window[r] ?? "") === (previousWindow[r] ?? "")) continue;
|
|
@@ -3826,10 +3626,11 @@ export class TUI extends Container {
|
|
|
3826
3626
|
return;
|
|
3827
3627
|
}
|
|
3828
3628
|
let buffer = this.#paintBeginSequence + purgeSequence;
|
|
3829
|
-
if (
|
|
3830
|
-
// The cursor tracker can be stale after overlay-only frames
|
|
3831
|
-
//
|
|
3832
|
-
//
|
|
3629
|
+
if (inPlaceRewrite) {
|
|
3630
|
+
// The cursor tracker can be stale after overlay-only frames, and
|
|
3631
|
+
// meaningless after an uncommitted slide. A large CUU clamps at the
|
|
3632
|
+
// viewport top without using absolute cursor home, so the following
|
|
3633
|
+
// full-window rewrite cannot overflow the bottom.
|
|
3833
3634
|
if (height > 1) buffer += `\x1b[${height - 1}A`;
|
|
3834
3635
|
} else {
|
|
3835
3636
|
const rowDelta = firstChanged - currentScreenRow;
|
|
@@ -3909,7 +3710,7 @@ export class TUI extends Container {
|
|
|
3909
3710
|
: `fullPaint(clearScrollback=${intent.clearScrollback})`;
|
|
3910
3711
|
const state =
|
|
3911
3712
|
`committed=${this.#committedRows}, windowTop=${this.#windowTopRow}, ` +
|
|
3912
|
-
`lrStart=${this.#nativeScrollbackLiveRegionStart}
|
|
3713
|
+
`lrStart=${this.#nativeScrollbackLiveRegionStart}`;
|
|
3913
3714
|
const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousFrameLength}, new=${newLength}, height=${height}, ${state})\n`;
|
|
3914
3715
|
fs.appendFileSync(getDebugLogPath(), msg);
|
|
3915
3716
|
}
|