@oh-my-pi/pi-tui 18.8.5 → 18.8.7

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 (76) hide show
  1. package/CHANGELOG.md +30 -16
  2. package/README.md +7 -5
  3. package/dist/types/chat/assistant-message.d.ts +1 -0
  4. package/dist/types/chat/compaction-summary-message.d.ts +1 -0
  5. package/dist/types/chat/user-message.d.ts +2 -0
  6. package/dist/types/components/box.d.ts +1 -0
  7. package/dist/types/components/disclosure.d.ts +1 -0
  8. package/dist/types/components/image.d.ts +1 -0
  9. package/dist/types/components/key-value-list.d.ts +1 -0
  10. package/dist/types/components/layout/row.d.ts +1 -0
  11. package/dist/types/components/layout/stack.d.ts +1 -0
  12. package/dist/types/components/markdown.d.ts +1 -0
  13. package/dist/types/components/scroll-view.d.ts +1 -0
  14. package/dist/types/components/section.d.ts +1 -0
  15. package/dist/types/components/table.d.ts +1 -0
  16. package/dist/types/components/text.d.ts +1 -0
  17. package/dist/types/components/truncated-text.d.ts +1 -0
  18. package/dist/types/native/blobs.d.ts +3 -1
  19. package/dist/types/overlays/session-selector.d.ts +6 -2
  20. package/dist/types/render/output-pane.d.ts +1 -0
  21. package/dist/types/render/render-utils.d.ts +1 -0
  22. package/dist/types/render/tool-card.d.ts +6 -0
  23. package/dist/types/render/width-aware-text.d.ts +2 -0
  24. package/dist/types/terminal.d.ts +10 -2
  25. package/dist/types/theme/theme.d.ts +1 -1
  26. package/dist/types/theme/tui-adapters.d.ts +3 -0
  27. package/dist/types/tools/write.d.ts +1 -1
  28. package/dist/types/tools/yield.d.ts +22 -0
  29. package/dist/types/tui.d.ts +11 -0
  30. package/package.json +13 -9
  31. package/src/chat/advisor-message.ts +11 -0
  32. package/src/chat/assistant-message.ts +8 -2
  33. package/src/chat/compaction-summary-message.ts +8 -0
  34. package/src/chat/read-tool-group.ts +6 -4
  35. package/src/chat/skill-message.ts +6 -0
  36. package/src/chat/table-chart.ts +17 -20
  37. package/src/chat/tool-execution.ts +18 -0
  38. package/src/chat/user-message.ts +14 -0
  39. package/src/chrome/transcript-container.ts +32 -1
  40. package/src/components/box.ts +5 -0
  41. package/src/components/disclosure.ts +8 -0
  42. package/src/components/image.ts +7 -5
  43. package/src/components/key-value-list.ts +4 -0
  44. package/src/components/layout/row.ts +7 -0
  45. package/src/components/layout/stack.ts +7 -0
  46. package/src/components/markdown.ts +190 -8
  47. package/src/components/scroll-view.ts +5 -0
  48. package/src/components/section.ts +5 -0
  49. package/src/components/table.ts +4 -0
  50. package/src/components/text.ts +7 -0
  51. package/src/components/truncated-text.ts +5 -0
  52. package/src/native/backend.ts +16 -15
  53. package/src/native/blobs.ts +43 -10
  54. package/src/native/reconcile.ts +4 -0
  55. package/src/overlays/model-hub.ts +39 -4
  56. package/src/overlays/session-selector.ts +40 -22
  57. package/src/overlays/snapcompact-shape-preview.ts +2 -3
  58. package/src/prompt/attachment-chips.ts +13 -10
  59. package/src/prompt/tips.txt +2 -1
  60. package/src/prompt/welcome.ts +46 -9
  61. package/src/render/output-pane.ts +6 -0
  62. package/src/render/render-utils.ts +11 -0
  63. package/src/render/tool-card.ts +15 -0
  64. package/src/render/width-aware-text.ts +5 -0
  65. package/src/terminal.ts +24 -3
  66. package/src/theme/theme.ts +1 -0
  67. package/src/theme/tui-adapters.ts +68 -0
  68. package/src/tools/bash.ts +10 -11
  69. package/src/tools/edit.ts +142 -120
  70. package/src/tools/fetch.ts +5 -6
  71. package/src/tools/index.ts +2 -0
  72. package/src/tools/mcp.ts +15 -2
  73. package/src/tools/read.ts +7 -5
  74. package/src/tools/write.ts +70 -56
  75. package/src/tools/yield.ts +225 -0
  76. package/src/tui.ts +25 -0
