@oh-my-pi/pi-tui 18.4.2 → 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 +20 -0
- package/dist/types/chat/thinking-display.d.ts +2 -0
- package/dist/types/components/editor.d.ts +5 -0
- package/dist/types/loop-watchdog.d.ts +9 -0
- package/dist/types/overlays/model-picker.d.ts +2 -1
- 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 +9 -0
- package/dist/types/tools/web-search-types.d.ts +90 -0
- package/dist/types/tools/web-search.d.ts +1 -168
- package/package.json +9 -9
- package/src/chat/assistant-message.ts +192 -40
- package/src/chat/thinking-display.ts +73 -11
- package/src/chrome/transcript-container.ts +102 -17
- package/src/components/editor.ts +23 -9
- package/src/components/markdown.ts +110 -56
- package/src/loop-watchdog.ts +38 -10
- package/src/overlays/model-picker.ts +9 -6
- package/src/overlays/settings-selector.ts +2 -10
- 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 +16 -2
- package/src/stdin-buffer.ts +27 -21
- package/src/terminal.ts +23 -9
- package/src/theme/mermaid-cache.ts +10 -1
- 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 +1 -1
- 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
|
@@ -33,7 +33,8 @@ const EMPTY_STABLE_RENDER: readonly string[] = [];
|
|
|
33
33
|
|
|
34
34
|
type ThinkingContentBlock = Extract<AssistantMessage["content"][number], { type: "thinking" }>;
|
|
35
35
|
type DisplayThinkingContentBlock = ThinkingContentBlock & { rawThinking?: string };
|
|
36
|
-
type
|
|
36
|
+
type StablePartKind = "thinking" | "text";
|
|
37
|
+
type StablePart = { kind: StablePartKind; text: string } | { kind: "spacer" };
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* One published prefix of the block's finished content. Later snapshots extend
|
|
@@ -47,6 +48,37 @@ interface StableSnapshot {
|
|
|
47
48
|
readonly lastTextLength: number;
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Stable-row renders at one width. `rows` renders snapshot `newest`; every
|
|
53
|
+
* count in `ends` renders byte-identically to `rows.slice(0, end)` — checked
|
|
54
|
+
* against `rows` when recorded — so an earlier prefix is a slice of the newest
|
|
55
|
+
* render instead of a re-render, and one row array stays resident per width.
|
|
56
|
+
*/
|
|
57
|
+
interface StableRowLedger {
|
|
58
|
+
newest: number;
|
|
59
|
+
rows: readonly string[];
|
|
60
|
+
readonly ends: Map<number, number>;
|
|
61
|
+
/** Rendered rows of parts a snapshot has closed (full text final), by part index. */
|
|
62
|
+
readonly parts: (
|
|
63
|
+
| { readonly kind: StablePartKind; readonly text: string; readonly rows: readonly string[] }
|
|
64
|
+
| undefined
|
|
65
|
+
)[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One Markdown instance reused while a stable part's text grows between renders. */
|
|
69
|
+
interface StablePartRenderer {
|
|
70
|
+
readonly index: number;
|
|
71
|
+
readonly kind: StablePartKind;
|
|
72
|
+
readonly md: Markdown;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Theme inputs cached stable renders were produced with; any change drops them. */
|
|
76
|
+
interface StableRenderInputs {
|
|
77
|
+
readonly prose: MarkdownTheme;
|
|
78
|
+
readonly markdown: MarkdownTheme;
|
|
79
|
+
readonly color: ((text: string) => string) | undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
50
82
|
function isSnapshotExtension(previous: readonly StablePart[], current: readonly StablePart[]): boolean {
|
|
51
83
|
if (previous.length > current.length) return false;
|
|
52
84
|
for (let index = 0; index < previous.length; index++) {
|
|
@@ -260,13 +292,20 @@ export class AssistantMessageComponent extends Container {
|
|
|
260
292
|
#nextStableRowId = 0;
|
|
261
293
|
#transcriptStableRows: TranscriptStableRow[] = [];
|
|
262
294
|
/**
|
|
263
|
-
*
|
|
264
|
-
* for several
|
|
265
|
-
* projected
|
|
266
|
-
*
|
|
267
|
-
*
|
|
295
|
+
* Verified stable-row renders per width ({@link StableRowLedger}). The
|
|
296
|
+
* container asks for several counts per frame (emitted, offered end,
|
|
297
|
+
* projected) and each publication re-checks the previous prefix; the ledger
|
|
298
|
+
* answers all of them from the newest render. A few widths cover resizes.
|
|
299
|
+
* Cleared on reset, finalize, and theme change.
|
|
268
300
|
*/
|
|
269
|
-
#
|
|
301
|
+
#stableLedgers = new LRUCache<number, StableRowLedger>({ max: 4 });
|
|
302
|
+
/** Prefixes handed out as ledger slices or rendered off-ledger, by `${count}:${width}`. */
|
|
303
|
+
#stableRenderCache = new LRUCache<string, readonly string[]>({ max: 8 });
|
|
304
|
+
/** Reused for the growing final part of each publication candidate. */
|
|
305
|
+
#stableHeadRenderer: StablePartRenderer | undefined;
|
|
306
|
+
/** Reused for final parts of published snapshots rendered off-ledger (reflow, replay). */
|
|
307
|
+
#stableReplayRenderer: StablePartRenderer | undefined;
|
|
308
|
+
#stableRenderInputs: StableRenderInputs | undefined;
|
|
270
309
|
/** Provider-reported tokens in the live thinking block — reasoning tokens when
|
|
271
310
|
* the provider streams them, else total output — shown dimmed beside the
|
|
272
311
|
* speed badge. 0 when no thinking is streaming. */
|
|
@@ -626,22 +665,33 @@ export class AssistantMessageComponent extends Container {
|
|
|
626
665
|
this.#stableSnapshots = [];
|
|
627
666
|
this.#stableParts = [];
|
|
628
667
|
this.#transcriptStableRows = [];
|
|
629
|
-
this.#
|
|
668
|
+
this.#dropStableRenders();
|
|
630
669
|
}
|
|
631
670
|
|
|
632
671
|
renderTranscriptStableRows(count: number, width: number): readonly string[] {
|
|
633
672
|
const index = Math.min(Math.trunc(count), this.#stableSnapshots.length);
|
|
634
673
|
if (index <= 0) return EMPTY_STABLE_RENDER;
|
|
674
|
+
this.#syncStableRenderInputs();
|
|
675
|
+
const ledger = this.#stableLedgers.get(width);
|
|
676
|
+
if (ledger?.newest === index) return ledger.rows;
|
|
635
677
|
const key = `${index}:${width}`;
|
|
636
678
|
const cached = this.#stableRenderCache.get(key);
|
|
637
679
|
if (cached) return cached;
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
680
|
+
const end = ledger?.ends.get(index);
|
|
681
|
+
let rows: readonly string[];
|
|
682
|
+
if (ledger !== undefined && end !== undefined) {
|
|
683
|
+
rows = ledger.rows.slice(0, end);
|
|
684
|
+
} else {
|
|
685
|
+
const snapshot = this.#stableSnapshots[index - 1]!;
|
|
686
|
+
rows = this.#renderStableParts(
|
|
687
|
+
this.#stableParts,
|
|
688
|
+
snapshot.partCount,
|
|
689
|
+
snapshot.lastTextLength,
|
|
690
|
+
width,
|
|
691
|
+
"replay",
|
|
692
|
+
);
|
|
693
|
+
if (this.#recordStableRows(index, width, rows)) return rows;
|
|
643
694
|
}
|
|
644
|
-
const rows = this.#renderStableSnapshot(parts, width);
|
|
645
695
|
this.#stableRenderCache.set(key, rows);
|
|
646
696
|
return rows;
|
|
647
697
|
}
|
|
@@ -662,9 +712,12 @@ export class AssistantMessageComponent extends Container {
|
|
|
662
712
|
if (!last || last.kind === "spacer") return;
|
|
663
713
|
const snapshot = { partCount: parts.length, lastTextLength: last.text.length };
|
|
664
714
|
const previous = this.#stableSnapshots.at(-1);
|
|
665
|
-
|
|
715
|
+
// An unmoved boundary publishes nothing whether or not it still extends
|
|
716
|
+
// the last snapshot, so most frames skip the whole-document comparison.
|
|
666
717
|
if (previous?.partCount === snapshot.partCount && previous.lastTextLength === snapshot.lastTextLength) return;
|
|
667
|
-
|
|
718
|
+
if (previous && !isSnapshotExtension(this.#stableParts, parts)) return;
|
|
719
|
+
this.#syncStableRenderInputs();
|
|
720
|
+
const currentRows = this.#renderStableParts(parts, parts.length, last.text.length, width, "head");
|
|
668
721
|
// The container verifies stable rows against the blank-trimmed render.
|
|
669
722
|
if (!isRowPrefix(currentRows, trimBlankEdges(rendered))) return;
|
|
670
723
|
const previousRows = previous
|
|
@@ -676,7 +729,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
676
729
|
this.#stableParts = parts;
|
|
677
730
|
this.#stableSnapshots.push(snapshot);
|
|
678
731
|
this.#transcriptStableRows.push({ key: `thinking:${this.#nextStableRowId++}` });
|
|
679
|
-
this.#
|
|
732
|
+
this.#recordStableRows(this.#stableSnapshots.length, width, currentRows);
|
|
680
733
|
}
|
|
681
734
|
|
|
682
735
|
/**
|
|
@@ -726,37 +779,136 @@ export class AssistantMessageComponent extends Container {
|
|
|
726
779
|
return parts;
|
|
727
780
|
}
|
|
728
781
|
|
|
729
|
-
|
|
782
|
+
/**
|
|
783
|
+
* Render the first `partCount` stable parts, the final one cut to
|
|
784
|
+
* `lastLength`. Rows match fresh Markdown renders of each part byte for
|
|
785
|
+
* byte — {@link #createStableMarkdown} mirrors the live children — but a
|
|
786
|
+
* closed part renders once per width, and the growing final part reuses one
|
|
787
|
+
* Markdown instance so its already-frozen blocks are not re-lexed.
|
|
788
|
+
*/
|
|
789
|
+
#renderStableParts(
|
|
790
|
+
parts: readonly StablePart[],
|
|
791
|
+
partCount: number,
|
|
792
|
+
lastLength: number,
|
|
793
|
+
width: number,
|
|
794
|
+
role: "head" | "replay",
|
|
795
|
+
): readonly string[] {
|
|
796
|
+
const ledger = this.#stableLedger(width);
|
|
730
797
|
const rows: string[] = [];
|
|
731
|
-
|
|
798
|
+
const lastIndex = partCount - 1;
|
|
799
|
+
for (let index = 0; index < partCount; index++) {
|
|
800
|
+
const part = parts[index]!;
|
|
732
801
|
if (part.kind === "spacer") {
|
|
733
802
|
rows.push("");
|
|
734
803
|
continue;
|
|
735
804
|
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
this.#getProseTheme(),
|
|
748
|
-
this.#textColorTransform ? { color: this.#textColorTransform } : undefined,
|
|
749
|
-
0,
|
|
750
|
-
)
|
|
751
|
-
: new Markdown(text, 1, 0, getMarkdownTheme(), {
|
|
752
|
-
color: (value: string) => theme.fg("thinkingText", value),
|
|
753
|
-
italic: true,
|
|
754
|
-
});
|
|
755
|
-
rows.push(...markdown.render(width));
|
|
805
|
+
const closed = index < lastIndex;
|
|
806
|
+
const text = closed || lastLength === part.text.length ? part.text : part.text.slice(0, lastLength);
|
|
807
|
+
const cached = ledger.parts[index];
|
|
808
|
+
let partRows: readonly string[];
|
|
809
|
+
if (cached?.kind === part.kind && cached.text === text) {
|
|
810
|
+
partRows = cached.rows;
|
|
811
|
+
} else {
|
|
812
|
+
partRows = this.#renderStablePart(index, part.kind, text, width, closed ? undefined : role);
|
|
813
|
+
if (closed) ledger.parts[index] = { kind: part.kind, text, rows: partRows };
|
|
814
|
+
}
|
|
815
|
+
for (const row of partRows) rows.push(row);
|
|
756
816
|
}
|
|
757
817
|
return rows;
|
|
758
818
|
}
|
|
759
819
|
|
|
820
|
+
/**
|
|
821
|
+
* Render one part's Markdown. `role` names the instance reused for a final
|
|
822
|
+
* part as its text grows; a closed part continues whichever instance was
|
|
823
|
+
* already growing it, else renders once. Finalized blocks keep no instance.
|
|
824
|
+
*/
|
|
825
|
+
#renderStablePart(
|
|
826
|
+
index: number,
|
|
827
|
+
kind: StablePartKind,
|
|
828
|
+
text: string,
|
|
829
|
+
width: number,
|
|
830
|
+
role: "head" | "replay" | undefined,
|
|
831
|
+
): readonly string[] {
|
|
832
|
+
// Trim like the live children, dropping the trailing blank line a frozen
|
|
833
|
+
// prefix still carries.
|
|
834
|
+
const trimmed = text.trim();
|
|
835
|
+
if (this.#transcriptBlockFinalized) return this.#createStableMarkdown(kind, trimmed).render(width);
|
|
836
|
+
const head = this.#stableHeadRenderer;
|
|
837
|
+
const replay = this.#stableReplayRenderer;
|
|
838
|
+
const renderer = role === "head" ? head : role === "replay" ? replay : head?.index === index ? head : replay;
|
|
839
|
+
if (renderer?.index === index && renderer.kind === kind) {
|
|
840
|
+
renderer.md.setText(trimmed);
|
|
841
|
+
return renderer.md.render(width);
|
|
842
|
+
}
|
|
843
|
+
const md = this.#createStableMarkdown(kind, trimmed);
|
|
844
|
+
if (role === "head") this.#stableHeadRenderer = { index, kind, md };
|
|
845
|
+
else if (role === "replay") this.#stableReplayRenderer = { index, kind, md };
|
|
846
|
+
return md.render(width);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Constructor args mirror the live child Markdown so stable rows prefix the block render. */
|
|
850
|
+
#createStableMarkdown(kind: StablePartKind, text: string): Markdown {
|
|
851
|
+
return kind === "text"
|
|
852
|
+
? new Markdown(
|
|
853
|
+
text,
|
|
854
|
+
1,
|
|
855
|
+
0,
|
|
856
|
+
this.#getProseTheme(),
|
|
857
|
+
this.#textColorTransform ? { color: this.#textColorTransform } : undefined,
|
|
858
|
+
0,
|
|
859
|
+
)
|
|
860
|
+
: new Markdown(text, 1, 0, getMarkdownTheme(), {
|
|
861
|
+
color: (value: string) => theme.fg("thinkingText", value),
|
|
862
|
+
italic: true,
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
#stableLedger(width: number): StableRowLedger {
|
|
867
|
+
let ledger = this.#stableLedgers.get(width);
|
|
868
|
+
if (ledger === undefined) {
|
|
869
|
+
ledger = { newest: 0, rows: EMPTY_STABLE_RENDER, ends: new Map(), parts: [] };
|
|
870
|
+
this.#stableLedgers.set(width, ledger);
|
|
871
|
+
}
|
|
872
|
+
return ledger;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Remember `rows` as snapshot `index`'s render at `width`. A newer snapshot
|
|
877
|
+
* becomes the ledger's newest render — keeping earlier counts only when it
|
|
878
|
+
* extends their rows; an older one that prefixes the newest keeps just its
|
|
879
|
+
* row count. Returns whether `rows` is now the newest render.
|
|
880
|
+
*/
|
|
881
|
+
#recordStableRows(index: number, width: number, rows: readonly string[]): boolean {
|
|
882
|
+
const ledger = this.#stableLedger(width);
|
|
883
|
+
if (index > ledger.newest) {
|
|
884
|
+
if (!isRowPrefix(ledger.rows, rows)) ledger.ends.clear();
|
|
885
|
+
ledger.newest = index;
|
|
886
|
+
ledger.rows = rows;
|
|
887
|
+
ledger.ends.set(index, rows.length);
|
|
888
|
+
return true;
|
|
889
|
+
}
|
|
890
|
+
if (index < ledger.newest && isRowPrefix(rows, ledger.rows)) ledger.ends.set(index, rows.length);
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Drop cached stable renders once the themes they were rendered with change. */
|
|
895
|
+
#syncStableRenderInputs(): void {
|
|
896
|
+
const prose = this.#getProseTheme();
|
|
897
|
+
const markdown = getMarkdownTheme();
|
|
898
|
+
const color = this.#textColorTransform;
|
|
899
|
+
const inputs = this.#stableRenderInputs;
|
|
900
|
+
if (inputs?.prose === prose && inputs.markdown === markdown && inputs.color === color) return;
|
|
901
|
+
this.#dropStableRenders();
|
|
902
|
+
this.#stableRenderInputs = { prose, markdown, color };
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
#dropStableRenders(): void {
|
|
906
|
+
this.#stableLedgers.clear();
|
|
907
|
+
this.#stableRenderCache.clear();
|
|
908
|
+
this.#stableHeadRenderer = undefined;
|
|
909
|
+
this.#stableReplayRenderer = undefined;
|
|
910
|
+
}
|
|
911
|
+
|
|
760
912
|
/** Render completed prose rather than an earlier thinking row under emergency viewport pressure. */
|
|
761
913
|
renderTranscriptBlockEmergencyRow(width: number): string | undefined {
|
|
762
914
|
if (!this.#transcriptBlockFinalized) return undefined;
|
|
@@ -769,7 +921,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
769
921
|
|
|
770
922
|
markTranscriptBlockFinalized(): void {
|
|
771
923
|
this.#transcriptBlockFinalized = true;
|
|
772
|
-
this.#
|
|
924
|
+
this.#dropStableRenders();
|
|
773
925
|
this.#stopThinkingAnimation();
|
|
774
926
|
// If the live pulse was on screen when the block sealed, drop the fast path
|
|
775
927
|
// and rebuild so the placeholder is removed — finalized blocks never animate.
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
2
2
|
import { FENCE_RE } from "../render/render-utils";
|
|
3
3
|
|
|
4
|
-
//
|
|
4
|
+
// Small per-mode MRU memo for formatThinkingForDisplay. During a streaming
|
|
5
5
|
// tick the same growing thinking text is formatted up to three times (reveal
|
|
6
6
|
// count, reveal slice, component render); this collapses them to one
|
|
7
7
|
// computation. Prose and raw modes produce different output for the same text,
|
|
8
|
-
// so each mode keeps its own
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// so each mode keeps its own slots. A message routinely holds several thinking
|
|
9
|
+
// blocks (thinking → text → thinking), and every tick formats each of them, so
|
|
10
|
+
// one slot per mode would thrash between blocks and turn every append into a
|
|
11
|
+
// full refold; {@link DISPLAY_CACHE_SLOTS} slots keep one checkpoint per live
|
|
12
|
+
// block. A miss recomputes exactly as before and evicts the least recently
|
|
13
|
+
// used slot.
|
|
11
14
|
//
|
|
12
15
|
// Each slot also carries the fold state for incremental extension: when the
|
|
13
16
|
// incoming text is an append of the previously formatted text (streaming only
|
|
@@ -85,8 +88,69 @@ function freshDisplayCache(): DisplayCache {
|
|
|
85
88
|
return { text: "", value: "", hadComment: false, startLineByte: 0, state: freshFoldState(), resumable: true };
|
|
86
89
|
}
|
|
87
90
|
|
|
88
|
-
|
|
89
|
-
const
|
|
91
|
+
/** Slots per mode: enough for the thinking blocks of one streaming message. */
|
|
92
|
+
const DISPLAY_CACHE_SLOTS = 4;
|
|
93
|
+
// Most recently used first.
|
|
94
|
+
const proseSlots: DisplayCache[] = [];
|
|
95
|
+
const rawSlots: DisplayCache[] = [];
|
|
96
|
+
|
|
97
|
+
/** Drop every memo slot so the next call recomputes from scratch. Tests only. */
|
|
98
|
+
export function resetThinkingDisplayCacheForTests(): void {
|
|
99
|
+
proseSlots.length = 0;
|
|
100
|
+
rawSlots.length = 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Pick the slot to format `text` into and move it to the front: an exact memo
|
|
105
|
+
* hit, else the resumable slot holding the longest verbatim prefix of `text`
|
|
106
|
+
* (an append). A miss overwrites a non-resumable slot holding a prefix of
|
|
107
|
+
* `text` — the same stream's retired checkpoint, e.g. the raw identity
|
|
108
|
+
* shortcut — so such a stream never evicts the other blocks' checkpoints;
|
|
109
|
+
* otherwise the least recently used slot (or a new one below the cap).
|
|
110
|
+
* Append detection runs here once so the caller never re-verifies.
|
|
111
|
+
*/
|
|
112
|
+
function acquireDisplayCache(
|
|
113
|
+
slots: DisplayCache[],
|
|
114
|
+
text: string,
|
|
115
|
+
): { cache: DisplayCache; match: "hit" | "append" | "miss" } {
|
|
116
|
+
// Identity pass first: the repeated renders of one streaming tick must not
|
|
117
|
+
// pay any prefix verification against the other blocks' slots.
|
|
118
|
+
let index = slots.findIndex(slot => slot.text === text);
|
|
119
|
+
let match: "hit" | "append" | "miss" = index === -1 ? "miss" : "hit";
|
|
120
|
+
if (index === -1) {
|
|
121
|
+
let appendLength = 0;
|
|
122
|
+
let retired = -1;
|
|
123
|
+
let retiredLength = 0;
|
|
124
|
+
for (let i = 0; i < slots.length; i++) {
|
|
125
|
+
const slot = slots[i]!;
|
|
126
|
+
const length = slot.text.length;
|
|
127
|
+
if (length <= (slot.resumable ? appendLength : retiredLength) || !isAppend(slot, text)) continue;
|
|
128
|
+
if (slot.resumable) {
|
|
129
|
+
index = i;
|
|
130
|
+
match = "append";
|
|
131
|
+
appendLength = length;
|
|
132
|
+
} else {
|
|
133
|
+
retired = i;
|
|
134
|
+
retiredLength = length;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (index === -1) index = retired;
|
|
138
|
+
}
|
|
139
|
+
if (index === -1) {
|
|
140
|
+
if (slots.length < DISPLAY_CACHE_SLOTS) {
|
|
141
|
+
const cache = freshDisplayCache();
|
|
142
|
+
slots.unshift(cache);
|
|
143
|
+
return { cache, match };
|
|
144
|
+
}
|
|
145
|
+
index = slots.length - 1;
|
|
146
|
+
}
|
|
147
|
+
const cache = slots[index]!;
|
|
148
|
+
if (index > 0) {
|
|
149
|
+
slots.splice(index, 1);
|
|
150
|
+
slots.unshift(cache);
|
|
151
|
+
}
|
|
152
|
+
return { cache, match };
|
|
153
|
+
}
|
|
90
154
|
|
|
91
155
|
export function canonicalizeMessage(text: string | null | undefined): string {
|
|
92
156
|
if (!text) return "";
|
|
@@ -190,14 +254,12 @@ function renderFold(state: FoldState): string {
|
|
|
190
254
|
*/
|
|
191
255
|
export function formatThinkingForDisplay(text: string, proseOnly: boolean): string {
|
|
192
256
|
if (!text) return text;
|
|
193
|
-
const cache = proseOnly ?
|
|
194
|
-
|
|
195
|
-
// the same text and must not pay the prefix verification below.
|
|
196
|
-
if (text === cache.text) return cache.value;
|
|
257
|
+
const { cache, match } = acquireDisplayCache(proseOnly ? proseSlots : rawSlots, text);
|
|
258
|
+
if (match === "hit") return cache.value;
|
|
197
259
|
let hasComment: boolean;
|
|
198
260
|
let fromByte: number;
|
|
199
261
|
let state: FoldState;
|
|
200
|
-
if (
|
|
262
|
+
if (match === "append") {
|
|
201
263
|
// Append: a `<!--` introduced by the suffix flips the noise gate, and a
|
|
202
264
|
// marker straddling the seam can start at most 3 bytes back — identical
|
|
203
265
|
// to rescanning the full text, at O(delta).
|
|
@@ -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;
|
|
@@ -189,6 +195,10 @@ export class TranscriptContainer extends Container {
|
|
|
189
195
|
*/
|
|
190
196
|
#frameRows = new Map<TranscriptEntry, readonly string[]>();
|
|
191
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;
|
|
192
202
|
override addChild(component: Component): void {
|
|
193
203
|
if (isToolActivityComponent(component)) component.setToolActivityVisible(this.#toolActivityVisible);
|
|
194
204
|
super.addChild(component);
|
|
@@ -202,6 +212,9 @@ export class TranscriptContainer extends Container {
|
|
|
202
212
|
emitted: 0,
|
|
203
213
|
stableFrozen: false,
|
|
204
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;
|
|
205
218
|
}
|
|
206
219
|
|
|
207
220
|
override removeChild(component: Component): void {
|
|
@@ -639,20 +652,9 @@ export class TranscriptContainer extends Container {
|
|
|
639
652
|
// rows left behind here are rows dropped from the top of the viewport.
|
|
640
653
|
// `liveRows` is exact here: the append path measured every live block.
|
|
641
654
|
const overflow = liveRows - room;
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
while (emittedEnd < appendHead.stableRows.length && rows.length < overflow) {
|
|
646
|
-
const after = this.#renderStablePrefix(appendHead, emittedEnd + 1, width);
|
|
647
|
-
if (!isRowPrefix(before, after) || after.length === before.length) {
|
|
648
|
-
if (emittedEnd === appendHead.emitted) {
|
|
649
|
-
this.#freezeStableRows(appendHead, EMPTY_ROWS, "semantic row render added no suffix");
|
|
650
|
-
}
|
|
651
|
-
break;
|
|
652
|
-
}
|
|
653
|
-
rows = after.slice(before.length);
|
|
654
|
-
emittedEnd += 1;
|
|
655
|
-
}
|
|
655
|
+
const { rows, emittedEnd } =
|
|
656
|
+
this.#measuredAppendBatch(appendHead, width, overflow) ??
|
|
657
|
+
this.#renderedAppendBatch(appendHead, width, overflow);
|
|
656
658
|
if (emittedEnd > appendHead.emitted) {
|
|
657
659
|
const batch: HistoryBatch = {
|
|
658
660
|
id: this.#nextBatchId++,
|
|
@@ -840,6 +842,64 @@ export class TranscriptContainer extends Container {
|
|
|
840
842
|
if (memo !== undefined) return memo;
|
|
841
843
|
return this.#renderStablePrefix(entry, count, width).length;
|
|
842
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
|
+
|
|
843
903
|
/**
|
|
844
904
|
* Record that pressure retirement is blocked behind a not-yet-settled
|
|
845
905
|
* frontier block, and log its identity once the episode outlives the grace
|
|
@@ -1069,12 +1129,27 @@ export class TranscriptContainer extends Container {
|
|
|
1069
1129
|
return this.#entries.length - this.#frontier;
|
|
1070
1130
|
}
|
|
1071
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
|
+
*/
|
|
1072
1141
|
#syncEntries(): void {
|
|
1142
|
+
const children = this.children;
|
|
1073
1143
|
if (
|
|
1074
|
-
this.#
|
|
1075
|
-
|
|
1076
|
-
|
|
1144
|
+
!this.#entriesUnverified &&
|
|
1145
|
+
children === this.#syncedChildren &&
|
|
1146
|
+
this.#entriesMatch(children, this.#frontier)
|
|
1147
|
+
) {
|
|
1077
1148
|
return;
|
|
1149
|
+
}
|
|
1150
|
+
this.#entriesUnverified = false;
|
|
1151
|
+
this.#syncedChildren = children;
|
|
1152
|
+
if (this.#entriesMatch(children, 0)) return;
|
|
1078
1153
|
const existing = new Map(this.#entries.map(entry => [entry.component, entry]));
|
|
1079
1154
|
this.#entries = this.children.map(
|
|
1080
1155
|
component =>
|
|
@@ -1092,6 +1167,16 @@ export class TranscriptContainer extends Container {
|
|
|
1092
1167
|
this.#frontier = this.#entries.findIndex(entry => entry.state !== "committed");
|
|
1093
1168
|
if (this.#frontier < 0) this.#frontier = this.#entries.length;
|
|
1094
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
|
+
}
|
|
1095
1180
|
}
|
|
1096
1181
|
|
|
1097
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 = {
|
|
@@ -1539,15 +1539,15 @@ export class Editor implements Component, Focusable {
|
|
|
1539
1539
|
if (bottomRow !== undefined) result.push(bottomRow);
|
|
1540
1540
|
|
|
1541
1541
|
// Add autocomplete list if active
|
|
1542
|
-
|
|
1542
|
+
const autocompleteList = this.#visibleAutocompleteList();
|
|
1543
|
+
if (autocompleteList) {
|
|
1543
1544
|
// Clamp the dropdown to the terminal viewport: the editor rows already
|
|
1544
1545
|
// rendered above plus a small reserve must stay visible.
|
|
1545
1546
|
const viewportRows = this.viewportRowsProvider?.() || process.stdout.rows || Number(Bun.env.LINES) || 24;
|
|
1546
|
-
|
|
1547
|
+
autocompleteList.setMaxVisible(
|
|
1547
1548
|
Math.max(3, Math.min(this.#autocompleteMaxVisible, viewportRows - result.length - 2)),
|
|
1548
1549
|
);
|
|
1549
|
-
|
|
1550
|
-
result.push(...autocompleteResult);
|
|
1550
|
+
result.push(...autocompleteList.render(width));
|
|
1551
1551
|
}
|
|
1552
1552
|
|
|
1553
1553
|
return result;
|
|
@@ -1651,10 +1651,13 @@ export class Editor implements Component, Focusable {
|
|
|
1651
1651
|
|
|
1652
1652
|
// Handle autocomplete special keys first (but don't block other input)
|
|
1653
1653
|
if (this.#autocompleteState && this.#autocompleteList) {
|
|
1654
|
-
// 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.
|
|
1655
1657
|
if (kb.matchesCanonical(canonical, "tui.select.cancel")) {
|
|
1658
|
+
const visible = this.isShowingAutocomplete();
|
|
1656
1659
|
this.#cancelAutocomplete(true);
|
|
1657
|
-
return;
|
|
1660
|
+
if (visible) return;
|
|
1658
1661
|
}
|
|
1659
1662
|
// Right arrow at end of line accepts the selection like Tab (fish-style).
|
|
1660
1663
|
// Mid-line, right arrow keeps its cursor-movement role and falls through.
|
|
@@ -4246,8 +4249,19 @@ export class Editor implements Component, Focusable {
|
|
|
4246
4249
|
}
|
|
4247
4250
|
}
|
|
4248
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
|
+
*/
|
|
4249
4257
|
isShowingAutocomplete(): boolean {
|
|
4250
|
-
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;
|
|
4251
4265
|
}
|
|
4252
4266
|
|
|
4253
4267
|
async #updateAutocomplete(): Promise<void> {
|