@oh-my-pi/pi-tui 18.9.2 → 18.10.1
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 +53 -105
- package/dist/types/app-keybindings.d.ts +5 -0
- package/dist/types/chat/assistant-message.d.ts +1 -1
- package/dist/types/chat/user-message.d.ts +3 -2
- package/dist/types/chrome/countdown-timer.d.ts +3 -2
- package/dist/types/debug-server.d.ts +2 -0
- package/dist/types/overlays/annotation-overlay.d.ts +19 -0
- package/dist/types/overlays/ask-dialog.d.ts +11 -6
- package/dist/types/overlays/btw-history-panel.d.ts +2 -6
- package/dist/types/overlays/hook-editor.d.ts +7 -2
- package/dist/types/overlays/hook-input.d.ts +12 -4
- package/dist/types/overlays/jobs-panel.d.ts +8 -7
- package/dist/types/overlays/login-dialog.d.ts +8 -3
- package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
- package/dist/types/overlays/pause-screen.d.ts +5 -0
- package/dist/types/overlays/report-panel.d.ts +5 -1
- package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
- package/dist/types/overlays/session-account-selector.d.ts +4 -0
- package/dist/types/overlays/session-info-overlay.d.ts +7 -3
- package/dist/types/overlays/usage-dashboard.d.ts +6 -6
- package/dist/types/prompt/composer.d.ts +1 -20
- package/dist/types/status-line/context-usage.d.ts +1 -1
- package/dist/types/tools/grouped-file-output.d.ts +4 -1
- package/dist/types/tui.d.ts +10 -3
- package/package.json +9 -9
- package/src/app-keybindings.ts +5 -0
- package/src/apps/autoresearch-dashboard.ts +440 -27
- package/src/chat/assistant-message.ts +41 -13
- package/src/chat/user-message.ts +32 -19
- package/src/chrome/countdown-timer.ts +3 -3
- package/src/chrome/transcript-container.ts +202 -28
- package/src/debug-server.ts +51 -69
- package/src/hotkeys-markdown.ts +1 -0
- package/src/native/reconcile.ts +24 -6
- package/src/overlays/advisor-config.ts +259 -8
- package/src/overlays/annotation-overlay.ts +420 -168
- package/src/overlays/ask-dialog.ts +249 -105
- package/src/overlays/btw-history-panel.ts +178 -124
- package/src/overlays/hook-editor.ts +90 -32
- package/src/overlays/hook-input.ts +105 -19
- package/src/overlays/jobs-panel.ts +154 -132
- package/src/overlays/login-dialog.ts +201 -91
- package/src/overlays/mcp-add-wizard.ts +559 -279
- package/src/overlays/pause-screen.ts +81 -23
- package/src/overlays/plugin-selector.ts +17 -1
- package/src/overlays/queue-mode-selector.ts +10 -0
- package/src/overlays/report-panel.ts +6 -2
- package/src/overlays/reset-usage-selector.ts +130 -21
- package/src/overlays/session-account-selector.ts +36 -0
- package/src/overlays/session-info-overlay.ts +197 -76
- package/src/overlays/show-images-selector.ts +1 -0
- package/src/overlays/theme-selector.ts +6 -1
- package/src/overlays/thinking-selector.ts +8 -4
- package/src/overlays/usage-dashboard.ts +495 -143
- package/src/prompt/composer.ts +35 -72
- package/src/status-line/context-usage.ts +154 -57
- package/src/tools/grep.ts +1 -0
- package/src/tools/grouped-file-output.ts +11 -2
- package/src/tui.ts +27 -4
|
@@ -828,10 +828,10 @@ export class AssistantMessageComponent extends Container {
|
|
|
828
828
|
this.#nativeViewVersion++;
|
|
829
829
|
}
|
|
830
830
|
|
|
831
|
-
/** Mirrors a thinking section collapsed or expanded in the terminal. */
|
|
831
|
+
/** Mirrors a thinking section collapsed or expanded in the terminal; runs its controls' clicks. */
|
|
832
832
|
handleNativeEvent(event: NativeUiEvent): void {
|
|
833
833
|
if (event.type === "action") {
|
|
834
|
-
this.#
|
|
834
|
+
this.#handleAction(event.act);
|
|
835
835
|
return;
|
|
836
836
|
}
|
|
837
837
|
if (event.type !== "toggle" || !event.key.startsWith("k")) return;
|
|
@@ -860,6 +860,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
860
860
|
return entry.node;
|
|
861
861
|
};
|
|
862
862
|
const children: NativeChild[] = [];
|
|
863
|
+
let answered = false;
|
|
863
864
|
if (message) {
|
|
864
865
|
const live = !this.#transcriptBlockFinalized && this.#lastUpdateTransient;
|
|
865
866
|
let tailIndex = -1;
|
|
@@ -876,6 +877,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
876
877
|
const content = message.content[index]!;
|
|
877
878
|
const streaming = live && index === tailIndex;
|
|
878
879
|
if (content.type === "text" && canonicalizeMessage(content.text)) {
|
|
880
|
+
answered = true;
|
|
879
881
|
const source = content.text.trim();
|
|
880
882
|
// The terminal renders this source itself and would resolve a relative
|
|
881
883
|
// link against its own idea of the cwd: hand it the session-resolved
|
|
@@ -983,16 +985,29 @@ export class AssistantMessageComponent extends Container {
|
|
|
983
985
|
}
|
|
984
986
|
if (this.#turnUsage) {
|
|
985
987
|
const usage = formatTurnUsage(this.#turnUsage);
|
|
988
|
+
const line: NativeChild[] = [];
|
|
989
|
+
// Led by Copy and a dot: `⧉ · 12.4s · 19K tok this turn`.
|
|
990
|
+
if (answered && hasTranscriptActions()) {
|
|
991
|
+
line.push(
|
|
992
|
+
// `copy-answer`, not Tern's local `copy` (that would copy the icon's empty label).
|
|
993
|
+
node(
|
|
994
|
+
"icon",
|
|
995
|
+
{
|
|
996
|
+
name: "copy",
|
|
997
|
+
role: "omp.turn.copy",
|
|
998
|
+
title: "Copy answer",
|
|
999
|
+
aria: "Copy answer",
|
|
1000
|
+
actions: { click: "copy-answer" },
|
|
1001
|
+
},
|
|
1002
|
+
undefined,
|
|
1003
|
+
"copy",
|
|
1004
|
+
),
|
|
1005
|
+
node("text", { text: "·" }, undefined, "dot"),
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
line.push(node("text", { text: usage.text }, undefined, "text"));
|
|
986
1009
|
children.push(
|
|
987
|
-
node(
|
|
988
|
-
"row",
|
|
989
|
-
{ role: "omp.turn.usage", gap: "xs", align: "center", title: usage.title },
|
|
990
|
-
[
|
|
991
|
-
node("icon", { name: "time" }, undefined, "icon"),
|
|
992
|
-
node("text", { text: usage.text }, undefined, "text"),
|
|
993
|
-
],
|
|
994
|
-
"turn-usage",
|
|
995
|
-
),
|
|
1010
|
+
node("row", { role: "omp.turn.usage", gap: "xs", align: "center", title: usage.title }, line, "turn-usage"),
|
|
996
1011
|
);
|
|
997
1012
|
}
|
|
998
1013
|
return col(children, { role: this.#rewound ? "omp.assistant.rewound" : "omp.assistant" });
|
|
@@ -1081,11 +1096,24 @@ export class AssistantMessageComponent extends Container {
|
|
|
1081
1096
|
return card({ role: "omp.error", tone: "error", key: "error" }, children);
|
|
1082
1097
|
}
|
|
1083
1098
|
|
|
1084
|
-
/**
|
|
1085
|
-
#
|
|
1099
|
+
/** Control clicks (the error frame's actions, the turn footer's Copy): omp's own retry, clipboard and model-picker paths. */
|
|
1100
|
+
#handleAction(act: string): void {
|
|
1086
1101
|
if (act === "retry") runTranscriptAction({ act: "retry" });
|
|
1087
1102
|
else if (act === "switch-model") runTranscriptAction({ act: "switch-model" });
|
|
1088
1103
|
else if (act === "copy-error" && this.#errorText) runTranscriptAction({ act: "copy", text: this.#errorText });
|
|
1104
|
+
else if (act === "copy-answer") {
|
|
1105
|
+
const text = this.#answerText();
|
|
1106
|
+
if (text) runTranscriptAction({ act: "copy", text });
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** The answer as the transcript shows it: its text blocks' markdown, a blank line apart, without thinking or tool calls. */
|
|
1111
|
+
#answerText(): string {
|
|
1112
|
+
const blocks: string[] = [];
|
|
1113
|
+
for (const content of this.#displayedMessage?.content ?? []) {
|
|
1114
|
+
if (content.type === "text" && canonicalizeMessage(content.text)) blocks.push(content.text.trim());
|
|
1115
|
+
}
|
|
1116
|
+
return blocks.join("\n\n");
|
|
1089
1117
|
}
|
|
1090
1118
|
|
|
1091
1119
|
/** Width-independent stable identities for the streamed leading thinking run. */
|
package/src/chat/user-message.ts
CHANGED
|
@@ -195,8 +195,9 @@ export class UserMessageComponent extends Container implements ReactionTarget, T
|
|
|
195
195
|
/**
|
|
196
196
|
* A head-less user-toned frame with the prompt as markdown under its
|
|
197
197
|
* attached images (titled `#N` like their chips, opening the materialized
|
|
198
|
-
* file on click): the fill says "you". A quiet
|
|
199
|
-
*
|
|
198
|
+
* file on click): the fill says "you". A quiet footer like a turn's usage line
|
|
199
|
+
* (`⧉ ⏪ · 09:45`: Copy, Rewind, the time sent) fades in at its bottom-left on hover when
|
|
200
|
+
* the host handles transcript actions. The live-steering marker and the
|
|
200
201
|
* agent's reaction are chips at the bottom-right, so a reaction landing
|
|
201
202
|
* later updates the frame in place, even deep in scrollback.
|
|
202
203
|
*/
|
|
@@ -207,32 +208,44 @@ export class UserMessageComponent extends Container implements ReactionTarget, T
|
|
|
207
208
|
this.#nativeHour12 = hour12;
|
|
208
209
|
const children: NativeChild[] = [];
|
|
209
210
|
if (!this.#synthetic && hasTranscriptActions()) {
|
|
210
|
-
const tools: NativeChild[] = [
|
|
211
|
+
const tools: NativeChild[] = [
|
|
212
|
+
// `copy-message`, not Tern's local `copy` (that would copy the icon's empty label).
|
|
213
|
+
node(
|
|
214
|
+
"icon",
|
|
215
|
+
{
|
|
216
|
+
name: "copy",
|
|
217
|
+
role: "omp.user.tool",
|
|
218
|
+
title: "Copy message",
|
|
219
|
+
aria: "Copy message",
|
|
220
|
+
actions: { click: "copy-message" },
|
|
221
|
+
},
|
|
222
|
+
undefined,
|
|
223
|
+
"copy",
|
|
224
|
+
),
|
|
225
|
+
node(
|
|
226
|
+
"icon",
|
|
227
|
+
{
|
|
228
|
+
name: "rewind",
|
|
229
|
+
role: "omp.user.tool",
|
|
230
|
+
title: "Rewind the conversation to an earlier message",
|
|
231
|
+
aria: "Rewind",
|
|
232
|
+
actions: { click: "rewind" },
|
|
233
|
+
},
|
|
234
|
+
undefined,
|
|
235
|
+
"rewind",
|
|
236
|
+
),
|
|
237
|
+
];
|
|
211
238
|
if (this.#timestamp !== undefined) {
|
|
212
239
|
const at = new Date(this.#timestamp);
|
|
213
240
|
tools.push(
|
|
241
|
+
node("text", { text: "·" }, undefined, "dot"),
|
|
214
242
|
text([span(at.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12 }), "dim mono")], {
|
|
215
243
|
role: "omp.user.time",
|
|
216
244
|
title: at.toLocaleString([], { hour12 }),
|
|
217
245
|
}),
|
|
218
246
|
);
|
|
219
247
|
}
|
|
220
|
-
|
|
221
|
-
// `copy-message`, not Tern's local `copy` (that would copy the label).
|
|
222
|
-
text("Copy", {
|
|
223
|
-
role: "omp.user.tool",
|
|
224
|
-
actions: { click: "copy-message" },
|
|
225
|
-
title: "Copy message",
|
|
226
|
-
key: "copy",
|
|
227
|
-
}),
|
|
228
|
-
text("Rewind", {
|
|
229
|
-
role: "omp.user.tool",
|
|
230
|
-
actions: { click: "rewind" },
|
|
231
|
-
title: "Rewind the conversation to an earlier message",
|
|
232
|
-
key: "rewind",
|
|
233
|
-
}),
|
|
234
|
-
);
|
|
235
|
-
children.push(node("row", { gap: "xs", role: "omp.user.tools" }, tools, "tools"));
|
|
248
|
+
children.push(node("row", { gap: "xs", align: "center", role: "omp.user.tools" }, tools, "tools"));
|
|
236
249
|
}
|
|
237
250
|
// Videos keep their chip only: the native image node decodes stills.
|
|
238
251
|
const thumbs: NativeNode[] = [];
|
|
@@ -49,15 +49,15 @@ export class CountdownTimer {
|
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* The remaining time as a terminal-clocked `elapsed` node: its age is the
|
|
52
|
-
* negative remaining time (the start lies in the future), so it counts
|
|
53
|
-
*
|
|
52
|
+
* negative remaining time (the start lies in the future), so it counts down
|
|
53
|
+
* to zero and holds there at expiry (no `stopped`: that would freeze it at
|
|
54
|
+
* zero from the start). The age is taken on the first
|
|
54
55
|
* call after a (re)start, when the node is first written, and the node is
|
|
55
56
|
* reused until {@link reset}.
|
|
56
57
|
*/
|
|
57
58
|
describe(): NativeNode {
|
|
58
59
|
this.#node ??= node("elapsed", {
|
|
59
60
|
age: Math.min(0, Date.now() - this.#deadlineMs),
|
|
60
|
-
stopped: 0,
|
|
61
61
|
format: "short",
|
|
62
62
|
});
|
|
63
63
|
return this.#node;
|
|
@@ -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");
|