@@ -16,8 +16,8 @@ import { chartAlt, renderChartSvg } from "../charts/chart-svg";
16
16
  import { analyzeTable, type TableAnalysis } from "../charts/table-data";
17
17
  import type { ImageBudget } from "../components/image";
18
18
  import { lexDocument } from "../components/markdown";
19
- import { node } from "../native/describe";
20
- import { registerNativeBlob } from "../native/blobs";
19
+ import { keyed } from "../native/describe";
20
+ import { nativeImageNode } from "../native/blobs";
21
21
  import type { NativeNode } from "../native/node";
22
22
  import { getThemeEpoch } from "../theme";
23
23
  import type { Component } from "../tui";
@@ -85,11 +85,11 @@ interface ChartEntry {
85
85
  let chartMode: TableChartMode = "off";
86
86
  let chartPlanner: TableChartPlanner | undefined;
87
87
  const charts = new Map<string, ChartEntry>();
88
- /** The theme-resolved native blob last registered for a chart. */
89
- const kNativeBlob = Symbol("tableChart.nativeBlob");
88
+ /** The theme-resolved image node owning a chart's native bytes. */
89
+ const kNativeImage = Symbol("tableChart.nativeImage");
90
90
 
91
91
  interface NativeTagged extends TableChart {
92
- [kNativeBlob]?: { epoch: number; blob: string };
92
+ [kNativeImage]?: { epoch: number; image: NativeNode };
93
93
  }
94
94
 
95
95
  /** Set how tables become charts (the host's `tui.autoGraph`) and its `smart` planner; the host rebuilds the transcript after. */
@@ -205,24 +205,21 @@ function drawChart(analysis: TableAnalysis, plan: ChartPlan, guessed = false): T
205
205
  export function describeTableChart(chart: TableChart, key: string): NativeNode {
206
206
  const epoch = getThemeEpoch();
207
207
  const tagged: NativeTagged = chart;
208
- let entry = tagged[kNativeBlob];
208
+ let entry = tagged[kNativeImage];
209
209
  if (entry?.epoch !== epoch) {
210
210
  const svg = prepareSvg(chart.svg, svgFigurePalette());
211
- entry = { epoch, blob: registerNativeBlob(new TextEncoder().encode(svg), "image/svg+xml") };
212
- tagged[kNativeBlob] = entry;
211
+ entry = {
212
+ epoch,
213
+ image: nativeImageNode(new TextEncoder().encode(svg), "image/svg+xml", {
214
+ alt: chart.alt,
215
+ w: chart.width,
216
+ h: chart.height,
217
+ max: { w: `${Math.round(chart.width / UNITS_PER_COLUMN)}ch` },
218
+ }),
219
+ };
220
+ tagged[kNativeImage] = entry;
213
221
  }
214
- return node(
215
- "image",
216
- {
217
- blob: entry.blob,
218
- alt: chart.alt,
219
- w: chart.width,
220
- h: chart.height,
221
- max: { w: `${Math.round(chart.width / UNITS_PER_COLUMN)}ch` },
222
- },
223
- undefined,
224
- key,
225
- );
222
+ return keyed(entry.image, key);
226
223
  }
227
224
 
228
225
  export interface TableChartFigureOptions {
@@ -170,6 +170,24 @@ class SafeToolRendererComponent implements Component {
170
170
  invalidate.call(this.#component);
171
171
  }
172
172
 
173
+ releaseRenderCaches(): void {
174
+ const release = this.#component.releaseRenderCaches;
175
+ if (release === undefined) return;
176
+ try {
177
+ release.call(this.#component);
178
+ } catch (err) {
179
+ if (!this.#warned) {
180
+ this.#warned = true;
181
+ logger.warn("Tool renderer failed", {
182
+ tool: this.#toolName,
183
+ stage: this.#stage,
184
+ phase: "releaseRenderCaches",
185
+ error: String(err),
186
+ });
187
+ }
188
+ }
189
+ }
190
+
173
191
  setIgnoreTight(ignore: boolean): void {
174
192
  const setIgnoreTight = this.#component.setIgnoreTight;
175
193
  if (setIgnoreTight === undefined) return;
@@ -177,6 +177,12 @@ export class UserMessageComponent extends Container implements ReactionTarget {
177
177
  this.addChild(markdown);
178
178
  }
179
179
 
180
+ override releaseRenderCaches(): void {
181
+ this.#zoneSource = undefined;
182
+ this.#zoneLines = undefined;
183
+ super.releaseRenderCaches();
184
+ }
185
+
180
186
  setReaction(emoji: string): void {
181
187
  if (this.#reaction === emoji) return;
182
188
  this.#reaction = emoji;
@@ -337,6 +343,10 @@ class SyntheticSummary implements Component {
337
343
  this.#summary = summary;
338
344
  }
339
345
 
346
+ releaseRenderCaches(): void {
347
+ this.#cache = undefined;
348
+ }
349
+
340
350
  invalidate(): void {
341
351
  this.#cache = undefined;
342
352
  }
@@ -425,6 +435,10 @@ export class CollapsedSyntheticMessageComponent implements Component {
425
435
  this.#disclosure.invalidate();
426
436
  }
427
437
 
438
+ releaseRenderCaches(): void {
439
+ this.#disclosure.releaseRenderCaches();
440
+ }
441
+
428
442
  dispose(): void {
429
443
  this.#disclosure.dispose();
430
444
  }
@@ -206,6 +206,7 @@ export class TranscriptContainer extends Container {
206
206
  */
207
207
  #frameRows = new Map<TranscriptEntry, readonly string[]>();
208
208
  #frameRowsWidth = 0;
209
+ #releaseFailures = new WeakSet<Component>();
209
210
  /** The `children` array `#entries` last mirrored; see {@link #syncEntries}. */
210
211
  #syncedChildren: Component[] | undefined;
211
212
  /** Forces the next {@link #syncEntries} to compare every entry, not just the live tail. */
@@ -583,7 +584,10 @@ export class TranscriptContainer extends Container {
583
584
  popLoopPhase();
584
585
  }
585
586
  this.#replayPending = false;
586
- if (rows.length === 0) return undefined;
587
+ if (rows.length === 0) {
588
+ this.#releaseCommittedRenderCaches();
589
+ return undefined;
590
+ }
587
591
  const batch: HistoryBatch = { id: this.#nextBatchId++, rows, kind: "replay" };
588
592
  this.#offered = { batch, kind: "replay" };
589
593
  return batch;
@@ -747,6 +751,8 @@ export class TranscriptContainer extends Container {
747
751
  this.#retireEntry(this.#entries[index]!);
748
752
  }
749
753
  this.#frontier = offered.end;
754
+ } else {
755
+ this.#releaseCommittedRenderCaches();
750
756
  }
751
757
  this.#offered = undefined;
752
758
  if (this.#replayRequested) this.#startReplay();
@@ -766,6 +772,7 @@ export class TranscriptContainer extends Container {
766
772
  const entry = this.#entries[index]!;
767
773
  this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
768
774
  const block = trimBlankEdges(entry.component.render(width));
775
+ if (entry.state === "committed") this.#releaseRenderCaches(entry);
769
776
  if (block.length === 0) continue;
770
777
  if (rows.length > 0) rows.unshift("");
771
778
  rows.unshift(...block);
@@ -813,6 +820,7 @@ export class TranscriptContainer extends Container {
813
820
  for (const entry of this.#entries) {
814
821
  this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
815
822
  const block = this.#renderEntry(entry, width);
823
+ if (entry.state === "committed") this.#releaseRenderCaches(entry);
816
824
  if (block.length === 0) continue;
817
825
  if (rows.length > 0) rows.push("");
818
826
  this.#childStartRows.set(entry.component, rows.length);
@@ -1054,6 +1062,29 @@ export class TranscriptContainer extends Container {
1054
1062
  entry.stableRows = EMPTY_STABLE_ROWS;
1055
1063
  entry.renderedStableByWidth = new Map();
1056
1064
  entry.stableRowCountByWidth = new Map();
1065
+ this.#frameRows.delete(entry);
1066
+ this.#releaseRenderCaches(entry);
1067
+ }
1068
+
1069
+ #releaseCommittedRenderCaches(): void {
1070
+ for (let index = 0; index < this.#frontier; index++) {
1071
+ this.#releaseRenderCaches(this.#entries[index]!);
1072
+ }
1073
+ }
1074
+
1075
+ #releaseRenderCaches(entry: TranscriptEntry): void {
1076
+ const release = entry.component.releaseRenderCaches;
1077
+ if (release === undefined) return;
1078
+ try {
1079
+ release.call(entry.component);
1080
+ } catch (err) {
1081
+ if (this.#releaseFailures.has(entry.component)) return;
1082
+ this.#releaseFailures.add(entry.component);
1083
+ logger.warn("Transcript block failed to release render caches", {
1084
+ component: entry.component.constructor.name,
1085
+ error: String(err),
1086
+ });
1087
+ }
1057
1088
  }
1058
1089
 
1059
1090
  #startReplay(): void {
@@ -135,6 +135,11 @@ export class Box implements Component {
135
135
  }
136
136
  }
137
137
 
138
+ releaseRenderCaches(): void {
139
+ this.#invalidateCache();
140
+ for (const child of this.children) child.releaseRenderCaches?.();
141
+ }
142
+
138
143
  /**
139
144
  * A `card` when the box has a border or background: the background token
140
145
  * picks tone/role (user message, tool state, selection), a border colour
@@ -100,6 +100,14 @@ export class Disclosure implements Component {
100
100
  this.#body?.invalidate?.();
101
101
  }
102
102
 
103
+ releaseRenderCaches(): void {
104
+ this.#cache = undefined;
105
+ this.#boundedCache = undefined;
106
+ this.#summary?.releaseRenderCaches?.();
107
+ this.#collapsedBody?.releaseRenderCaches?.();
108
+ this.#body?.releaseRenderCaches?.();
109
+ }
110
+
103
111
  dispose(): void {
104
112
  if (this.#disposed) return;
105
113
  this.#disposed = true;
@@ -10,8 +10,7 @@ import {
10
10
  renderImage,
11
11
  TERMINAL,
12
12
  } from "../terminal-capabilities";
13
- import { registerNativeBlob } from "../native/blobs";
14
- import { node } from "../native/describe";
13
+ import { nativeImageNode } from "../native/blobs";
15
14
  import type { DescribeContext, NativeNode } from "../native/node";
16
15
  import type { Component } from "../tui";
17
16
 
@@ -826,6 +825,11 @@ export class Image implements Component {
826
825
  this.#cachedWidth = undefined;
827
826
  }
828
827
 
828
+ releaseRenderCaches(): void {
829
+ this.#cachedLines = undefined;
830
+ this.#cachedWidth = undefined;
831
+ }
832
+
829
833
  /**
830
834
  * SIXEL sequence for a target size. A new size starts the encode off the JS
831
835
  * thread and answers `undefined` until it settles; the settled encode
@@ -865,11 +869,9 @@ export class Image implements Component {
865
869
  */
866
870
  describe(_cx: DescribeContext): NativeNode {
867
871
  if (this.#native) return this.#native;
868
- const blob = registerNativeBlob(Buffer.from(this.#base64Data, "base64"), this.#mimeType);
869
872
  const maxW = this.#options.maxWidthCells;
870
873
  const maxH = this.#options.maxHeightCells;
871
- this.#native = node("image", {
872
- blob,
874
+ this.#native = nativeImageNode(Buffer.from(this.#base64Data, "base64"), this.#mimeType, {
873
875
  alt: imageFallback(this.#mimeType, this.#dimensions, this.#options.filename),
874
876
  w: this.#dimensions.widthPx,
875
877
  h: this.#dimensions.heightPx,
@@ -54,6 +54,10 @@ export class KeyValueList implements Component {
54
54
  this.#native = undefined;
55
55
  }
56
56
 
57
+ releaseRenderCaches(): void {
58
+ this.#cache = undefined;
59
+ }
60
+
57
61
  /** An aligned `kv` grid; label width and value wrapping are the terminal's. */
58
62
  describe(_cx: DescribeContext): NativeNode {
59
63
  this.#native ??= node("kv", {
@@ -168,6 +168,13 @@ export class Row implements Component, MouseRoutable {
168
168
  }
169
169
  }
170
170
 
171
+ releaseRenderCaches(): void {
172
+ this.#memo = undefined;
173
+ for (const child of uniqueLayoutComponents(this.#children.map(item => item.content))) {
174
+ child.releaseRenderCaches?.();
175
+ }
176
+ }
177
+
171
178
  dispose(): void {
172
179
  for (const child of uniqueLayoutComponents(this.#children.map(item => item.content))) {
173
180
  child.dispose?.();
@@ -140,6 +140,13 @@ export class Stack implements Component, MouseRoutable {
140
140
  }
141
141
  }
142
142
 
143
+ releaseRenderCaches(): void {
144
+ this.#memo = undefined;
145
+ for (const child of uniqueLayoutComponents(this.#children.map(item => item.content))) {
146
+ child.releaseRenderCaches?.();
147
+ }
148
+ }
149
+
143
150
  dispose(): void {
144
151
  for (const child of uniqueLayoutComponents(this.#children.map(item => item.content))) {
145
152
  child.dispose?.();
@@ -25,7 +25,8 @@ import { md } from "../native/describe";
25
25
  import type { DescribeContext, NativeNode } from "../native/node";
26
26
  import type { SymbolTheme } from "../symbols";
27
27
  import { TERMINAL } from "../terminal-capabilities";
28
- import { getSymbolTheme } from "../theme/theme";
28
+ import { getSymbolTheme, getThemeEpoch } from "../theme/theme";
29
+ import { canCacheMarkdownListItems } from "../theme/tui-adapters";
29
30
  import type { Component } from "../tui";
30
31
  import {
31
32
  applyBackgroundToLine,
@@ -1390,10 +1391,27 @@ interface InlineStyleContext {
1390
1391
  stylePrefix: string;
1391
1392
  }
1392
1393
 
1393
- type ListToken = Token & { items: Array<{ tokens?: Token[] }>; ordered: boolean; start?: number };
1394
+ type ListToken = Token & {
1395
+ items: Array<{ raw: string; tokens?: Token[] }>;
1396
+ ordered: boolean;
1397
+ start?: number;
1398
+ loose: boolean;
1399
+ };
1394
1400
  type TableCellToken = { tokens?: Token[] };
1395
1401
  type TableToken = Token & { header: TableCellToken[]; rows: TableCellToken[][]; raw?: string };
1396
1402
 
1403
+ /** `item` as marked lexes it inside a loose list: tight `text` blocks become paragraphs. */
1404
+ function loosenListItem(item: Tokens.ListItem): Tokens.ListItem {
1405
+ if (item.loose) return item;
1406
+ return {
1407
+ ...item,
1408
+ loose: true,
1409
+ tokens: item.tokens.map(token =>
1410
+ token.type === "text" ? ({ ...token, type: "paragraph" } as Tokens.Paragraph) : token,
1411
+ ),
1412
+ };
1413
+ }
1414
+
1397
1415
  function formatHyperlink(text: string, target: string): string {
1398
1416
  if (!TERMINAL.hyperlinks || !target) {
1399
1417
  return text;
@@ -1743,6 +1761,25 @@ interface TailRowCache extends RenderSignature {
1743
1761
  // type of token[i+1] when the rows were produced (blank/spacing gate).
1744
1762
  nextTypes: (string | undefined)[];
1745
1763
  }
1764
+ interface ListItemRowsCache {
1765
+ signature: RenderSignature;
1766
+ width: number;
1767
+ themeEpoch: number;
1768
+ ordered: boolean;
1769
+ start: number | undefined;
1770
+ loose: boolean;
1771
+ raws: string[];
1772
+ rows: RenderedLine[][];
1773
+ }
1774
+
1775
+ interface StreamingListCache {
1776
+ text: string;
1777
+ before: Token[];
1778
+ start: number;
1779
+ tailStart: number;
1780
+ stableCount: number;
1781
+ list: Tokens.List;
1782
+ }
1746
1783
  /**
1747
1784
  * Mutable per-token record collector passed to #renderContentLines while
1748
1785
  * rendering the streaming tail. The render loop fills `raws`/`nextTypes`
@@ -1856,6 +1893,8 @@ export class Markdown implements Component {
1856
1893
  // the blank-replacement branch of setText and the fallback branch of
1857
1894
  // #lexTokens.
1858
1895
  #tailRowCache?: TailRowCache;
1896
+ #listItemRowsCache?: ListItemRowsCache;
1897
+ #streamingListCache?: StreamingListCache;
1859
1898
  // True while #renderStreamingContentLines renders the frozen token range:
1860
1899
  // frozen code blocks highlight even in transient mode so their bytes match
1861
1900
  // the finalized render (they render once into the prefix line cache, so
@@ -1962,6 +2001,7 @@ export class Markdown implements Component {
1962
2001
  // the non-append edit, so drop the frozen stream state here or it
1963
2002
  // outlives the content it indexed.
1964
2003
  this.#dropStreamPrefix();
2004
+ this.#listItemRowsCache = undefined;
1965
2005
  // B+: the captured fast-path rows index the replaced content — drop
1966
2006
  // the recipe so a fresh stream cannot splice onto stale rows.
1967
2007
  this.#fastTail = undefined;
@@ -1976,6 +2016,14 @@ export class Markdown implements Component {
1976
2016
  this.#cachedLines = undefined;
1977
2017
  }
1978
2018
 
2019
+ releaseRenderCaches(): void {
2020
+ this.invalidate();
2021
+ this.#dropStreamPrefix();
2022
+ this.#streamingHighlightCache = undefined;
2023
+ this.#fastTail = undefined;
2024
+ this.#lastTailCapture = undefined;
2025
+ }
2026
+
1979
2027
  /**
1980
2028
  * The unrendered source as an `md` node, `stream` while the streaming
1981
2029
  * (transient) cache is on. Append-only growth yields a fresh node of the
@@ -2031,7 +2079,9 @@ export class Markdown implements Component {
2031
2079
  // render consumes it (see #lexTokens / the L2 hit path).
2032
2080
  this.#streamPrefixLineCache = undefined;
2033
2081
  this.#tailRowCache = undefined;
2082
+ this.#listItemRowsCache = undefined;
2034
2083
  this.#streamingHighlightCache = undefined;
2084
+ this.#streamingListCache = undefined;
2035
2085
  this.#streamRewound = undefined;
2036
2086
  }
2037
2087
  this.invalidate();
@@ -2108,32 +2158,98 @@ export class Markdown implements Component {
2108
2158
  }
2109
2159
  this.#lastScanLength = text.length;
2110
2160
  this.#lastScanCanStream = canStream;
2161
+ if (!canStream) this.#listItemRowsCache = undefined;
2111
2162
  this.#lastScanValid = true;
2112
2163
  this.#appendOnlySinceLastScan = true;
2164
+ if (canStream && retainPrefix) {
2165
+ const tokens = this.#resumeStreamingList(text);
2166
+ if (tokens !== undefined) {
2167
+ this.#freezeStablePrefix(text, tokens, { preserveExisting: true });
2168
+ this.#checkpointStreamingList(text, tokens);
2169
+ return tokens;
2170
+ }
2171
+ }
2113
2172
  if (canStream && hasPrefix) {
2114
- const tailTokens = markdownParser.lexer(refDefText);
2115
- // HAS_REF_DEF sees top-level definition lines only. A definition nested
2116
- // in a quote or list item still registers for the whole document and
2117
- // can resolve a reference in the frozen prefix, which was lexed
2173
+ const tailTokens = lexDocument(refDefText);
2174
+ // A definition at any nesting depth registers for the whole document
2175
+ // and can resolve a reference in the frozen prefix, which was lexed
2118
2176
  // without it, so any definition in the tail sends the text to a full lex.
2119
2177
  if (Object.keys(tailTokens.links).length === 0) {
2120
2178
  const tokens = [...prefixTokens, ...tailTokens];
2121
2179
  if (retainPrefix) this.#freezeStablePrefix(text, tokens, { preserveExisting: true });
2122
2180
  else this.#dropStreamPrefix();
2181
+ if (retainPrefix) this.#checkpointStreamingList(text, tokens);
2123
2182
  return tokens;
2124
2183
  }
2125
2184
  }
2126
- const tokens = markdownParser.lexer(text);
2185
+ const tokens = lexDocument(text);
2186
+ const hasReferenceDefinitions = Object.keys(tokens.links).length > 0;
2187
+ if (hasReferenceDefinitions) this.#lastScanCanStream = false;
2127
2188
  // A definition frozen into the prefix would be missing from every later
2128
2189
  // tail lex, so a full lex that registered any definition freezes nothing.
2129
- if (canStream && retainPrefix && Object.keys(tokens.links).length === 0) {
2190
+ if (canStream && retainPrefix && !hasReferenceDefinitions) {
2130
2191
  this.#freezeStablePrefix(text, tokens, { preserveExisting: false });
2192
+ this.#checkpointStreamingList(text, tokens);
2131
2193
  } else {
2132
2194
  this.#dropStreamPrefix();
2133
2195
  }
2134
2196
  return tokens;
2135
2197
  }
2136
2198
 
2199
+ #resumeStreamingList(text: string): Token[] | undefined {
2200
+ const cached = this.#streamingListCache;
2201
+ if (cached === undefined || text.length <= cached.text.length || !text.startsWith(cached.text)) return undefined;
2202
+ const tail = lexDocument(text.slice(cached.tailStart));
2203
+ if (Object.keys(tail.links).length > 0) return undefined;
2204
+ const first = tail[0];
2205
+ if (first?.type !== "list") return undefined;
2206
+ const list = first as Tokens.List;
2207
+ if (!listMayContinueAt(list.raw, 0, cached.list.raw)) return undefined;
2208
+ const loose = cached.list.loose || list.loose;
2209
+ const completed = cached.list.items.slice(0, cached.stableCount);
2210
+ const items = [...completed, ...list.items];
2211
+ const merged: Tokens.List = {
2212
+ ...cached.list,
2213
+ raw: text.slice(cached.start, cached.tailStart + list.raw.length),
2214
+ loose,
2215
+ items: loose ? items.map(loosenListItem) : items,
2216
+ };
2217
+ return [...cached.before, merged, ...tail.slice(1)];
2218
+ }
2219
+
2220
+ #checkpointStreamingList(text: string, tokens: Token[]): void {
2221
+ const previous = this.#streamingListCache;
2222
+ this.#streamingListCache = undefined;
2223
+ let index = tokens.length - 1;
2224
+ while (tokens[index]?.type === "space") index--;
2225
+ const token = tokens[index];
2226
+ if (token?.type !== "list") return;
2227
+ const list = token as Tokens.List;
2228
+ let start = 0;
2229
+ for (let i = 0; i < index; i++) start += tokens[i]!.raw.length;
2230
+ // Late block delimiters can absorb an unfrozen preceding region. Bare
2231
+ // environments can also absorb list items through an equation prefix.
2232
+ if (start !== (this.#streamPrefixText?.length ?? 0) || BARE_ENV_BEGIN.test(list.raw)) return;
2233
+ const canResume = previous !== undefined && previous.start === start && text.startsWith(previous.text);
2234
+ let cursor = canResume ? previous.tailStart : start;
2235
+ let tailStart = start;
2236
+ let stableCount = 0;
2237
+ const indent = /^ */.exec(list.raw)![0].length;
2238
+ for (let i = canResume ? previous.stableCount : 0; i < list.items.length; i++) {
2239
+ const item = list.items[i]!;
2240
+ const position = text.indexOf(item.raw, cursor);
2241
+ if (position < cursor || position >= start + list.raw.length) return;
2242
+ // List continuation uses the first item's indentation. Resume only at
2243
+ // an item with that same indentation so the lexer sees the same context.
2244
+ if (/^ */.exec(item.raw)![0].length === indent) {
2245
+ tailStart = position;
2246
+ stableCount = i;
2247
+ }
2248
+ cursor = position + item.raw.length;
2249
+ }
2250
+ this.#streamingListCache = { text, before: tokens.slice(0, index), start, tailStart, stableCount, list };
2251
+ }
2252
+
2137
2253
  /**
2138
2254
  * Drop the frozen lex prefix and the transient row caches keyed on it. A
2139
2255
  * prefix set aside for #restoreRewoundPrefix checks itself against each
@@ -2146,6 +2262,8 @@ export class Markdown implements Component {
2146
2262
  this.#streamSettledText = undefined;
2147
2263
  this.#streamPrefixLineCache = undefined;
2148
2264
  this.#tailRowCache = undefined;
2265
+ this.#listItemRowsCache = undefined;
2266
+ this.#streamingListCache = undefined;
2149
2267
  }
2150
2268
 
2151
2269
  /**
@@ -2186,6 +2304,8 @@ export class Markdown implements Component {
2186
2304
  #rewindStreamPrefix(end: number, count: number): void {
2187
2305
  this.#streamPrefixLineCache = rewoundLineCache(this.#streamPrefixLineCache, count);
2188
2306
  this.#tailRowCache = undefined;
2307
+ this.#listItemRowsCache = undefined;
2308
+ this.#streamingListCache = undefined;
2189
2309
  if (count === 0) {
2190
2310
  this.#streamPrefixText = undefined;
2191
2311
  this.#streamPrefixTokens = undefined;
@@ -2223,6 +2343,8 @@ export class Markdown implements Component {
2223
2343
  this.#streamPrefixOpeners = rewound.openers;
2224
2344
  this.#streamPrefixLineCache = rewound.lineCache;
2225
2345
  this.#tailRowCache = rewound.tailRowCache;
2346
+ this.#listItemRowsCache = undefined;
2347
+ this.#streamingListCache = undefined;
2226
2348
  }
2227
2349
 
2228
2350
  // Freeze the largest run of leading blocks that end on a hard "\n\n" boundary
@@ -2591,6 +2713,7 @@ export class Markdown implements Component {
2591
2713
  signature: RenderSignature,
2592
2714
  contentWidth: number,
2593
2715
  ): string[] {
2716
+ if (tokens[tokens.length - 1]?.type !== "list") this.#listItemRowsCache = undefined;
2594
2717
  const stableText = this.#streamPrefixText;
2595
2718
  const stableTokenCount = this.#streamPrefixTokens?.length ?? 0;
2596
2719
  if (stableText === undefined || stableTokenCount === 0 || !normalizedText.startsWith(stableText)) {
@@ -3594,6 +3717,41 @@ export class Markdown implements Component {
3594
3717
  const indent = " ".repeat(depth);
3595
3718
  // Use the list's start property (defaults to 1 for ordered lists)
3596
3719
  const startNumber = token.start ?? 1;
3720
+ const signature =
3721
+ depth === 0 &&
3722
+ styleContext === undefined &&
3723
+ this.#defaultTextStyle === undefined &&
3724
+ this.#transientRenderCache &&
3725
+ !this.#renderingStablePrefix &&
3726
+ this.#lastScanCanStream &&
3727
+ canCacheMarkdownListItems(this.#theme)
3728
+ ? this.#activeRenderSignature
3729
+ : undefined;
3730
+ const themeEpoch = signature === undefined ? undefined : getThemeEpoch();
3731
+ const previous = this.#listItemRowsCache;
3732
+ const reusable =
3733
+ signature !== undefined &&
3734
+ themeEpoch !== undefined &&
3735
+ previous !== undefined &&
3736
+ previous.width === width &&
3737
+ previous.themeEpoch === themeEpoch &&
3738
+ previous.ordered === token.ordered &&
3739
+ previous.start === token.start &&
3740
+ previous.loose === token.loose &&
3741
+ this.#signatureEquals(previous.signature, signature)
3742
+ ? previous
3743
+ : undefined;
3744
+ let reusableCount = 0;
3745
+ if (reusable !== undefined) {
3746
+ while (
3747
+ reusableCount < token.items.length &&
3748
+ token.items[reusableCount]?.raw === reusable.raws[reusableCount]
3749
+ ) {
3750
+ reusableCount++;
3751
+ }
3752
+ }
3753
+ const raws: string[] | undefined = signature === undefined ? undefined : [];
3754
+ const rows: RenderedLine[][] | undefined = signature === undefined ? undefined : [];
3597
3755
  const pushWrapped = (line: RenderedLine, firstPrefix: string, continuationPrefix: string): void => {
3598
3756
  if (line.literalCode) {
3599
3757
  const wrappedLiteralRows = wrapTextWithAnsi(line.text, Math.max(1, width));
@@ -3629,6 +3787,14 @@ export class Markdown implements Component {
3629
3787
 
3630
3788
  for (let i = 0; i < token.items.length; i++) {
3631
3789
  const item = token.items[i];
3790
+ if (i < reusableCount && reusable !== undefined && raws !== undefined && rows !== undefined) {
3791
+ const cached = reusable.rows[i]!;
3792
+ for (const row of cached) lines.push(row);
3793
+ raws.push(item.raw);
3794
+ rows.push(cached);
3795
+ continue;
3796
+ }
3797
+ const rowStart = lines.length;
3632
3798
  const bullet = token.ordered ? `${startNumber + i}. ` : "- ";
3633
3799
  const firstPrefix = indent + this.#theme.listBullet(bullet);
3634
3800
  // Continuation rows align under the item text, so the hang matches the
@@ -3658,6 +3824,22 @@ export class Markdown implements Component {
3658
3824
  } else {
3659
3825
  lines.push(renderedLine(firstPrefix));
3660
3826
  }
3827
+ if (raws !== undefined && rows !== undefined) {
3828
+ raws.push(item.raw);
3829
+ rows.push(lines.slice(rowStart));
3830
+ }
3831
+ }
3832
+ if (signature !== undefined && themeEpoch !== undefined && raws !== undefined && rows !== undefined) {
3833
+ this.#listItemRowsCache = {
3834
+ signature,
3835
+ width,
3836
+ themeEpoch,
3837
+ ordered: token.ordered,
3838
+ start: token.start,
3839
+ loose: token.loose,
3840
+ raws,
3841
+ rows,
3842
+ };
3661
3843
  }
3662
3844
 
3663
3845
  return lines;
@@ -408,6 +408,11 @@ export class ScrollView implements Component {
408
408
  this.#child?.invalidate?.();
409
409
  }
410
410
 
411
+ releaseRenderCaches(): void {
412
+ this.#cache = undefined;
413
+ this.#child?.releaseRenderCaches?.();
414
+ }
415
+
411
416
  /** Permanently release a wrapped child's timers, subscriptions, and other resources. */
412
417
  dispose(): void {
413
418
  if (this.#disposed) return;
@@ -51,6 +51,11 @@ export class Section implements Component {
51
51
  this.#cache = undefined;
52
52
  }
53
53
 
54
+ releaseRenderCaches(): void {
55
+ this.#cache = undefined;
56
+ if (isComponent(this.#options.body)) this.#options.body.releaseRenderCaches?.();
57
+ }
58
+
54
59
  dispose(): void {
55
60
  if (this.#disposed) return;
56
61
  this.#disposed = true;
@@ -117,6 +117,10 @@ export class Table implements Component {
117
117
  this.#native = undefined;
118
118
  }
119
119
 
120
+ releaseRenderCaches(): void {
121
+ this.#cache = undefined;
122
+ }
123
+
120
124
  /**
121
125
  * A native `table`. Column priorities carry over unchanged (lower gives
122
126
  * way first); truncating columns truncate at the end, the rest keep
@@ -109,6 +109,13 @@ export class Text implements Component {
109
109
  this.#cachedLines = undefined;
110
110
  }
111
111
 
112
+ releaseRenderCaches(): void {
113
+ this.#cachedText = undefined;
114
+ this.#cachedWidth = undefined;
115
+ this.#cachedWidthConfigEpoch = undefined;
116
+ this.#cachedLines = undefined;
117
+ }
118
+
112
119
  /**
113
120
  * Wrapped text; styling re-derived from the styled source so a theme swap
114
121
  * re-resolves tokens. A background fill becomes the node's tone.