@oh-my-pi/pi-tui 18.4.1 → 18.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/dist/types/chat/thinking-display.d.ts +2 -0
- package/dist/types/chrome/transcript-container.d.ts +15 -1
- package/dist/types/components/editor.d.ts +5 -0
- package/dist/types/loop-watchdog.d.ts +9 -0
- package/dist/types/overlays/model-browser.d.ts +15 -0
- package/dist/types/overlays/model-picker.d.ts +2 -1
- package/dist/types/overlays/rewind-selector.d.ts +2 -1
- package/dist/types/prompt/composer-attachments.d.ts +7 -0
- package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
- package/dist/types/render/width-aware-text.d.ts +6 -0
- package/dist/types/setup/scenes/sign-in.d.ts +9 -9
- package/dist/types/setup/scenes/types.d.ts +0 -26
- package/dist/types/stdin-buffer.d.ts +10 -7
- package/dist/types/terminal.d.ts +16 -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/web-search-types.d.ts +90 -0
- package/dist/types/tools/web-search.d.ts +1 -168
- package/dist/types/tui.d.ts +3 -2
- package/package.json +9 -9
- package/src/chat/assistant-message.ts +192 -40
- package/src/chat/thinking-display.ts +73 -11
- package/src/chat/tool-execution.ts +24 -4
- package/src/chrome/transcript-container.ts +178 -31
- package/src/components/editor.ts +51 -15
- package/src/components/image.ts +15 -7
- package/src/components/loader.ts +37 -17
- package/src/components/markdown.ts +110 -56
- package/src/loop-watchdog.ts +38 -10
- package/src/overlays/agent-hub.ts +16 -6
- package/src/overlays/copy-selector.ts +5 -0
- package/src/overlays/model-browser.ts +106 -25
- package/src/overlays/model-picker.ts +9 -6
- package/src/overlays/rewind-selector.ts +10 -2
- package/src/overlays/settings-selector.ts +2 -10
- package/src/prompt/composer-attachments.ts +54 -7
- package/src/prompt/composer.ts +5 -2
- package/src/prompt/custom-editor.ts +24 -6
- package/src/prompt/model-mention-autocomplete.ts +22 -10
- package/src/render/width-aware-text.ts +9 -0
- package/src/setup/scenes/sign-in.ts +17 -12
- package/src/setup/scenes/types.ts +0 -27
- package/src/setup/wizard.ts +1 -1
- package/src/status-line/component.ts +229 -134
- package/src/status-line/segments.ts +34 -29
- package/src/stdin-buffer.ts +27 -21
- package/src/terminal.ts +60 -20
- package/src/theme/color.ts +15 -0
- package/src/theme/mermaid-cache.ts +10 -1
- 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/theme/tui-adapters.ts +14 -1
- package/src/tools/bash.ts +53 -13
- package/src/tools/edit.ts +110 -29
- package/src/tools/streaming-output.ts +79 -16
- package/src/tools/web-search-types.ts +99 -0
- package/src/tools/web-search.ts +1 -172
- package/src/tui.ts +143 -113
- package/src/utils.ts +15 -0
- package/dist/types/setup/scenes/providers.d.ts +0 -3
- package/dist/types/setup/scenes/web-search.d.ts +0 -22
- package/src/setup/scenes/providers.ts +0 -110
- package/src/setup/scenes/web-search.ts +0 -165
|
@@ -285,6 +285,14 @@ export class ToolExecutionComponent extends Container {
|
|
|
285
285
|
// so a terminal resize re-shapes image-bearing results to rescale them without
|
|
286
286
|
// forcing the common image-free result to re-shape on every resize tick.
|
|
287
287
|
#renderedImageCount = 0;
|
|
288
|
+
// `stateBgKey|themeEpoch` of the tint last handed to #contentText. Re-tinting
|
|
289
|
+
// drops its wrap cache, so a rebuild whose tint is unchanged skips it and the
|
|
290
|
+
// inner Text re-wraps only when the reformatted card text actually differs.
|
|
291
|
+
#contentTextBgKey: string | undefined;
|
|
292
|
+
// Memoized #getTextOutput(), keyed by every input it reads: the result
|
|
293
|
+
// (versioned by #resultVersion), #showImages, and the image protocol.
|
|
294
|
+
#textOutput = "";
|
|
295
|
+
#textOutputKey: string | undefined;
|
|
288
296
|
#tool?: AgentTool;
|
|
289
297
|
#renderer?: ToolRenderer;
|
|
290
298
|
#ui: ToolExecutionUi;
|
|
@@ -1200,8 +1208,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
1200
1208
|
// Generic fallback (no custom/built-in renderer). WidthAwareText
|
|
1201
1209
|
// reformats at render time so output fills the actual terminal width
|
|
1202
1210
|
// instead of a fixed column cap.
|
|
1203
|
-
this.#
|
|
1204
|
-
this.#contentText.invalidate();
|
|
1211
|
+
this.#refreshContentText(stateBgKey, stateBgFn);
|
|
1205
1212
|
}
|
|
1206
1213
|
|
|
1207
1214
|
// Handle images (same for both custom and built-in)
|
|
@@ -1339,6 +1346,8 @@ export class ToolExecutionComponent extends Container {
|
|
|
1339
1346
|
|
|
1340
1347
|
#getTextOutput(): string {
|
|
1341
1348
|
if (!this.#result) return "";
|
|
1349
|
+
const key = `${this.#resultVersion}|${this.#showImages}|${TERMINAL.imageProtocol ?? "-"}`;
|
|
1350
|
+
if (key === this.#textOutputKey) return this.#textOutput;
|
|
1342
1351
|
|
|
1343
1352
|
const textBlocks = this.#result.content.filter(c => c.type === "text");
|
|
1344
1353
|
const imageBlocks = this.#getAllImageBlocks();
|
|
@@ -1360,9 +1369,21 @@ export class ToolExecutionComponent extends Container {
|
|
|
1360
1369
|
output = output ? `${output}\n${imageIndicators}` : imageIndicators;
|
|
1361
1370
|
}
|
|
1362
1371
|
|
|
1372
|
+
this.#textOutputKey = key;
|
|
1373
|
+
this.#textOutput = output;
|
|
1363
1374
|
return output;
|
|
1364
1375
|
}
|
|
1365
1376
|
|
|
1377
|
+
/** Re-tint (only when the tint changed) and reformat the generic #contentText card. */
|
|
1378
|
+
#refreshContentText(stateBgKey: string, stateBgFn: (text: string) => string): void {
|
|
1379
|
+
const bgKey = `${stateBgKey}|${getThemeEpoch()}`;
|
|
1380
|
+
if (bgKey !== this.#contentTextBgKey) {
|
|
1381
|
+
this.#contentTextBgKey = bgKey;
|
|
1382
|
+
this.#contentText.setCustomBgFn(stateBgFn);
|
|
1383
|
+
}
|
|
1384
|
+
this.#contentText.reformat();
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1366
1387
|
/**
|
|
1367
1388
|
* Format the generic call/result card at `contentWidth`. Shared by the
|
|
1368
1389
|
* #contentText fallback and the benign-skip path so both render identically.
|
|
@@ -1406,8 +1427,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
1406
1427
|
*/
|
|
1407
1428
|
#renderBenignSkipCard(stateBgFn: (text: string) => string): void {
|
|
1408
1429
|
if (!this.#usesContentBox) {
|
|
1409
|
-
this.#
|
|
1410
|
-
this.#contentText.invalidate();
|
|
1430
|
+
this.#refreshContentText("toolPendingBg", stateBgFn);
|
|
1411
1431
|
return;
|
|
1412
1432
|
}
|
|
1413
1433
|
for (const box of this.#multiFileBoxes) {
|
|
@@ -93,6 +93,12 @@ type Offered =
|
|
|
93
93
|
| { batch: HistoryBatch; kind: "commit"; end: number }
|
|
94
94
|
| { batch: HistoryBatch; kind: "replay" };
|
|
95
95
|
|
|
96
|
+
/** Rows a progressive-append retirement offers, and the stable count they bring the head to. */
|
|
97
|
+
interface AppendBatch {
|
|
98
|
+
rows: readonly string[];
|
|
99
|
+
emittedEnd: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
96
102
|
const MAX_LIVE_BLOCKS = 256;
|
|
97
103
|
/** Grace before a pressure-blocked frontier is reported; a streaming block may legitimately hold it briefly. */
|
|
98
104
|
const PINNED_FRONTIER_WARN_MS = 30_000;
|
|
@@ -174,6 +180,25 @@ export class TranscriptContainer extends Container {
|
|
|
174
180
|
#pinnedFrontier: { index: number; since: number; logged: boolean } | undefined;
|
|
175
181
|
/** Block spans of the last `renderViewport` output, for click hit-testing. */
|
|
176
182
|
#lastViewportSpans: TranscriptViewportSpan[] = [];
|
|
183
|
+
/**
|
|
184
|
+
* The composed frame {@link beginFrame} opened; `undefined` outside one.
|
|
185
|
+
* {@link renderViewport} closes it, so it never outlives the synchronous
|
|
186
|
+
* composition that opened it.
|
|
187
|
+
*/
|
|
188
|
+
#openFrame: AnimationFrame | undefined;
|
|
189
|
+
/**
|
|
190
|
+
* Full-allocation blank-trimmed renders of the blocks measured during the
|
|
191
|
+
* open frame, keyed by entry at {@link #frameRowsWidth}. A retirement peek
|
|
192
|
+
* and the viewport measure the same live blocks back to back inside one
|
|
193
|
+
* composition, with no block mutation possible in between; replaying the
|
|
194
|
+
* first measurement spares every block its second render per frame.
|
|
195
|
+
*/
|
|
196
|
+
#frameRows = new Map<TranscriptEntry, readonly string[]>();
|
|
197
|
+
#frameRowsWidth = 0;
|
|
198
|
+
/** The `children` array `#entries` last mirrored; see {@link #syncEntries}. */
|
|
199
|
+
#syncedChildren: Component[] | undefined;
|
|
200
|
+
/** Forces the next {@link #syncEntries} to compare every entry, not just the live tail. */
|
|
201
|
+
#entriesUnverified = false;
|
|
177
202
|
override addChild(component: Component): void {
|
|
178
203
|
if (isToolActivityComponent(component)) component.setToolActivityVisible(this.#toolActivityVisible);
|
|
179
204
|
super.addChild(component);
|
|
@@ -187,6 +212,9 @@ export class TranscriptContainer extends Container {
|
|
|
187
212
|
emitted: 0,
|
|
188
213
|
stableFrozen: false,
|
|
189
214
|
});
|
|
215
|
+
// Callers may splice a just-added block into place (insert after an
|
|
216
|
+
// anchor); re-check the whole list once instead of trusting positions.
|
|
217
|
+
this.#entriesUnverified = true;
|
|
190
218
|
}
|
|
191
219
|
|
|
192
220
|
override removeChild(component: Component): void {
|
|
@@ -300,6 +328,21 @@ export class TranscriptContainer extends Container {
|
|
|
300
328
|
this.#replayRequested = false;
|
|
301
329
|
}
|
|
302
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Open one composed frame: every live-block measurement until this frame's
|
|
333
|
+
* {@link renderViewport} returns renders against `frame` and is taken once,
|
|
334
|
+
* so the retirement peek and the viewport share each block's render.
|
|
335
|
+
*
|
|
336
|
+
* Callers MUST call {@link renderViewport} with the same `frame` in the same
|
|
337
|
+
* synchronous composition, without mutating any transcript block in
|
|
338
|
+
* between: the shared rows are only as fresh as that first measurement.
|
|
339
|
+
*/
|
|
340
|
+
beginFrame(frame: AnimationFrame): void {
|
|
341
|
+
this.#lastFrame = frame;
|
|
342
|
+
this.#frameRows.clear();
|
|
343
|
+
this.#openFrame = frame;
|
|
344
|
+
}
|
|
345
|
+
|
|
303
346
|
/**
|
|
304
347
|
* Total rows the live, un-emitted tail occupies at `width`.
|
|
305
348
|
*
|
|
@@ -322,8 +365,33 @@ export class TranscriptContainer extends Container {
|
|
|
322
365
|
|
|
323
366
|
/** One live block's un-emitted rows at `width`, rendered against its full-height allocation. */
|
|
324
367
|
#liveBlockRows(entry: TranscriptEntry, index: number, width: number): readonly string[] {
|
|
368
|
+
const rows = this.#measuredRows(entry, width);
|
|
369
|
+
const emitted = this.#projectedEmittedRowCount(entry, index, width);
|
|
370
|
+
return emitted === 0 ? rows : rows.slice(emitted);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* One block's blank-trimmed render at its full-height allocation. Inside an
|
|
375
|
+
* open frame the first measurement of each block is replayed to later ones.
|
|
376
|
+
*/
|
|
377
|
+
#measuredRows(entry: TranscriptEntry, width: number): readonly string[] {
|
|
325
378
|
this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
|
|
326
|
-
|
|
379
|
+
if (this.#openFrame === undefined) return this.#renderEntry(entry, width);
|
|
380
|
+
if (this.#frameRowsWidth !== width) {
|
|
381
|
+
this.#frameRows.clear();
|
|
382
|
+
this.#frameRowsWidth = width;
|
|
383
|
+
}
|
|
384
|
+
let rows = this.#frameRows.get(entry);
|
|
385
|
+
if (rows === undefined) {
|
|
386
|
+
rows = this.#renderEntry(entry, width);
|
|
387
|
+
this.#frameRows.set(entry, rows);
|
|
388
|
+
}
|
|
389
|
+
return rows;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
#closeFrame(): void {
|
|
393
|
+
this.#openFrame = undefined;
|
|
394
|
+
this.#frameRows.clear();
|
|
327
395
|
}
|
|
328
396
|
|
|
329
397
|
/** Block spans of the last `renderViewport` output, in output coordinates. Empty when the tail is empty. */
|
|
@@ -349,9 +417,22 @@ export class TranscriptContainer extends Container {
|
|
|
349
417
|
this.#lastViewportSpans = spans;
|
|
350
418
|
}
|
|
351
419
|
|
|
352
|
-
/**
|
|
420
|
+
/**
|
|
421
|
+
* Render the live tail, constrained to the supplied transcript height.
|
|
422
|
+
* Closes the frame {@link beginFrame} opened; a different `frame` discards
|
|
423
|
+
* its measurements first.
|
|
424
|
+
*/
|
|
353
425
|
renderViewport(width: number, rows: number, frame: AnimationFrame): readonly string[] {
|
|
426
|
+
if (frame !== this.#openFrame) this.#closeFrame();
|
|
354
427
|
this.#lastFrame = frame;
|
|
428
|
+
try {
|
|
429
|
+
return this.#composeViewport(width, rows, frame);
|
|
430
|
+
} finally {
|
|
431
|
+
this.#closeFrame();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
#composeViewport(width: number, rows: number, frame: AnimationFrame): readonly string[] {
|
|
355
436
|
this.#syncEntries();
|
|
356
437
|
this.#settleFinalized();
|
|
357
438
|
const live = this.#liveEntries();
|
|
@@ -522,7 +603,7 @@ export class TranscriptContainer extends Container {
|
|
|
522
603
|
// Only a render publishes a block's stable rows, so the head renders
|
|
523
604
|
// before its progressive-append eligibility is read.
|
|
524
605
|
const head = this.#entries[this.#frontier];
|
|
525
|
-
if (head !== undefined) this.#
|
|
606
|
+
if (head !== undefined) this.#measuredRows(head, width);
|
|
526
607
|
const appendHead =
|
|
527
608
|
policy === "pressure" &&
|
|
528
609
|
head?.mode === "appendOnly" &&
|
|
@@ -571,20 +652,9 @@ export class TranscriptContainer extends Container {
|
|
|
571
652
|
// rows left behind here are rows dropped from the top of the viewport.
|
|
572
653
|
// `liveRows` is exact here: the append path measured every live block.
|
|
573
654
|
const overflow = liveRows - room;
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
while (emittedEnd < appendHead.stableRows.length && rows.length < overflow) {
|
|
578
|
-
const after = this.#renderStablePrefix(appendHead, emittedEnd + 1, width);
|
|
579
|
-
if (!isRowPrefix(before, after) || after.length === before.length) {
|
|
580
|
-
if (emittedEnd === appendHead.emitted) {
|
|
581
|
-
this.#freezeStableRows(appendHead, EMPTY_ROWS, "semantic row render added no suffix");
|
|
582
|
-
}
|
|
583
|
-
break;
|
|
584
|
-
}
|
|
585
|
-
rows = after.slice(before.length);
|
|
586
|
-
emittedEnd += 1;
|
|
587
|
-
}
|
|
655
|
+
const { rows, emittedEnd } =
|
|
656
|
+
this.#measuredAppendBatch(appendHead, width, overflow) ??
|
|
657
|
+
this.#renderedAppendBatch(appendHead, width, overflow);
|
|
588
658
|
if (emittedEnd > appendHead.emitted) {
|
|
589
659
|
const batch: HistoryBatch = {
|
|
590
660
|
id: this.#nextBatchId++,
|
|
@@ -723,15 +793,10 @@ export class TranscriptContainer extends Container {
|
|
|
723
793
|
return this.#freezeStableRows(entry, rendered, "stable rows changed within a width epoch");
|
|
724
794
|
}
|
|
725
795
|
entry.stableRows = published;
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
|
|
730
|
-
if (
|
|
731
|
-
priorRows === undefined ||
|
|
732
|
-
priorRows.length !== stableRendered.length ||
|
|
733
|
-
!isRowPrefix(priorRows, stableRendered)
|
|
734
|
-
) {
|
|
796
|
+
// The prefix check above proves equal-length rows are byte-identical.
|
|
797
|
+
// Reuse our snapshot then; copy new rows so mutable renderer buffers
|
|
798
|
+
// cannot change the bytes checked on the next frame.
|
|
799
|
+
if (priorRender === undefined || priorRender.length !== stableRendered.length) {
|
|
735
800
|
entry.renderedStableByWidth.set(width, stableRendered.slice());
|
|
736
801
|
}
|
|
737
802
|
let perCount = entry.stableRowCountByWidth.get(width);
|
|
@@ -777,6 +842,64 @@ export class TranscriptContainer extends Container {
|
|
|
777
842
|
if (memo !== undefined) return memo;
|
|
778
843
|
return this.#renderStablePrefix(entry, count, width).length;
|
|
779
844
|
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Size a progressive-append batch from the stable renders `#renderEntry`
|
|
848
|
+
* recorded at this width, without rendering any prefix. Each recorded count
|
|
849
|
+
* was checked to render as a byte prefix of every later one, and
|
|
850
|
+
* `renderedStableByWidth` holds the newest, so each count's rows are a
|
|
851
|
+
* prefix of it and the batch is one slice. Undefined when a count the walk
|
|
852
|
+
* needs was never recorded here (published between renders, or before a
|
|
853
|
+
* resize); the rendered walk then decides.
|
|
854
|
+
*/
|
|
855
|
+
#measuredAppendBatch(entry: TranscriptEntry, width: number, overflow: number): AppendBatch | undefined {
|
|
856
|
+
const counts = entry.stableRowCountByWidth.get(width);
|
|
857
|
+
const newest = entry.renderedStableByWidth.get(width);
|
|
858
|
+
const target = entry.stableRows.length;
|
|
859
|
+
if (counts === undefined || newest === undefined || counts.get(target) !== newest.length) return undefined;
|
|
860
|
+
const start = entry.emitted === 0 ? 0 : counts.get(entry.emitted);
|
|
861
|
+
if (start === undefined) return undefined;
|
|
862
|
+
let emittedEnd = entry.emitted;
|
|
863
|
+
let end = start;
|
|
864
|
+
while (emittedEnd < target && end - start < overflow) {
|
|
865
|
+
const next = counts.get(emittedEnd + 1);
|
|
866
|
+
if (next === undefined) return undefined;
|
|
867
|
+
if (next <= start) {
|
|
868
|
+
if (emittedEnd === entry.emitted) {
|
|
869
|
+
this.#freezeStableRows(entry, EMPTY_ROWS, "semantic row render added no suffix");
|
|
870
|
+
}
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
end = next;
|
|
874
|
+
emittedEnd += 1;
|
|
875
|
+
}
|
|
876
|
+
return { rows: emittedEnd > entry.emitted ? newest.slice(start, end) : EMPTY_ROWS, emittedEnd };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Size a progressive-append batch by rendering each further stable prefix:
|
|
881
|
+
* extend the emitted prefix until its new rows cover `overflow`, stopping
|
|
882
|
+
* at the first prefix that adds no row or stops extending the emitted one
|
|
883
|
+
* (freezing the block when that is the very next prefix).
|
|
884
|
+
*/
|
|
885
|
+
#renderedAppendBatch(entry: TranscriptEntry, width: number, overflow: number): AppendBatch {
|
|
886
|
+
const before = this.#renderStablePrefix(entry, entry.emitted, width);
|
|
887
|
+
let emittedEnd = entry.emitted;
|
|
888
|
+
let after = before;
|
|
889
|
+
while (emittedEnd < entry.stableRows.length && after.length - before.length < overflow) {
|
|
890
|
+
const next = this.#renderStablePrefix(entry, emittedEnd + 1, width);
|
|
891
|
+
if (!isRowPrefix(before, next) || next.length === before.length) {
|
|
892
|
+
if (emittedEnd === entry.emitted) {
|
|
893
|
+
this.#freezeStableRows(entry, EMPTY_ROWS, "semantic row render added no suffix");
|
|
894
|
+
}
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
after = next;
|
|
898
|
+
emittedEnd += 1;
|
|
899
|
+
}
|
|
900
|
+
return { rows: emittedEnd > entry.emitted ? after.slice(before.length) : EMPTY_ROWS, emittedEnd };
|
|
901
|
+
}
|
|
902
|
+
|
|
780
903
|
/**
|
|
781
904
|
* Record that pressure retirement is blocked behind a not-yet-settled
|
|
782
905
|
* frontier block, and log its identity once the episode outlives the grace
|
|
@@ -858,8 +981,7 @@ export class TranscriptContainer extends Container {
|
|
|
858
981
|
while (this.#frontier < this.#entries.length) {
|
|
859
982
|
const entry = this.#entries[this.#frontier]!;
|
|
860
983
|
if (entry.mode !== "appendOnly" || entry.state !== "settled") return;
|
|
861
|
-
this.#
|
|
862
|
-
const rendered = this.#renderEntry(entry, width);
|
|
984
|
+
const rendered = this.#measuredRows(entry, width);
|
|
863
985
|
if (entry.emitted !== entry.stableRows.length) return;
|
|
864
986
|
if (this.#renderStablePrefix(entry, entry.emitted, width).length !== rendered.length) return;
|
|
865
987
|
this.#retireEntry(entry);
|
|
@@ -1007,12 +1129,27 @@ export class TranscriptContainer extends Container {
|
|
|
1007
1129
|
return this.#entries.length - this.#frontier;
|
|
1008
1130
|
}
|
|
1009
1131
|
|
|
1132
|
+
/**
|
|
1133
|
+
* Mirror `children` into `#entries`. The container's own add/remove/clear
|
|
1134
|
+
* keep the two aligned, so the per-frame check stays off the committed
|
|
1135
|
+
* ledger: external edits to the public `children` array are caught by array
|
|
1136
|
+
* identity (replacement), length (push, removing splices), and an identity
|
|
1137
|
+
* scan of the live tail (in-place reorders and index writes). Committed
|
|
1138
|
+
* blocks are immutable history nothing reorders, and the scan skipping
|
|
1139
|
+
* them keeps this proportional to the live tail rather than the session.
|
|
1140
|
+
*/
|
|
1010
1141
|
#syncEntries(): void {
|
|
1142
|
+
const children = this.children;
|
|
1011
1143
|
if (
|
|
1012
|
-
this.#
|
|
1013
|
-
|
|
1014
|
-
|
|
1144
|
+
!this.#entriesUnverified &&
|
|
1145
|
+
children === this.#syncedChildren &&
|
|
1146
|
+
this.#entriesMatch(children, this.#frontier)
|
|
1147
|
+
) {
|
|
1015
1148
|
return;
|
|
1149
|
+
}
|
|
1150
|
+
this.#entriesUnverified = false;
|
|
1151
|
+
this.#syncedChildren = children;
|
|
1152
|
+
if (this.#entriesMatch(children, 0)) return;
|
|
1016
1153
|
const existing = new Map(this.#entries.map(entry => [entry.component, entry]));
|
|
1017
1154
|
this.#entries = this.children.map(
|
|
1018
1155
|
component =>
|
|
@@ -1030,6 +1167,16 @@ export class TranscriptContainer extends Container {
|
|
|
1030
1167
|
this.#frontier = this.#entries.findIndex(entry => entry.state !== "committed");
|
|
1031
1168
|
if (this.#frontier < 0) this.#frontier = this.#entries.length;
|
|
1032
1169
|
}
|
|
1170
|
+
|
|
1171
|
+
/** Whether `#entries` has `children`'s length and components from `start` on. */
|
|
1172
|
+
#entriesMatch(children: readonly Component[], start: number): boolean {
|
|
1173
|
+
const entries = this.#entries;
|
|
1174
|
+
if (entries.length !== children.length) return false;
|
|
1175
|
+
for (let index = start; index < entries.length; index++) {
|
|
1176
|
+
if (entries[index]!.component !== children[index]) return false;
|
|
1177
|
+
}
|
|
1178
|
+
return true;
|
|
1179
|
+
}
|
|
1033
1180
|
}
|
|
1034
1181
|
|
|
1035
1182
|
/** Groups sibling rows into one conservative mutable semantic transcript block. */
|
package/src/components/editor.ts
CHANGED
|
@@ -63,12 +63,12 @@ const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
|
63
63
|
/**
|
|
64
64
|
* `@` file lists are narrowed in place (`setFilter(liveToken)`) while a fresh
|
|
65
65
|
* search runs, so a slow walk never leaves entries that contradict the typed
|
|
66
|
-
* token on screen. An emptied list means the refresh is still pending
|
|
66
|
+
* token on screen. An emptied list means the refresh is still pending; the
|
|
67
|
+
* popup stays open but renders nothing until results arrive.
|
|
67
68
|
*/
|
|
68
69
|
const AT_FILE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
69
70
|
...AUTOCOMPLETE_SELECT_LIST_LAYOUT,
|
|
70
71
|
filterItems: (items, token) => items.filter(item => atCompletionMatches(token, item.value)),
|
|
71
|
-
noMatchText: "Searching…",
|
|
72
72
|
};
|
|
73
73
|
|
|
74
74
|
const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
@@ -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 {
|
|
@@ -1532,15 +1539,15 @@ export class Editor implements Component, Focusable {
|
|
|
1532
1539
|
if (bottomRow !== undefined) result.push(bottomRow);
|
|
1533
1540
|
|
|
1534
1541
|
// Add autocomplete list if active
|
|
1535
|
-
|
|
1542
|
+
const autocompleteList = this.#visibleAutocompleteList();
|
|
1543
|
+
if (autocompleteList) {
|
|
1536
1544
|
// Clamp the dropdown to the terminal viewport: the editor rows already
|
|
1537
1545
|
// rendered above plus a small reserve must stay visible.
|
|
1538
1546
|
const viewportRows = this.viewportRowsProvider?.() || process.stdout.rows || Number(Bun.env.LINES) || 24;
|
|
1539
|
-
|
|
1547
|
+
autocompleteList.setMaxVisible(
|
|
1540
1548
|
Math.max(3, Math.min(this.#autocompleteMaxVisible, viewportRows - result.length - 2)),
|
|
1541
1549
|
);
|
|
1542
|
-
|
|
1543
|
-
result.push(...autocompleteResult);
|
|
1550
|
+
result.push(...autocompleteList.render(width));
|
|
1544
1551
|
}
|
|
1545
1552
|
|
|
1546
1553
|
return result;
|
|
@@ -1644,10 +1651,13 @@ export class Editor implements Component, Focusable {
|
|
|
1644
1651
|
|
|
1645
1652
|
// Handle autocomplete special keys first (but don't block other input)
|
|
1646
1653
|
if (this.#autocompleteState && this.#autocompleteList) {
|
|
1647
|
-
// Escape - cancel autocomplete
|
|
1654
|
+
// Escape - cancel autocomplete. A hidden popup (empty narrowed `@` list) is
|
|
1655
|
+
// dropped too, so its pending refresh cannot pop up afterward, but the key
|
|
1656
|
+
// falls through: the user never saw anything to dismiss.
|
|
1648
1657
|
if (kb.matchesCanonical(canonical, "tui.select.cancel")) {
|
|
1658
|
+
const visible = this.isShowingAutocomplete();
|
|
1649
1659
|
this.#cancelAutocomplete(true);
|
|
1650
|
-
return;
|
|
1660
|
+
if (visible) return;
|
|
1651
1661
|
}
|
|
1652
1662
|
// Right arrow at end of line accepts the selection like Tab (fish-style).
|
|
1653
1663
|
// Mid-line, right arrow keeps its cursor-movement role and falls through.
|
|
@@ -1682,9 +1692,16 @@ export class Editor implements Component, Focusable {
|
|
|
1682
1692
|
kb.matchesCanonical(canonical, "tui.select.pageUp") ||
|
|
1683
1693
|
kb.matchesCanonical(canonical, "tui.select.pageDown")
|
|
1684
1694
|
) {
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1695
|
+
// An `@` popup whose narrowing filter matched nothing holds no candidate;
|
|
1696
|
+
// let the key fall through instead of swallowing it.
|
|
1697
|
+
if (!this.#autocompleteList.getSelectedItem()) {
|
|
1698
|
+
this.#cancelAutocomplete();
|
|
1699
|
+
this.onAutocompleteUpdate?.();
|
|
1700
|
+
} else {
|
|
1701
|
+
this.#autocompleteList.handleInput(data);
|
|
1702
|
+
this.onAutocompleteUpdate?.();
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1688
1705
|
}
|
|
1689
1706
|
|
|
1690
1707
|
// If Tab was pressed, always apply the selection
|
|
@@ -1699,7 +1716,14 @@ export class Editor implements Component, Focusable {
|
|
|
1699
1716
|
this.#cancelAutocomplete();
|
|
1700
1717
|
return;
|
|
1701
1718
|
}
|
|
1702
|
-
if (selected
|
|
1719
|
+
if (!selected) {
|
|
1720
|
+
// An `@` popup whose narrowing filter matched nothing stays open with no
|
|
1721
|
+
// candidate (see #debouncedUpdateAutocomplete). Nothing to accept: cancel the
|
|
1722
|
+
// popup and fall through so Tab keeps its normal completion role and a right
|
|
1723
|
+
// arrow at end of line moves the cursor.
|
|
1724
|
+
this.#cancelAutocomplete();
|
|
1725
|
+
this.onAutocompleteUpdate?.();
|
|
1726
|
+
} else if (this.#autocompleteProvider) {
|
|
1703
1727
|
const shouldChainAutocomplete =
|
|
1704
1728
|
this.#isSlashCommandNameAutocompleteSelection() || isDirectoryCompletionValue(selected.value);
|
|
1705
1729
|
const result = this.#autocompleteProvider.applyCompletion(
|
|
@@ -1725,7 +1749,8 @@ export class Editor implements Component, Focusable {
|
|
|
1725
1749
|
queueMicrotask(() => void this.#tryTriggerAutocomplete());
|
|
1726
1750
|
}
|
|
1727
1751
|
}
|
|
1728
|
-
|
|
1752
|
+
// Only an accepted candidate consumes the key; an empty list falls through.
|
|
1753
|
+
if (selected) return;
|
|
1729
1754
|
}
|
|
1730
1755
|
|
|
1731
1756
|
// If Enter was pressed on a submitted slash command (not an absolute-path
|
|
@@ -4224,8 +4249,19 @@ export class Editor implements Component, Focusable {
|
|
|
4224
4249
|
}
|
|
4225
4250
|
}
|
|
4226
4251
|
|
|
4252
|
+
/**
|
|
4253
|
+
* Whether an autocomplete popup is on screen. An `@` list narrowed to no match
|
|
4254
|
+
* while its refresh is pending stays open internally but is hidden, so it does
|
|
4255
|
+
* not claim keys (Escape, Vim mode switches) meant for the editor or app.
|
|
4256
|
+
*/
|
|
4227
4257
|
isShowingAutocomplete(): boolean {
|
|
4228
|
-
return this.#
|
|
4258
|
+
return this.#visibleAutocompleteList() !== undefined;
|
|
4259
|
+
}
|
|
4260
|
+
|
|
4261
|
+
/** The open autocomplete list, unless it has no candidate to show. */
|
|
4262
|
+
#visibleAutocompleteList(): SelectList | undefined {
|
|
4263
|
+
if (this.#autocompleteState === null) return undefined;
|
|
4264
|
+
return this.#autocompleteList?.getSelectedItem() ? this.#autocompleteList : undefined;
|
|
4229
4265
|
}
|
|
4230
4266
|
|
|
4231
4267
|
async #updateAutocomplete(): Promise<void> {
|
package/src/components/image.ts
CHANGED
|
@@ -85,7 +85,7 @@ interface SurfaceSplit {
|
|
|
85
85
|
* id so a partial pass reproduces the on-screen live/text split without a
|
|
86
86
|
* full, correctly-ordered walk.
|
|
87
87
|
*/
|
|
88
|
-
suppressedIds: Set<number>;
|
|
88
|
+
readonly suppressedIds: Set<number>;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
function newSurfaceSplit(): SurfaceSplit {
|
|
@@ -97,7 +97,7 @@ function resetSurfaceSplit(split: SurfaceSplit): void {
|
|
|
97
97
|
split.onTerminal = 0;
|
|
98
98
|
split.planned = 0;
|
|
99
99
|
split.lastTotal = 0;
|
|
100
|
-
split.suppressedIds
|
|
100
|
+
if (split.suppressedIds.size > 0) split.suppressedIds.clear();
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
let nextImageBudgetSeed = Math.floor(Math.random() * 0xffffff);
|
|
@@ -284,8 +284,8 @@ export class ImageBudget {
|
|
|
284
284
|
*/
|
|
285
285
|
beginPass(stable = false, altScreen = false): void {
|
|
286
286
|
this.#passIds.length = 0;
|
|
287
|
-
this.#passSuppression.clear();
|
|
288
|
-
this.#passIndex.clear();
|
|
287
|
+
if (this.#passSuppression.size > 0) this.#passSuppression.clear();
|
|
288
|
+
if (this.#passIndex.size > 0) this.#passIndex.clear();
|
|
289
289
|
this.#stablePass = stable;
|
|
290
290
|
this.#surface = altScreen ? "alt" : "screen";
|
|
291
291
|
this.#split = altScreen ? this.#altSplit : this.#screenSplit;
|
|
@@ -296,7 +296,7 @@ export class ImageBudget {
|
|
|
296
296
|
// first. Note that leaving alt mode is not the same as unstacking a
|
|
297
297
|
// fullscreen overlay: the flush must exclude one that is still stacked
|
|
298
298
|
// from the pass itself, which is that caller's job, not this line's.
|
|
299
|
-
if (!altScreen) this.#liveIds.alt.clear();
|
|
299
|
+
if (!altScreen && this.#liveIds.alt.size > 0) this.#liveIds.alt.clear();
|
|
300
300
|
this.#applyingReset = !stable && this.#cap > 0 && this.#split.planned > this.#split.onTerminal;
|
|
301
301
|
}
|
|
302
302
|
|
|
@@ -349,7 +349,10 @@ export class ImageBudget {
|
|
|
349
349
|
// [0, onTerminal) is what this surface currently shows as text. Partial
|
|
350
350
|
// passes replay this per id (see #stablePass) instead of re-deriving it
|
|
351
351
|
// from a reversed, tail-only walk.
|
|
352
|
-
|
|
352
|
+
const suppressedIds = split.suppressedIds;
|
|
353
|
+
if (suppressedIds.size > 0) suppressedIds.clear();
|
|
354
|
+
const suppressedCount = Math.min(total, split.onTerminal);
|
|
355
|
+
for (let i = 0; i < suppressedCount; i++) suppressedIds.add(this.#passIds[i]);
|
|
353
356
|
return retry;
|
|
354
357
|
}
|
|
355
358
|
|
|
@@ -362,7 +365,12 @@ export class ImageBudget {
|
|
|
362
365
|
* the next pass on the *other* surface knows what it may not destroy.
|
|
363
366
|
*/
|
|
364
367
|
limitResidentImages(): void {
|
|
365
|
-
this.#liveIds[this.#surface]
|
|
368
|
+
const liveIds = this.#liveIds[this.#surface];
|
|
369
|
+
if (liveIds.size > 0) liveIds.clear();
|
|
370
|
+
for (let i = 0; i < this.#passIds.length; i++) {
|
|
371
|
+
const id = this.#passIds[i];
|
|
372
|
+
if (this.#passShowsLive(id)) liveIds.add(id);
|
|
373
|
+
}
|
|
366
374
|
const transmitted = this.#transmitted[this.#surface];
|
|
367
375
|
if (this.#cap <= 0 || transmitted.size <= this.#cap) return;
|
|
368
376
|
for (const id of transmitted) {
|
package/src/components/loader.ts
CHANGED
|
@@ -26,7 +26,14 @@ export class Loader extends Text {
|
|
|
26
26
|
#ui: TUI | null = null;
|
|
27
27
|
#lastSpinnerTick = 0;
|
|
28
28
|
#layoutSource?: readonly string[];
|
|
29
|
-
#layout?: readonly {
|
|
29
|
+
#layout?: readonly {
|
|
30
|
+
leading: string;
|
|
31
|
+
message: string;
|
|
32
|
+
trailing: string;
|
|
33
|
+
spinner: boolean;
|
|
34
|
+
separator: string;
|
|
35
|
+
bodyWidth?: number;
|
|
36
|
+
}[];
|
|
30
37
|
#layoutFrames: readonly string[];
|
|
31
38
|
#layoutFrame: string;
|
|
32
39
|
#trailer?: () => string | undefined;
|
|
@@ -72,14 +79,28 @@ export class Loader extends Text {
|
|
|
72
79
|
if (source !== this.#layoutSource) {
|
|
73
80
|
const paddingX = getPaddingX(1);
|
|
74
81
|
this.#layoutSource = source;
|
|
75
|
-
this.#layout = source.map(line => {
|
|
82
|
+
this.#layout = source.map((line, i) => {
|
|
76
83
|
const clamped = visibleWidth(line) > width ? sliceByColumn(line, 0, width, true) : line;
|
|
77
84
|
const body = clamped.slice(paddingX);
|
|
78
85
|
const content = body.trimEnd();
|
|
86
|
+
const leading = clamped.slice(0, paddingX);
|
|
87
|
+
const spinner = i === 0 && content.startsWith(this.#layoutFrame);
|
|
88
|
+
const remainder = spinner ? content.slice(this.#layoutFrame.length) : content;
|
|
89
|
+
const separator = spinner && remainder.startsWith(" ") ? " " : "";
|
|
90
|
+
const message = remainder.slice(separator.length);
|
|
91
|
+
const plainMessage = i === 0 ? Bun.stripANSI(message) : "";
|
|
92
|
+
// A visible non-whitespace ending makes trimEnd independent of
|
|
93
|
+
// colorizer ANSI placement. The separator isolates spinner width
|
|
94
|
+
// from message graphemes; other cases retain whole-body measuring.
|
|
95
|
+
const stableBodyWidth =
|
|
96
|
+
plainMessage.length > 0 && plainMessage.trimEnd() === plainMessage && (!spinner || separator === " ");
|
|
79
97
|
return {
|
|
80
|
-
leading
|
|
81
|
-
|
|
98
|
+
leading,
|
|
99
|
+
message,
|
|
82
100
|
trailing: body.slice(content.length),
|
|
101
|
+
spinner,
|
|
102
|
+
separator,
|
|
103
|
+
bodyWidth: stableBodyWidth ? visibleWidth(leading + separator + message) : undefined,
|
|
83
104
|
};
|
|
84
105
|
});
|
|
85
106
|
}
|
|
@@ -88,28 +109,27 @@ export class Loader extends Text {
|
|
|
88
109
|
// The wrapped text carries one stable representative per frame width.
|
|
89
110
|
// Same-width frames swap only the visible glyph here; crossing widths
|
|
90
111
|
// rewraps against the representative selected by #syncText.
|
|
91
|
-
const sentinel = this.#layoutFrame;
|
|
92
112
|
const lines = [""];
|
|
93
113
|
const layout = this.#layout ?? [];
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
const separator = remainder.startsWith(" ") ? " " : "";
|
|
99
|
-
const message = remainder.slice(separator.length);
|
|
100
|
-
lines.push(
|
|
101
|
-
`${leading}${this.spinnerColorFn(frame)}${separator}${message ? this.messageColorFn(message) : ""}${trailing}`,
|
|
102
|
-
);
|
|
103
|
-
} else {
|
|
104
|
-
lines.push(`${leading}${content ? this.messageColorFn(content) : ""}${trailing}`);
|
|
114
|
+
let coloredSpinner = "";
|
|
115
|
+
for (const { leading, message, trailing, spinner, separator } of layout) {
|
|
116
|
+
if (spinner) {
|
|
117
|
+
coloredSpinner = this.spinnerColorFn(frame);
|
|
105
118
|
}
|
|
119
|
+
lines.push(
|
|
120
|
+
`${leading}${spinner ? coloredSpinner : ""}${separator}${message ? this.messageColorFn(message) : ""}${trailing}`,
|
|
121
|
+
);
|
|
106
122
|
}
|
|
107
123
|
if (this.#trailer && lines.length > 1) {
|
|
108
124
|
const trailer = this.#trailer();
|
|
109
125
|
if (trailer) {
|
|
110
126
|
// Text pads rows to full width; drop that pad before docking right.
|
|
111
127
|
const body = lines[1].trimEnd();
|
|
112
|
-
const
|
|
128
|
+
const bodyWidth =
|
|
129
|
+
layout[0].bodyWidth === undefined
|
|
130
|
+
? visibleWidth(body)
|
|
131
|
+
: layout[0].bodyWidth + visibleWidth(coloredSpinner);
|
|
132
|
+
const gap = width - bodyWidth - visibleWidth(trailer);
|
|
113
133
|
if (gap >= 2) lines[1] = body + padding(gap) + trailer;
|
|
114
134
|
}
|
|
115
135
|
}
|