@oh-my-pi/pi-tui 18.6.1 → 18.7.0
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 +50 -58
- package/README.md +6 -2
- package/dist/types/app-keybindings.d.ts +7 -2
- package/dist/types/autocomplete.d.ts +5 -2
- package/dist/types/charts/chart-plan.d.ts +92 -0
- package/dist/types/charts/chart-svg.d.ts +30 -0
- package/dist/types/charts/table-data.d.ts +107 -0
- package/dist/types/chat/assistant-message.d.ts +10 -1
- package/dist/types/chat/chat-transcript-builder.d.ts +3 -0
- package/dist/types/chat/figure-markdown.d.ts +42 -0
- package/dist/types/chat/svg-figure.d.ts +33 -0
- package/dist/types/chat/svg-source.d.ts +49 -0
- package/dist/types/chat/table-chart.d.ts +88 -0
- package/dist/types/chat/user-message.d.ts +2 -2
- package/dist/types/chrome/status-notice.d.ts +15 -3
- package/dist/types/components/editor.d.ts +5 -1
- package/dist/types/components/image.d.ts +23 -0
- package/dist/types/components/input.d.ts +1 -0
- package/dist/types/components/loader.d.ts +5 -6
- package/dist/types/components/markdown.d.ts +9 -0
- package/dist/types/fuzzy.d.ts +14 -0
- package/dist/types/key-hint-format.d.ts +8 -0
- package/dist/types/keybindings.d.ts +1 -1
- package/dist/types/native/backend.d.ts +2 -2
- package/dist/types/native/node.d.ts +6 -0
- package/dist/types/native/overlay.d.ts +3 -3
- package/dist/types/overlays/agent-hub.d.ts +1 -0
- package/dist/types/overlays/agent-transcript-viewer.d.ts +1 -0
- package/dist/types/overlays/agents-hub.d.ts +9 -0
- package/dist/types/overlays/annotation-overlay.d.ts +5 -2
- package/dist/types/overlays/btw-history-panel.d.ts +5 -1
- package/dist/types/overlays/copy-selector.d.ts +1 -0
- package/dist/types/overlays/model-browser.d.ts +25 -3
- package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
- package/dist/types/overlays/rewind-selector.d.ts +1 -0
- package/dist/types/overlays/usage-dashboard.d.ts +4 -4
- package/dist/types/prompt/custom-editor.d.ts +10 -4
- package/dist/types/prompt/interactive-context-helpers.d.ts +3 -0
- package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
- package/dist/types/render/composite.d.ts +2 -0
- package/dist/types/space-hold.d.ts +40 -25
- package/dist/types/terminal-capabilities.d.ts +13 -0
- package/dist/types/tools/glob.d.ts +4 -0
- package/dist/types/tui.d.ts +5 -0
- package/dist/types/utils.d.ts +6 -0
- package/package.json +9 -9
- package/src/app-keybindings.ts +6 -1
- package/src/apps/git/diff-pane.ts +42 -16
- package/src/apps/git/git-tui.ts +9 -9
- package/src/apps/git/sidebar.ts +6 -1
- package/src/autocomplete.ts +8 -10
- package/src/charts/chart-plan.ts +367 -0
- package/src/charts/chart-svg.ts +693 -0
- package/src/charts/table-data.ts +436 -0
- package/src/chat/assistant-message.ts +165 -25
- package/src/chat/chat-transcript-builder.ts +8 -0
- package/src/chat/figure-markdown.ts +174 -0
- package/src/chat/svg-figure.ts +364 -0
- package/src/chat/svg-source.ts +188 -0
- package/src/chat/table-chart.ts +301 -0
- package/src/chat/tool-execution.ts +6 -1
- package/src/chat/user-message.ts +16 -5
- package/src/chrome/status-notice.ts +25 -7
- package/src/chrome/transcript-container.ts +13 -4
- package/src/components/editor.ts +66 -30
- package/src/components/image.ts +95 -2
- package/src/components/input.ts +14 -6
- package/src/components/loader.ts +10 -17
- package/src/components/markdown.ts +6 -3
- package/src/fuzzy.ts +59 -19
- package/src/hotkeys-markdown.ts +9 -2
- package/src/key-hint-format.ts +25 -10
- package/src/keybindings.ts +1 -1
- package/src/native/backend.ts +10 -3
- package/src/native/node.ts +6 -0
- package/src/native/overlay.ts +4 -4
- package/src/overlays/agent-hub.ts +4 -0
- package/src/overlays/agent-transcript-viewer.ts +4 -0
- package/src/overlays/agents-hub.ts +14 -1
- package/src/overlays/annotation-overlay.ts +179 -80
- package/src/overlays/ask-dialog.ts +1 -1
- package/src/overlays/btw-history-panel.ts +9 -2
- package/src/overlays/copy-selector.ts +2 -0
- package/src/overlays/extensions/extension-dashboard.ts +4 -4
- package/src/overlays/model-browser.ts +169 -76
- package/src/overlays/pause-screen.ts +2 -2
- package/src/overlays/plan-review-overlay.ts +41 -4
- package/src/overlays/rewind-selector.ts +2 -0
- package/src/overlays/usage-dashboard.ts +17 -8
- package/src/overlays/usage-row.ts +22 -8
- package/src/prompt/composer.ts +2 -14
- package/src/prompt/custom-editor.ts +188 -67
- package/src/prompt/interactive-context-helpers.ts +5 -0
- package/src/prompt/model-mention-autocomplete.ts +14 -14
- package/src/prompt/queued-messages.ts +6 -4
- package/src/render/composite.ts +62 -0
- package/src/space-hold.ts +156 -52
- package/src/status-line/segments.ts +3 -1
- package/src/terminal-capabilities.ts +42 -23
- package/src/tools/glob.ts +23 -6
- package/src/tui.ts +154 -119
- package/src/utils.ts +19 -0
|
@@ -27,6 +27,10 @@ import { type ServedModelMismatch, ServedModelMarkerComponent } from "./served-m
|
|
|
27
27
|
import { isReactionTarget, type ReactionSplit, type ReactionTarget, splitReaction } from "./reaction";
|
|
28
28
|
import { isRowPrefix, type TranscriptStableRow, trimBlankEdges } from "../chrome/transcript-container";
|
|
29
29
|
import { formatTurnUsage, type TurnUsageSummary } from "../overlays/usage-row";
|
|
30
|
+
import { FigureMarkdown } from "./figure-markdown";
|
|
31
|
+
import { svgFigureRendering } from "./svg-figure";
|
|
32
|
+
import { hasSvgFence } from "./svg-source";
|
|
33
|
+
import { describeTableChart, hasChartTable, lookupTableChart, splitTableCharts } from "./table-chart";
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
36
|
* Max wrapped rows of a turn-ending provider error rendered inline in the
|
|
@@ -46,6 +50,15 @@ function thoughtLabel(clock: { start: number; end?: number } | undefined): strin
|
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
type ThinkingContentBlock = Extract<AssistantMessage["content"][number], { type: "thinking" }>;
|
|
53
|
+
/** Renders one text or thinking block: Markdown, or {@link FigureMarkdown} for text holding a ```svg fence. */
|
|
54
|
+
type ProseBlock = Markdown | FigureMarkdown;
|
|
55
|
+
/** A streamed block the fast path updates in place. */
|
|
56
|
+
interface FastPathItem {
|
|
57
|
+
md: ProseBlock;
|
|
58
|
+
contentIndex: number;
|
|
59
|
+
blockType: "text" | "thinking";
|
|
60
|
+
lastText: string;
|
|
61
|
+
}
|
|
49
62
|
type DisplayThinkingContentBlock = ThinkingContentBlock & { rawThinking?: string };
|
|
50
63
|
type StablePartKind = "thinking" | "text";
|
|
51
64
|
type StablePart = { kind: StablePartKind; text: string } | { kind: "spacer" };
|
|
@@ -230,7 +243,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
230
243
|
#cacheMarker?: CacheInvalidationMarkerComponent;
|
|
231
244
|
#servedModelMarker?: ServedModelMarkerComponent;
|
|
232
245
|
#lastMessage?: AssistantMessage;
|
|
233
|
-
#emergencyText?:
|
|
246
|
+
#emergencyText?: ProseBlock;
|
|
234
247
|
#toolImagesByCallId = new Map<string, ImageContent[]>();
|
|
235
248
|
/**
|
|
236
249
|
* Payload keys ({@link imagePayloadKey}) whose Kitty PNG conversion this
|
|
@@ -246,6 +259,8 @@ export class AssistantMessageComponent extends Container {
|
|
|
246
259
|
#kittyConverted = new Map<string, ImageContent>();
|
|
247
260
|
#showImages = true;
|
|
248
261
|
#showToolResultImages = true;
|
|
262
|
+
/** Charts under numeric tables; off for subagent transcripts. */
|
|
263
|
+
#showTableCharts = true;
|
|
249
264
|
#transcriptBlockFinalized: boolean;
|
|
250
265
|
/** See {@link setMidStreamPublication}; the wire's `stream-revision` axis decides it. */
|
|
251
266
|
#midStreamPublication = true;
|
|
@@ -293,9 +308,13 @@ export class AssistantMessageComponent extends Container {
|
|
|
293
308
|
#lastUpdateTransient = false;
|
|
294
309
|
// Fast-path state: reuse Markdown children when message shape is stable during streaming.
|
|
295
310
|
#fastPathKey: string | undefined;
|
|
296
|
-
#fastPathItems:
|
|
297
|
-
|
|
298
|
-
|
|
311
|
+
#fastPathItems: FastPathItem[] | undefined;
|
|
312
|
+
/**
|
|
313
|
+
* Text blocks whose ```svg fences are lifted into figures, by content index.
|
|
314
|
+
* Kept across slow-path rebuilds so a figure keeps its raster (and its
|
|
315
|
+
* in-flight one) instead of starting over on every update.
|
|
316
|
+
*/
|
|
317
|
+
#figureBlocks = new Map<number, FigureMarkdown>();
|
|
299
318
|
/** Live "thinking" pulse shown in place of a hidden thinking block while it
|
|
300
319
|
* streams; undefined when not animating. Driven by {@link #thinkingDotsTimer}. */
|
|
301
320
|
#thinkingDots: Text | undefined;
|
|
@@ -356,6 +375,8 @@ export class AssistantMessageComponent extends Container {
|
|
|
356
375
|
/** Markdown nodes by key, reused while their text and streaming flag are unchanged. */
|
|
357
376
|
#nativeParts = new Map<string, { text: string; stream: boolean; node: NativeNode }>();
|
|
358
377
|
readonly #nativeImages = new NativeImageCache();
|
|
378
|
+
/** Smart table chart picks a TSP describe is waiting on. */
|
|
379
|
+
readonly #chartPicks = new Set<Promise<void>>();
|
|
359
380
|
|
|
360
381
|
setTextColorTransform(transform?: (text: string) => string): void {
|
|
361
382
|
this.#textColorTransform = transform;
|
|
@@ -387,6 +408,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
387
408
|
this.#markdownTheme = undefined;
|
|
388
409
|
this.#fastPathKey = undefined;
|
|
389
410
|
this.#fastPathItems = undefined;
|
|
411
|
+
for (const block of this.#figureBlocks.values()) block.restyle();
|
|
390
412
|
if (this.#lastMessage) {
|
|
391
413
|
this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
|
|
392
414
|
}
|
|
@@ -456,6 +478,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
456
478
|
readonly #thinkingRenderers: readonly AssistantThinkingRenderer[];
|
|
457
479
|
readonly #imageBudget?: ImageBudget;
|
|
458
480
|
#proseOnlyThinking: boolean;
|
|
481
|
+
#expandThinkingBlocks: boolean;
|
|
459
482
|
|
|
460
483
|
constructor(
|
|
461
484
|
message?: AssistantMessage,
|
|
@@ -465,6 +488,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
465
488
|
imageBudget?: ImageBudget,
|
|
466
489
|
proseOnlyThinking = true,
|
|
467
490
|
linkTargets?: ReadonlyMap<string, string>,
|
|
491
|
+
expandThinkingBlocks = false,
|
|
468
492
|
) {
|
|
469
493
|
super();
|
|
470
494
|
this.#hideThinkingBlock = hideThinkingBlock;
|
|
@@ -472,6 +496,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
472
496
|
this.#thinkingRenderers = thinkingRenderers;
|
|
473
497
|
this.#imageBudget = imageBudget;
|
|
474
498
|
this.#proseOnlyThinking = proseOnlyThinking;
|
|
499
|
+
this.#expandThinkingBlocks = expandThinkingBlocks;
|
|
475
500
|
|
|
476
501
|
ensureThemeSync();
|
|
477
502
|
this.#transcriptBlockFinalized = message !== undefined;
|
|
@@ -534,6 +559,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
534
559
|
this.#markdownTheme = undefined;
|
|
535
560
|
this.#fastPathKey = undefined;
|
|
536
561
|
this.#fastPathItems = undefined;
|
|
562
|
+
for (const block of this.#figureBlocks.values()) block.restyle();
|
|
537
563
|
if (this.#lastMessage) {
|
|
538
564
|
this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
|
|
539
565
|
}
|
|
@@ -560,6 +586,16 @@ export class AssistantMessageComponent extends Container {
|
|
|
560
586
|
this.#proseOnlyThinking = proseOnly;
|
|
561
587
|
}
|
|
562
588
|
|
|
589
|
+
/**
|
|
590
|
+
* Keep finished thinking sections expanded instead of folding them to "Thought for 12s".
|
|
591
|
+
* Sections the user folded or unfolded by hand keep that choice.
|
|
592
|
+
*/
|
|
593
|
+
setExpandThinkingBlocks(expand: boolean): void {
|
|
594
|
+
if (this.#expandThinkingBlocks === expand) return;
|
|
595
|
+
this.#expandThinkingBlocks = expand;
|
|
596
|
+
this.#nativeViewVersion++;
|
|
597
|
+
}
|
|
598
|
+
|
|
563
599
|
override dispose(): void {
|
|
564
600
|
this.#stopThinkingAnimation();
|
|
565
601
|
super.dispose();
|
|
@@ -700,6 +736,12 @@ export class AssistantMessageComponent extends Container {
|
|
|
700
736
|
return this.#transcriptBlockFinalized;
|
|
701
737
|
}
|
|
702
738
|
|
|
739
|
+
/** Whether a figure still waits for the raster of its final source; the transcript holds retirement meanwhile. */
|
|
740
|
+
isTranscriptBlockPending(): boolean {
|
|
741
|
+
for (const block of this.#figureBlocks.values()) if (block.pending) return true;
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
|
|
703
745
|
override render(width: number): readonly string[] {
|
|
704
746
|
const rows = super.render(width);
|
|
705
747
|
this.#publishStableSnapshot(rows, width);
|
|
@@ -793,7 +835,25 @@ export class AssistantMessageComponent extends Container {
|
|
|
793
835
|
const content = message.content[index]!;
|
|
794
836
|
const streaming = live && index === tailIndex;
|
|
795
837
|
if (content.type === "text" && canonicalizeMessage(content.text)) {
|
|
796
|
-
|
|
838
|
+
const source = content.text.trim();
|
|
839
|
+
if (!this.#showImages || !this.#showTableCharts || !hasChartTable(source)) {
|
|
840
|
+
children.push(markdown(`t${index}`, `t${index}`, source, streaming));
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
// A chart follows its table as an SVG image node; the prose splits around it.
|
|
844
|
+
const segments = splitTableCharts(source, streaming);
|
|
845
|
+
segments.forEach((segment, part) => {
|
|
846
|
+
const slot = part === 0 ? `t${index}` : `t${index}.${part}`;
|
|
847
|
+
if (segment.kind === "markdown") {
|
|
848
|
+
children.push(
|
|
849
|
+
markdown(slot, slot, segment.text.trim(), streaming && part === segments.length - 1),
|
|
850
|
+
);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const chart = lookupTableChart(segment.table);
|
|
854
|
+
if (chart instanceof Promise) this.#awaitChart(chart);
|
|
855
|
+
else if (chart) children.push(describeTableChart(chart, slot));
|
|
856
|
+
});
|
|
797
857
|
} else if (content.type === "thinking") {
|
|
798
858
|
const display = resolveThinkingDisplay(content, this.#proseOnlyThinking);
|
|
799
859
|
if (!display.visible) continue;
|
|
@@ -831,8 +891,10 @@ export class AssistantMessageComponent extends Container {
|
|
|
831
891
|
// `.live` while streaming: the body clamps to its tail under a fade.
|
|
832
892
|
role: thinkingLive ? "omp.thinking.live" : "omp.thinking",
|
|
833
893
|
collapsible: true,
|
|
834
|
-
// Open while it streams; a finished thought folds to its "Thought for 12s" line.
|
|
835
|
-
collapsed:
|
|
894
|
+
// Open while it streams; a finished thought folds to its "Thought for 12s" line unless the user keeps thinking expanded.
|
|
895
|
+
collapsed:
|
|
896
|
+
this.#thinkingCollapsed.get(index) ??
|
|
897
|
+
(this.#hideThinkingBlock || (!thinkingLive && !this.#expandThinkingBlocks)),
|
|
836
898
|
// Tern's fold head sums it into "Worked for 12s".
|
|
837
899
|
took: clock?.end === undefined ? undefined : Math.max(0, Math.round(clock.end - clock.start)),
|
|
838
900
|
},
|
|
@@ -1074,12 +1136,18 @@ export class AssistantMessageComponent extends Container {
|
|
|
1074
1136
|
for (const child of this.#contentContainer.children) {
|
|
1075
1137
|
const item = items[itemIndex];
|
|
1076
1138
|
if (item?.md === child) {
|
|
1139
|
+
const md = item.md;
|
|
1140
|
+
if (md instanceof FigureMarkdown) {
|
|
1141
|
+
// Plain Markdown reproduces only the prose ahead of the first figure.
|
|
1142
|
+
if (md.leadingProse) parts.push({ kind: item.blockType, text: md.leadingProse });
|
|
1143
|
+
break;
|
|
1144
|
+
}
|
|
1077
1145
|
if (itemIndex === items.length - 1) {
|
|
1078
1146
|
// Streaming child: publish Markdown's frozen prefix, and only
|
|
1079
1147
|
// once non-blank content exists past it — the block's last
|
|
1080
1148
|
// non-blank line is still being written (thinking's prose fold
|
|
1081
1149
|
// may rewrite it) and must stay out of published bytes.
|
|
1082
|
-
const raw =
|
|
1150
|
+
const raw = md.getLastRenderStableText();
|
|
1083
1151
|
const frozen = raw.trim();
|
|
1084
1152
|
if (frozen.length > 0 && /\S/.test(item.lastText.slice(raw.length))) {
|
|
1085
1153
|
parts.push({ kind: item.blockType, text: frozen });
|
|
@@ -1107,7 +1175,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
1107
1175
|
/**
|
|
1108
1176
|
* Render the first `partCount` stable parts, the final one cut to
|
|
1109
1177
|
* `lastLength`. Rows match fresh Markdown renders of each part byte for
|
|
1110
|
-
* byte — {@link #
|
|
1178
|
+
* byte — {@link #createMarkdown} builds the live children too — but a
|
|
1111
1179
|
* closed part renders once per width, and the growing final part reuses one
|
|
1112
1180
|
* Markdown instance so its already-frozen blocks are not re-lexed.
|
|
1113
1181
|
*/
|
|
@@ -1157,7 +1225,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
1157
1225
|
// Trim like the live children, dropping the trailing blank line a frozen
|
|
1158
1226
|
// prefix still carries.
|
|
1159
1227
|
const trimmed = text.trim();
|
|
1160
|
-
if (this.#transcriptBlockFinalized) return this.#
|
|
1228
|
+
if (this.#transcriptBlockFinalized) return this.#createMarkdown(kind, trimmed).render(width);
|
|
1161
1229
|
const head = this.#stableHeadRenderer;
|
|
1162
1230
|
const replay = this.#stableReplayRenderer;
|
|
1163
1231
|
const renderer = role === "head" ? head : role === "replay" ? replay : head?.index === index ? head : replay;
|
|
@@ -1165,14 +1233,14 @@ export class AssistantMessageComponent extends Container {
|
|
|
1165
1233
|
renderer.md.setText(trimmed);
|
|
1166
1234
|
return renderer.md.render(width);
|
|
1167
1235
|
}
|
|
1168
|
-
const md = this.#
|
|
1236
|
+
const md = this.#createMarkdown(kind, trimmed);
|
|
1169
1237
|
if (role === "head") this.#stableHeadRenderer = { index, kind, md };
|
|
1170
1238
|
else if (role === "replay") this.#stableReplayRenderer = { index, kind, md };
|
|
1171
1239
|
return md.render(width);
|
|
1172
1240
|
}
|
|
1173
1241
|
|
|
1174
|
-
/**
|
|
1175
|
-
#
|
|
1242
|
+
/** Markdown for a text or thinking block; live children and stable-row renders share it so stable rows prefix the block render. */
|
|
1243
|
+
#createMarkdown(kind: StablePartKind, text: string): Markdown {
|
|
1176
1244
|
return kind === "text"
|
|
1177
1245
|
? new Markdown(
|
|
1178
1246
|
text,
|
|
@@ -1188,6 +1256,58 @@ export class AssistantMessageComponent extends Container {
|
|
|
1188
1256
|
});
|
|
1189
1257
|
}
|
|
1190
1258
|
|
|
1259
|
+
/**
|
|
1260
|
+
* Whether `text` holds a ```svg fence this terminal draws as a figure, or a
|
|
1261
|
+
* table it draws a chart under. Native (TSP) terminals receive the fence
|
|
1262
|
+
* verbatim in the `md` node and draw it themselves, and get charts as image
|
|
1263
|
+
* nodes from {@link describe}; image-less terminals keep fences as code and
|
|
1264
|
+
* tables as tables.
|
|
1265
|
+
*/
|
|
1266
|
+
#liftsFigures(text: string): boolean {
|
|
1267
|
+
return (
|
|
1268
|
+
this.#showImages &&
|
|
1269
|
+
TERMINAL.imageProtocol !== null &&
|
|
1270
|
+
!isNativeRendering() &&
|
|
1271
|
+
((svgFigureRendering() && hasSvgFence(text)) || (this.#showTableCharts && hasChartTable(text)))
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/** Re-describe once a smart table chart pick lands. */
|
|
1276
|
+
#awaitChart(pick: Promise<void>): void {
|
|
1277
|
+
if (this.#chartPicks.has(pick)) return;
|
|
1278
|
+
this.#chartPicks.add(pick);
|
|
1279
|
+
void pick.then(() => {
|
|
1280
|
+
this.#chartPicks.delete(pick);
|
|
1281
|
+
this.#blockVersion++;
|
|
1282
|
+
this.#onImageUpdate?.();
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* The component for text block `index`: Markdown, or a {@link FigureMarkdown}
|
|
1288
|
+
* when it holds a ```svg fence to lift or a table to chart — taken over from
|
|
1289
|
+
* `previous` so its figures keep their rasters across rebuilds.
|
|
1290
|
+
*/
|
|
1291
|
+
#proseBlock(index: number, text: string, previous: ReadonlyMap<number, FigureMarkdown>): ProseBlock {
|
|
1292
|
+
if (!this.#liftsFigures(text)) return this.#createMarkdown("text", text);
|
|
1293
|
+
let block = previous.get(index);
|
|
1294
|
+
if (block) {
|
|
1295
|
+
block.setText(text);
|
|
1296
|
+
} else {
|
|
1297
|
+
block = new FigureMarkdown(text, {
|
|
1298
|
+
markdown: value => this.#createMarkdown("text", value),
|
|
1299
|
+
charts: this.#showTableCharts,
|
|
1300
|
+
budget: this.#imageBudget,
|
|
1301
|
+
onChange: () => {
|
|
1302
|
+
this.#blockVersion++;
|
|
1303
|
+
this.#onImageUpdate?.();
|
|
1304
|
+
},
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
this.#figureBlocks.set(index, block);
|
|
1308
|
+
return block;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1191
1311
|
#stableLedger(width: number): StableRowLedger {
|
|
1192
1312
|
let ledger = this.#stableLedgers.get(width);
|
|
1193
1313
|
if (ledger === undefined) {
|
|
@@ -1307,6 +1427,18 @@ export class AssistantMessageComponent extends Container {
|
|
|
1307
1427
|
}
|
|
1308
1428
|
}
|
|
1309
1429
|
|
|
1430
|
+
/** Toggle charts under numeric tables (the main session's transcript only, never a subagent's). */
|
|
1431
|
+
setTableChartsVisible(visible: boolean): void {
|
|
1432
|
+
if (this.#showTableCharts === visible) return;
|
|
1433
|
+
this.#showTableCharts = visible;
|
|
1434
|
+
// Figure blocks bake the switch in; rebuild them rather than reuse.
|
|
1435
|
+
for (const block of this.#figureBlocks.values()) block.dispose();
|
|
1436
|
+
this.#figureBlocks.clear();
|
|
1437
|
+
if (this.#lastMessage) {
|
|
1438
|
+
this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1310
1442
|
/** Toggle only images produced by tool results; assistant-native images remain governed by setImagesVisible. */
|
|
1311
1443
|
setToolResultImagesVisible(visible: boolean): void {
|
|
1312
1444
|
if (this.#showToolResultImages === visible) return;
|
|
@@ -1373,7 +1505,12 @@ export class AssistantMessageComponent extends Container {
|
|
|
1373
1505
|
displayImage.data,
|
|
1374
1506
|
displayImage.mimeType,
|
|
1375
1507
|
{ fallbackColor: (text: string) => theme.fg("toolOutput", text) },
|
|
1376
|
-
{
|
|
1508
|
+
{
|
|
1509
|
+
...resolveImageOptions(),
|
|
1510
|
+
budget: this.#imageBudget,
|
|
1511
|
+
imageKey: key,
|
|
1512
|
+
requestRender: this.#onImageUpdate,
|
|
1513
|
+
},
|
|
1377
1514
|
),
|
|
1378
1515
|
);
|
|
1379
1516
|
continue;
|
|
@@ -1415,10 +1552,14 @@ export class AssistantMessageComponent extends Container {
|
|
|
1415
1552
|
}
|
|
1416
1553
|
|
|
1417
1554
|
#computeShapeKey(message: AssistantMessage): string {
|
|
1418
|
-
const parts: string[] = [
|
|
1555
|
+
const parts: string[] = [
|
|
1556
|
+
`htb:${this.#hideThinkingBlock ? 1 : 0}|pot:${this.#proseOnlyThinking ? 1 : 0}|etb:${this.#expandThinkingBlocks ? 1 : 0}`,
|
|
1557
|
+
];
|
|
1419
1558
|
for (const content of message.content) {
|
|
1420
1559
|
if (content.type === "text") {
|
|
1421
|
-
parts.push(
|
|
1560
|
+
parts.push(
|
|
1561
|
+
!canonicalizeMessage(content.text) ? "T0" : this.#liftsFigures(content.text.trim()) ? "TF" : "T1",
|
|
1562
|
+
);
|
|
1422
1563
|
} else if (content.type === "thinking") {
|
|
1423
1564
|
if (this.#hideThinkingBlock) {
|
|
1424
1565
|
// Match the pulse's empty/nonempty transition without formatting hidden text.
|
|
@@ -1579,9 +1720,9 @@ export class AssistantMessageComponent extends Container {
|
|
|
1579
1720
|
|
|
1580
1721
|
// Determine if we should capture Markdown instances for next fast path
|
|
1581
1722
|
const shouldCapture = this.#canFastPath(message);
|
|
1582
|
-
const captureItems:
|
|
1583
|
-
|
|
1584
|
-
|
|
1723
|
+
const captureItems: FastPathItem[] | undefined = shouldCapture ? [] : undefined;
|
|
1724
|
+
const previousFigures = this.#figureBlocks;
|
|
1725
|
+
this.#figureBlocks = new Map();
|
|
1585
1726
|
|
|
1586
1727
|
const hasVisibleContent = message.content.some(
|
|
1587
1728
|
c =>
|
|
@@ -1600,8 +1741,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
1600
1741
|
if (content.type === "text" && canonicalizeMessage(content.text)) {
|
|
1601
1742
|
// Set paddingY=0 to avoid extra spacing before tool executions
|
|
1602
1743
|
const trimmed = content.text.trim();
|
|
1603
|
-
const
|
|
1604
|
-
const md = new Markdown(trimmed, 1, 0, this.#getProseTheme(), mdOptions, 0);
|
|
1744
|
+
const md = this.#proseBlock(i, trimmed, previousFigures);
|
|
1605
1745
|
this.#contentContainer.addChild(md);
|
|
1606
1746
|
this.#emergencyText = md;
|
|
1607
1747
|
captureItems?.push({ md, contentIndex: i, blockType: "text", lastText: trimmed });
|
|
@@ -1626,10 +1766,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
1626
1766
|
);
|
|
1627
1767
|
|
|
1628
1768
|
// Thinking traces in thinkingText color, italic
|
|
1629
|
-
const md =
|
|
1630
|
-
color: (text: string) => theme.fg("thinkingText", text),
|
|
1631
|
-
italic: true,
|
|
1632
|
-
});
|
|
1769
|
+
const md = this.#createMarkdown("thinking", thinkingText);
|
|
1633
1770
|
md.transientRenderCache = this.#lastUpdateTransient;
|
|
1634
1771
|
this.#contentContainer.addChild(md);
|
|
1635
1772
|
captureItems?.push({ md, contentIndex: i, blockType: "thinking", lastText: thinkingText });
|
|
@@ -1644,6 +1781,9 @@ export class AssistantMessageComponent extends Container {
|
|
|
1644
1781
|
hasRenderedContent ||= this.#showImages;
|
|
1645
1782
|
}
|
|
1646
1783
|
}
|
|
1784
|
+
for (const [index, block] of previousFigures) {
|
|
1785
|
+
if (this.#figureBlocks.get(index) !== block) block.dispose();
|
|
1786
|
+
}
|
|
1647
1787
|
|
|
1648
1788
|
if (this.#shouldAnimateThinking(message)) {
|
|
1649
1789
|
if (hasVisibleContent) this.#contentContainer.addChild(new Spacer(1));
|
|
@@ -80,8 +80,11 @@ export interface ChatTranscriptBuilderDeps {
|
|
|
80
80
|
cwd: string;
|
|
81
81
|
hideThinkingBlock?: () => boolean;
|
|
82
82
|
proseOnlyThinking?: () => boolean;
|
|
83
|
+
expandThinkingBlocks?: () => boolean;
|
|
83
84
|
/** Session-scoped resolved destinations for model-authored Markdown links. */
|
|
84
85
|
linkTargets?: ReadonlyMap<string, string>;
|
|
86
|
+
/** Draw charts under numeric tables; false for transcripts that are not the main session's. Default: true. */
|
|
87
|
+
tableCharts?: boolean;
|
|
85
88
|
requestRender: () => void;
|
|
86
89
|
}
|
|
87
90
|
|
|
@@ -404,6 +407,7 @@ export class ChatTranscriptBuilder {
|
|
|
404
407
|
#appendAssistantMessage(message: Extract<AgentMessage, { role: "assistant" }>): void {
|
|
405
408
|
const hideThinkingBlock = this.#deps.hideThinkingBlock?.() ?? false;
|
|
406
409
|
const proseOnlyThinking = this.#deps.proseOnlyThinking ? this.#deps.proseOnlyThinking() : true;
|
|
410
|
+
const expandThinkingBlocks = this.#deps.expandThinkingBlocks?.() ?? false;
|
|
407
411
|
const timeline = splitAssistantMessageToolTimeline(message);
|
|
408
412
|
const assistantComponent = new AssistantMessageComponent(
|
|
409
413
|
timeline.beforeTools,
|
|
@@ -413,9 +417,11 @@ export class ChatTranscriptBuilder {
|
|
|
413
417
|
this.#deps.ui.imageBudget,
|
|
414
418
|
proseOnlyThinking,
|
|
415
419
|
this.#deps.linkTargets,
|
|
420
|
+
expandThinkingBlocks,
|
|
416
421
|
);
|
|
417
422
|
assistantComponent.setImagesVisible(displayPreferences.showImages);
|
|
418
423
|
assistantComponent.setToolResultImagesVisible(!displayPreferences.hideToolActivity);
|
|
424
|
+
assistantComponent.setTableChartsVisible(this.#deps.tableCharts !== false);
|
|
419
425
|
this.#trackExpandable(assistantComponent);
|
|
420
426
|
assistantComponent.pickReactionTarget(this.container.children);
|
|
421
427
|
this.container.addChild(assistantComponent);
|
|
@@ -450,9 +456,11 @@ export class ChatTranscriptBuilder {
|
|
|
450
456
|
undefined,
|
|
451
457
|
proseOnlyThinking,
|
|
452
458
|
this.#deps.linkTargets,
|
|
459
|
+
expandThinkingBlocks,
|
|
453
460
|
);
|
|
454
461
|
component.setImagesVisible(displayPreferences.showImages);
|
|
455
462
|
component.setToolResultImagesVisible(!displayPreferences.hideToolActivity);
|
|
463
|
+
component.setTableChartsVisible(this.#deps.tableCharts !== false);
|
|
456
464
|
this.#trackExpandable(component);
|
|
457
465
|
this.container.addChild(component);
|
|
458
466
|
lastAssistantComponent = component;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { Tokens } from "@oh-my-pi/pi-utils/marked";
|
|
2
|
+
import type { ImageBudget } from "../components/image";
|
|
3
|
+
import type { Markdown } from "../components/markdown";
|
|
4
|
+
import { Spacer } from "../components/spacer";
|
|
5
|
+
import { Container } from "../tui";
|
|
6
|
+
import { SvgFigure, svgFigureRendering } from "./svg-figure";
|
|
7
|
+
import { type FigureSegment, splitSvgFences } from "./svg-source";
|
|
8
|
+
import { type ChartSegment, hasChartTable, splitTableCharts, TableChartFigure } from "./table-chart";
|
|
9
|
+
|
|
10
|
+
export interface FigureMarkdownOptions {
|
|
11
|
+
/** Markdown for one prose run (and a failed figure's code fallback); FigureMarkdown drives its transience. */
|
|
12
|
+
markdown: (text: string) => Markdown;
|
|
13
|
+
/** Draw charts under numeric tables; off for transcripts that are not the main session's. */
|
|
14
|
+
charts: boolean;
|
|
15
|
+
/** Shared inline-image budget for the figures. */
|
|
16
|
+
budget?: ImageBudget;
|
|
17
|
+
/** A figure's rows changed outside a text update (raster landed, fell back to code). */
|
|
18
|
+
onChange: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Part =
|
|
22
|
+
| { readonly kind: "markdown"; readonly md: Markdown; text: string }
|
|
23
|
+
| { readonly kind: "svg"; readonly figure: SvgFigure; source: string; closed: boolean }
|
|
24
|
+
| { readonly kind: "chart"; readonly figure: TableChartFigure; table: Tokens.Table };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Assistant Markdown whose top-level ```svg fences are lifted out of the prose
|
|
28
|
+
* and drawn as images ({@link SvgFigure}), and whose numeric tables get a chart
|
|
29
|
+
* drawn under them ({@link TableChartFigure}), with ordinary {@link Markdown}
|
|
30
|
+
* for the prose between them. Stands in for Markdown on an assistant text
|
|
31
|
+
* block that holds an svg fence or a chartable table: {@link setText} streams
|
|
32
|
+
* in place, reusing every part whose kind is unchanged, and
|
|
33
|
+
* {@link transientRenderCache} marks the block's tail as still streaming — the
|
|
34
|
+
* last prose run renders transient, the last figure throttles its rasters
|
|
35
|
+
* until its fence closes, and a table at the tail waits for its last row.
|
|
36
|
+
*/
|
|
37
|
+
export class FigureMarkdown extends Container {
|
|
38
|
+
readonly #options: FigureMarkdownOptions;
|
|
39
|
+
#text = "";
|
|
40
|
+
#transient = false;
|
|
41
|
+
#parts: Part[] = [];
|
|
42
|
+
|
|
43
|
+
constructor(text: string, options: FigureMarkdownOptions) {
|
|
44
|
+
super();
|
|
45
|
+
this.#options = options;
|
|
46
|
+
this.setText(text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Replace the source; returns whether it changed. */
|
|
50
|
+
setText(text: string): boolean {
|
|
51
|
+
if (text === this.#text) return false;
|
|
52
|
+
this.#text = text;
|
|
53
|
+
this.#reconcile();
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get transientRenderCache(): boolean {
|
|
58
|
+
return this.#transient;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
set transientRenderCache(value: boolean) {
|
|
62
|
+
if (this.#transient === value) return;
|
|
63
|
+
this.#transient = value;
|
|
64
|
+
// The end of the stream completes a tail table, which may now get its chart.
|
|
65
|
+
this.#reconcile();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The trimmed prose ahead of the first figure, or "" when the block opens
|
|
70
|
+
* with one. These are the only rows plain Markdown reproduces byte for
|
|
71
|
+
* byte, so they are all a transcript may publish ahead of the figure.
|
|
72
|
+
*/
|
|
73
|
+
get leadingProse(): string {
|
|
74
|
+
const first = this.#parts[0];
|
|
75
|
+
return first?.kind === "markdown" ? first.text : "";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether a figure's final source still waits for its raster, or a chart for its pick or raster. */
|
|
79
|
+
get pending(): boolean {
|
|
80
|
+
return this.#parts.some(part => part.kind !== "markdown" && part.figure.pending);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Rebuild the prose runs through the Markdown factory after its theme changed; figures keep their rasters. */
|
|
84
|
+
restyle(): void {
|
|
85
|
+
this.#parts = this.#parts.map(part =>
|
|
86
|
+
part.kind === "markdown" ? { kind: "markdown", md: this.#options.markdown(part.text), text: part.text } : part,
|
|
87
|
+
);
|
|
88
|
+
this.#mount();
|
|
89
|
+
this.#syncTails();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#reconcile(): void {
|
|
93
|
+
const previous = this.#parts;
|
|
94
|
+
const segments = this.#segments();
|
|
95
|
+
let reshaped = previous.length !== segments.length;
|
|
96
|
+
this.#parts = segments.map((segment, index): Part => {
|
|
97
|
+
const old = previous[index];
|
|
98
|
+
if (segment.kind === "chart") {
|
|
99
|
+
if (old?.kind === "chart") {
|
|
100
|
+
old.table = segment.table;
|
|
101
|
+
return old;
|
|
102
|
+
}
|
|
103
|
+
reshaped = true;
|
|
104
|
+
const { budget, onChange } = this.#options;
|
|
105
|
+
return { kind: "chart", figure: new TableChartFigure({ budget, onChange }), table: segment.table };
|
|
106
|
+
}
|
|
107
|
+
if (segment.kind === "markdown") {
|
|
108
|
+
const text = segment.text.trim();
|
|
109
|
+
if (old?.kind === "markdown") {
|
|
110
|
+
old.md.setText(text);
|
|
111
|
+
old.text = text;
|
|
112
|
+
return old;
|
|
113
|
+
}
|
|
114
|
+
reshaped = true;
|
|
115
|
+
return { kind: "markdown", md: this.#options.markdown(text), text };
|
|
116
|
+
}
|
|
117
|
+
if (old?.kind === "svg") {
|
|
118
|
+
old.source = segment.source;
|
|
119
|
+
old.closed = segment.closed;
|
|
120
|
+
return old;
|
|
121
|
+
}
|
|
122
|
+
reshaped = true;
|
|
123
|
+
const { markdown, budget, onChange } = this.#options;
|
|
124
|
+
return {
|
|
125
|
+
kind: "svg",
|
|
126
|
+
figure: new SvgFigure({ markdown, budget, onChange }),
|
|
127
|
+
source: segment.source,
|
|
128
|
+
closed: segment.closed,
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
if (reshaped) {
|
|
132
|
+
for (const old of previous) {
|
|
133
|
+
if (old.kind !== "markdown" && !this.#parts.includes(old)) old.figure.dispose();
|
|
134
|
+
}
|
|
135
|
+
this.#mount();
|
|
136
|
+
}
|
|
137
|
+
this.#syncTails();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The block as prose, ```svg fences (when figures are on) and charts after
|
|
142
|
+
* complete tables (when charts are on); only the last run can still stream.
|
|
143
|
+
*/
|
|
144
|
+
#segments(): (FigureSegment | ChartSegment)[] {
|
|
145
|
+
const fences: FigureSegment[] = svgFigureRendering()
|
|
146
|
+
? splitSvgFences(this.#text)
|
|
147
|
+
: [{ kind: "markdown", text: this.#text }];
|
|
148
|
+
if (!this.#options.charts || !hasChartTable(this.#text)) return fences;
|
|
149
|
+
const last = fences.length - 1;
|
|
150
|
+
return fences.flatMap((segment, index): (FigureSegment | ChartSegment)[] =>
|
|
151
|
+
segment.kind === "markdown" ? splitTableCharts(segment.text, this.#transient && index === last) : [segment],
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Mount the parts as children, a blank row between neighbours; a chart draws its own when it has rows. */
|
|
156
|
+
#mount(): void {
|
|
157
|
+
this.clear();
|
|
158
|
+
this.#parts.forEach((part, index) => {
|
|
159
|
+
if (index > 0 && part.kind !== "chart") this.addChild(new Spacer(1));
|
|
160
|
+
this.addChild(part.kind === "markdown" ? part.md : part.figure);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Push sources and streaming state down: only the last part can still be streaming. */
|
|
165
|
+
#syncTails(): void {
|
|
166
|
+
const last = this.#parts.length - 1;
|
|
167
|
+
this.#parts.forEach((part, index) => {
|
|
168
|
+
const streaming = this.#transient && index === last;
|
|
169
|
+
if (part.kind === "markdown") part.md.transientRenderCache = streaming;
|
|
170
|
+
else if (part.kind === "svg") part.figure.update(part.source, part.closed || !streaming);
|
|
171
|
+
else part.figure.update(part.table);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|