@oh-my-pi/pi-tui 18.4.0 → 18.4.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 +28 -0
- package/dist/types/chat/image-loading.d.ts +24 -0
- package/dist/types/chat/transcript-entry.d.ts +9 -0
- package/dist/types/chrome/transcript-container.d.ts +9 -2
- package/dist/types/overlays/agents-hub.d.ts +7 -4
- package/dist/types/overlays/rewind-selector.d.ts +3 -3
- package/dist/types/overlays/usage-dashboard.d.ts +9 -2
- package/dist/types/prompt/composer-cache.d.ts +34 -16
- package/dist/types/prompt/composer.d.ts +15 -18
- package/dist/types/prompt/welcome.d.ts +5 -2
- package/dist/types/status-line/component.d.ts +13 -4
- package/dist/types/status-line/metrics.d.ts +0 -1
- package/dist/types/status-line/startup.d.ts +38 -0
- package/dist/types/status-line/types.d.ts +0 -2
- package/dist/types/terminal-capabilities.d.ts +10 -6
- package/dist/types/tools/output-meta.d.ts +2 -1
- package/dist/types/tools/vibe.d.ts +4 -0
- package/package.json +9 -9
- package/src/chat/assistant-message.ts +27 -21
- package/src/chat/image-loading.ts +81 -0
- package/src/chat/tool-execution.ts +29 -13
- package/src/chat/transcript-entry.ts +16 -0
- package/src/chrome/transcript-container.ts +208 -74
- package/src/components/image.ts +10 -2
- package/src/overlays/agents-hub.ts +7 -18
- package/src/overlays/copy-selector.ts +3 -31
- package/src/overlays/model-hub.ts +49 -16
- package/src/overlays/rewind-selector.ts +51 -12
- package/src/overlays/usage-dashboard.ts +47 -6
- package/src/prompt/composer-cache.ts +217 -196
- package/src/prompt/composer.ts +40 -70
- package/src/prompt/welcome.ts +31 -28
- package/src/status-line/component.ts +73 -50
- package/src/status-line/metrics.ts +3 -21
- package/src/status-line/segments.ts +34 -50
- package/src/status-line/startup.ts +181 -0
- package/src/status-line/types.ts +0 -2
- package/src/terminal-capabilities.ts +12 -6
- package/src/terminal.ts +38 -9
- package/src/tools/output-meta.ts +11 -3
- package/src/tools/vibe.ts +15 -2
- package/src/tui.ts +66 -42
|
@@ -110,6 +110,87 @@ export async function convertImageToPng(image: ImageContent): Promise<ImageConte
|
|
|
110
110
|
return { ...image, data, mimeType: "image/png" };
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Byte ceiling for {@link convertImageToPngShared}'s resident conversions.
|
|
115
|
+
* Converted PNGs are larger than the webp/jpeg they came from, so the cache is
|
|
116
|
+
* bounded and evicts least-recently-used entries rather than growing with the
|
|
117
|
+
* session's image history.
|
|
118
|
+
*/
|
|
119
|
+
const PNG_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
|
120
|
+
|
|
121
|
+
/** Least-recently-used first; {@link touchPngCache} re-inserts on hit. */
|
|
122
|
+
const pngCache = new Map<string, ImageContent>();
|
|
123
|
+
let pngCacheBytes = 0;
|
|
124
|
+
const pngConversionsInFlight = new Map<string, Promise<ImageContent>>();
|
|
125
|
+
|
|
126
|
+
/** Cache lookup that also marks `key` as most recently used. */
|
|
127
|
+
function touchPngCache(key: string): ImageContent | undefined {
|
|
128
|
+
const hit = pngCache.get(key);
|
|
129
|
+
if (!hit) return undefined;
|
|
130
|
+
pngCache.delete(key);
|
|
131
|
+
pngCache.set(key, hit);
|
|
132
|
+
return hit;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Content-addressed identity of an image payload, stable across components and
|
|
137
|
+
* transcript rebuilds. Callers key their own per-image state by this instead of
|
|
138
|
+
* positional ids like `${toolCallId}:${index}`, which go stale when the images
|
|
139
|
+
* behind a position are replaced.
|
|
140
|
+
*/
|
|
141
|
+
export function imagePayloadKey(image: ImageContent): string {
|
|
142
|
+
return `${image.mimeType}:${image.data.length}:${Bun.hash(image.data)}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The PNG conversion of `image` when one is still resident, else `undefined`.
|
|
147
|
+
* Synchronous so renderers can use an already-converted image on the spot
|
|
148
|
+
* instead of scheduling another async re-render.
|
|
149
|
+
*/
|
|
150
|
+
export function cachedPngConversion(image: ImageContent): ImageContent | undefined {
|
|
151
|
+
return touchPngCache(imagePayloadKey(image));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Converts `image` to PNG at most once per distinct payload: concurrent callers
|
|
156
|
+
* share one in-flight conversion and later callers hit {@link pngCache}.
|
|
157
|
+
* Kitty-graphics renderers use this because `convertImageToPng` is a full
|
|
158
|
+
* decode plus re-encode, and the same image is delivered repeatedly (read-result
|
|
159
|
+
* replay) and rebuilt from scratch on resume, rewind, and `/tree` navigation.
|
|
160
|
+
*
|
|
161
|
+
* Rejections are not cached — a payload that failed to decode is retried by the
|
|
162
|
+
* next caller, matching {@link convertImageToPng}'s contract.
|
|
163
|
+
*/
|
|
164
|
+
export function convertImageToPngShared(image: ImageContent): Promise<ImageContent> {
|
|
165
|
+
const key = imagePayloadKey(image);
|
|
166
|
+
const cached = touchPngCache(key);
|
|
167
|
+
if (cached) return Promise.resolve(cached);
|
|
168
|
+
const running = pngConversionsInFlight.get(key);
|
|
169
|
+
if (running) return running;
|
|
170
|
+
const conversion = convertImageToPng(image)
|
|
171
|
+
.then(({ data }) => {
|
|
172
|
+
pngConversionsInFlight.delete(key);
|
|
173
|
+
// Only the payload is shared; caller-specific fields (`url`,
|
|
174
|
+
// `providerFile`, `detail`) describe the source image, not this PNG.
|
|
175
|
+
const converted: ImageContent = { type: "image", data, mimeType: "image/png" };
|
|
176
|
+
pngCache.set(key, converted);
|
|
177
|
+
pngCacheBytes += data.length;
|
|
178
|
+
for (const [oldest, entry] of pngCache) {
|
|
179
|
+
if (pngCacheBytes <= PNG_CACHE_MAX_BYTES) break;
|
|
180
|
+
if (oldest === key) continue;
|
|
181
|
+
pngCache.delete(oldest);
|
|
182
|
+
pngCacheBytes -= entry.data.length;
|
|
183
|
+
}
|
|
184
|
+
return converted;
|
|
185
|
+
})
|
|
186
|
+
.catch(error => {
|
|
187
|
+
pngConversionsInFlight.delete(key);
|
|
188
|
+
throw error;
|
|
189
|
+
});
|
|
190
|
+
pngConversionsInFlight.set(key, conversion);
|
|
191
|
+
return conversion;
|
|
192
|
+
}
|
|
193
|
+
|
|
113
194
|
export async function ensureSupportedImageInput(image: ImageContent): Promise<ImageContent | null> {
|
|
114
195
|
if (SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(image.mimeType)) {
|
|
115
196
|
return image;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
1
2
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
2
3
|
import { Box } from "../components/box";
|
|
3
4
|
import { SPINNER_ADVANCE_MS } from "../components/loader";
|
|
@@ -26,7 +27,7 @@ import { isWaitingPollDetails } from "../tools/wait";
|
|
|
26
27
|
import { formatStatusIcon, replaceTabs, resolveImageOptions } from "../render/render-utils";
|
|
27
28
|
import type { XdevMountedState } from "../tools/xdev";
|
|
28
29
|
import { isFramedBlockComponent, markFramedBlockComponent, renderStatusLine, WidthAwareText } from "../render/index";
|
|
29
|
-
import {
|
|
30
|
+
import { cachedPngConversion, convertImageToPngShared, imagePayloadKey } from "./image-loading";
|
|
30
31
|
import { sanitizeWithOptionalSixelPassthrough } from "../render/sixel";
|
|
31
32
|
import { renderDiff } from "../chrome/diff";
|
|
32
33
|
import { type AnimationFrame, trimBlankEdges } from "../chrome/transcript-container";
|
|
@@ -296,8 +297,13 @@ export class ToolExecutionComponent extends Container {
|
|
|
296
297
|
#editMode?: EditMode;
|
|
297
298
|
#editDiffPreview?: PerFileDiffPreview[];
|
|
298
299
|
#previewReady?: PromiseWithResolvers<void>;
|
|
299
|
-
//
|
|
300
|
-
|
|
300
|
+
// Payload keys whose Kitty PNG conversion is already awaited; the converted
|
|
301
|
+
// images themselves live in the process-wide cache behind
|
|
302
|
+
// `convertImageToPngShared`, so rebuilt components reuse them.
|
|
303
|
+
#kittyConversionsAwaited = new Set<string>();
|
|
304
|
+
// Conversions this component displays, held so a later re-render still finds
|
|
305
|
+
// them after the bounded shared cache evicts them.
|
|
306
|
+
#kittyConverted = new Map<string, ImageContent>();
|
|
301
307
|
// Spinner animation for partial task results
|
|
302
308
|
#spinnerFrame?: number;
|
|
303
309
|
#spinnerActive = false;
|
|
@@ -545,26 +551,32 @@ export class ToolExecutionComponent extends Container {
|
|
|
545
551
|
if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
|
|
546
552
|
if (!this.#result) return;
|
|
547
553
|
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
for (let i = 0; i < imageBlocks.length; i++) {
|
|
551
|
-
const img = imageBlocks[i];
|
|
554
|
+
for (const img of this.#getAllImageBlocks()) {
|
|
552
555
|
if (!img.data || !img.mimeType) continue;
|
|
553
|
-
// Skip if already PNG or already converted
|
|
556
|
+
// Skip if already PNG or already converted anywhere in this process
|
|
554
557
|
if (img.mimeType === "image/png") continue;
|
|
555
|
-
|
|
558
|
+
const image: ImageContent = { type: "image", data: img.data, mimeType: img.mimeType };
|
|
559
|
+
const key = imagePayloadKey(image);
|
|
560
|
+
if (this.#kittyConverted.has(key)) continue;
|
|
561
|
+
const cached = cachedPngConversion(image);
|
|
562
|
+
if (cached) {
|
|
563
|
+
this.#kittyConverted.set(key, cached);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (this.#kittyConversionsAwaited.has(key)) continue;
|
|
567
|
+
this.#kittyConversionsAwaited.add(key);
|
|
556
568
|
|
|
557
569
|
// Convert async - catch errors from processing
|
|
558
|
-
|
|
559
|
-
convertImageToPng({ type: "image", data: img.data, mimeType: img.mimeType })
|
|
570
|
+
convertImageToPngShared(image)
|
|
560
571
|
.then(converted => {
|
|
561
|
-
this.#
|
|
572
|
+
this.#kittyConverted.set(key, converted);
|
|
562
573
|
this.#displayInputVersion++;
|
|
563
574
|
this.#updateDisplay();
|
|
564
575
|
this.#ui.requestRender();
|
|
565
576
|
})
|
|
566
577
|
.catch(() => {
|
|
567
578
|
// Ignore conversion failures - display will use original image format
|
|
579
|
+
this.#kittyConversionsAwaited.delete(key);
|
|
568
580
|
});
|
|
569
581
|
}
|
|
570
582
|
}
|
|
@@ -1209,7 +1221,11 @@ export class ToolExecutionComponent extends Container {
|
|
|
1209
1221
|
const img = imageBlocks[i];
|
|
1210
1222
|
if (TERMINAL.imageProtocol && this.#showImages && img.data && img.mimeType) {
|
|
1211
1223
|
// Use converted PNG for Kitty protocol if available
|
|
1212
|
-
const
|
|
1224
|
+
const source: ImageContent = { type: "image", data: img.data, mimeType: img.mimeType };
|
|
1225
|
+
const converted =
|
|
1226
|
+
TERMINAL.imageProtocol === ImageProtocol.Kitty && img.mimeType !== "image/png"
|
|
1227
|
+
? (this.#kittyConverted.get(imagePayloadKey(source)) ?? cachedPngConversion(source))
|
|
1228
|
+
: undefined;
|
|
1213
1229
|
const imageData = converted?.data ?? img.data;
|
|
1214
1230
|
const imageMimeType = converted?.mimeType ?? img.mimeType;
|
|
1215
1231
|
|
|
@@ -68,6 +68,22 @@ export function isUserRequestEntry(entry: TranscriptEntryLike | { type: string }
|
|
|
68
68
|
return false;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Recent transcript tail starting at a user-request boundary, so tool calls
|
|
73
|
+
* and results stay together. ChatTranscriptBuilder drops a tool result whose
|
|
74
|
+
* initiating call was sliced away, so a tail of orphaned results can leave
|
|
75
|
+
* the picker without any target.
|
|
76
|
+
*
|
|
77
|
+
* A whole user turn may exceed `limit`; paginate rendering if one turn grows too large.
|
|
78
|
+
*/
|
|
79
|
+
export function recentTranscriptEntries(entries: TranscriptEntryLike[], limit = 600): TranscriptEntryLike[] {
|
|
80
|
+
if (entries.length <= limit) return entries;
|
|
81
|
+
for (let index = entries.length - limit; index > 0; index--) {
|
|
82
|
+
if (isUserRequestEntry(entries[index]!)) return entries.slice(index);
|
|
83
|
+
}
|
|
84
|
+
return entries;
|
|
85
|
+
}
|
|
86
|
+
|
|
71
87
|
/** Editable user request text, preserving skill invocation syntax. */
|
|
72
88
|
export function userTurnDraft(entry: TranscriptEntryLike): string | undefined {
|
|
73
89
|
const message = transcriptEntryMessage(entry);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Component, Container, type HistoryBatch } from "../tui";
|
|
2
2
|
import * as logger from "@oh-my-pi/pi-utils/logger";
|
|
3
|
+
import { popLoopPhase, pushLoopPhase } from "@oh-my-pi/pi-utils";
|
|
3
4
|
import { isToolActivityComponent } from "./tool-activity";
|
|
4
5
|
|
|
5
6
|
/** Shared animation time supplied by the constrained transcript root. */
|
|
@@ -95,6 +96,15 @@ type Offered =
|
|
|
95
96
|
const MAX_LIVE_BLOCKS = 256;
|
|
96
97
|
/** Grace before a pressure-blocked frontier is reported; a streaming block may legitimately hold it briefly. */
|
|
97
98
|
const PINNED_FRONTIER_WARN_MS = 30_000;
|
|
99
|
+
/**
|
|
100
|
+
* Wall-clock budget for composing one retirement batch. A resumed session
|
|
101
|
+
* hands the container its whole ledger at once, and rendering all of it in the
|
|
102
|
+
* frame that first paints it blocks the loop for as long as that render takes
|
|
103
|
+
* (#12933). Retirement stops after the first block that crosses the budget;
|
|
104
|
+
* the remainder follows on the next frames, which the TUI schedules through
|
|
105
|
+
* timers, so terminal input runs between batches.
|
|
106
|
+
*/
|
|
107
|
+
const RETIREMENT_BUDGET_MS = 8;
|
|
98
108
|
const EMPTY_ROWS: readonly string[] = [];
|
|
99
109
|
const EMPTY_STABLE_ROWS: readonly TranscriptStableRow[] = [];
|
|
100
110
|
|
|
@@ -290,20 +300,32 @@ export class TranscriptContainer extends Container {
|
|
|
290
300
|
this.#replayRequested = false;
|
|
291
301
|
}
|
|
292
302
|
|
|
293
|
-
/**
|
|
294
|
-
|
|
303
|
+
/**
|
|
304
|
+
* Total rows the live, un-emitted tail occupies at `width`.
|
|
305
|
+
*
|
|
306
|
+
* `limit` stops the walk once the total passes it: measuring a resumed
|
|
307
|
+
* session's whole ledger costs one full render per block, and callers only
|
|
308
|
+
* compare the height against a viewport budget. Past `limit` the result is
|
|
309
|
+
* a lower bound, guaranteed only to be greater than `limit`.
|
|
310
|
+
*/
|
|
311
|
+
liveRowCount(width: number, limit = Number.POSITIVE_INFINITY): number {
|
|
295
312
|
this.#syncEntries();
|
|
296
313
|
this.#settleFinalized();
|
|
297
314
|
let total = 0;
|
|
298
315
|
for (const { entry, index } of this.#liveEntries()) {
|
|
299
|
-
this.#
|
|
300
|
-
const rendered = this.#renderEntry(entry, width);
|
|
301
|
-
const block = rendered.slice(this.#projectedEmittedRowCount(entry, index, width));
|
|
316
|
+
const block = this.#liveBlockRows(entry, index, width);
|
|
302
317
|
if (block.length > 0) total += block.length + (total > 0 ? 1 : 0);
|
|
318
|
+
if (total > limit) break;
|
|
303
319
|
}
|
|
304
320
|
return total;
|
|
305
321
|
}
|
|
306
322
|
|
|
323
|
+
/** One live block's un-emitted rows at `width`, rendered against its full-height allocation. */
|
|
324
|
+
#liveBlockRows(entry: TranscriptEntry, index: number, width: number): readonly string[] {
|
|
325
|
+
this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
|
|
326
|
+
return this.#renderEntry(entry, width).slice(this.#projectedEmittedRowCount(entry, index, width));
|
|
327
|
+
}
|
|
328
|
+
|
|
307
329
|
/** Block spans of the last `renderViewport` output, in output coordinates. Empty when the tail is empty. */
|
|
308
330
|
getLastViewportSpans(): readonly TranscriptViewportSpan[] {
|
|
309
331
|
return this.#lastViewportSpans;
|
|
@@ -339,23 +361,37 @@ export class TranscriptContainer extends Container {
|
|
|
339
361
|
return EMPTY_ROWS;
|
|
340
362
|
}
|
|
341
363
|
|
|
364
|
+
// Collect newest-first and stop one block past what the viewport can
|
|
365
|
+
// hold: beyond that the emergency layout is already certain, and every
|
|
366
|
+
// further block would cost a full render to produce rows no frame can
|
|
367
|
+
// show — the whole ledger on a resumed session's first paint (#12933).
|
|
342
368
|
const shown: Array<{ entry: TranscriptEntry; index: number }> = [];
|
|
343
369
|
const blocks: (readonly string[])[] = [];
|
|
344
|
-
let
|
|
345
|
-
for (
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
370
|
+
let unrendered = 0;
|
|
371
|
+
for (let cursor = live.length - 1; cursor >= 0; cursor--) {
|
|
372
|
+
if (shown.length > capacity) {
|
|
373
|
+
unrendered = cursor + 1;
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
const candidate = live[cursor]!;
|
|
377
|
+
const block = this.#liveBlockRows(candidate.entry, candidate.index, width);
|
|
349
378
|
if (block.length === 0) continue;
|
|
350
|
-
total += block.length + (shown.length > 0 ? 1 : 0);
|
|
351
379
|
shown.push(candidate);
|
|
352
380
|
blocks.push(block);
|
|
353
381
|
}
|
|
382
|
+
shown.reverse();
|
|
383
|
+
blocks.reverse();
|
|
384
|
+
let total = 0;
|
|
385
|
+
for (const block of blocks) total += block.length + (total > 0 ? 1 : 0);
|
|
354
386
|
if (shown.length === 0) {
|
|
355
387
|
this.#lastViewportSpans = [];
|
|
356
388
|
return EMPTY_ROWS;
|
|
357
389
|
}
|
|
358
|
-
if (shown.length > capacity)
|
|
390
|
+
if (shown.length > capacity) {
|
|
391
|
+
// Blocks the walk never reached are still transcript state: the
|
|
392
|
+
// emergency layout consults them only where it must.
|
|
393
|
+
return this.#renderEmergency(shown, live.slice(0, unrendered), width, capacity, frame);
|
|
394
|
+
}
|
|
359
395
|
if (total <= capacity) {
|
|
360
396
|
const output: string[] = [];
|
|
361
397
|
const owners: (Component | undefined)[] = [];
|
|
@@ -430,7 +466,15 @@ export class TranscriptContainer extends Container {
|
|
|
430
466
|
return this.#offered.kind === "replay" ? this.#offered.batch : undefined;
|
|
431
467
|
}
|
|
432
468
|
if (!this.#replayPending) return undefined;
|
|
433
|
-
|
|
469
|
+
// The one path that must compose the whole ledger in a single frame; the
|
|
470
|
+
// phase label attributes any watchdog block here instead of "unknown".
|
|
471
|
+
pushLoopPhase("ui.transcript-replay");
|
|
472
|
+
let rows: readonly string[];
|
|
473
|
+
try {
|
|
474
|
+
rows = this.#renderReplay(width);
|
|
475
|
+
} finally {
|
|
476
|
+
popLoopPhase();
|
|
477
|
+
}
|
|
434
478
|
this.#replayPending = false;
|
|
435
479
|
if (rows.length === 0) return undefined;
|
|
436
480
|
const batch: HistoryBatch = { id: this.#nextBatchId++, rows, kind: "replay" };
|
|
@@ -455,7 +499,7 @@ export class TranscriptContainer extends Container {
|
|
|
455
499
|
const after = this.#renderStablePrefix(entry, offered.emittedEnd, width);
|
|
456
500
|
rows = after.slice(before.length);
|
|
457
501
|
} else if (offered.kind === "commit") {
|
|
458
|
-
rows = this.#renderRange(this.#frontier, offered.end, width, true);
|
|
502
|
+
rows = this.#renderRange(this.#frontier, offered.end, width, true).rows;
|
|
459
503
|
} else {
|
|
460
504
|
rows = this.#renderReplay(width);
|
|
461
505
|
}
|
|
@@ -474,58 +518,74 @@ export class TranscriptContainer extends Container {
|
|
|
474
518
|
const room = Math.max(0, Math.trunc(capacity));
|
|
475
519
|
const live = this.#liveEntries();
|
|
476
520
|
if (live.length === 0) return undefined;
|
|
477
|
-
// oxlint-disable-next-line unicorn/no-new-array -- length preallocation
|
|
478
|
-
const rendered: (readonly string[])[] = new Array(live.length);
|
|
479
|
-
// oxlint-disable-next-line unicorn/no-new-array -- length preallocation
|
|
480
|
-
const heights: number[] = new Array(live.length);
|
|
481
|
-
let total = 0;
|
|
482
|
-
let visible = 0;
|
|
483
|
-
for (let index = 0; index < live.length; index++) {
|
|
484
|
-
const candidate = live[index]!;
|
|
485
|
-
this.#setAllocation(candidate.entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
|
|
486
|
-
const renderedEntry = this.#renderEntry(candidate.entry, width);
|
|
487
|
-
const rows = renderedEntry.slice(
|
|
488
|
-
this.#renderStablePrefix(candidate.entry, candidate.entry.emitted, width).length,
|
|
489
|
-
);
|
|
490
|
-
rendered[index] = rows;
|
|
491
|
-
heights[index] = rows.length;
|
|
492
|
-
if (rows.length > 0) total += rows.length + (visible++ > 0 ? 1 : 0);
|
|
493
|
-
}
|
|
494
|
-
const overflowing = total > room || this.#liveCount() >= MAX_LIVE_BLOCKS;
|
|
495
|
-
if (policy === "pressure" && !overflowing) {
|
|
496
|
-
this.#pinnedFrontier = undefined;
|
|
497
|
-
return undefined;
|
|
498
|
-
}
|
|
499
521
|
|
|
522
|
+
// Only a render publishes a block's stable rows, so the head renders
|
|
523
|
+
// before its progressive-append eligibility is read.
|
|
500
524
|
const head = this.#entries[this.#frontier];
|
|
501
|
-
if (
|
|
525
|
+
if (head !== undefined) this.#liveBlockRows(head, this.#frontier, width);
|
|
526
|
+
const appendHead =
|
|
502
527
|
policy === "pressure" &&
|
|
503
|
-
total > room &&
|
|
504
528
|
head?.mode === "appendOnly" &&
|
|
505
529
|
!head.stableFrozen &&
|
|
506
530
|
head.state !== "committed" &&
|
|
507
531
|
head.emitted < head.stableRows.length
|
|
508
|
-
|
|
532
|
+
? head
|
|
533
|
+
: undefined;
|
|
534
|
+
|
|
535
|
+
// Measure the live tail newest-first and stop at the first block that
|
|
536
|
+
// does not fit: `keep` counts the leading blocks bound for scrollback,
|
|
537
|
+
// and everything behind them stays unmeasured. Measuring the whole live
|
|
538
|
+
// region costs one full render per block, which on a resumed session's
|
|
539
|
+
// first paint is every message it ever had (#12933). The progressive
|
|
540
|
+
// append path below needs the exact live height to size its emission,
|
|
541
|
+
// and only runs while a streaming head pins retirement.
|
|
542
|
+
let tailRows = 0;
|
|
543
|
+
let liveRows = 0;
|
|
544
|
+
let keep = 0;
|
|
545
|
+
let fits = true;
|
|
546
|
+
for (let cursor = live.length - 1; cursor >= 0; cursor--) {
|
|
547
|
+
const candidate = live[cursor]!;
|
|
548
|
+
const height = this.#liveBlockRows(candidate.entry, candidate.index, width).length;
|
|
549
|
+
if (height > 0) liveRows += height + (liveRows > 0 ? 1 : 0);
|
|
550
|
+
if (fits) {
|
|
551
|
+
const next = height > 0 ? tailRows + height + (tailRows > 0 ? 1 : 0) : tailRows;
|
|
552
|
+
if (next > room) {
|
|
553
|
+
fits = false;
|
|
554
|
+
keep = cursor + 1;
|
|
555
|
+
} else {
|
|
556
|
+
tailRows = next;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (!fits && appendHead === undefined) break;
|
|
560
|
+
}
|
|
561
|
+
const overflowing = keep > 0 || this.#liveCount() >= MAX_LIVE_BLOCKS;
|
|
562
|
+
if (policy === "pressure" && !overflowing) {
|
|
563
|
+
this.#pinnedFrontier = undefined;
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (appendHead !== undefined && keep > 0) {
|
|
509
568
|
// Emit as many finished rows as the overflow needs, in one batch. A
|
|
510
569
|
// fast stream adds finished rows quicker than one per pressure cycle,
|
|
511
570
|
// and the live region has to fall back under `room` to stay readable:
|
|
512
571
|
// rows left behind here are rows dropped from the top of the viewport.
|
|
513
|
-
|
|
514
|
-
const
|
|
515
|
-
|
|
572
|
+
// `liveRows` is exact here: the append path measured every live block.
|
|
573
|
+
const overflow = liveRows - room;
|
|
574
|
+
const before = this.#renderStablePrefix(appendHead, appendHead.emitted, width);
|
|
575
|
+
let emittedEnd = appendHead.emitted;
|
|
516
576
|
let rows: readonly string[] = EMPTY_ROWS;
|
|
517
|
-
while (emittedEnd <
|
|
518
|
-
const after = this.#renderStablePrefix(
|
|
577
|
+
while (emittedEnd < appendHead.stableRows.length && rows.length < overflow) {
|
|
578
|
+
const after = this.#renderStablePrefix(appendHead, emittedEnd + 1, width);
|
|
519
579
|
if (!isRowPrefix(before, after) || after.length === before.length) {
|
|
520
|
-
if (emittedEnd ===
|
|
521
|
-
this.#freezeStableRows(
|
|
580
|
+
if (emittedEnd === appendHead.emitted) {
|
|
581
|
+
this.#freezeStableRows(appendHead, EMPTY_ROWS, "semantic row render added no suffix");
|
|
522
582
|
}
|
|
523
583
|
break;
|
|
524
584
|
}
|
|
525
585
|
rows = after.slice(before.length);
|
|
526
586
|
emittedEnd += 1;
|
|
527
587
|
}
|
|
528
|
-
if (emittedEnd >
|
|
588
|
+
if (emittedEnd > appendHead.emitted) {
|
|
529
589
|
const batch: HistoryBatch = {
|
|
530
590
|
id: this.#nextBatchId++,
|
|
531
591
|
rows,
|
|
@@ -537,31 +597,41 @@ export class TranscriptContainer extends Container {
|
|
|
537
597
|
}
|
|
538
598
|
}
|
|
539
599
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
let
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
policy === "pressure" &&
|
|
546
|
-
total - freed <= room &&
|
|
547
|
-
this.#liveCount() - (end - this.#frontier) < MAX_LIVE_BLOCKS
|
|
548
|
-
)
|
|
549
|
-
break;
|
|
550
|
-
freed += heights[index]! > 0 ? heights[index]! + 1 : 0;
|
|
551
|
-
end++;
|
|
552
|
-
index++;
|
|
600
|
+
// Shutdown retires everything eligible; pressure retires exactly the
|
|
601
|
+
// blocks that no longer fit, plus whatever the live-block cap demands.
|
|
602
|
+
let limit = this.#frontier + keep;
|
|
603
|
+
if (this.#liveCount() >= MAX_LIVE_BLOCKS) {
|
|
604
|
+
limit = Math.max(limit, this.#frontier + (this.#liveCount() - (MAX_LIVE_BLOCKS - 1)));
|
|
553
605
|
}
|
|
606
|
+
if (policy === "flush") limit = this.#entries.length;
|
|
607
|
+
let end = this.#frontier;
|
|
608
|
+
while (end < limit && end < this.#entries.length && this.#entries[end]!.state === "settled") end++;
|
|
554
609
|
if (end === this.#frontier) {
|
|
555
610
|
if (policy === "pressure") this.#notePinnedFrontier();
|
|
556
611
|
return undefined;
|
|
557
612
|
}
|
|
558
613
|
this.#pinnedFrontier = undefined;
|
|
614
|
+
pushLoopPhase("ui.transcript-retire");
|
|
615
|
+
let retirement: { rows: readonly string[]; end: number };
|
|
616
|
+
try {
|
|
617
|
+
// Shutdown must hand over the full prefix; a live frame stops at the
|
|
618
|
+
// budget and offers the rest on the next frames.
|
|
619
|
+
retirement = this.#renderRange(
|
|
620
|
+
this.#frontier,
|
|
621
|
+
end,
|
|
622
|
+
width,
|
|
623
|
+
true,
|
|
624
|
+
policy === "flush" ? undefined : RETIREMENT_BUDGET_MS,
|
|
625
|
+
);
|
|
626
|
+
} finally {
|
|
627
|
+
popLoopPhase();
|
|
628
|
+
}
|
|
559
629
|
const batch: HistoryBatch = {
|
|
560
630
|
id: this.#nextBatchId++,
|
|
561
|
-
rows:
|
|
631
|
+
rows: retirement.rows,
|
|
562
632
|
kind: "append",
|
|
563
633
|
};
|
|
564
|
-
this.#offered = { batch, end, kind: "commit" };
|
|
634
|
+
this.#offered = { batch, end: retirement.end, kind: "commit" };
|
|
565
635
|
return batch;
|
|
566
636
|
}
|
|
567
637
|
|
|
@@ -578,8 +648,7 @@ export class TranscriptContainer extends Container {
|
|
|
578
648
|
entry.emitted = offered.emittedEnd;
|
|
579
649
|
} else if (offered.kind === "commit") {
|
|
580
650
|
for (let index = this.#frontier; index < offered.end; index++) {
|
|
581
|
-
this.#entries[index]
|
|
582
|
-
this.#entries[index]!.emitted = 0;
|
|
651
|
+
this.#retireEntry(this.#entries[index]!);
|
|
583
652
|
}
|
|
584
653
|
this.#frontier = offered.end;
|
|
585
654
|
}
|
|
@@ -632,7 +701,7 @@ export class TranscriptContainer extends Container {
|
|
|
632
701
|
|
|
633
702
|
#renderEntry(entry: TranscriptEntry, width: number): readonly string[] {
|
|
634
703
|
const rendered = trimBlankEdges(entry.component.render(width));
|
|
635
|
-
if (entry.mode === "mutable" || entry.stableFrozen) return rendered;
|
|
704
|
+
if (entry.state === "committed" || entry.mode === "mutable" || entry.stableFrozen) return rendered;
|
|
636
705
|
const appendOnly = entry.component as Component & AppendOnlyTranscriptBlock;
|
|
637
706
|
const stable = appendOnly.getTranscriptStableRows();
|
|
638
707
|
if (!isStablePrefix(entry.stableRows, stable)) {
|
|
@@ -733,8 +802,25 @@ export class TranscriptContainer extends Container {
|
|
|
733
802
|
});
|
|
734
803
|
}
|
|
735
804
|
|
|
736
|
-
|
|
805
|
+
/**
|
|
806
|
+
* Compose entries `[start, end)` as one ordered retirement payload.
|
|
807
|
+
*
|
|
808
|
+
* `budgetMs` stops the walk after the first block that crosses it — always
|
|
809
|
+
* at least one block — and reports the index actually reached, so a single
|
|
810
|
+
* frame never renders more of a resumed ledger than it can afford (#12933).
|
|
811
|
+
* Callers that must emit a whole prefix (replay, shutdown flush, recompose
|
|
812
|
+
* of an already offered batch) omit it.
|
|
813
|
+
*/
|
|
814
|
+
#renderRange(
|
|
815
|
+
start: number,
|
|
816
|
+
end: number,
|
|
817
|
+
width: number,
|
|
818
|
+
trailingBlank: boolean,
|
|
819
|
+
budgetMs?: number,
|
|
820
|
+
): { rows: readonly string[]; end: number } {
|
|
737
821
|
const rows: string[] = [];
|
|
822
|
+
const startedAt = budgetMs === undefined ? 0 : performance.now();
|
|
823
|
+
let reached = start;
|
|
738
824
|
for (let index = start; index < end; index++) {
|
|
739
825
|
const entry = this.#entries[index]!;
|
|
740
826
|
this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
|
|
@@ -746,16 +832,19 @@ export class TranscriptContainer extends Container {
|
|
|
746
832
|
index === start ? this.#renderEntry(entry, width) : trimBlankEdges(entry.component.render(width));
|
|
747
833
|
const emittedRows = index === start ? this.#renderStablePrefix(entry, entry.emitted, width).length : 0;
|
|
748
834
|
const block = rendered.slice(emittedRows);
|
|
749
|
-
|
|
750
|
-
if (
|
|
751
|
-
|
|
835
|
+
reached = index + 1;
|
|
836
|
+
if (block.length > 0) {
|
|
837
|
+
if (rows.length > 0) rows.push("");
|
|
838
|
+
rows.push(...block);
|
|
839
|
+
}
|
|
840
|
+
if (budgetMs !== undefined && performance.now() - startedAt >= budgetMs) break;
|
|
752
841
|
}
|
|
753
842
|
if (trailingBlank && rows.length > 0) rows.push("");
|
|
754
|
-
return rows;
|
|
843
|
+
return { rows, end: reached };
|
|
755
844
|
}
|
|
756
845
|
|
|
757
846
|
#renderReplay(width: number): readonly string[] {
|
|
758
|
-
const rows = Array.from(this.#renderRange(0, this.#frontier, width, true));
|
|
847
|
+
const rows = Array.from(this.#renderRange(0, this.#frontier, width, true).rows);
|
|
759
848
|
const head = this.#entries[this.#frontier];
|
|
760
849
|
if (head?.mode === "appendOnly" && head.emitted > 0) {
|
|
761
850
|
this.#setAllocation(head.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
|
|
@@ -773,24 +862,61 @@ export class TranscriptContainer extends Container {
|
|
|
773
862
|
const rendered = this.#renderEntry(entry, width);
|
|
774
863
|
if (entry.emitted !== entry.stableRows.length) return;
|
|
775
864
|
if (this.#renderStablePrefix(entry, entry.emitted, width).length !== rendered.length) return;
|
|
776
|
-
entry
|
|
777
|
-
entry.emitted = 0;
|
|
865
|
+
this.#retireEntry(entry);
|
|
778
866
|
this.#frontier++;
|
|
779
867
|
}
|
|
780
868
|
}
|
|
781
869
|
|
|
870
|
+
#retireEntry(entry: TranscriptEntry): void {
|
|
871
|
+
entry.state = "committed";
|
|
872
|
+
entry.emitted = 0;
|
|
873
|
+
entry.stableRows = EMPTY_STABLE_ROWS;
|
|
874
|
+
entry.renderedStableByWidth = new Map();
|
|
875
|
+
entry.stableRowCountByWidth = new Map();
|
|
876
|
+
}
|
|
877
|
+
|
|
782
878
|
#startReplay(): void {
|
|
783
879
|
const head = this.#entries[this.#frontier];
|
|
784
880
|
this.#replayPending = this.#frontier > 0 || (head?.mode === "appendOnly" && head.emitted > 0);
|
|
785
881
|
this.#replayRequested = false;
|
|
786
882
|
}
|
|
787
883
|
|
|
884
|
+
/**
|
|
885
|
+
* One-row-per-block fallback for a live region that cannot fit the viewport.
|
|
886
|
+
* `behind` holds the older live blocks `renderViewport` deliberately left
|
|
887
|
+
* unrendered. Only its active blocks (few) and the newest settled block
|
|
888
|
+
* offering an emergency row are rendered, so the summary count and the
|
|
889
|
+
* surviving emergency row match a full walk without rendering the ledger.
|
|
890
|
+
*/
|
|
788
891
|
#renderEmergency(
|
|
789
892
|
shown: readonly { entry: TranscriptEntry; index: number }[],
|
|
893
|
+
behind: readonly { entry: TranscriptEntry; index: number }[],
|
|
790
894
|
width: number,
|
|
791
895
|
rows: number,
|
|
792
896
|
frame: AnimationFrame,
|
|
793
897
|
): readonly string[] {
|
|
898
|
+
let hiddenBelow = 0;
|
|
899
|
+
for (const candidate of behind) {
|
|
900
|
+
if (candidate.entry.state !== "active") continue;
|
|
901
|
+
if (this.#liveBlockRows(candidate.entry, candidate.index, width).length > 0) hiddenBelow++;
|
|
902
|
+
}
|
|
903
|
+
let behindEmergency: { candidate: { entry: TranscriptEntry; index: number }; row: string } | null | undefined;
|
|
904
|
+
const findBehindEmergency = () => {
|
|
905
|
+
if (behindEmergency !== undefined) return behindEmergency;
|
|
906
|
+
behindEmergency = null;
|
|
907
|
+
for (let index = behind.length - 1; index >= 0; index--) {
|
|
908
|
+
const candidate = behind[index]!;
|
|
909
|
+
if (candidate.entry.state !== "settled") continue;
|
|
910
|
+
const block = candidate.entry.component as Component & FinalizableBlock;
|
|
911
|
+
if (block.renderTranscriptBlockEmergencyRow === undefined) continue;
|
|
912
|
+
if (this.#liveBlockRows(candidate.entry, candidate.index, width).length === 0) continue;
|
|
913
|
+
const row = block.renderTranscriptBlockEmergencyRow(width);
|
|
914
|
+
if (row === undefined) continue;
|
|
915
|
+
behindEmergency = { candidate, row };
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
return behindEmergency;
|
|
919
|
+
};
|
|
794
920
|
let visibleRows = rows;
|
|
795
921
|
let visible: { entry: TranscriptEntry; index: number }[] = [];
|
|
796
922
|
let emergencyCandidate: { entry: TranscriptEntry; index: number } | undefined;
|
|
@@ -812,8 +938,16 @@ export class TranscriptContainer extends Container {
|
|
|
812
938
|
visible = [candidate, ...visible.slice(1)];
|
|
813
939
|
break;
|
|
814
940
|
}
|
|
941
|
+
if (emergencyCandidate === undefined) {
|
|
942
|
+
const found = findBehindEmergency();
|
|
943
|
+
if (found !== null) {
|
|
944
|
+
emergencyCandidate = found.candidate;
|
|
945
|
+
emergencyRow = found.row;
|
|
946
|
+
visible = [found.candidate, ...visible.slice(1)];
|
|
947
|
+
}
|
|
948
|
+
}
|
|
815
949
|
|
|
816
|
-
let activeTotal =
|
|
950
|
+
let activeTotal = hiddenBelow;
|
|
817
951
|
for (const candidate of shown) {
|
|
818
952
|
if (candidate.entry.state === "active") activeTotal++;
|
|
819
953
|
}
|