@oh-my-pi/pi-tui 18.9.2 → 18.10.0
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 +29 -0
- package/dist/types/app-keybindings.d.ts +5 -0
- package/dist/types/debug-server.d.ts +2 -0
- package/dist/types/overlays/ask-dialog.d.ts +2 -2
- package/dist/types/prompt/composer.d.ts +1 -20
- package/dist/types/tui.d.ts +10 -3
- package/package.json +9 -9
- package/src/app-keybindings.ts +5 -0
- package/src/chrome/transcript-container.ts +202 -28
- package/src/debug-server.ts +51 -69
- package/src/hotkeys-markdown.ts +1 -0
- package/src/overlays/ask-dialog.ts +111 -31
- package/src/prompt/composer.ts +35 -72
- package/src/tui.ts +27 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.10.0] - 2026-10-12
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- Removed `RuntimeChildrenOptions.transient` and `Component.retireDisplacedTranscript`: the composer now bills all chrome below the transcript at the smallest height it has rendered, so no root opts in or out ([#15405](https://github.com/can1357/oh-my-pi/pull/15405) by [@H4vC](https://github.com/H4vC))
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added the `app.ask.hide` keybinding (default `Alt+H`) and Hide/Show actions on `AskDialogComponent`, which collapse the dialog to a single line while its question stays pending ([#15400](https://github.com/can1357/oh-my-pi/pull/15400) by [@H4vC](https://github.com/H4vC))
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- The `OMP_TUI_DEBUG` control socket now uses the native IPC transport: it is owner-only and no longer replaces a socket another live process is serving.
|
|
18
|
+
- Transcript rows pushed aside by an open dialog, report or HUD now reach scrollback when it closes or newer output pushes them out, instead of while it is open ([#15405](https://github.com/can1357/oh-my-pi/pull/15405) by [@H4vC](https://github.com/H4vC))
|
|
19
|
+
- A transcript block straddling the top of the screen now sends only its off-screen rows to scrollback; the rest stays live ([#15405](https://github.com/can1357/oh-my-pi/pull/15405) by [@H4vC](https://github.com/H4vC))
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Fixed blank rows opening under the editor after an ask or other inline dialog, command report or todo/loader HUD closed in a session that had filled the screen; the screen now refills with the transcript ([#15405](https://github.com/can1357/oh-my-pi/pull/15405) by [@H4vC](https://github.com/H4vC))
|
|
24
|
+
- Fixed the editor rising above blank rows when a live tool preview collapses under scrolled-off output; it now stays on the bottom row ([#15405](https://github.com/can1357/oh-my-pi/pull/15405) by [@H4vC](https://github.com/H4vC))
|
|
25
|
+
- Fixed blank rows opening inside a streamed answer when the working loader went away at the end of a turn ([#15405](https://github.com/can1357/oh-my-pi/pull/15405) by [@H4vC](https://github.com/H4vC))
|
|
26
|
+
- Fixed a split transcript block dropping punctuation-only rows it reveals after the split (such as a tool pane's rule line on expand); the seam now stops before them instead of treating them as already committed ([#15420](https://github.com/can1357/oh-my-pi/pull/15420) by [@H4vC](https://github.com/H4vC))
|
|
27
|
+
- Fixed a split transcript block fingerprinting measured rows instead of the bytes committed to scrollback, which could duplicate a SIXEL image prefix once its pending encode landed ([#15420](https://github.com/can1357/oh-my-pi/pull/15420) by [@H4vC](https://github.com/H4vC))
|
|
28
|
+
- Fixed a transcript block straddling the top of the screen losing its off-screen rows when the inline-image limit demoted its images to text in the same frame; those rows now reach scrollback as text
|
|
29
|
+
|
|
30
|
+
### Removed
|
|
31
|
+
|
|
32
|
+
- Removed `Composer.pinInputToBottom()`; the composer keeps the input on the bottom row by itself ([#15405](https://github.com/can1357/oh-my-pi/pull/15405) by [@H4vC](https://github.com/H4vC))
|
|
33
|
+
|
|
5
34
|
## [18.9.2] - 2026-10-11
|
|
6
35
|
|
|
7
36
|
### Added
|
|
@@ -17,6 +17,7 @@ interface AppKeybindings {
|
|
|
17
17
|
"app.model.selectTemporary": true;
|
|
18
18
|
"app.tools.expand": true;
|
|
19
19
|
"app.tools.toggleVisibility": true;
|
|
20
|
+
"app.ask.hide": true;
|
|
20
21
|
"app.editor.external": true;
|
|
21
22
|
"app.message.followUp": true;
|
|
22
23
|
"app.retry": true;
|
|
@@ -238,6 +239,10 @@ export declare const KEYBINDINGS: {
|
|
|
238
239
|
readonly defaultKeys: "ctrl+shift+o";
|
|
239
240
|
readonly description: "Show or hide tool activity";
|
|
240
241
|
};
|
|
242
|
+
readonly "app.ask.hide": {
|
|
243
|
+
readonly defaultKeys: "alt+h";
|
|
244
|
+
readonly description: "Hide or show a pending ask dialog";
|
|
245
|
+
};
|
|
241
246
|
readonly "app.editor.external": {
|
|
242
247
|
readonly defaultKeys: "ctrl+g";
|
|
243
248
|
readonly description: "Open external editor";
|
|
@@ -22,6 +22,8 @@ export interface TuiDebugTree {
|
|
|
22
22
|
export declare class TuiDebugServer {
|
|
23
23
|
#private;
|
|
24
24
|
constructor(tui: TUI, path: string);
|
|
25
|
+
/** Resolves true once the socket accepts connections, false if listen failed or the server stopped first. */
|
|
26
|
+
get ready(): Promise<boolean>;
|
|
25
27
|
start(): void;
|
|
26
28
|
stop(): void;
|
|
27
29
|
}
|
|
@@ -88,7 +88,6 @@ export declare class AskDialogComponent implements Component {
|
|
|
88
88
|
#private;
|
|
89
89
|
private readonly callbacks;
|
|
90
90
|
private readonly options;
|
|
91
|
-
readonly retireDisplacedTranscript = true;
|
|
92
91
|
constructor(questions: ExtensionAskDialogQuestion[], callbacks: AskDialogCallbacks, options?: AskDialogOptions);
|
|
93
92
|
invalidate(): void;
|
|
94
93
|
dispose(): void;
|
|
@@ -112,7 +111,8 @@ export declare class AskDialogComponent implements Component {
|
|
|
112
111
|
* Pointer actions: a tab click switches to it; a click on an answer row is
|
|
113
112
|
* Enter on it (Space for a multi-select option, so it toggles; its
|
|
114
113
|
* double-click `activate` is Enter); the action bar's Submit, Note and Skip
|
|
115
|
-
* run exactly what Enter, `n` and Esc run on the highlighted row
|
|
114
|
+
* run exactly what Enter, `n` and Esc run on the highlighted row; Hide/Show
|
|
115
|
+
* collapse and restore the dialog.
|
|
116
116
|
*/
|
|
117
117
|
handleNativeEvent(event: NativeUiEvent): void;
|
|
118
118
|
}
|
|
@@ -50,18 +50,8 @@ export interface ComposerOptions {
|
|
|
50
50
|
readonly exit?: (code: number) => void;
|
|
51
51
|
readonly now?: () => number;
|
|
52
52
|
}
|
|
53
|
-
/** How {@link Composer.setRuntimeChildren}
|
|
53
|
+
/** How {@link Composer.setRuntimeChildren} lays out the roots below the transcript. */
|
|
54
54
|
export interface RuntimeChildrenOptions {
|
|
55
|
-
/**
|
|
56
|
-
* Below-transcript roots whose growth is momentary (inline dialogs, a tall
|
|
57
|
-
* multi-line editor). They are billed at their smallest height so expansion
|
|
58
|
-
* clips the live tail instead of retiring rows a later shrink could not
|
|
59
|
-
* reclaim (#11007). Every other root is billed at its current height, so
|
|
60
|
-
* settled rows it displaces retire to native scrollback. Decision panels
|
|
61
|
-
* declaring `retireDisplacedTranscript` opt out of this floor even when
|
|
62
|
-
* mounted as a child of a transient editor container.
|
|
63
|
-
*/
|
|
64
|
-
readonly transient?: readonly Component[];
|
|
65
55
|
/**
|
|
66
56
|
* Dock order on a TSP terminal (no status strip; the composer carries its
|
|
67
57
|
* facts): the native dock stacks HUD pills, the working row and queued messages over
|
|
@@ -123,15 +113,6 @@ export declare class Composer implements TerminalFrameProvider, NativeSurfacePro
|
|
|
123
113
|
* the screen rows left above them; `undefined` before `root` was laid out.
|
|
124
114
|
*/
|
|
125
115
|
rowsBelow(root: Component): number | undefined;
|
|
126
|
-
/**
|
|
127
|
-
* Keep the input on the bottom row while the live rows cannot fill the
|
|
128
|
-
* screen, as after an inline decision panel closes. A tall block that just
|
|
129
|
-
* left the chrome above the editor (a command report) may have scrolled
|
|
130
|
-
* rows into native history that cannot be pulled back; without the pin the
|
|
131
|
-
* editor would jump up to where the shorter frame now ends. The pin lifts
|
|
132
|
-
* once live rows fill the screen again.
|
|
133
|
-
*/
|
|
134
|
-
pinInputToBottom(): void;
|
|
135
116
|
/** Compose the bounded mutable viewport and the next ordered history append. */
|
|
136
117
|
renderFrame(viewport: ViewportSize): TerminalFramePlan;
|
|
137
118
|
/**
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -115,9 +115,6 @@ export interface Component {
|
|
|
115
115
|
* last time.
|
|
116
116
|
*/
|
|
117
117
|
render(width: number): readonly string[];
|
|
118
|
-
/** Inline decision panels must keep displaced settled transcript rows in
|
|
119
|
-
* native scrollback even when mounted inside a transient editor container. */
|
|
120
|
-
readonly retireDisplacedTranscript?: boolean;
|
|
121
118
|
/**
|
|
122
119
|
* Describe the component semantically for a Tern Surface Protocol
|
|
123
120
|
* terminal (see `native/node.ts`). Called instead of `render()` when the
|
|
@@ -440,6 +437,16 @@ export declare class TUI extends Container {
|
|
|
440
437
|
top: number;
|
|
441
438
|
length: number;
|
|
442
439
|
};
|
|
440
|
+
/**
|
|
441
|
+
* Rows of native history the last paint left on screen above a mutable
|
|
442
|
+
* viewport that reached the bottom row, or `undefined` when that paint left
|
|
443
|
+
* rows free below it (or none happened, or a destructive reset will repaint
|
|
444
|
+
* from row zero). Rows above a bottom-reaching viewport may already sit in
|
|
445
|
+
* native scrollback, which a later shrink cannot pull back down.
|
|
446
|
+
*/
|
|
447
|
+
getFilledHistoryRows(): number | undefined;
|
|
448
|
+
/** Whether native history holds rows the frame provider committed since the last destructive reset. */
|
|
449
|
+
hasProviderHistory(): boolean;
|
|
443
450
|
/**
|
|
444
451
|
* Probe for opt-in normal-buffer click capture. The provider is read every
|
|
445
452
|
* frame; while it returns true (and no fullscreen overlay owns the
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "18.
|
|
4
|
+
"version": "18.10.0",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": {
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"gen:glyphs": "bun scripts/gen-glyphs.ts"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@oh-my-pi/omptype": "18.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "18.
|
|
46
|
-
"@oh-my-pi/pi-ai": "18.
|
|
47
|
-
"@oh-my-pi/pi-catalog": "18.
|
|
48
|
-
"@oh-my-pi/pi-natives": "18.
|
|
49
|
-
"@oh-my-pi/pi-utils": "18.
|
|
50
|
-
"@oh-my-pi/pi-wire": "18.
|
|
51
|
-
"@oh-my-pi/snapcompact": "18.
|
|
44
|
+
"@oh-my-pi/omptype": "18.10.0",
|
|
45
|
+
"@oh-my-pi/pi-agent-core": "18.10.0",
|
|
46
|
+
"@oh-my-pi/pi-ai": "18.10.0",
|
|
47
|
+
"@oh-my-pi/pi-catalog": "18.10.0",
|
|
48
|
+
"@oh-my-pi/pi-natives": "18.10.0",
|
|
49
|
+
"@oh-my-pi/pi-utils": "18.10.0",
|
|
50
|
+
"@oh-my-pi/pi-wire": "18.10.0",
|
|
51
|
+
"@oh-my-pi/snapcompact": "18.10.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"kitty-vt-wasm": "^0.2.0"
|
package/src/app-keybindings.ts
CHANGED
|
@@ -37,6 +37,7 @@ interface AppKeybindings {
|
|
|
37
37
|
"app.model.selectTemporary": true;
|
|
38
38
|
"app.tools.expand": true;
|
|
39
39
|
"app.tools.toggleVisibility": true;
|
|
40
|
+
"app.ask.hide": true;
|
|
40
41
|
"app.editor.external": true;
|
|
41
42
|
"app.message.followUp": true;
|
|
42
43
|
"app.retry": true;
|
|
@@ -138,6 +139,10 @@ export const KEYBINDINGS = {
|
|
|
138
139
|
defaultKeys: "ctrl+shift+o",
|
|
139
140
|
description: "Show or hide tool activity",
|
|
140
141
|
},
|
|
142
|
+
"app.ask.hide": {
|
|
143
|
+
defaultKeys: "alt+h",
|
|
144
|
+
description: "Hide or show a pending ask dialog",
|
|
145
|
+
},
|
|
141
146
|
"app.editor.external": {
|
|
142
147
|
defaultKeys: "ctrl+g",
|
|
143
148
|
description: "Open external editor",
|
|
@@ -96,12 +96,37 @@ interface TranscriptEntry {
|
|
|
96
96
|
* state for emitted-row slicing but never emits another mid-stream row.
|
|
97
97
|
*/
|
|
98
98
|
stableFrozen: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Head rows of a settled block already in native history while the rest
|
|
101
|
+
* stays live: pressure commits only the rows that left the screen, so no
|
|
102
|
+
* committed row is ever still visible above the viewport.
|
|
103
|
+
*/
|
|
104
|
+
split: RowSplit | undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A committed physical row prefix of one block. `rows` counts trimmed render
|
|
109
|
+
* rows at `width` (any emitted stable prefix included) and `hash` fingerprints
|
|
110
|
+
* them; `text` is their letters and digits in order. A settled block can still
|
|
111
|
+
* change (a tool pane expanding) or rewrap at another width without a replay,
|
|
112
|
+
* so every render re-locates the seam: the committed rows when they render
|
|
113
|
+
* unchanged, else by continuing `text` — per-row decoration (quote bars,
|
|
114
|
+
* borders, rules) is punctuation it ignores.
|
|
115
|
+
*/
|
|
116
|
+
interface RowSplit {
|
|
117
|
+
readonly rows: number;
|
|
118
|
+
readonly width: number;
|
|
119
|
+
readonly hash: number | bigint;
|
|
120
|
+
readonly text: string;
|
|
121
|
+
/** Seam of the last render it was located in; renders are memoized per frame. */
|
|
122
|
+
located?: { readonly rendered: readonly string[]; readonly count: number };
|
|
99
123
|
}
|
|
100
124
|
|
|
101
125
|
type RetirementPolicy = "pressure" | "flush";
|
|
102
126
|
type Offered =
|
|
103
127
|
| { batch: HistoryBatch; kind: "append"; entry: number; emittedEnd: number }
|
|
104
|
-
|
|
128
|
+
/** Whole blocks `[frontier, end)`, then optionally the head rows of block `end`. */
|
|
129
|
+
| { batch: HistoryBatch; kind: "commit"; end: number; split?: RowSplit }
|
|
105
130
|
| { batch: HistoryBatch; kind: "replay" };
|
|
106
131
|
|
|
107
132
|
/** Rows a progressive-append retirement offers, and the stable count they bring the head to. */
|
|
@@ -140,6 +165,24 @@ function isPlainBlank(line: string): boolean {
|
|
|
140
165
|
return !/\S/.test(line);
|
|
141
166
|
}
|
|
142
167
|
|
|
168
|
+
/** Letters and digits of `rows` in reading order: the content rewrapping preserves. */
|
|
169
|
+
function contentOf(rows: readonly string[]): string {
|
|
170
|
+
let text = "";
|
|
171
|
+
for (const row of rows) text += Bun.stripANSI(row).replace(/[^\p{L}\p{N}]/gu, "");
|
|
172
|
+
return text;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Split committing the first `rows` rows of `scrollback` at `width`. The
|
|
177
|
+
* fingerprint covers the scrollback bytes history receives, which can differ
|
|
178
|
+
* from the measured render at the same row count (a SIXEL image encodes
|
|
179
|
+
* synchronously instead of returning its reserved rows).
|
|
180
|
+
*/
|
|
181
|
+
function rowSplit(scrollback: readonly string[], rows: number, width: number): RowSplit {
|
|
182
|
+
const committed = scrollback.slice(0, rows);
|
|
183
|
+
return { rows, width, hash: Bun.hash(committed.join("\n")), text: contentOf(committed) };
|
|
184
|
+
}
|
|
185
|
+
|
|
143
186
|
/** Whether `prefix` matches `rows` byte-for-byte from the top. */
|
|
144
187
|
export function isRowPrefix(prefix: readonly string[], rows: readonly string[]): boolean {
|
|
145
188
|
if (prefix.length > rows.length) return false;
|
|
@@ -226,6 +269,7 @@ export class TranscriptContainer extends Container {
|
|
|
226
269
|
stableRowCountByWidth: new Map(),
|
|
227
270
|
emitted: 0,
|
|
228
271
|
stableFrozen: false,
|
|
272
|
+
split: undefined,
|
|
229
273
|
});
|
|
230
274
|
// `children` is public and may be edited externally; re-check the whole
|
|
231
275
|
// list once instead of trusting positions. Insert-after-anchor callers
|
|
@@ -300,13 +344,16 @@ export class TranscriptContainer extends Container {
|
|
|
300
344
|
*/
|
|
301
345
|
resetStableEmission(): void {
|
|
302
346
|
this.#syncEntries();
|
|
303
|
-
if (this.#offered?.kind === "append"
|
|
347
|
+
if (this.#offered?.kind === "append" || (this.#offered?.kind === "commit" && this.#offered.split)) {
|
|
348
|
+
this.#offered = undefined;
|
|
349
|
+
}
|
|
304
350
|
for (const entry of this.#entries) {
|
|
305
351
|
entry.emitted = 0;
|
|
306
352
|
entry.stableRows = EMPTY_STABLE_ROWS;
|
|
307
353
|
entry.renderedStableByWidth = new Map();
|
|
308
354
|
entry.stableRowCountByWidth = new Map();
|
|
309
355
|
entry.stableFrozen = false;
|
|
356
|
+
entry.split = undefined;
|
|
310
357
|
if (entry.mode === "appendOnly") {
|
|
311
358
|
(entry.component as Component & AppendOnlyTranscriptBlock).resetTranscriptStableRows?.();
|
|
312
359
|
}
|
|
@@ -329,8 +376,8 @@ export class TranscriptContainer extends Container {
|
|
|
329
376
|
const index = this.#entries.findLastIndex(entry => entry.component === component);
|
|
330
377
|
if (index < 0) return -1;
|
|
331
378
|
const entry = this.#entries[index]!;
|
|
332
|
-
if (entry.state === "committed" || entry.emitted > 0) return -1;
|
|
333
|
-
if (this.#offered?.kind === "commit" && index < this.#offered.end) return -1;
|
|
379
|
+
if (entry.state === "committed" || entry.emitted > 0 || entry.split !== undefined) return -1;
|
|
380
|
+
if (this.#offered?.kind === "commit" && index < this.#offered.end + (this.#offered.split ? 1 : 0)) return -1;
|
|
334
381
|
if (this.#offered?.kind === "append" && index === this.#offered.entry) return -1;
|
|
335
382
|
return index;
|
|
336
383
|
}
|
|
@@ -434,17 +481,21 @@ export class TranscriptContainer extends Container {
|
|
|
434
481
|
|
|
435
482
|
/**
|
|
436
483
|
* One block's blank-trimmed render at its full-height allocation. Inside an
|
|
437
|
-
* open frame the first measurement of each block is replayed to later ones
|
|
484
|
+
* open frame the first measurement of each block is replayed to later ones,
|
|
485
|
+
* leaving whatever allocation the viewport has since rendered it at.
|
|
438
486
|
*/
|
|
439
487
|
#measuredRows(entry: TranscriptEntry, width: number): readonly string[] {
|
|
440
|
-
this.#
|
|
441
|
-
|
|
488
|
+
if (this.#openFrame === undefined) {
|
|
489
|
+
this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
|
|
490
|
+
return this.#renderEntry(entry, width);
|
|
491
|
+
}
|
|
442
492
|
if (this.#frameRowsWidth !== width) {
|
|
443
493
|
this.#frameRows.clear();
|
|
444
494
|
this.#frameRowsWidth = width;
|
|
445
495
|
}
|
|
446
496
|
let rows = this.#frameRows.get(entry);
|
|
447
497
|
if (rows === undefined) {
|
|
498
|
+
this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
|
|
448
499
|
rows = this.#renderEntry(entry, width);
|
|
449
500
|
this.#frameRows.set(entry, rows);
|
|
450
501
|
}
|
|
@@ -646,6 +697,20 @@ export class TranscriptContainer extends Container {
|
|
|
646
697
|
rows = after.slice(before.length);
|
|
647
698
|
} else if (offered.kind === "commit") {
|
|
648
699
|
rows = this.#renderRange(this.#frontier, offered.end, width, true).rows;
|
|
700
|
+
const head = offered.split ? this.#entries[offered.end] : undefined;
|
|
701
|
+
if (head !== undefined && offered.split !== undefined) {
|
|
702
|
+
// The offer's fingerprint covers bytes of a discarded frame history
|
|
703
|
+
// never received (an image-budget retry demotes graphics to text),
|
|
704
|
+
// so the split keeps its cut and re-fingerprints the rows it now
|
|
705
|
+
// delivers. Only another width relocates the cut.
|
|
706
|
+
const start = this.#emittedRowCount(head, width);
|
|
707
|
+
const scrollback = renderForScrollback(() => this.#renderEntry(head, width));
|
|
708
|
+
const cut =
|
|
709
|
+
width === offered.split.width ? offered.split.rows : this.#splitRowCount(head, offered.split, width);
|
|
710
|
+
const end = Math.min(cut, scrollback.length);
|
|
711
|
+
offered.split = rowSplit(scrollback, end, width);
|
|
712
|
+
rows = [...rows, ...scrollback.slice(start, end)];
|
|
713
|
+
}
|
|
649
714
|
} else {
|
|
650
715
|
rows = this.#renderReplay(width);
|
|
651
716
|
}
|
|
@@ -674,6 +739,7 @@ export class TranscriptContainer extends Container {
|
|
|
674
739
|
head?.mode === "appendOnly" &&
|
|
675
740
|
!head.stableFrozen &&
|
|
676
741
|
head.state !== "committed" &&
|
|
742
|
+
head.split === undefined &&
|
|
677
743
|
head.emitted < head.stableRows.length
|
|
678
744
|
? head
|
|
679
745
|
: undefined;
|
|
@@ -739,9 +805,26 @@ export class TranscriptContainer extends Container {
|
|
|
739
805
|
limit = Math.max(limit, this.#frontier + (this.#liveCount() - (MAX_LIVE_BLOCKS - 1)));
|
|
740
806
|
}
|
|
741
807
|
if (policy === "flush") limit = this.#entries.length;
|
|
808
|
+
// The block straddling the top edge commits only the rows that left the
|
|
809
|
+
// screen; the rest stays live, so a later shrink of the chrome or the
|
|
810
|
+
// tail refills the screen from live rows instead of opening a band no
|
|
811
|
+
// frame can fill (rows still on screen cannot return from scrollback).
|
|
812
|
+
let split: { index: number; rows: readonly string[]; split: RowSplit } | undefined;
|
|
813
|
+
if (policy === "pressure" && keep > 0 && limit === this.#frontier + keep) {
|
|
814
|
+
const boundary = live[keep - 1]!;
|
|
815
|
+
const visible = room - tailRows - (tailRows > 0 ? 1 : 0);
|
|
816
|
+
if (boundary.entry.state === "settled" && visible > 0) {
|
|
817
|
+
const offer = this.#splitOffer(boundary.entry, width, visible);
|
|
818
|
+
if (offer !== undefined) {
|
|
819
|
+
split = { index: boundary.index, ...offer };
|
|
820
|
+
limit = boundary.index;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
742
824
|
let end = this.#frontier;
|
|
743
825
|
while (end < limit && end < this.#entries.length && this.#entries[end]!.state === "settled") end++;
|
|
744
|
-
if (end
|
|
826
|
+
if (split !== undefined && end !== split.index) split = undefined;
|
|
827
|
+
if (end === this.#frontier && split === undefined) {
|
|
745
828
|
if (policy === "pressure") this.#notePinnedFrontier();
|
|
746
829
|
return undefined;
|
|
747
830
|
}
|
|
@@ -761,15 +844,35 @@ export class TranscriptContainer extends Container {
|
|
|
761
844
|
} finally {
|
|
762
845
|
popLoopPhase();
|
|
763
846
|
}
|
|
847
|
+
if (split !== undefined && retirement.end !== split.index) split = undefined;
|
|
764
848
|
const batch: HistoryBatch = {
|
|
765
849
|
id: this.#nextBatchId++,
|
|
766
|
-
rows: retirement.rows,
|
|
850
|
+
rows: split === undefined ? retirement.rows : [...retirement.rows, ...split.rows],
|
|
767
851
|
kind: "append",
|
|
768
852
|
};
|
|
769
|
-
this.#offered = { batch, end: retirement.end, kind: "commit" };
|
|
853
|
+
this.#offered = { batch, end: retirement.end, kind: "commit", split: split?.split };
|
|
770
854
|
return batch;
|
|
771
855
|
}
|
|
772
856
|
|
|
857
|
+
/**
|
|
858
|
+
* The head rows of settled `entry` beyond its committed prefix that leave
|
|
859
|
+
* `visible` rows live, or undefined when nothing would remain committed
|
|
860
|
+
* past that prefix or the scrollback render would not line up row for row.
|
|
861
|
+
*/
|
|
862
|
+
#splitOffer(
|
|
863
|
+
entry: TranscriptEntry,
|
|
864
|
+
width: number,
|
|
865
|
+
visible: number,
|
|
866
|
+
): { rows: readonly string[]; split: RowSplit } | undefined {
|
|
867
|
+
const measured = this.#measuredRows(entry, width);
|
|
868
|
+
const start = this.#emittedRowCount(entry, width);
|
|
869
|
+
const total = measured.length - visible;
|
|
870
|
+
if (total <= start) return undefined;
|
|
871
|
+
const scrollback = renderForScrollback(() => this.#renderEntry(entry, width));
|
|
872
|
+
if (scrollback.length !== measured.length) return undefined;
|
|
873
|
+
return { rows: scrollback.slice(start, total), split: rowSplit(scrollback, total, width) };
|
|
874
|
+
}
|
|
875
|
+
|
|
773
876
|
/** Acknowledges exactly the most recently offered append, commit, or replay transaction. */
|
|
774
877
|
acknowledgeFinalizedBatch(id: number): void {
|
|
775
878
|
const offered = this.#offered;
|
|
@@ -786,6 +889,8 @@ export class TranscriptContainer extends Container {
|
|
|
786
889
|
this.#retireEntry(this.#entries[index]!);
|
|
787
890
|
}
|
|
788
891
|
this.#frontier = offered.end;
|
|
892
|
+
const head = offered.split ? this.#entries[offered.end] : undefined;
|
|
893
|
+
if (head !== undefined) head.split = offered.split;
|
|
789
894
|
} else {
|
|
790
895
|
this.#releaseCommittedRenderCaches();
|
|
791
896
|
}
|
|
@@ -928,14 +1033,32 @@ export class TranscriptContainer extends Container {
|
|
|
928
1033
|
}
|
|
929
1034
|
|
|
930
1035
|
/**
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
* purely to read `.length` off the result.
|
|
1036
|
+
* Rows of `entry` at `width` already in native history or in the
|
|
1037
|
+
* unacknowledged offer: the row split when one is pending or committed,
|
|
1038
|
+
* else its emitted stable prefix.
|
|
935
1039
|
*/
|
|
936
1040
|
#projectedEmittedRowCount(entry: TranscriptEntry, index: number, width: number): number {
|
|
937
1041
|
const offered = this.#offered;
|
|
938
|
-
|
|
1042
|
+
if (offered?.kind === "commit" && offered.split !== undefined && offered.end === index) {
|
|
1043
|
+
return this.#splitRowCount(entry, offered.split, width);
|
|
1044
|
+
}
|
|
1045
|
+
if (offered?.kind === "append" && offered.entry === index && entry.split === undefined) {
|
|
1046
|
+
return this.#stablePrefixRowCount(entry, offered.emittedEnd, width);
|
|
1047
|
+
}
|
|
1048
|
+
return this.#emittedRowCount(entry, width);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** Rows of `entry` at `width` already acknowledged into native history. */
|
|
1052
|
+
#emittedRowCount(entry: TranscriptEntry, width: number): number {
|
|
1053
|
+
if (entry.split !== undefined) return this.#splitRowCount(entry, entry.split, width);
|
|
1054
|
+
return this.#stablePrefixRowCount(entry, entry.emitted, width);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Length-only variant of `#renderStablePrefix`: answers the emitted row
|
|
1059
|
+
* count without re-rendering the prefix when this width already recorded it.
|
|
1060
|
+
*/
|
|
1061
|
+
#stablePrefixRowCount(entry: TranscriptEntry, count: number, width: number): number {
|
|
939
1062
|
if (count === 0) return 0;
|
|
940
1063
|
const perCount = entry.stableRowCountByWidth.get(width);
|
|
941
1064
|
const memo = perCount?.get(Math.min(count, entry.stableRows.length));
|
|
@@ -943,6 +1066,39 @@ export class TranscriptContainer extends Container {
|
|
|
943
1066
|
return this.#renderStablePrefix(entry, count, width).length;
|
|
944
1067
|
}
|
|
945
1068
|
|
|
1069
|
+
/**
|
|
1070
|
+
* Rows of `entry`'s current render at `width` that `split` committed: all
|
|
1071
|
+
* of them while they render unchanged, else the longest prefix whose
|
|
1072
|
+
* content continues the committed text exactly. A row the seam falls
|
|
1073
|
+
* inside, a row the block changed, a row with no content (a rule or other
|
|
1074
|
+
* punctuation the block revealed), or one whose decoration reads as
|
|
1075
|
+
* content stays live: it can repeat committed text but never drop any.
|
|
1076
|
+
*/
|
|
1077
|
+
#splitRowCount(entry: TranscriptEntry, split: RowSplit, width: number): number {
|
|
1078
|
+
const rendered = this.#measuredRows(entry, width);
|
|
1079
|
+
if (split.located?.rendered === rendered) return split.located.count;
|
|
1080
|
+
let count = 0;
|
|
1081
|
+
if (
|
|
1082
|
+
width === split.width &&
|
|
1083
|
+
rendered.length >= split.rows &&
|
|
1084
|
+
Bun.hash(rendered.slice(0, split.rows).join("\n")) === split.hash
|
|
1085
|
+
) {
|
|
1086
|
+
count = split.rows;
|
|
1087
|
+
} else {
|
|
1088
|
+
let consumed = 0;
|
|
1089
|
+
while (count < rendered.length && consumed < split.text.length) {
|
|
1090
|
+
const row = contentOf([rendered[count]!]);
|
|
1091
|
+
// An empty-content row matches at any offset, so the walk cannot
|
|
1092
|
+
// tell it apart from a revealed row history never received.
|
|
1093
|
+
if (row.length === 0 || !split.text.startsWith(row, consumed)) break;
|
|
1094
|
+
consumed += row.length;
|
|
1095
|
+
count++;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
split.located = { rendered, count };
|
|
1099
|
+
return count;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
946
1102
|
/**
|
|
947
1103
|
* Size a progressive-append batch from the stable renders `#renderEntry`
|
|
948
1104
|
* recorded at this width, without rendering any prefix. Each recorded count
|
|
@@ -950,7 +1106,8 @@ export class TranscriptContainer extends Container {
|
|
|
950
1106
|
* `renderedStableByWidth` holds the newest, so each count's rows are a
|
|
951
1107
|
* prefix of it and the batch is one slice. Undefined when a count the walk
|
|
952
1108
|
* needs was never recorded here (published between renders, or before a
|
|
953
|
-
* resize); the rendered walk then decides.
|
|
1109
|
+
* resize); the rendered walk then decides. Like the rendered walk, it never
|
|
1110
|
+
* passes `overflow`: a prefix reaching onto the screen waits.
|
|
954
1111
|
*/
|
|
955
1112
|
#measuredAppendBatch(entry: TranscriptEntry, width: number, overflow: number): AppendBatch | undefined {
|
|
956
1113
|
const counts = entry.stableRowCountByWidth.get(width);
|
|
@@ -961,7 +1118,7 @@ export class TranscriptContainer extends Container {
|
|
|
961
1118
|
if (start === undefined) return undefined;
|
|
962
1119
|
let emittedEnd = entry.emitted;
|
|
963
1120
|
let end = start;
|
|
964
|
-
while (emittedEnd < target
|
|
1121
|
+
while (emittedEnd < target) {
|
|
965
1122
|
const next = counts.get(emittedEnd + 1);
|
|
966
1123
|
if (next === undefined) return undefined;
|
|
967
1124
|
if (next <= start) {
|
|
@@ -970,6 +1127,7 @@ export class TranscriptContainer extends Container {
|
|
|
970
1127
|
}
|
|
971
1128
|
break;
|
|
972
1129
|
}
|
|
1130
|
+
if (next - start > overflow) break;
|
|
973
1131
|
end = next;
|
|
974
1132
|
emittedEnd += 1;
|
|
975
1133
|
}
|
|
@@ -978,15 +1136,18 @@ export class TranscriptContainer extends Container {
|
|
|
978
1136
|
|
|
979
1137
|
/**
|
|
980
1138
|
* Size a progressive-append batch by rendering each further stable prefix:
|
|
981
|
-
* extend the emitted prefix
|
|
982
|
-
*
|
|
983
|
-
*
|
|
1139
|
+
* extend the emitted prefix while its new rows stay within `overflow` — the
|
|
1140
|
+
* rows that left the screen — stopping at the first prefix that would reach
|
|
1141
|
+
* onto it, adds no row, or stops extending the emitted one (freezing the
|
|
1142
|
+
* block when that is the very next prefix). A prefix still partly on screen
|
|
1143
|
+
* stays live: committing it would strand those rows above the live tail,
|
|
1144
|
+
* where a later shrink of the chrome below opens a band no frame can fill.
|
|
984
1145
|
*/
|
|
985
1146
|
#renderedAppendBatch(entry: TranscriptEntry, width: number, overflow: number): AppendBatch {
|
|
986
1147
|
const before = this.#renderStablePrefix(entry, entry.emitted, width);
|
|
987
1148
|
let emittedEnd = entry.emitted;
|
|
988
1149
|
let after = before;
|
|
989
|
-
while (emittedEnd < entry.stableRows.length
|
|
1150
|
+
while (emittedEnd < entry.stableRows.length) {
|
|
990
1151
|
const next = this.#renderStablePrefix(entry, emittedEnd + 1, width);
|
|
991
1152
|
if (!isRowPrefix(before, next) || next.length === before.length) {
|
|
992
1153
|
if (emittedEnd === entry.emitted) {
|
|
@@ -994,6 +1155,7 @@ export class TranscriptContainer extends Container {
|
|
|
994
1155
|
}
|
|
995
1156
|
break;
|
|
996
1157
|
}
|
|
1158
|
+
if (next.length - before.length > overflow) break;
|
|
997
1159
|
after = next;
|
|
998
1160
|
emittedEnd += 1;
|
|
999
1161
|
}
|
|
@@ -1055,7 +1217,7 @@ export class TranscriptContainer extends Container {
|
|
|
1055
1217
|
const rendered = renderForScrollback(() =>
|
|
1056
1218
|
index === start ? this.#renderEntry(entry, width) : trimBlankEdges(entry.component.render(width)),
|
|
1057
1219
|
);
|
|
1058
|
-
const emittedRows = index === start ? this.#
|
|
1220
|
+
const emittedRows = index === start ? this.#emittedRowCount(entry, width) : 0;
|
|
1059
1221
|
const block = rendered.slice(emittedRows);
|
|
1060
1222
|
reached = index + 1;
|
|
1061
1223
|
if (block.length > 0) {
|
|
@@ -1071,7 +1233,11 @@ export class TranscriptContainer extends Container {
|
|
|
1071
1233
|
#renderReplay(width: number): readonly string[] {
|
|
1072
1234
|
const rows = Array.from(this.#renderRange(0, this.#frontier, width, true).rows);
|
|
1073
1235
|
const head = this.#entries[this.#frontier];
|
|
1074
|
-
if (head?.
|
|
1236
|
+
if (head?.split !== undefined) {
|
|
1237
|
+
this.#setAllocation(head.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
|
|
1238
|
+
const committed = this.#splitRowCount(head, head.split, width);
|
|
1239
|
+
rows.push(...renderForScrollback(() => this.#renderEntry(head, width)).slice(0, committed));
|
|
1240
|
+
} else if (head?.mode === "appendOnly" && head.emitted > 0) {
|
|
1075
1241
|
this.#setAllocation(head.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
|
|
1076
1242
|
this.#renderEntry(head, width);
|
|
1077
1243
|
rows.push(...this.#renderStablePrefix(head, head.emitted, width));
|
|
@@ -1082,10 +1248,15 @@ export class TranscriptContainer extends Container {
|
|
|
1082
1248
|
#completeFullyEmittedHeads(width: number): void {
|
|
1083
1249
|
while (this.#frontier < this.#entries.length) {
|
|
1084
1250
|
const entry = this.#entries[this.#frontier]!;
|
|
1085
|
-
if (entry.
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1251
|
+
if (entry.state !== "settled") return;
|
|
1252
|
+
if (entry.split !== undefined) {
|
|
1253
|
+
if (this.#splitRowCount(entry, entry.split, width) < this.#measuredRows(entry, width).length) return;
|
|
1254
|
+
} else {
|
|
1255
|
+
if (entry.mode !== "appendOnly") return;
|
|
1256
|
+
const rendered = this.#measuredRows(entry, width);
|
|
1257
|
+
if (entry.emitted !== entry.stableRows.length) return;
|
|
1258
|
+
if (this.#renderStablePrefix(entry, entry.emitted, width).length !== rendered.length) return;
|
|
1259
|
+
}
|
|
1089
1260
|
this.#retireEntry(entry);
|
|
1090
1261
|
this.#frontier++;
|
|
1091
1262
|
}
|
|
@@ -1094,6 +1265,7 @@ export class TranscriptContainer extends Container {
|
|
|
1094
1265
|
#retireEntry(entry: TranscriptEntry): void {
|
|
1095
1266
|
entry.state = "committed";
|
|
1096
1267
|
entry.emitted = 0;
|
|
1268
|
+
entry.split = undefined;
|
|
1097
1269
|
entry.stableRows = EMPTY_STABLE_ROWS;
|
|
1098
1270
|
entry.renderedStableByWidth = new Map();
|
|
1099
1271
|
entry.stableRowCountByWidth = new Map();
|
|
@@ -1124,7 +1296,8 @@ export class TranscriptContainer extends Container {
|
|
|
1124
1296
|
|
|
1125
1297
|
#startReplay(): void {
|
|
1126
1298
|
const head = this.#entries[this.#frontier];
|
|
1127
|
-
this.#replayPending =
|
|
1299
|
+
this.#replayPending =
|
|
1300
|
+
this.#frontier > 0 || head?.split !== undefined || (head?.mode === "appendOnly" && head.emitted > 0);
|
|
1128
1301
|
this.#replayRequested = false;
|
|
1129
1302
|
}
|
|
1130
1303
|
|
|
@@ -1295,6 +1468,7 @@ export class TranscriptContainer extends Container {
|
|
|
1295
1468
|
stableRowCountByWidth: new Map(),
|
|
1296
1469
|
emitted: 0,
|
|
1297
1470
|
stableFrozen: false,
|
|
1471
|
+
split: undefined,
|
|
1298
1472
|
},
|
|
1299
1473
|
);
|
|
1300
1474
|
this.#frontier = this.#entries.findIndex(entry => entry.state !== "committed");
|