@oh-my-pi/pi-tui 18.4.0 → 18.4.2
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 +42 -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 +24 -3
- package/dist/types/overlays/agents-hub.d.ts +7 -4
- package/dist/types/overlays/model-browser.d.ts +15 -0
- package/dist/types/overlays/rewind-selector.d.ts +4 -3
- package/dist/types/overlays/usage-dashboard.d.ts +9 -2
- package/dist/types/prompt/composer-attachments.d.ts +7 -0
- package/dist/types/prompt/composer-cache.d.ts +34 -16
- package/dist/types/prompt/composer.d.ts +15 -18
- package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
- package/dist/types/prompt/welcome.d.ts +5 -2
- package/dist/types/render/width-aware-text.d.ts +6 -0
- 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/terminal.d.ts +7 -0
- package/dist/types/theme/color.d.ts +1 -0
- package/dist/types/theme/session-color.d.ts +3 -3
- package/dist/types/theme/shimmer.d.ts +1 -1
- package/dist/types/theme/theme-class.d.ts +1 -1
- package/dist/types/tools/output-meta.d.ts +2 -1
- package/dist/types/tools/vibe.d.ts +4 -0
- package/dist/types/tui.d.ts +3 -2
- 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 +53 -17
- package/src/chat/transcript-entry.ts +16 -0
- package/src/chrome/transcript-container.ts +282 -86
- package/src/components/editor.ts +28 -6
- package/src/components/image.ts +25 -9
- package/src/components/loader.ts +37 -17
- package/src/overlays/agent-hub.ts +16 -6
- package/src/overlays/agents-hub.ts +7 -18
- package/src/overlays/copy-selector.ts +8 -31
- package/src/overlays/model-browser.ts +106 -25
- package/src/overlays/model-hub.ts +49 -16
- package/src/overlays/rewind-selector.ts +60 -13
- package/src/overlays/usage-dashboard.ts +47 -6
- package/src/prompt/composer-attachments.ts +54 -7
- package/src/prompt/composer-cache.ts +217 -196
- package/src/prompt/composer.ts +45 -72
- package/src/prompt/custom-editor.ts +24 -6
- package/src/prompt/model-mention-autocomplete.ts +22 -10
- package/src/prompt/welcome.ts +31 -28
- package/src/render/width-aware-text.ts +9 -0
- package/src/status-line/component.ts +286 -182
- package/src/status-line/metrics.ts +3 -21
- package/src/status-line/segments.ts +68 -79
- 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 +75 -20
- package/src/theme/color.ts +15 -0
- package/src/theme/session-color.ts +26 -4
- package/src/theme/shimmer.ts +55 -76
- package/src/theme/theme-class.ts +29 -12
- package/src/tools/output-meta.ts +11 -3
- package/src/tools/vibe.ts +15 -2
- package/src/tui.ts +208 -154
- package/src/utils.ts +15 -0
|
@@ -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
|
|
|
@@ -164,6 +174,21 @@ export class TranscriptContainer extends Container {
|
|
|
164
174
|
#pinnedFrontier: { index: number; since: number; logged: boolean } | undefined;
|
|
165
175
|
/** Block spans of the last `renderViewport` output, for click hit-testing. */
|
|
166
176
|
#lastViewportSpans: TranscriptViewportSpan[] = [];
|
|
177
|
+
/**
|
|
178
|
+
* The composed frame {@link beginFrame} opened; `undefined` outside one.
|
|
179
|
+
* {@link renderViewport} closes it, so it never outlives the synchronous
|
|
180
|
+
* composition that opened it.
|
|
181
|
+
*/
|
|
182
|
+
#openFrame: AnimationFrame | undefined;
|
|
183
|
+
/**
|
|
184
|
+
* Full-allocation blank-trimmed renders of the blocks measured during the
|
|
185
|
+
* open frame, keyed by entry at {@link #frameRowsWidth}. A retirement peek
|
|
186
|
+
* and the viewport measure the same live blocks back to back inside one
|
|
187
|
+
* composition, with no block mutation possible in between; replaying the
|
|
188
|
+
* first measurement spares every block its second render per frame.
|
|
189
|
+
*/
|
|
190
|
+
#frameRows = new Map<TranscriptEntry, readonly string[]>();
|
|
191
|
+
#frameRowsWidth = 0;
|
|
167
192
|
override addChild(component: Component): void {
|
|
168
193
|
if (isToolActivityComponent(component)) component.setToolActivityVisible(this.#toolActivityVisible);
|
|
169
194
|
super.addChild(component);
|
|
@@ -290,20 +315,72 @@ export class TranscriptContainer extends Container {
|
|
|
290
315
|
this.#replayRequested = false;
|
|
291
316
|
}
|
|
292
317
|
|
|
293
|
-
/**
|
|
294
|
-
|
|
318
|
+
/**
|
|
319
|
+
* Open one composed frame: every live-block measurement until this frame's
|
|
320
|
+
* {@link renderViewport} returns renders against `frame` and is taken once,
|
|
321
|
+
* so the retirement peek and the viewport share each block's render.
|
|
322
|
+
*
|
|
323
|
+
* Callers MUST call {@link renderViewport} with the same `frame` in the same
|
|
324
|
+
* synchronous composition, without mutating any transcript block in
|
|
325
|
+
* between: the shared rows are only as fresh as that first measurement.
|
|
326
|
+
*/
|
|
327
|
+
beginFrame(frame: AnimationFrame): void {
|
|
328
|
+
this.#lastFrame = frame;
|
|
329
|
+
this.#frameRows.clear();
|
|
330
|
+
this.#openFrame = frame;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Total rows the live, un-emitted tail occupies at `width`.
|
|
335
|
+
*
|
|
336
|
+
* `limit` stops the walk once the total passes it: measuring a resumed
|
|
337
|
+
* session's whole ledger costs one full render per block, and callers only
|
|
338
|
+
* compare the height against a viewport budget. Past `limit` the result is
|
|
339
|
+
* a lower bound, guaranteed only to be greater than `limit`.
|
|
340
|
+
*/
|
|
341
|
+
liveRowCount(width: number, limit = Number.POSITIVE_INFINITY): number {
|
|
295
342
|
this.#syncEntries();
|
|
296
343
|
this.#settleFinalized();
|
|
297
344
|
let total = 0;
|
|
298
345
|
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));
|
|
346
|
+
const block = this.#liveBlockRows(entry, index, width);
|
|
302
347
|
if (block.length > 0) total += block.length + (total > 0 ? 1 : 0);
|
|
348
|
+
if (total > limit) break;
|
|
303
349
|
}
|
|
304
350
|
return total;
|
|
305
351
|
}
|
|
306
352
|
|
|
353
|
+
/** One live block's un-emitted rows at `width`, rendered against its full-height allocation. */
|
|
354
|
+
#liveBlockRows(entry: TranscriptEntry, index: number, width: number): readonly string[] {
|
|
355
|
+
const rows = this.#measuredRows(entry, width);
|
|
356
|
+
const emitted = this.#projectedEmittedRowCount(entry, index, width);
|
|
357
|
+
return emitted === 0 ? rows : rows.slice(emitted);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* One block's blank-trimmed render at its full-height allocation. Inside an
|
|
362
|
+
* open frame the first measurement of each block is replayed to later ones.
|
|
363
|
+
*/
|
|
364
|
+
#measuredRows(entry: TranscriptEntry, width: number): readonly string[] {
|
|
365
|
+
this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
|
|
366
|
+
if (this.#openFrame === undefined) return this.#renderEntry(entry, width);
|
|
367
|
+
if (this.#frameRowsWidth !== width) {
|
|
368
|
+
this.#frameRows.clear();
|
|
369
|
+
this.#frameRowsWidth = width;
|
|
370
|
+
}
|
|
371
|
+
let rows = this.#frameRows.get(entry);
|
|
372
|
+
if (rows === undefined) {
|
|
373
|
+
rows = this.#renderEntry(entry, width);
|
|
374
|
+
this.#frameRows.set(entry, rows);
|
|
375
|
+
}
|
|
376
|
+
return rows;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
#closeFrame(): void {
|
|
380
|
+
this.#openFrame = undefined;
|
|
381
|
+
this.#frameRows.clear();
|
|
382
|
+
}
|
|
383
|
+
|
|
307
384
|
/** Block spans of the last `renderViewport` output, in output coordinates. Empty when the tail is empty. */
|
|
308
385
|
getLastViewportSpans(): readonly TranscriptViewportSpan[] {
|
|
309
386
|
return this.#lastViewportSpans;
|
|
@@ -327,9 +404,22 @@ export class TranscriptContainer extends Container {
|
|
|
327
404
|
this.#lastViewportSpans = spans;
|
|
328
405
|
}
|
|
329
406
|
|
|
330
|
-
/**
|
|
407
|
+
/**
|
|
408
|
+
* Render the live tail, constrained to the supplied transcript height.
|
|
409
|
+
* Closes the frame {@link beginFrame} opened; a different `frame` discards
|
|
410
|
+
* its measurements first.
|
|
411
|
+
*/
|
|
331
412
|
renderViewport(width: number, rows: number, frame: AnimationFrame): readonly string[] {
|
|
413
|
+
if (frame !== this.#openFrame) this.#closeFrame();
|
|
332
414
|
this.#lastFrame = frame;
|
|
415
|
+
try {
|
|
416
|
+
return this.#composeViewport(width, rows, frame);
|
|
417
|
+
} finally {
|
|
418
|
+
this.#closeFrame();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
#composeViewport(width: number, rows: number, frame: AnimationFrame): readonly string[] {
|
|
333
423
|
this.#syncEntries();
|
|
334
424
|
this.#settleFinalized();
|
|
335
425
|
const live = this.#liveEntries();
|
|
@@ -339,23 +429,37 @@ export class TranscriptContainer extends Container {
|
|
|
339
429
|
return EMPTY_ROWS;
|
|
340
430
|
}
|
|
341
431
|
|
|
432
|
+
// Collect newest-first and stop one block past what the viewport can
|
|
433
|
+
// hold: beyond that the emergency layout is already certain, and every
|
|
434
|
+
// further block would cost a full render to produce rows no frame can
|
|
435
|
+
// show — the whole ledger on a resumed session's first paint (#12933).
|
|
342
436
|
const shown: Array<{ entry: TranscriptEntry; index: number }> = [];
|
|
343
437
|
const blocks: (readonly string[])[] = [];
|
|
344
|
-
let
|
|
345
|
-
for (
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
438
|
+
let unrendered = 0;
|
|
439
|
+
for (let cursor = live.length - 1; cursor >= 0; cursor--) {
|
|
440
|
+
if (shown.length > capacity) {
|
|
441
|
+
unrendered = cursor + 1;
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
const candidate = live[cursor]!;
|
|
445
|
+
const block = this.#liveBlockRows(candidate.entry, candidate.index, width);
|
|
349
446
|
if (block.length === 0) continue;
|
|
350
|
-
total += block.length + (shown.length > 0 ? 1 : 0);
|
|
351
447
|
shown.push(candidate);
|
|
352
448
|
blocks.push(block);
|
|
353
449
|
}
|
|
450
|
+
shown.reverse();
|
|
451
|
+
blocks.reverse();
|
|
452
|
+
let total = 0;
|
|
453
|
+
for (const block of blocks) total += block.length + (total > 0 ? 1 : 0);
|
|
354
454
|
if (shown.length === 0) {
|
|
355
455
|
this.#lastViewportSpans = [];
|
|
356
456
|
return EMPTY_ROWS;
|
|
357
457
|
}
|
|
358
|
-
if (shown.length > capacity)
|
|
458
|
+
if (shown.length > capacity) {
|
|
459
|
+
// Blocks the walk never reached are still transcript state: the
|
|
460
|
+
// emergency layout consults them only where it must.
|
|
461
|
+
return this.#renderEmergency(shown, live.slice(0, unrendered), width, capacity, frame);
|
|
462
|
+
}
|
|
359
463
|
if (total <= capacity) {
|
|
360
464
|
const output: string[] = [];
|
|
361
465
|
const owners: (Component | undefined)[] = [];
|
|
@@ -430,7 +534,15 @@ export class TranscriptContainer extends Container {
|
|
|
430
534
|
return this.#offered.kind === "replay" ? this.#offered.batch : undefined;
|
|
431
535
|
}
|
|
432
536
|
if (!this.#replayPending) return undefined;
|
|
433
|
-
|
|
537
|
+
// The one path that must compose the whole ledger in a single frame; the
|
|
538
|
+
// phase label attributes any watchdog block here instead of "unknown".
|
|
539
|
+
pushLoopPhase("ui.transcript-replay");
|
|
540
|
+
let rows: readonly string[];
|
|
541
|
+
try {
|
|
542
|
+
rows = this.#renderReplay(width);
|
|
543
|
+
} finally {
|
|
544
|
+
popLoopPhase();
|
|
545
|
+
}
|
|
434
546
|
this.#replayPending = false;
|
|
435
547
|
if (rows.length === 0) return undefined;
|
|
436
548
|
const batch: HistoryBatch = { id: this.#nextBatchId++, rows, kind: "replay" };
|
|
@@ -455,7 +567,7 @@ export class TranscriptContainer extends Container {
|
|
|
455
567
|
const after = this.#renderStablePrefix(entry, offered.emittedEnd, width);
|
|
456
568
|
rows = after.slice(before.length);
|
|
457
569
|
} else if (offered.kind === "commit") {
|
|
458
|
-
rows = this.#renderRange(this.#frontier, offered.end, width, true);
|
|
570
|
+
rows = this.#renderRange(this.#frontier, offered.end, width, true).rows;
|
|
459
571
|
} else {
|
|
460
572
|
rows = this.#renderReplay(width);
|
|
461
573
|
}
|
|
@@ -474,58 +586,74 @@ export class TranscriptContainer extends Container {
|
|
|
474
586
|
const room = Math.max(0, Math.trunc(capacity));
|
|
475
587
|
const live = this.#liveEntries();
|
|
476
588
|
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
589
|
|
|
590
|
+
// Only a render publishes a block's stable rows, so the head renders
|
|
591
|
+
// before its progressive-append eligibility is read.
|
|
500
592
|
const head = this.#entries[this.#frontier];
|
|
501
|
-
if (
|
|
593
|
+
if (head !== undefined) this.#measuredRows(head, width);
|
|
594
|
+
const appendHead =
|
|
502
595
|
policy === "pressure" &&
|
|
503
|
-
total > room &&
|
|
504
596
|
head?.mode === "appendOnly" &&
|
|
505
597
|
!head.stableFrozen &&
|
|
506
598
|
head.state !== "committed" &&
|
|
507
599
|
head.emitted < head.stableRows.length
|
|
508
|
-
|
|
600
|
+
? head
|
|
601
|
+
: undefined;
|
|
602
|
+
|
|
603
|
+
// Measure the live tail newest-first and stop at the first block that
|
|
604
|
+
// does not fit: `keep` counts the leading blocks bound for scrollback,
|
|
605
|
+
// and everything behind them stays unmeasured. Measuring the whole live
|
|
606
|
+
// region costs one full render per block, which on a resumed session's
|
|
607
|
+
// first paint is every message it ever had (#12933). The progressive
|
|
608
|
+
// append path below needs the exact live height to size its emission,
|
|
609
|
+
// and only runs while a streaming head pins retirement.
|
|
610
|
+
let tailRows = 0;
|
|
611
|
+
let liveRows = 0;
|
|
612
|
+
let keep = 0;
|
|
613
|
+
let fits = true;
|
|
614
|
+
for (let cursor = live.length - 1; cursor >= 0; cursor--) {
|
|
615
|
+
const candidate = live[cursor]!;
|
|
616
|
+
const height = this.#liveBlockRows(candidate.entry, candidate.index, width).length;
|
|
617
|
+
if (height > 0) liveRows += height + (liveRows > 0 ? 1 : 0);
|
|
618
|
+
if (fits) {
|
|
619
|
+
const next = height > 0 ? tailRows + height + (tailRows > 0 ? 1 : 0) : tailRows;
|
|
620
|
+
if (next > room) {
|
|
621
|
+
fits = false;
|
|
622
|
+
keep = cursor + 1;
|
|
623
|
+
} else {
|
|
624
|
+
tailRows = next;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (!fits && appendHead === undefined) break;
|
|
628
|
+
}
|
|
629
|
+
const overflowing = keep > 0 || this.#liveCount() >= MAX_LIVE_BLOCKS;
|
|
630
|
+
if (policy === "pressure" && !overflowing) {
|
|
631
|
+
this.#pinnedFrontier = undefined;
|
|
632
|
+
return undefined;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (appendHead !== undefined && keep > 0) {
|
|
509
636
|
// Emit as many finished rows as the overflow needs, in one batch. A
|
|
510
637
|
// fast stream adds finished rows quicker than one per pressure cycle,
|
|
511
638
|
// and the live region has to fall back under `room` to stay readable:
|
|
512
639
|
// rows left behind here are rows dropped from the top of the viewport.
|
|
513
|
-
|
|
514
|
-
const
|
|
515
|
-
|
|
640
|
+
// `liveRows` is exact here: the append path measured every live block.
|
|
641
|
+
const overflow = liveRows - room;
|
|
642
|
+
const before = this.#renderStablePrefix(appendHead, appendHead.emitted, width);
|
|
643
|
+
let emittedEnd = appendHead.emitted;
|
|
516
644
|
let rows: readonly string[] = EMPTY_ROWS;
|
|
517
|
-
while (emittedEnd <
|
|
518
|
-
const after = this.#renderStablePrefix(
|
|
645
|
+
while (emittedEnd < appendHead.stableRows.length && rows.length < overflow) {
|
|
646
|
+
const after = this.#renderStablePrefix(appendHead, emittedEnd + 1, width);
|
|
519
647
|
if (!isRowPrefix(before, after) || after.length === before.length) {
|
|
520
|
-
if (emittedEnd ===
|
|
521
|
-
this.#freezeStableRows(
|
|
648
|
+
if (emittedEnd === appendHead.emitted) {
|
|
649
|
+
this.#freezeStableRows(appendHead, EMPTY_ROWS, "semantic row render added no suffix");
|
|
522
650
|
}
|
|
523
651
|
break;
|
|
524
652
|
}
|
|
525
653
|
rows = after.slice(before.length);
|
|
526
654
|
emittedEnd += 1;
|
|
527
655
|
}
|
|
528
|
-
if (emittedEnd >
|
|
656
|
+
if (emittedEnd > appendHead.emitted) {
|
|
529
657
|
const batch: HistoryBatch = {
|
|
530
658
|
id: this.#nextBatchId++,
|
|
531
659
|
rows,
|
|
@@ -537,31 +665,41 @@ export class TranscriptContainer extends Container {
|
|
|
537
665
|
}
|
|
538
666
|
}
|
|
539
667
|
|
|
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++;
|
|
668
|
+
// Shutdown retires everything eligible; pressure retires exactly the
|
|
669
|
+
// blocks that no longer fit, plus whatever the live-block cap demands.
|
|
670
|
+
let limit = this.#frontier + keep;
|
|
671
|
+
if (this.#liveCount() >= MAX_LIVE_BLOCKS) {
|
|
672
|
+
limit = Math.max(limit, this.#frontier + (this.#liveCount() - (MAX_LIVE_BLOCKS - 1)));
|
|
553
673
|
}
|
|
674
|
+
if (policy === "flush") limit = this.#entries.length;
|
|
675
|
+
let end = this.#frontier;
|
|
676
|
+
while (end < limit && end < this.#entries.length && this.#entries[end]!.state === "settled") end++;
|
|
554
677
|
if (end === this.#frontier) {
|
|
555
678
|
if (policy === "pressure") this.#notePinnedFrontier();
|
|
556
679
|
return undefined;
|
|
557
680
|
}
|
|
558
681
|
this.#pinnedFrontier = undefined;
|
|
682
|
+
pushLoopPhase("ui.transcript-retire");
|
|
683
|
+
let retirement: { rows: readonly string[]; end: number };
|
|
684
|
+
try {
|
|
685
|
+
// Shutdown must hand over the full prefix; a live frame stops at the
|
|
686
|
+
// budget and offers the rest on the next frames.
|
|
687
|
+
retirement = this.#renderRange(
|
|
688
|
+
this.#frontier,
|
|
689
|
+
end,
|
|
690
|
+
width,
|
|
691
|
+
true,
|
|
692
|
+
policy === "flush" ? undefined : RETIREMENT_BUDGET_MS,
|
|
693
|
+
);
|
|
694
|
+
} finally {
|
|
695
|
+
popLoopPhase();
|
|
696
|
+
}
|
|
559
697
|
const batch: HistoryBatch = {
|
|
560
698
|
id: this.#nextBatchId++,
|
|
561
|
-
rows:
|
|
699
|
+
rows: retirement.rows,
|
|
562
700
|
kind: "append",
|
|
563
701
|
};
|
|
564
|
-
this.#offered = { batch, end, kind: "commit" };
|
|
702
|
+
this.#offered = { batch, end: retirement.end, kind: "commit" };
|
|
565
703
|
return batch;
|
|
566
704
|
}
|
|
567
705
|
|
|
@@ -578,8 +716,7 @@ export class TranscriptContainer extends Container {
|
|
|
578
716
|
entry.emitted = offered.emittedEnd;
|
|
579
717
|
} else if (offered.kind === "commit") {
|
|
580
718
|
for (let index = this.#frontier; index < offered.end; index++) {
|
|
581
|
-
this.#entries[index]
|
|
582
|
-
this.#entries[index]!.emitted = 0;
|
|
719
|
+
this.#retireEntry(this.#entries[index]!);
|
|
583
720
|
}
|
|
584
721
|
this.#frontier = offered.end;
|
|
585
722
|
}
|
|
@@ -632,7 +769,7 @@ export class TranscriptContainer extends Container {
|
|
|
632
769
|
|
|
633
770
|
#renderEntry(entry: TranscriptEntry, width: number): readonly string[] {
|
|
634
771
|
const rendered = trimBlankEdges(entry.component.render(width));
|
|
635
|
-
if (entry.mode === "mutable" || entry.stableFrozen) return rendered;
|
|
772
|
+
if (entry.state === "committed" || entry.mode === "mutable" || entry.stableFrozen) return rendered;
|
|
636
773
|
const appendOnly = entry.component as Component & AppendOnlyTranscriptBlock;
|
|
637
774
|
const stable = appendOnly.getTranscriptStableRows();
|
|
638
775
|
if (!isStablePrefix(entry.stableRows, stable)) {
|
|
@@ -654,15 +791,10 @@ export class TranscriptContainer extends Container {
|
|
|
654
791
|
return this.#freezeStableRows(entry, rendered, "stable rows changed within a width epoch");
|
|
655
792
|
}
|
|
656
793
|
entry.stableRows = published;
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
|
|
661
|
-
if (
|
|
662
|
-
priorRows === undefined ||
|
|
663
|
-
priorRows.length !== stableRendered.length ||
|
|
664
|
-
!isRowPrefix(priorRows, stableRendered)
|
|
665
|
-
) {
|
|
794
|
+
// The prefix check above proves equal-length rows are byte-identical.
|
|
795
|
+
// Reuse our snapshot then; copy new rows so mutable renderer buffers
|
|
796
|
+
// cannot change the bytes checked on the next frame.
|
|
797
|
+
if (priorRender === undefined || priorRender.length !== stableRendered.length) {
|
|
666
798
|
entry.renderedStableByWidth.set(width, stableRendered.slice());
|
|
667
799
|
}
|
|
668
800
|
let perCount = entry.stableRowCountByWidth.get(width);
|
|
@@ -733,8 +865,25 @@ export class TranscriptContainer extends Container {
|
|
|
733
865
|
});
|
|
734
866
|
}
|
|
735
867
|
|
|
736
|
-
|
|
868
|
+
/**
|
|
869
|
+
* Compose entries `[start, end)` as one ordered retirement payload.
|
|
870
|
+
*
|
|
871
|
+
* `budgetMs` stops the walk after the first block that crosses it — always
|
|
872
|
+
* at least one block — and reports the index actually reached, so a single
|
|
873
|
+
* frame never renders more of a resumed ledger than it can afford (#12933).
|
|
874
|
+
* Callers that must emit a whole prefix (replay, shutdown flush, recompose
|
|
875
|
+
* of an already offered batch) omit it.
|
|
876
|
+
*/
|
|
877
|
+
#renderRange(
|
|
878
|
+
start: number,
|
|
879
|
+
end: number,
|
|
880
|
+
width: number,
|
|
881
|
+
trailingBlank: boolean,
|
|
882
|
+
budgetMs?: number,
|
|
883
|
+
): { rows: readonly string[]; end: number } {
|
|
737
884
|
const rows: string[] = [];
|
|
885
|
+
const startedAt = budgetMs === undefined ? 0 : performance.now();
|
|
886
|
+
let reached = start;
|
|
738
887
|
for (let index = start; index < end; index++) {
|
|
739
888
|
const entry = this.#entries[index]!;
|
|
740
889
|
this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
|
|
@@ -746,16 +895,19 @@ export class TranscriptContainer extends Container {
|
|
|
746
895
|
index === start ? this.#renderEntry(entry, width) : trimBlankEdges(entry.component.render(width));
|
|
747
896
|
const emittedRows = index === start ? this.#renderStablePrefix(entry, entry.emitted, width).length : 0;
|
|
748
897
|
const block = rendered.slice(emittedRows);
|
|
749
|
-
|
|
750
|
-
if (
|
|
751
|
-
|
|
898
|
+
reached = index + 1;
|
|
899
|
+
if (block.length > 0) {
|
|
900
|
+
if (rows.length > 0) rows.push("");
|
|
901
|
+
rows.push(...block);
|
|
902
|
+
}
|
|
903
|
+
if (budgetMs !== undefined && performance.now() - startedAt >= budgetMs) break;
|
|
752
904
|
}
|
|
753
905
|
if (trailingBlank && rows.length > 0) rows.push("");
|
|
754
|
-
return rows;
|
|
906
|
+
return { rows, end: reached };
|
|
755
907
|
}
|
|
756
908
|
|
|
757
909
|
#renderReplay(width: number): readonly string[] {
|
|
758
|
-
const rows = Array.from(this.#renderRange(0, this.#frontier, width, true));
|
|
910
|
+
const rows = Array.from(this.#renderRange(0, this.#frontier, width, true).rows);
|
|
759
911
|
const head = this.#entries[this.#frontier];
|
|
760
912
|
if (head?.mode === "appendOnly" && head.emitted > 0) {
|
|
761
913
|
this.#setAllocation(head.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
|
|
@@ -769,28 +921,64 @@ export class TranscriptContainer extends Container {
|
|
|
769
921
|
while (this.#frontier < this.#entries.length) {
|
|
770
922
|
const entry = this.#entries[this.#frontier]!;
|
|
771
923
|
if (entry.mode !== "appendOnly" || entry.state !== "settled") return;
|
|
772
|
-
this.#
|
|
773
|
-
const rendered = this.#renderEntry(entry, width);
|
|
924
|
+
const rendered = this.#measuredRows(entry, width);
|
|
774
925
|
if (entry.emitted !== entry.stableRows.length) return;
|
|
775
926
|
if (this.#renderStablePrefix(entry, entry.emitted, width).length !== rendered.length) return;
|
|
776
|
-
entry
|
|
777
|
-
entry.emitted = 0;
|
|
927
|
+
this.#retireEntry(entry);
|
|
778
928
|
this.#frontier++;
|
|
779
929
|
}
|
|
780
930
|
}
|
|
781
931
|
|
|
932
|
+
#retireEntry(entry: TranscriptEntry): void {
|
|
933
|
+
entry.state = "committed";
|
|
934
|
+
entry.emitted = 0;
|
|
935
|
+
entry.stableRows = EMPTY_STABLE_ROWS;
|
|
936
|
+
entry.renderedStableByWidth = new Map();
|
|
937
|
+
entry.stableRowCountByWidth = new Map();
|
|
938
|
+
}
|
|
939
|
+
|
|
782
940
|
#startReplay(): void {
|
|
783
941
|
const head = this.#entries[this.#frontier];
|
|
784
942
|
this.#replayPending = this.#frontier > 0 || (head?.mode === "appendOnly" && head.emitted > 0);
|
|
785
943
|
this.#replayRequested = false;
|
|
786
944
|
}
|
|
787
945
|
|
|
946
|
+
/**
|
|
947
|
+
* One-row-per-block fallback for a live region that cannot fit the viewport.
|
|
948
|
+
* `behind` holds the older live blocks `renderViewport` deliberately left
|
|
949
|
+
* unrendered. Only its active blocks (few) and the newest settled block
|
|
950
|
+
* offering an emergency row are rendered, so the summary count and the
|
|
951
|
+
* surviving emergency row match a full walk without rendering the ledger.
|
|
952
|
+
*/
|
|
788
953
|
#renderEmergency(
|
|
789
954
|
shown: readonly { entry: TranscriptEntry; index: number }[],
|
|
955
|
+
behind: readonly { entry: TranscriptEntry; index: number }[],
|
|
790
956
|
width: number,
|
|
791
957
|
rows: number,
|
|
792
958
|
frame: AnimationFrame,
|
|
793
959
|
): readonly string[] {
|
|
960
|
+
let hiddenBelow = 0;
|
|
961
|
+
for (const candidate of behind) {
|
|
962
|
+
if (candidate.entry.state !== "active") continue;
|
|
963
|
+
if (this.#liveBlockRows(candidate.entry, candidate.index, width).length > 0) hiddenBelow++;
|
|
964
|
+
}
|
|
965
|
+
let behindEmergency: { candidate: { entry: TranscriptEntry; index: number }; row: string } | null | undefined;
|
|
966
|
+
const findBehindEmergency = () => {
|
|
967
|
+
if (behindEmergency !== undefined) return behindEmergency;
|
|
968
|
+
behindEmergency = null;
|
|
969
|
+
for (let index = behind.length - 1; index >= 0; index--) {
|
|
970
|
+
const candidate = behind[index]!;
|
|
971
|
+
if (candidate.entry.state !== "settled") continue;
|
|
972
|
+
const block = candidate.entry.component as Component & FinalizableBlock;
|
|
973
|
+
if (block.renderTranscriptBlockEmergencyRow === undefined) continue;
|
|
974
|
+
if (this.#liveBlockRows(candidate.entry, candidate.index, width).length === 0) continue;
|
|
975
|
+
const row = block.renderTranscriptBlockEmergencyRow(width);
|
|
976
|
+
if (row === undefined) continue;
|
|
977
|
+
behindEmergency = { candidate, row };
|
|
978
|
+
break;
|
|
979
|
+
}
|
|
980
|
+
return behindEmergency;
|
|
981
|
+
};
|
|
794
982
|
let visibleRows = rows;
|
|
795
983
|
let visible: { entry: TranscriptEntry; index: number }[] = [];
|
|
796
984
|
let emergencyCandidate: { entry: TranscriptEntry; index: number } | undefined;
|
|
@@ -812,8 +1000,16 @@ export class TranscriptContainer extends Container {
|
|
|
812
1000
|
visible = [candidate, ...visible.slice(1)];
|
|
813
1001
|
break;
|
|
814
1002
|
}
|
|
1003
|
+
if (emergencyCandidate === undefined) {
|
|
1004
|
+
const found = findBehindEmergency();
|
|
1005
|
+
if (found !== null) {
|
|
1006
|
+
emergencyCandidate = found.candidate;
|
|
1007
|
+
emergencyRow = found.row;
|
|
1008
|
+
visible = [found.candidate, ...visible.slice(1)];
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
815
1011
|
|
|
816
|
-
let activeTotal =
|
|
1012
|
+
let activeTotal = hiddenBelow;
|
|
817
1013
|
for (const candidate of shown) {
|
|
818
1014
|
if (candidate.entry.state === "active") activeTotal++;
|
|
819
1015
|
}
|
package/src/components/editor.ts
CHANGED
|
@@ -580,6 +580,8 @@ export class Editor implements Component, Focusable {
|
|
|
580
580
|
#wrapCache = new Map<string, WrapEntry>();
|
|
581
581
|
#wrapCacheWidth = -1;
|
|
582
582
|
#wrapCacheEpoch = -1;
|
|
583
|
+
/** Last `#getPromptGutter` result, keyed by (gutter string, clamped gutter width). */
|
|
584
|
+
#promptGutterCache: { source: string; firstLine: string; continuation: string; width: number } | undefined;
|
|
583
585
|
#paddingXOverride: number | undefined;
|
|
584
586
|
#maxHeight?: number;
|
|
585
587
|
#scrollOffset: number = 0;
|
|
@@ -1075,11 +1077,16 @@ export class Editor implements Component, Focusable {
|
|
|
1075
1077
|
if (!gutter) return undefined;
|
|
1076
1078
|
const gutterWidth = this.#getPromptGutterWidth(width, paddingX);
|
|
1077
1079
|
if (gutterWidth === 0) return undefined;
|
|
1078
|
-
|
|
1080
|
+
const cached = this.#promptGutterCache;
|
|
1081
|
+
if (cached !== undefined && cached.source === gutter && cached.width === gutterWidth) return cached;
|
|
1082
|
+
const next = {
|
|
1083
|
+
source: gutter,
|
|
1079
1084
|
firstLine: sliceByColumn(gutter, 0, gutterWidth, true),
|
|
1080
1085
|
continuation: padding(gutterWidth),
|
|
1081
1086
|
width: gutterWidth,
|
|
1082
1087
|
};
|
|
1088
|
+
this.#promptGutterCache = next;
|
|
1089
|
+
return next;
|
|
1083
1090
|
}
|
|
1084
1091
|
|
|
1085
1092
|
#getContentWidth(width: number, paddingX: number): number {
|
|
@@ -1682,9 +1689,16 @@ export class Editor implements Component, Focusable {
|
|
|
1682
1689
|
kb.matchesCanonical(canonical, "tui.select.pageUp") ||
|
|
1683
1690
|
kb.matchesCanonical(canonical, "tui.select.pageDown")
|
|
1684
1691
|
) {
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1692
|
+
// An `@` popup whose narrowing filter matched nothing holds no candidate;
|
|
1693
|
+
// let the key fall through instead of swallowing it.
|
|
1694
|
+
if (!this.#autocompleteList.getSelectedItem()) {
|
|
1695
|
+
this.#cancelAutocomplete();
|
|
1696
|
+
this.onAutocompleteUpdate?.();
|
|
1697
|
+
} else {
|
|
1698
|
+
this.#autocompleteList.handleInput(data);
|
|
1699
|
+
this.onAutocompleteUpdate?.();
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1688
1702
|
}
|
|
1689
1703
|
|
|
1690
1704
|
// If Tab was pressed, always apply the selection
|
|
@@ -1699,7 +1713,14 @@ export class Editor implements Component, Focusable {
|
|
|
1699
1713
|
this.#cancelAutocomplete();
|
|
1700
1714
|
return;
|
|
1701
1715
|
}
|
|
1702
|
-
if (selected
|
|
1716
|
+
if (!selected) {
|
|
1717
|
+
// An `@` popup whose narrowing filter matched nothing stays open with no
|
|
1718
|
+
// candidate (see #debouncedUpdateAutocomplete). Nothing to accept: cancel the
|
|
1719
|
+
// popup and fall through so Tab keeps its normal completion role and a right
|
|
1720
|
+
// arrow at end of line moves the cursor.
|
|
1721
|
+
this.#cancelAutocomplete();
|
|
1722
|
+
this.onAutocompleteUpdate?.();
|
|
1723
|
+
} else if (this.#autocompleteProvider) {
|
|
1703
1724
|
const shouldChainAutocomplete =
|
|
1704
1725
|
this.#isSlashCommandNameAutocompleteSelection() || isDirectoryCompletionValue(selected.value);
|
|
1705
1726
|
const result = this.#autocompleteProvider.applyCompletion(
|
|
@@ -1725,7 +1746,8 @@ export class Editor implements Component, Focusable {
|
|
|
1725
1746
|
queueMicrotask(() => void this.#tryTriggerAutocomplete());
|
|
1726
1747
|
}
|
|
1727
1748
|
}
|
|
1728
|
-
|
|
1749
|
+
// Only an accepted candidate consumes the key; an empty list falls through.
|
|
1750
|
+
if (selected) return;
|
|
1729
1751
|
}
|
|
1730
1752
|
|
|
1731
1753
|
// If Enter was pressed on a submitted slash command (not an absolute-path
|