@oh-my-pi/pi-tui 18.6.3 → 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.
Files changed (57) hide show
  1. package/CHANGELOG.md +26 -58
  2. package/dist/types/app-keybindings.d.ts +1 -1
  3. package/dist/types/autocomplete.d.ts +5 -2
  4. package/dist/types/charts/chart-plan.d.ts +92 -0
  5. package/dist/types/charts/chart-svg.d.ts +30 -0
  6. package/dist/types/charts/table-data.d.ts +107 -0
  7. package/dist/types/chat/assistant-message.d.ts +4 -0
  8. package/dist/types/chat/chat-transcript-builder.d.ts +2 -0
  9. package/dist/types/chat/figure-markdown.d.ts +42 -0
  10. package/dist/types/chat/svg-figure.d.ts +33 -0
  11. package/dist/types/chat/svg-source.d.ts +49 -0
  12. package/dist/types/chat/table-chart.d.ts +88 -0
  13. package/dist/types/components/editor.d.ts +1 -1
  14. package/dist/types/components/image.d.ts +9 -0
  15. package/dist/types/components/markdown.d.ts +9 -0
  16. package/dist/types/fuzzy.d.ts +14 -0
  17. package/dist/types/keybindings.d.ts +1 -1
  18. package/dist/types/overlays/agents-hub.d.ts +9 -0
  19. package/dist/types/overlays/annotation-overlay.d.ts +5 -2
  20. package/dist/types/overlays/model-browser.d.ts +25 -3
  21. package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
  22. package/dist/types/prompt/interactive-context-helpers.d.ts +2 -0
  23. package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
  24. package/dist/types/render/composite.d.ts +2 -0
  25. package/dist/types/tools/glob.d.ts +4 -0
  26. package/dist/types/utils.d.ts +6 -0
  27. package/package.json +9 -9
  28. package/src/autocomplete.ts +8 -10
  29. package/src/charts/chart-plan.ts +367 -0
  30. package/src/charts/chart-svg.ts +693 -0
  31. package/src/charts/table-data.ts +436 -0
  32. package/src/chat/assistant-message.ts +139 -21
  33. package/src/chat/chat-transcript-builder.ts +4 -0
  34. package/src/chat/figure-markdown.ts +174 -0
  35. package/src/chat/svg-figure.ts +364 -0
  36. package/src/chat/svg-source.ts +188 -0
  37. package/src/chat/table-chart.ts +301 -0
  38. package/src/chrome/transcript-container.ts +7 -1
  39. package/src/components/editor.ts +52 -30
  40. package/src/components/image.ts +27 -1
  41. package/src/components/markdown.ts +6 -3
  42. package/src/fuzzy.ts +59 -19
  43. package/src/keybindings.ts +1 -1
  44. package/src/overlays/agent-transcript-viewer.ts +2 -0
  45. package/src/overlays/agents-hub.ts +14 -1
  46. package/src/overlays/annotation-overlay.ts +179 -80
  47. package/src/overlays/ask-dialog.ts +1 -1
  48. package/src/overlays/model-browser.ts +169 -76
  49. package/src/overlays/plan-review-overlay.ts +41 -4
  50. package/src/prompt/composer.ts +2 -14
  51. package/src/prompt/interactive-context-helpers.ts +3 -0
  52. package/src/prompt/model-mention-autocomplete.ts +14 -14
  53. package/src/render/composite.ts +62 -0
  54. package/src/status-line/segments.ts +3 -1
  55. package/src/tools/glob.ts +23 -6
  56. package/src/tui.ts +118 -105
  57. 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?: Markdown;
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
- | Array<{ md: Markdown; contentIndex: number; blockType: "text" | "thinking"; lastText: string }>
298
- | undefined;
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
  }
