@oh-my-pi/pi-tui 16.3.2 → 16.3.3
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 +9 -0
- package/dist/types/components/markdown.d.ts +1 -1
- package/dist/types/tui.d.ts +6 -0
- package/package.json +3 -3
- package/src/components/editor.ts +71 -23
- package/src/components/markdown.ts +8 -1
- package/src/terminal.ts +17 -9
- package/src/tui.ts +100 -29
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.3.3] - 2026-07-02
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed keyboard fallback behavior (modifyOtherKeys) on unknown SSH terminals, resolving broken Shift input in iOS SSH clients like Redock.
|
|
10
|
+
- Fixed a native scrollback rendering bug where finalized transcript rows below an active block would duplicate when the active block expanded.
|
|
11
|
+
- Fixed autocomplete popups remaining active with stale suggestions after destructive text editing (such as Ctrl+W, Ctrl+U, Ctrl+K, Alt+Backspace, Alt+D, paste, or yank), preventing input corruption when pressing Tab or Enter.
|
|
12
|
+
- Skipped Markdown re-lex + re-wrap when `setText` receives the identical text, mirroring the equality guard on `Text.setText` — cuts one of the top streaming CPU hotspots when providers re-emit unchanged content ([#4353](https://github.com/can1357/oh-my-pi/issues/4353)).
|
|
13
|
+
|
|
5
14
|
## [16.3.0] - 2026-07-02
|
|
6
15
|
|
|
7
16
|
### Fixed
|
|
@@ -51,7 +51,7 @@ export declare class Markdown implements Component {
|
|
|
51
51
|
#private;
|
|
52
52
|
setIgnoreTight(ignore: boolean): this;
|
|
53
53
|
constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, codeBlockIndent?: number);
|
|
54
|
-
setText(text: string):
|
|
54
|
+
setText(text: string): boolean;
|
|
55
55
|
invalidate(): void;
|
|
56
56
|
get transientRenderCache(): boolean;
|
|
57
57
|
set transientRenderCache(value: boolean);
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -105,6 +105,11 @@ export interface OverlayFocusOwner {
|
|
|
105
105
|
* dropped row or an audit re-anchor spray. Provisional live blocks (collapsing
|
|
106
106
|
* tool/edit previews whose head is a throwaway tail window) omit it. Defaults to
|
|
107
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.
|
|
108
113
|
*
|
|
109
114
|
* When several root children report a seam in the same frame, the topmost
|
|
110
115
|
* one (and its commit-safe / snapshot-safe extension) defines the boundary:
|
|
@@ -115,6 +120,7 @@ export interface NativeScrollbackLiveRegion {
|
|
|
115
120
|
getNativeScrollbackLiveRegionStart(): number | undefined;
|
|
116
121
|
getNativeScrollbackCommitSafeEnd?(): number | undefined;
|
|
117
122
|
getNativeScrollbackSnapshotSafeEnd?(): number | undefined;
|
|
123
|
+
getNativeScrollbackOfferSafeEnd?(): number | undefined;
|
|
118
124
|
}
|
|
119
125
|
export interface NativeScrollbackCommittedRows {
|
|
120
126
|
setNativeScrollbackCommittedRows(rows: number): void;
|
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.3",
|
|
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.3",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.3.3",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.5"
|
|
44
44
|
},
|
package/src/components/editor.ts
CHANGED
|
@@ -1119,6 +1119,15 @@ export class Editor implements Component, Focusable {
|
|
|
1119
1119
|
|
|
1120
1120
|
// If Tab was pressed, always apply the selection
|
|
1121
1121
|
if (kb.matches(data, "tui.input.tab")) {
|
|
1122
|
+
// Check for stale autocomplete state due to buffer edits since last refresh
|
|
1123
|
+
// (destructive keys or paste can outrun the debounced update).
|
|
1124
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1125
|
+
const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1126
|
+
if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor)) {
|
|
1127
|
+
// Autocomplete is stale - silently cancel; Tab has no fallback action here.
|
|
1128
|
+
this.#cancelAutocomplete();
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1122
1131
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
1123
1132
|
if (selected && this.#autocompleteProvider) {
|
|
1124
1133
|
const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
|
|
@@ -1183,30 +1192,38 @@ export class Editor implements Component, Focusable {
|
|
|
1183
1192
|
}
|
|
1184
1193
|
// If Enter was pressed on a file path, apply completion
|
|
1185
1194
|
else if (kb.matches(data, "tui.input.submit") || data === "\n") {
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
)
|
|
1195
|
+
// Check for stale autocomplete state due to buffer edits since last refresh.
|
|
1196
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1197
|
+
const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1198
|
+
if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor)) {
|
|
1199
|
+
// Autocomplete is stale - cancel and fall through to normal submission
|
|
1200
|
+
this.#cancelAutocomplete();
|
|
1201
|
+
} else {
|
|
1202
|
+
const selected = this.#autocompleteList.getSelectedItem();
|
|
1203
|
+
if (selected && this.#autocompleteProvider) {
|
|
1204
|
+
const result = this.#autocompleteProvider.applyCompletion(
|
|
1205
|
+
this.#state.lines,
|
|
1206
|
+
this.#state.cursorLine,
|
|
1207
|
+
this.#state.cursorCol,
|
|
1208
|
+
selected,
|
|
1209
|
+
this.#autocompletePrefix,
|
|
1210
|
+
);
|
|
1195
1211
|
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1212
|
+
this.#state.lines = result.lines;
|
|
1213
|
+
this.#state.cursorLine = result.cursorLine;
|
|
1214
|
+
this.#setCursorCol(result.cursorCol);
|
|
1199
1215
|
|
|
1200
|
-
|
|
1201
|
-
|
|
1216
|
+
this.#cancelAutocomplete();
|
|
1217
|
+
this.onAutocompleteUpdate?.();
|
|
1202
1218
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1219
|
+
if (this.onChange) {
|
|
1220
|
+
this.onChange(this.getText());
|
|
1221
|
+
}
|
|
1206
1222
|
|
|
1207
|
-
|
|
1223
|
+
result.onApplied?.();
|
|
1224
|
+
}
|
|
1225
|
+
return;
|
|
1208
1226
|
}
|
|
1209
|
-
return;
|
|
1210
1227
|
}
|
|
1211
1228
|
}
|
|
1212
1229
|
// For other keys (like regular typing), DON'T return here
|
|
@@ -1875,7 +1892,6 @@ export class Editor implements Component, Focusable {
|
|
|
1875
1892
|
// then evaluate autocomplete triggers once at the final cursor position.
|
|
1876
1893
|
if (filteredText) {
|
|
1877
1894
|
this.#insertTextAtCursor(filteredText);
|
|
1878
|
-
this.#retriggerAutocompleteAtCursor();
|
|
1879
1895
|
}
|
|
1880
1896
|
return;
|
|
1881
1897
|
}
|
|
@@ -2351,6 +2367,7 @@ export class Editor implements Component, Focusable {
|
|
|
2351
2367
|
if (this.onChange) {
|
|
2352
2368
|
this.onChange(this.getText());
|
|
2353
2369
|
}
|
|
2370
|
+
this.#retriggerAutocompleteAtCursor();
|
|
2354
2371
|
}
|
|
2355
2372
|
|
|
2356
2373
|
#yankFromKillRing(): void {
|
|
@@ -2457,6 +2474,7 @@ export class Editor implements Component, Focusable {
|
|
|
2457
2474
|
if (this.onChange) {
|
|
2458
2475
|
this.onChange(this.getText());
|
|
2459
2476
|
}
|
|
2477
|
+
this.#retriggerAutocompleteAtCursor();
|
|
2460
2478
|
}
|
|
2461
2479
|
|
|
2462
2480
|
#deleteToEndOfLine(): void {
|
|
@@ -2488,6 +2506,7 @@ export class Editor implements Component, Focusable {
|
|
|
2488
2506
|
if (this.onChange) {
|
|
2489
2507
|
this.onChange(this.getText());
|
|
2490
2508
|
}
|
|
2509
|
+
this.#retriggerAutocompleteAtCursor();
|
|
2491
2510
|
}
|
|
2492
2511
|
|
|
2493
2512
|
#deleteWordBackwards(): void {
|
|
@@ -2522,6 +2541,7 @@ export class Editor implements Component, Focusable {
|
|
|
2522
2541
|
if (this.onChange) {
|
|
2523
2542
|
this.onChange(this.getText());
|
|
2524
2543
|
}
|
|
2544
|
+
this.#retriggerAutocompleteAtCursor();
|
|
2525
2545
|
}
|
|
2526
2546
|
|
|
2527
2547
|
#deleteWordForwards(): void {
|
|
@@ -2553,6 +2573,7 @@ export class Editor implements Component, Focusable {
|
|
|
2553
2573
|
if (this.onChange) {
|
|
2554
2574
|
this.onChange(this.getText());
|
|
2555
2575
|
}
|
|
2576
|
+
this.#retriggerAutocompleteAtCursor();
|
|
2556
2577
|
}
|
|
2557
2578
|
|
|
2558
2579
|
#handleForwardDelete(): void {
|
|
@@ -2844,11 +2865,38 @@ export class Editor implements Component, Focusable {
|
|
|
2844
2865
|
return this.#isInSubmittedSlashCommandContext() || this.#isInMidPromptSkillSlashContext();
|
|
2845
2866
|
}
|
|
2846
2867
|
|
|
2868
|
+
/**
|
|
2869
|
+
* Decide whether the popup's `#autocompletePrefix` still safely maps onto the current
|
|
2870
|
+
* text before the cursor for an accept-time (`applyCompletion`) call. Mirrors the
|
|
2871
|
+
* re-anchoring branches in `CombinedAutocompleteProvider.applyCompletion`:
|
|
2872
|
+
*
|
|
2873
|
+
* - Exact match → always safe.
|
|
2874
|
+
* - Path branch is safe when the prefix is still a live suffix of the text; the
|
|
2875
|
+
* provider's default slice at `cursorCol - prefix.length` then hits the right span.
|
|
2876
|
+
* - Slash branch re-anchors when both the prefix and the current text carry a
|
|
2877
|
+
* leading slash command and the current slash token is clean (no whitespace or
|
|
2878
|
+
* inner slash), matching `applyCompletion`'s slash-branch guard.
|
|
2879
|
+
* - `@`-file branch re-anchors via `#extractAtPrefix`; safe when the current text
|
|
2880
|
+
* still ends in a whitespace-anchored `@<token>`.
|
|
2881
|
+
* - Everything else is stale — accepting it would corrupt the buffer (issue #4295).
|
|
2882
|
+
*/
|
|
2847
2883
|
#autocompletePrefixMatchesCursorText(currentTextBeforeCursor: string): boolean {
|
|
2848
2884
|
if (currentTextBeforeCursor === this.#autocompletePrefix) return true;
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2885
|
+
|
|
2886
|
+
if (findLeadingSlashCommandStart(this.#autocompletePrefix) !== null) {
|
|
2887
|
+
const currentLeadingStart = findLeadingSlashCommandStart(currentTextBeforeCursor);
|
|
2888
|
+
if (currentLeadingStart !== null) {
|
|
2889
|
+
const token = currentTextBeforeCursor.slice(currentLeadingStart);
|
|
2890
|
+
if (!token.includes(" ") && !token.slice(1).includes("/")) return true;
|
|
2891
|
+
}
|
|
2892
|
+
return false;
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
if (this.#autocompletePrefix.startsWith("@")) {
|
|
2896
|
+
return /(?:^|\s)@[^\s]*$/.test(currentTextBeforeCursor);
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
return currentTextBeforeCursor.endsWith(this.#autocompletePrefix);
|
|
2852
2900
|
}
|
|
2853
2901
|
|
|
2854
2902
|
#isSlashCommandNameAutocompleteSelection(): boolean {
|
|
@@ -842,7 +842,13 @@ export class Markdown implements Component {
|
|
|
842
842
|
this.#codeBlockIndent = Math.max(0, Math.floor(codeBlockIndent));
|
|
843
843
|
}
|
|
844
844
|
|
|
845
|
-
setText(text: string):
|
|
845
|
+
setText(text: string): boolean {
|
|
846
|
+
// Equality guard: streaming re-emits identical text on ticks that carried
|
|
847
|
+
// no delta (throttled provider frames, reconciled tool-execution updates).
|
|
848
|
+
// Without this, the caller-side `#cachedLines` gets thrown away and the
|
|
849
|
+
// full lex + wrap runs per re-emit — one of the top CPU hotspots during
|
|
850
|
+
// streaming (issue #4353). Mirrors `Text.setText`'s guard.
|
|
851
|
+
if (text === this.#text) return false;
|
|
846
852
|
this.#text = text;
|
|
847
853
|
if (!text.trim()) {
|
|
848
854
|
// Blank replacement: render() early-returns before #lexTokens can see
|
|
@@ -853,6 +859,7 @@ export class Markdown implements Component {
|
|
|
853
859
|
this.#streamPrefixLineCache = undefined;
|
|
854
860
|
}
|
|
855
861
|
this.invalidate();
|
|
862
|
+
return true;
|
|
856
863
|
}
|
|
857
864
|
|
|
858
865
|
invalidate(): void {
|
package/src/terminal.ts
CHANGED
|
@@ -35,6 +35,11 @@ export function resolveHangulCompatibilityJamoWidthFromTerminalIdentity(
|
|
|
35
35
|
return "platform";
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
function shouldEnableModifyOtherKeysFallback(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
39
|
+
if (!env.SSH_CONNECTION && !env.SSH_TTY && !env.SSH_CLIENT) return true;
|
|
40
|
+
return TERMINAL.id !== "base" && TERMINAL.id !== "trueColor";
|
|
41
|
+
}
|
|
42
|
+
|
|
38
43
|
/**
|
|
39
44
|
* Maximum encoded UTF-8 bytes per `process.stdout.write` call on Windows.
|
|
40
45
|
*
|
|
@@ -840,13 +845,13 @@ export class ProcessTerminal implements Terminal {
|
|
|
840
845
|
break;
|
|
841
846
|
}
|
|
842
847
|
case "keyboard": {
|
|
843
|
-
// Keyboard probe sentinel: kitty reply never arrived → fall back to modifyOtherKeys
|
|
844
|
-
|
|
848
|
+
// Keyboard probe sentinel: kitty reply never arrived → fall back to modifyOtherKeys
|
|
849
|
+
// only where the resolved terminal is known enough to tolerate it.
|
|
850
|
+
if (this.#modifyOtherKeysTimeout) {
|
|
845
851
|
clearTimeout(this.#modifyOtherKeysTimeout);
|
|
846
852
|
this.#modifyOtherKeysTimeout = undefined;
|
|
847
|
-
this.#safeWrite("\x1b[>4;2m");
|
|
848
|
-
this.#modifyOtherKeysActive = true;
|
|
849
853
|
}
|
|
854
|
+
this.#enableModifyOtherKeysFallback();
|
|
850
855
|
break;
|
|
851
856
|
}
|
|
852
857
|
case "osc99Probe": {
|
|
@@ -1054,6 +1059,13 @@ export class ProcessTerminal implements Terminal {
|
|
|
1054
1059
|
}
|
|
1055
1060
|
}
|
|
1056
1061
|
|
|
1062
|
+
#enableModifyOtherKeysFallback(): void {
|
|
1063
|
+
if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) return;
|
|
1064
|
+
if (!shouldEnableModifyOtherKeysFallback()) return;
|
|
1065
|
+
this.#safeWrite("\x1b[>4;2m");
|
|
1066
|
+
this.#modifyOtherKeysActive = true;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1057
1069
|
/**
|
|
1058
1070
|
* Query terminal for Kitty keyboard protocol support and enable if available.
|
|
1059
1071
|
*
|
|
@@ -1073,11 +1085,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
1073
1085
|
this.#safeWrite("\x1b[?u\x1b[c");
|
|
1074
1086
|
this.#modifyOtherKeysTimeout = setTimeout(() => {
|
|
1075
1087
|
this.#modifyOtherKeysTimeout = undefined;
|
|
1076
|
-
|
|
1077
|
-
return;
|
|
1078
|
-
}
|
|
1079
|
-
this.#safeWrite("\x1b[>4;2m");
|
|
1080
|
-
this.#modifyOtherKeysActive = true;
|
|
1088
|
+
this.#enableModifyOtherKeysFallback();
|
|
1081
1089
|
}, 150);
|
|
1082
1090
|
}
|
|
1083
1091
|
|
package/src/tui.ts
CHANGED
|
@@ -212,6 +212,11 @@ export interface OverlayFocusOwner {
|
|
|
212
212
|
* dropped row or an audit re-anchor spray. Provisional live blocks (collapsing
|
|
213
213
|
* tool/edit previews whose head is a throwaway tail window) omit it. Defaults to
|
|
214
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.
|
|
215
220
|
*
|
|
216
221
|
* When several root children report a seam in the same frame, the topmost
|
|
217
222
|
* one (and its commit-safe / snapshot-safe extension) defines the boundary:
|
|
@@ -222,6 +227,7 @@ export interface NativeScrollbackLiveRegion {
|
|
|
222
227
|
getNativeScrollbackLiveRegionStart(): number | undefined;
|
|
223
228
|
getNativeScrollbackCommitSafeEnd?(): number | undefined;
|
|
224
229
|
getNativeScrollbackSnapshotSafeEnd?(): number | undefined;
|
|
230
|
+
getNativeScrollbackOfferSafeEnd?(): number | undefined;
|
|
225
231
|
}
|
|
226
232
|
|
|
227
233
|
export interface NativeScrollbackCommittedRows {
|
|
@@ -251,6 +257,10 @@ function getNativeScrollbackSnapshotSafeEnd(component: Component): number | unde
|
|
|
251
257
|
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackSnapshotSafeEnd?.();
|
|
252
258
|
}
|
|
253
259
|
|
|
260
|
+
function getNativeScrollbackOfferSafeEnd(component: Component): number | undefined {
|
|
261
|
+
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackOfferSafeEnd?.();
|
|
262
|
+
}
|
|
263
|
+
|
|
254
264
|
/**
|
|
255
265
|
* Opt-in stability report for components that mutate their returned render
|
|
256
266
|
* array in place across frames (instead of returning a fresh array per
|
|
@@ -628,6 +638,7 @@ interface FrameSegment {
|
|
|
628
638
|
liveLocalStart?: number;
|
|
629
639
|
commitLocalEnd?: number;
|
|
630
640
|
snapshotLocalEnd?: number;
|
|
641
|
+
offerLocalEnd?: number;
|
|
631
642
|
}
|
|
632
643
|
|
|
633
644
|
/** Depth-first identity search through `Container`-shaped children. */
|
|
@@ -1009,23 +1020,27 @@ export class TUI extends Container {
|
|
|
1009
1020
|
// #auditCommittedPrefix). Holds references to component-cached strings, so
|
|
1010
1021
|
// the audit is a pointer walk in the common case.
|
|
1011
1022
|
#committedPrefix: string[] = [];
|
|
1012
|
-
// The committed prefix [0, committedRows) splits into
|
|
1013
|
-
//
|
|
1023
|
+
// The committed prefix [0, committedRows) splits into four zones by three
|
|
1024
|
+
// monotone marks auditRows ≤ durableRows ≤ offerRows ≤ committedRows:
|
|
1014
1025
|
// [0, auditRows) BYTE-STABLE — audited (re-anchor on any shift).
|
|
1015
1026
|
// [auditRows, durableRows) DURABLE snapshot — exempt: rows may drift in
|
|
1016
1027
|
// place (a streaming table widening) without re-anchoring, so their
|
|
1017
1028
|
// expected drift never sprays duplicate snapshots.
|
|
1018
|
-
// [durableRows,
|
|
1029
|
+
// [durableRows, offerRows) OFFERED — audited: rows were allowed into
|
|
1030
|
+
// native scrollback while a live block above could still shift them.
|
|
1031
|
+
// A mismatch here requires a destructive replay, not a duplicate tail.
|
|
1032
|
+
// [offerRows, committedRows) FORCED-overflow — audited: rows committed
|
|
1019
1033
|
// only because they scrolled above the window under a commit-unstable
|
|
1020
1034
|
// barrier; auditing them re-anchors (duplication, never loss) when the
|
|
1021
1035
|
// barrier later shifts/finalizes/removes, instead of stranding a stale
|
|
1022
1036
|
// prefix that silently drops the rows beneath it.
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1037
|
+
// Marks re-base on a wholesale re-slice (full paint / shrink / geometry) and
|
|
1038
|
+
// otherwise advance per the persistence rules in #updateCommittedAuditRows.
|
|
1025
1039
|
// #auditCommittedPrefix audits [0, committedRows) skipping the exempt window
|
|
1026
1040
|
// [auditRows, durableRows).
|
|
1027
1041
|
#committedPrefixAuditRows = 0;
|
|
1028
1042
|
#committedPrefixDurableRows = 0;
|
|
1043
|
+
#committedPrefixOfferRows = 0;
|
|
1029
1044
|
// Frame row currently mapped to screen row 0. Monotonic between full
|
|
1030
1045
|
// paints: a shrink never re-exposes scrolled-off rows (they cannot be
|
|
1031
1046
|
// un-scrolled without rewriting history); live rows repaint at fixed
|
|
@@ -1036,6 +1051,7 @@ export class TUI extends Container {
|
|
|
1036
1051
|
#nativeScrollbackLiveRegionStart: number | undefined;
|
|
1037
1052
|
#nativeScrollbackCommitSafeEnd: number | undefined;
|
|
1038
1053
|
#nativeScrollbackSnapshotSafeEnd: number | undefined;
|
|
1054
|
+
#nativeScrollbackOfferSafeEnd: number | undefined;
|
|
1039
1055
|
#fullRedrawCount = 0;
|
|
1040
1056
|
// Caps how many inline images render as live graphics; older ones fall back
|
|
1041
1057
|
// to text via a purge + full redraw. Cap is configured by the host app.
|
|
@@ -1155,6 +1171,7 @@ export class TUI extends Container {
|
|
|
1155
1171
|
this.#nativeScrollbackLiveRegionStart = undefined;
|
|
1156
1172
|
this.#nativeScrollbackCommitSafeEnd = undefined;
|
|
1157
1173
|
this.#nativeScrollbackSnapshotSafeEnd = undefined;
|
|
1174
|
+
this.#nativeScrollbackOfferSafeEnd = undefined;
|
|
1158
1175
|
const children = this.children;
|
|
1159
1176
|
const previousSegments = this.#frameSegments;
|
|
1160
1177
|
const segments: FrameSegment[] = new Array(children.length);
|
|
@@ -1177,12 +1194,14 @@ export class TUI extends Container {
|
|
|
1177
1194
|
let liveLocalStart: number | undefined;
|
|
1178
1195
|
let commitLocalEnd: number | undefined;
|
|
1179
1196
|
let snapshotLocalEnd: number | undefined;
|
|
1197
|
+
let offerLocalEnd: number | undefined;
|
|
1180
1198
|
let reported: number | undefined;
|
|
1181
1199
|
if (reuse) {
|
|
1182
1200
|
childLines = previous.lines;
|
|
1183
1201
|
liveLocalStart = previous.liveLocalStart;
|
|
1184
1202
|
commitLocalEnd = previous.commitLocalEnd;
|
|
1185
1203
|
snapshotLocalEnd = previous.snapshotLocalEnd;
|
|
1204
|
+
offerLocalEnd = previous.offerLocalEnd;
|
|
1186
1205
|
} else {
|
|
1187
1206
|
// Feed the engine's committed-row claim (from the previous frame's
|
|
1188
1207
|
// emit) before rendering so the child can skip re-deriving blocks
|
|
@@ -1211,6 +1230,13 @@ export class TUI extends Container {
|
|
|
1211
1230
|
? Math.max(snapshotFloor, Math.min(childLines.length, Math.trunc(snapshotSafeEnd)))
|
|
1212
1231
|
: childLines.length;
|
|
1213
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
|
+
}
|
|
1214
1240
|
}
|
|
1215
1241
|
// Consume the stability report unconditionally for implementers:
|
|
1216
1242
|
// reading re-bases the component's baseline to the state this
|
|
@@ -1234,6 +1260,9 @@ export class TUI extends Container {
|
|
|
1234
1260
|
if (snapshotLocalEnd !== undefined) {
|
|
1235
1261
|
this.#nativeScrollbackSnapshotSafeEnd = offset + snapshotLocalEnd;
|
|
1236
1262
|
}
|
|
1263
|
+
if (offerLocalEnd !== undefined) {
|
|
1264
|
+
this.#nativeScrollbackOfferSafeEnd = offset + offerLocalEnd;
|
|
1265
|
+
}
|
|
1237
1266
|
}
|
|
1238
1267
|
if (chainStable) {
|
|
1239
1268
|
if (previous !== undefined && previous.component === child && previous.start === offset) {
|
|
@@ -1264,6 +1293,7 @@ export class TUI extends Container {
|
|
|
1264
1293
|
liveLocalStart,
|
|
1265
1294
|
commitLocalEnd,
|
|
1266
1295
|
snapshotLocalEnd,
|
|
1296
|
+
offerLocalEnd,
|
|
1267
1297
|
};
|
|
1268
1298
|
offset += childLines.length;
|
|
1269
1299
|
}
|
|
@@ -2664,6 +2694,7 @@ export class TUI extends Container {
|
|
|
2664
2694
|
const liveRegionStart = this.#nativeScrollbackLiveRegionStart;
|
|
2665
2695
|
const commitSafeEnd = this.#nativeScrollbackCommitSafeEnd;
|
|
2666
2696
|
const snapshotSafeEnd = this.#nativeScrollbackSnapshotSafeEnd;
|
|
2697
|
+
const offerSafeEnd = this.#nativeScrollbackOfferSafeEnd;
|
|
2667
2698
|
|
|
2668
2699
|
// Commit boundaries (also used by the window/commit math in section 3),
|
|
2669
2700
|
// hoisted above the audit gate because the resync needs byteStableBoundary
|
|
@@ -2671,22 +2702,27 @@ export class TUI extends Container {
|
|
|
2671
2702
|
// The commit floor is windowTop in every non-frozen path (see chunkTo), so
|
|
2672
2703
|
// whatever scrolls above the window is committed — never committed nowhere
|
|
2673
2704
|
// AND painted nowhere (the loss bug). The boundaries no longer gate the
|
|
2674
|
-
// commit; they define
|
|
2705
|
+
// commit; they define audit and repair spans. byteStableBoundary: rows below
|
|
2675
2706
|
// it are byte-stable (never re-layout), audited. durableBoundary: rows in
|
|
2676
2707
|
// [byteStableBoundary, durableBoundary) are durable — permanent on scroll-off
|
|
2677
2708
|
// but may drift in place (a streaming table re-aligning), committed
|
|
2678
|
-
// audit-EXEMPT.
|
|
2679
|
-
//
|
|
2680
|
-
//
|
|
2681
|
-
//
|
|
2682
|
-
//
|
|
2683
|
-
//
|
|
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).
|
|
2684
2719
|
const frameLength = rawFrame.length;
|
|
2685
2720
|
const byteStableBoundary = Math.max(0, Math.min(frameLength, commitSafeEnd ?? liveRegionStart ?? frameLength));
|
|
2686
2721
|
const durableBoundary = Math.max(
|
|
2687
2722
|
byteStableBoundary,
|
|
2688
2723
|
Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary),
|
|
2689
2724
|
);
|
|
2725
|
+
const offerBoundary = Math.max(durableBoundary, Math.min(frameLength, offerSafeEnd ?? durableBoundary));
|
|
2690
2726
|
|
|
2691
2727
|
// 2. Transition state captured before any emitter runs.
|
|
2692
2728
|
const prevWindowTop = this.#windowTopRow;
|
|
@@ -2719,18 +2755,28 @@ export class TUI extends Container {
|
|
|
2719
2755
|
let committedRowsResynced = false;
|
|
2720
2756
|
// Audit covers [0, auditRows) and the forced suffix [durableRows,
|
|
2721
2757
|
// committedRows); the durable middle [auditRows, durableRows) is exempt
|
|
2722
|
-
// (in-place drift).
|
|
2758
|
+
// (in-place drift). Three reasons to run the audit this frame:
|
|
2723
2759
|
// - the stable prefix does not cover every audited row (auditUpper); or
|
|
2724
2760
|
// - a forced-overflow row this frame became durable/permanent
|
|
2725
|
-
// (committedPrefixDurableRows <
|
|
2726
|
-
// finalized, so its committed bytes must be re-checked
|
|
2727
|
-
// stable prefix says nothing moved — a stale committed
|
|
2728
|
-
// silently drop the row
|
|
2729
|
-
//
|
|
2761
|
+
// (committedPrefixDurableRows < min(committed, durableBoundary)): the
|
|
2762
|
+
// barrier above it finalized, so its committed bytes must be re-checked
|
|
2763
|
+
// even though the stable prefix says nothing moved — a stale committed
|
|
2764
|
+
// copy there would silently drop the row; or
|
|
2765
|
+
// - a forced-overflow row this frame joined the offered zone
|
|
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).
|
|
2730
2772
|
const auditUpper =
|
|
2731
2773
|
this.#committedPrefixDurableRows < this.#committedRows ? this.#committedRows : this.#committedPrefixAuditRows;
|
|
2732
|
-
const
|
|
2733
|
-
const
|
|
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;
|
|
2734
2780
|
const auditRan =
|
|
2735
2781
|
this.#hasEverRendered &&
|
|
2736
2782
|
!geometryChanged &&
|
|
@@ -2738,14 +2784,21 @@ export class TUI extends Container {
|
|
|
2738
2784
|
(this.#renderStablePrefixRows < auditUpper || needHardAudit);
|
|
2739
2785
|
if (auditRan) {
|
|
2740
2786
|
const committedRowsBeforeAudit = this.#committedRows;
|
|
2741
|
-
this.#
|
|
2787
|
+
const offeredRowsBeforeAudit = this.#committedPrefixOfferRows;
|
|
2788
|
+
const durableRowsBeforeAudit = this.#committedPrefixDurableRows;
|
|
2789
|
+
this.#auditCommittedPrefix(rawFrame, hardAuditEnd);
|
|
2742
2790
|
committedRowsResynced = this.#committedRows !== committedRowsBeforeAudit;
|
|
2791
|
+
repairOfferedScrollback =
|
|
2792
|
+
offeredRowsBeforeAudit > durableRowsBeforeAudit &&
|
|
2793
|
+
committedRowsResynced &&
|
|
2794
|
+
this.#committedRows < offeredRowsBeforeAudit;
|
|
2743
2795
|
}
|
|
2744
2796
|
// Committed-prefix state this frame's commit math extends from (post-audit).
|
|
2745
2797
|
// Drives the audit-rows / durable-rows caps recomputed after the emit.
|
|
2746
2798
|
const preCommitRows = this.#committedRows;
|
|
2747
2799
|
const preCommitAuditRows = this.#committedPrefixAuditRows;
|
|
2748
2800
|
const preCommitDurableRows = this.#committedPrefixDurableRows;
|
|
2801
|
+
const preCommitOfferRows = this.#committedPrefixOfferRows;
|
|
2749
2802
|
|
|
2750
2803
|
// 3. Window and commit math (lengths only; content prepared below).
|
|
2751
2804
|
let hasVisibleOverlay = false;
|
|
@@ -2763,7 +2816,7 @@ export class TUI extends Container {
|
|
|
2763
2816
|
// place, because an ED3 rewrap is unsafe (pane scrollback / alt-screen
|
|
2764
2817
|
// feedback loop), so committed history keeps its old wrap.
|
|
2765
2818
|
const firstPaint = !this.#hasEverRendered;
|
|
2766
|
-
const replaceRequested = this.#clearScrollbackOnNextRender;
|
|
2819
|
+
const replaceRequested = this.#clearScrollbackOnNextRender || repairOfferedScrollback;
|
|
2767
2820
|
const geometryRebuild = geometryChanged && !resizeRepaintsInPlace();
|
|
2768
2821
|
const fullPaint = firstPaint || replaceRequested || geometryRebuild;
|
|
2769
2822
|
let windowTop: number;
|
|
@@ -2875,8 +2928,10 @@ export class TUI extends Container {
|
|
|
2875
2928
|
preCommitRows,
|
|
2876
2929
|
preCommitAuditRows,
|
|
2877
2930
|
preCommitDurableRows,
|
|
2931
|
+
preCommitOfferRows,
|
|
2878
2932
|
byteStableBoundary,
|
|
2879
2933
|
durableBoundary,
|
|
2934
|
+
offerBoundary,
|
|
2880
2935
|
false,
|
|
2881
2936
|
);
|
|
2882
2937
|
this.#clearScrollbackOnNextRender = false;
|
|
@@ -2904,8 +2959,10 @@ export class TUI extends Container {
|
|
|
2904
2959
|
preCommitRows,
|
|
2905
2960
|
preCommitAuditRows,
|
|
2906
2961
|
preCommitDurableRows,
|
|
2962
|
+
preCommitOfferRows,
|
|
2907
2963
|
byteStableBoundary,
|
|
2908
2964
|
durableBoundary,
|
|
2965
|
+
offerBoundary,
|
|
2909
2966
|
auditRan,
|
|
2910
2967
|
);
|
|
2911
2968
|
}
|
|
@@ -2932,6 +2989,7 @@ export class TUI extends Container {
|
|
|
2932
2989
|
this.#committedRows = resyncTo;
|
|
2933
2990
|
this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, resyncTo);
|
|
2934
2991
|
this.#committedPrefixDurableRows = Math.min(this.#committedPrefixDurableRows, resyncTo);
|
|
2992
|
+
this.#committedPrefixOfferRows = Math.min(this.#committedPrefixOfferRows, resyncTo);
|
|
2935
2993
|
prefix.length = resyncTo;
|
|
2936
2994
|
if ($flag("PI_DEBUG_REDRAW")) {
|
|
2937
2995
|
const msg = `[${new Date().toISOString()}] commit resync: committed prefix diverged at row ${resyncTo}; recommitting\n`;
|
|
@@ -2944,21 +3002,24 @@ export class TUI extends Container {
|
|
|
2944
3002
|
* #committedPrefixAuditRows field doc for the three audit zones).
|
|
2945
3003
|
*
|
|
2946
3004
|
* auditRows tracks the byte-stable boundary; durableRows the durable snapshot
|
|
2947
|
-
* boundary
|
|
2948
|
-
*
|
|
2949
|
-
* extend keeps a mark once a row past it has
|
|
2950
|
-
* later RISE in a boundary (a table finalizing)
|
|
2951
|
-
* already-committed stale snapshots back under the
|
|
2952
|
-
* retroactively exempt forced-overflow rows already
|
|
2953
|
-
* floored at auditRows
|
|
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.
|
|
2954
3013
|
*/
|
|
2955
3014
|
#updateCommittedAuditRows(
|
|
2956
3015
|
resliced: boolean,
|
|
2957
3016
|
preCommittedRows: number,
|
|
2958
3017
|
preAuditRows: number,
|
|
2959
3018
|
preDurableRows: number,
|
|
3019
|
+
preOfferRows: number,
|
|
2960
3020
|
byteStableBoundary: number,
|
|
2961
3021
|
durableBoundary: number,
|
|
3022
|
+
offerBoundary: number,
|
|
2962
3023
|
hardAudited: boolean,
|
|
2963
3024
|
): void {
|
|
2964
3025
|
const committed = this.#committedRows;
|
|
@@ -2975,8 +3036,18 @@ export class TUI extends Container {
|
|
|
2975
3036
|
resliced || preDurableRows >= preCommittedRows || hardAudited
|
|
2976
3037
|
? Math.min(committed, durableBoundary)
|
|
2977
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);
|
|
2978
3048
|
this.#committedPrefixAuditRows = auditRows;
|
|
2979
3049
|
this.#committedPrefixDurableRows = Math.max(auditRows, durableRows);
|
|
3050
|
+
this.#committedPrefixOfferRows = Math.max(this.#committedPrefixDurableRows, offerRows);
|
|
2980
3051
|
}
|
|
2981
3052
|
|
|
2982
3053
|
/**
|