@@ -537,6 +559,7 @@ export class AssistantMessageComponent extends Container {
537
559
  this.#markdownTheme = undefined;
538
560
  this.#fastPathKey = undefined;
539
561
  this.#fastPathItems = undefined;
562
+ for (const block of this.#figureBlocks.values()) block.restyle();
540
563
  if (this.#lastMessage) {
541
564
  this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
542
565
  }
@@ -713,6 +736,12 @@ export class AssistantMessageComponent extends Container {
713
736
  return this.#transcriptBlockFinalized;
714
737
  }
715
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
+
716
745
  override render(width: number): readonly string[] {
717
746
  const rows = super.render(width);
718
747
  this.#publishStableSnapshot(rows, width);
@@ -806,7 +835,25 @@ export class AssistantMessageComponent extends Container {
806
835
  const content = message.content[index]!;
807
836
  const streaming = live && index === tailIndex;
808
837
  if (content.type === "text" && canonicalizeMessage(content.text)) {
809
- children.push(markdown(`t${index}`, `t${index}`, content.text.trim(), streaming));
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
+ });
810
857
  } else if (content.type === "thinking") {
811
858
  const display = resolveThinkingDisplay(content, this.#proseOnlyThinking);
812
859
  if (!display.visible) continue;
@@ -1089,12 +1136,18 @@ export class AssistantMessageComponent extends Container {
1089
1136
  for (const child of this.#contentContainer.children) {
1090
1137
  const item = items[itemIndex];
1091
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
+ }
1092
1145
  if (itemIndex === items.length - 1) {
1093
1146
  // Streaming child: publish Markdown's frozen prefix, and only
1094
1147
  // once non-blank content exists past it — the block's last
1095
1148
  // non-blank line is still being written (thinking's prose fold
1096
1149
  // may rewrite it) and must stay out of published bytes.
1097
- const raw = item.md.getLastRenderStableText();
1150
+ const raw = md.getLastRenderStableText();
1098
1151
  const frozen = raw.trim();
1099
1152
  if (frozen.length > 0 && /\S/.test(item.lastText.slice(raw.length))) {
1100
1153
  parts.push({ kind: item.blockType, text: frozen });
@@ -1122,7 +1175,7 @@ export class AssistantMessageComponent extends Container {
1122
1175
  /**
1123
1176
  * Render the first `partCount` stable parts, the final one cut to
1124
1177
  * `lastLength`. Rows match fresh Markdown renders of each part byte for
1125
- * byte — {@link #createStableMarkdown} mirrors the live children — but a
1178
+ * byte — {@link #createMarkdown} builds the live children too — but a
1126
1179
  * closed part renders once per width, and the growing final part reuses one
1127
1180
  * Markdown instance so its already-frozen blocks are not re-lexed.
1128
1181
  */
@@ -1172,7 +1225,7 @@ export class AssistantMessageComponent extends Container {
1172
1225
  // Trim like the live children, dropping the trailing blank line a frozen
1173
1226
  // prefix still carries.
1174
1227
  const trimmed = text.trim();
1175
- if (this.#transcriptBlockFinalized) return this.#createStableMarkdown(kind, trimmed).render(width);
1228
+ if (this.#transcriptBlockFinalized) return this.#createMarkdown(kind, trimmed).render(width);
1176
1229
  const head = this.#stableHeadRenderer;
1177
1230
  const replay = this.#stableReplayRenderer;
1178
1231
  const renderer = role === "head" ? head : role === "replay" ? replay : head?.index === index ? head : replay;
@@ -1180,14 +1233,14 @@ export class AssistantMessageComponent extends Container {
1180
1233
  renderer.md.setText(trimmed);
1181
1234
  return renderer.md.render(width);
1182
1235
  }
1183
- const md = this.#createStableMarkdown(kind, trimmed);
1236
+ const md = this.#createMarkdown(kind, trimmed);
1184
1237
  if (role === "head") this.#stableHeadRenderer = { index, kind, md };
1185
1238
  else if (role === "replay") this.#stableReplayRenderer = { index, kind, md };
1186
1239
  return md.render(width);
1187
1240
  }
1188
1241
 
1189
- /** Constructor args mirror the live child Markdown so stable rows prefix the block render. */
1190
- #createStableMarkdown(kind: StablePartKind, text: string): Markdown {
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 {
1191
1244
  return kind === "text"
1192
1245
  ? new Markdown(
1193
1246
  text,
@@ -1203,6 +1256,58 @@ export class AssistantMessageComponent extends Container {
1203
1256
  });
1204
1257
  }
1205
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
+
1206
1311
  #stableLedger(width: number): StableRowLedger {
1207
1312
  let ledger = this.#stableLedgers.get(width);
1208
1313
  if (ledger === undefined) {
@@ -1322,6 +1427,18 @@ export class AssistantMessageComponent extends Container {
1322
1427
  }
1323
1428
  }
1324
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
+
1325
1442
  /** Toggle only images produced by tool results; assistant-native images remain governed by setImagesVisible. */
1326
1443
  setToolResultImagesVisible(visible: boolean): void {
1327
1444
  if (this.#showToolResultImages === visible) return;
@@ -1440,7 +1557,9 @@ export class AssistantMessageComponent extends Container {
1440
1557
  ];
1441
1558
  for (const content of message.content) {
1442
1559
  if (content.type === "text") {
1443
- parts.push(canonicalizeMessage(content.text) ? "T1" : "T0");
1560
+ parts.push(
1561
+ !canonicalizeMessage(content.text) ? "T0" : this.#liftsFigures(content.text.trim()) ? "TF" : "T1",
1562
+ );
1444
1563
  } else if (content.type === "thinking") {
1445
1564
  if (this.#hideThinkingBlock) {
1446
1565
  // Match the pulse's empty/nonempty transition without formatting hidden text.
@@ -1601,9 +1720,9 @@ export class AssistantMessageComponent extends Container {
1601
1720
 
1602
1721
  // Determine if we should capture Markdown instances for next fast path
1603
1722
  const shouldCapture = this.#canFastPath(message);
1604
- const captureItems:
1605
- | Array<{ md: Markdown; contentIndex: number; blockType: "text" | "thinking"; lastText: string }>
1606
- | undefined = shouldCapture ? [] : undefined;
1723
+ const captureItems: FastPathItem[] | undefined = shouldCapture ? [] : undefined;
1724
+ const previousFigures = this.#figureBlocks;
1725
+ this.#figureBlocks = new Map();
1607
1726
 
1608
1727
  const hasVisibleContent = message.content.some(
1609
1728
  c =>
@@ -1622,8 +1741,7 @@ export class AssistantMessageComponent extends Container {
1622
1741
  if (content.type === "text" && canonicalizeMessage(content.text)) {
1623
1742
  // Set paddingY=0 to avoid extra spacing before tool executions
1624
1743
  const trimmed = content.text.trim();
1625
- const mdOptions = this.#textColorTransform ? { color: this.#textColorTransform } : undefined;
1626
- const md = new Markdown(trimmed, 1, 0, this.#getProseTheme(), mdOptions, 0);
1744
+ const md = this.#proseBlock(i, trimmed, previousFigures);
1627
1745
  this.#contentContainer.addChild(md);
1628
1746
  this.#emergencyText = md;
1629
1747
  captureItems?.push({ md, contentIndex: i, blockType: "text", lastText: trimmed });
@@ -1648,10 +1766,7 @@ export class AssistantMessageComponent extends Container {
1648
1766
  );
1649
1767
 
1650
1768
  // Thinking traces in thinkingText color, italic
1651
- const md = new Markdown(thinkingText, 1, 0, getMarkdownTheme(), {
1652
- color: (text: string) => theme.fg("thinkingText", text),
1653
- italic: true,
1654
- });
1769
+ const md = this.#createMarkdown("thinking", thinkingText);
1655
1770
  md.transientRenderCache = this.#lastUpdateTransient;
1656
1771
  this.#contentContainer.addChild(md);
1657
1772
  captureItems?.push({ md, contentIndex: i, blockType: "thinking", lastText: thinkingText });
@@ -1666,6 +1781,9 @@ export class AssistantMessageComponent extends Container {
1666
1781
  hasRenderedContent ||= this.#showImages;
1667
1782
  }
1668
1783
  }
1784
+ for (const [index, block] of previousFigures) {
1785
+ if (this.#figureBlocks.get(index) !== block) block.dispose();
1786
+ }
1669
1787
 
1670
1788
  if (this.#shouldAnimateThinking(message)) {
1671
1789
  if (hasVisibleContent) this.#contentContainer.addChild(new Spacer(1));
@@ -83,6 +83,8 @@ export interface ChatTranscriptBuilderDeps {
83
83
  expandThinkingBlocks?: () => boolean;
84
84
  /** Session-scoped resolved destinations for model-authored Markdown links. */
85
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;
86
88
  requestRender: () => void;
87
89
  }
88
90
 
@@ -419,6 +421,7 @@ export class ChatTranscriptBuilder {
419
421
  );
420
422
  assistantComponent.setImagesVisible(displayPreferences.showImages);
421
423
  assistantComponent.setToolResultImagesVisible(!displayPreferences.hideToolActivity);
424
+ assistantComponent.setTableChartsVisible(this.#deps.tableCharts !== false);
422
425
  this.#trackExpandable(assistantComponent);
423
426
  assistantComponent.pickReactionTarget(this.container.children);
424
427
  this.container.addChild(assistantComponent);
@@ -457,6 +460,7 @@ export class ChatTranscriptBuilder {
457
460
  );
458
461
  component.setImagesVisible(displayPreferences.showImages);
459
462
  component.setToolResultImagesVisible(!displayPreferences.hideToolActivity);
463
+ component.setTableChartsVisible(this.#deps.tableCharts !== false);
460
464
  this.#trackExpandable(component);
461
465
  this.container.addChild(component);
462
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
+ }