@oh-my-pi/pi-tui 18.11.2 → 18.12.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 (61) hide show
  1. package/CHANGELOG.md +37 -100
  2. package/README.md +36 -0
  3. package/dist/types/components/markdown.d.ts +4 -3
  4. package/dist/types/overlays/approval-dialog.d.ts +25 -15
  5. package/dist/types/overlays/model-picker.d.ts +2 -0
  6. package/dist/types/overlays/session-selector.d.ts +2 -0
  7. package/dist/types/prompt/internal-url-autocomplete.d.ts +1 -0
  8. package/dist/types/render/hanging-wrap.d.ts +2 -0
  9. package/dist/types/render/render-utils.d.ts +9 -1
  10. package/dist/types/status-line/types.d.ts +3 -0
  11. package/dist/types/terminal-capabilities.d.ts +8 -1
  12. package/dist/types/terminal-multiplexer.d.ts +6 -0
  13. package/dist/types/theme/symbols.d.ts +1 -1
  14. package/dist/types/theme/theme.d.ts +11 -0
  15. package/dist/types/tools/ast-edit.d.ts +2 -2
  16. package/dist/types/tools/ast-grep.d.ts +2 -2
  17. package/dist/types/tools/edit.d.ts +2 -0
  18. package/dist/types/tools/find.d.ts +14 -4
  19. package/dist/types/tools/glob.d.ts +3 -3
  20. package/dist/types/tools/grep.d.ts +2 -2
  21. package/dist/types/tools/read.d.ts +13 -5
  22. package/dist/types/tools/task.d.ts +8 -0
  23. package/dist/types/tools/write.d.ts +2 -0
  24. package/package.json +9 -9
  25. package/src/chat/assistant-message.ts +3 -1
  26. package/src/chat/read-tool-group.ts +17 -9
  27. package/src/chat/tool-execution.ts +23 -6
  28. package/src/components/image.ts +31 -14
  29. package/src/components/markdown.ts +74 -5
  30. package/src/overlays/approval-dialog.ts +104 -36
  31. package/src/overlays/model-picker.ts +58 -21
  32. package/src/overlays/session-selector.ts +14 -9
  33. package/src/prompt/internal-url-autocomplete.ts +1 -0
  34. package/src/render/hanging-wrap.ts +28 -1
  35. package/src/render/render-utils.ts +16 -2
  36. package/src/status-line/component.ts +1 -0
  37. package/src/status-line/segments.ts +16 -5
  38. package/src/status-line/types.ts +1 -0
  39. package/src/terminal-capabilities.ts +35 -21
  40. package/src/terminal-multiplexer.ts +14 -0
  41. package/src/terminal.ts +11 -2
  42. package/src/theme/symbols.ts +1 -0
  43. package/src/theme/theme-class.ts +1 -0
  44. package/src/theme/theme.ts +46 -14
  45. package/src/tmux.ts +7 -1
  46. package/src/tools/ast-edit.ts +28 -4
  47. package/src/tools/ast-grep.ts +24 -4
  48. package/src/tools/debug.ts +2 -1
  49. package/src/tools/default-renderer.ts +3 -2
  50. package/src/tools/edit.ts +77 -16
  51. package/src/tools/find.ts +65 -15
  52. package/src/tools/glob.ts +51 -17
  53. package/src/tools/grep.ts +94 -11
  54. package/src/tools/lsp.ts +23 -18
  55. package/src/tools/mcp.ts +2 -2
  56. package/src/tools/read.ts +52 -22
  57. package/src/tools/task.ts +17 -7
  58. package/src/tools/web-search.ts +2 -1
  59. package/src/tools/write.ts +17 -5
  60. package/src/tools/yield.ts +2 -2
  61. package/src/tui.ts +36 -0
@@ -102,6 +102,10 @@ export interface TaskItem {
102
102
  name?: string;
103
103
  /** Agent type to run this item (e.g. "scout"). Defaults to the spawn policy's default agent. */
104
104
  agent?: string;
105
+ /** Historical source session selected by the bundled consult agent. */
106
+ sourceSession?: string;
107
+ /** Optional model selector override for the historical source session. */
108
+ model?: string | string[];
105
109
  /** The work; required by the schema. */
106
110
  task?: string;
107
111
  /** How open-ended the work is; required by the schema and the child's sole `auto` thinking classification input. */
@@ -128,6 +132,10 @@ export interface TaskParams {
128
132
  name?: string;
129
133
  /** Agent type to spawn (flat form); omitted values resolve from the session spawn policy. */
130
134
  agent?: string;
135
+ /** Historical source session selected by the bundled consult agent. */
136
+ sourceSession?: string;
137
+ /** Optional model selector override for the historical source session. */
138
+ model?: string | string[];
131
139
  /** The work (flat form). */
132
140
  task?: string;
133
141
  /** How open-ended the work is (flat form); see {@link TaskItem.solutionSpace}. */
@@ -73,6 +73,8 @@ interface WriteStreamingPreviewStateCarrier {
73
73
  /** Render context for the write tool: resolves an `xd://`-mounted tool so its live renderer drives device dispatch previews. */
74
74
  export interface WriteRenderContext {
75
75
  resolveXdevMounted?: (name: string) => XdevMountedRenderer | undefined;
76
+ /** Original result metadata used only for filesystem link targets. */
77
+ rawDetails?: unknown;
76
78
  }
77
79
  /** Render file writes and delegated tool-device calls. */
78
80
  export declare const writeToolRenderer: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.11.2",
4
+ "version": "18.12.0",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -41,14 +41,14 @@
41
41
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@oh-my-pi/omptype": "18.11.2",
45
- "@oh-my-pi/pi-agent-core": "18.11.2",
46
- "@oh-my-pi/pi-ai": "18.11.2",
47
- "@oh-my-pi/pi-catalog": "18.11.2",
48
- "@oh-my-pi/pi-natives": "18.11.2",
49
- "@oh-my-pi/pi-utils": "18.11.2",
50
- "@oh-my-pi/pi-wire": "18.11.2",
51
- "@oh-my-pi/snapcompact": "18.11.2"
44
+ "@oh-my-pi/omptype": "18.12.0",
45
+ "@oh-my-pi/pi-agent-core": "18.12.0",
46
+ "@oh-my-pi/pi-ai": "18.12.0",
47
+ "@oh-my-pi/pi-catalog": "18.12.0",
48
+ "@oh-my-pi/pi-natives": "18.12.0",
49
+ "@oh-my-pi/pi-utils": "18.12.0",
50
+ "@oh-my-pi/pi-wire": "18.12.0",
51
+ "@oh-my-pi/snapcompact": "18.12.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -1,4 +1,5 @@
1
1
  import type { AssistantMessage, ImageContent, TextContent } from "@oh-my-pi/pi-ai";
2
+ import { presentNarrationAsAnswer } from "@oh-my-pi/pi-ai/utils/narration";
2
3
  import { type Component, Container } from "../tui";
3
4
  import { Image, type ImageBudget } from "../components/image";
4
5
  import { ImageProtocol, TERMINAL } from "../terminal-capabilities";
@@ -1803,7 +1804,8 @@ export class AssistantMessageComponent extends Container {
1803
1804
  this.#lastUpdateTransient = opts?.transient === true;
1804
1805
  // Everything below renders the display form; #lastMessage keeps the
1805
1806
  // verbatim message so re-renders re-derive the reaction deterministically.
1806
- message = this.#displayMessage(message, this.#lastUpdateTransient);
1807
+ // Narration the provider returned as thinking renders as answer text.
1808
+ message = this.#displayMessage(presentNarrationAsAnswer(message), this.#lastUpdateTransient);
1807
1809
  this.#displayedMessage = message;
1808
1810
 
1809
1811
  // Streaming-speed gauge: only a live, in-flight render of the single
@@ -22,7 +22,7 @@ import { canonicalizeMessage } from "./thinking-display";
22
22
  import { internalUrlSchemeSpec, splitUrlScheme } from "../tools/url-scheme-host";
23
23
  import type { ToolExecutionHandle } from "./tool-execution";
24
24
  import { formatUsageRow } from "../overlays/usage-row";
25
- import { formatCount } from "@oh-my-pi/pi-utils";
25
+ import { formatCount, sanitizeText, sanitizeTextDeep } from "@oh-my-pi/pi-utils";
26
26
  import type { TspCardStatus, TspSpan, TspText } from "@oh-my-pi/pi-wire";
27
27
  import type { NativeToolHead } from "../tools/renderer";
28
28
  import { card, code, keyed, md, node, span, text, withHidden } from "../native/describe";
@@ -188,9 +188,12 @@ const READ_STATUS_RANK: Record<ReadEntry["status"], number> = {
188
188
  error: 3,
189
189
  };
190
190
 
191
- function getDisplayReadTargets(details: ReadToolDetails | undefined): ReadDisplayPathSpec[] | undefined {
191
+ function getDisplayReadTargets(
192
+ details: ReadToolDetails | undefined,
193
+ rawDetails: ReadToolDetails | undefined,
194
+ ): ReadDisplayPathSpec[] | undefined {
192
195
  if (!Array.isArray(details?.displayReadTargets)) return undefined;
193
- const links = Array.isArray(details.displayReadTargetLinks) ? details.displayReadTargetLinks : undefined;
196
+ const links = Array.isArray(rawDetails?.displayReadTargetLinks) ? rawDetails.displayReadTargetLinks : undefined;
194
197
  const targets: ReadDisplayPathSpec[] = [];
195
198
  details.displayReadTargets.forEach((raw, index) => {
196
199
  if (typeof raw !== "string") return;
@@ -437,12 +440,14 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
437
440
  if (!toolCallId) return;
438
441
  const rawPath =
439
442
  typeof args.file_path === "string" ? args.file_path : typeof args.path === "string" ? args.path : "";
443
+ const displayPath = sanitizeText(rawPath);
440
444
  const entry: ReadEntry = this.#entries.get(toolCallId) ?? {
441
445
  toolCallId,
442
- path: rawPath,
446
+ path: displayPath,
443
447
  status: "pending",
444
448
  };
445
- entry.path = rawPath;
449
+ entry.path = displayPath;
450
+ entry.linkPath = path.isAbsolute(rawPath) ? rawPath : undefined;
446
451
  entry.raw = args.raw === true;
447
452
  this.#entries.set(toolCallId, entry);
448
453
  this.#updateDisplay();
@@ -484,12 +489,15 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
484
489
  if (!entry) return;
485
490
  if (isPartial) return;
486
491
  this.#blockVersion++;
487
- const details = result.details as ReadToolDetails | undefined;
492
+ // `details` can contain raw file text and filesystem paths. Keep the
493
+ // latter for links while only the sanitized copy supplies display labels.
494
+ const rawDetails = result.details as ReadToolDetails | undefined;
495
+ const details = sanitizeTextDeep(rawDetails) as ReadToolDetails | undefined;
488
496
  const suffixResolution = getSuffixResolution(details);
489
- const displayPaths = getDisplayReadTargets(details);
490
- entry.linkPath = readResultLinkPath(details);
497
+ const displayPaths = getDisplayReadTargets(details, rawDetails);
498
+ entry.linkPath = readResultLinkPath(rawDetails) ?? entry.linkPath;
491
499
  if (suffixResolution) {
492
- entry.path = displayPathWithSuffixResolution(entry.path, suffixResolution);
500
+ entry.path = sanitizeText(displayPathWithSuffixResolution(entry.path, suffixResolution));
493
501
  entry.correctedFrom = suffixResolution.from;
494
502
  entry.displayPaths = undefined;
495
503
  } else {
@@ -8,7 +8,7 @@ import { Text } from "../components/text";
8
8
  import { getImageDimensions, ImageProtocol, imageFallback, TERMINAL } from "../terminal-capabilities";
9
9
  import { type Component, Container, type TUI } from "../tui";
10
10
  import { truncateToWidth } from "../utils";
11
- import { getProjectDir, isRecord, logger, sanitizeText } from "@oh-my-pi/pi-utils";
11
+ import { getProjectDir, isRecord, logger, sanitizeText, sanitizeTextDeep } from "@oh-my-pi/pi-utils";
12
12
  import type { Theme } from "../theme/theme";
13
13
  import { ensureThemeSync, getThemeEpoch, theme } from "../theme/theme";
14
14
  import {
@@ -420,6 +420,8 @@ export class ToolExecutionComponent extends Container {
420
420
  isError?: boolean;
421
421
  details?: unknown;
422
422
  };
423
+ // Machine paths remain verbatim; only #result.details is handed to display renderers.
424
+ #rawDetails: unknown;
423
425
  // Edit preview state
424
426
  #editMode?: EditMode;
425
427
  #editDiffPreview?: PerFileDiffPreview[];
@@ -632,7 +634,11 @@ export class ToolExecutionComponent extends Container {
632
634
  const partialResultPainted = this.#partialResultShapePainted;
633
635
  this.#firstResultViewportRepaintShapePainted = false;
634
636
  this.#partialResultShapePainted = false;
635
- this.#result = result;
637
+ this.#rawDetails = result.details;
638
+ // Renderers receive sanitized details for display; raw paths are available
639
+ // separately for links and media through the built-in render context.
640
+ const details = sanitizeTextDeep(result.details);
641
+ this.#result = details === result.details ? result : { ...result, details };
636
642
  this.#resultVersion++;
637
643
  this.#blockVersion++;
638
644
  this.#isPartial = isPartial;
@@ -1251,7 +1257,7 @@ export class ToolExecutionComponent extends Container {
1251
1257
  #nativeResultImages(body: readonly NativeChild[] | undefined): NativeNode[] {
1252
1258
  if (!this.#result || !this.#showImages) return [];
1253
1259
  const shown = shownMedia(body);
1254
- const mediaPath = readMediaPath(this.#result.details);
1260
+ const mediaPath = readMediaPath(this.#rawDetails);
1255
1261
  if (mediaPath && shown.paths.has(mediaPath)) return [];
1256
1262
  const images: NativeNode[] = [];
1257
1263
  this.#getAllImageBlocks().forEach((image, index) => {
@@ -1558,6 +1564,10 @@ export class ToolExecutionComponent extends Container {
1558
1564
  this.#contentBox.clear();
1559
1565
 
1560
1566
  const renderContext = this.#buildRenderContext();
1567
+ const rawPerFileResults =
1568
+ isRecord(this.#rawDetails) && Array.isArray(this.#rawDetails.perFileResults)
1569
+ ? this.#rawDetails.perFileResults
1570
+ : undefined;
1561
1571
  this.#renderState.renderContext = renderContext;
1562
1572
 
1563
1573
  for (let i = 0; i < perFileResults.length; i++) {
@@ -1569,9 +1579,13 @@ export class ToolExecutionComponent extends Container {
1569
1579
  }
1570
1580
  const fileBox = new Box(0, 0);
1571
1581
  try {
1582
+ const fileOptions = {
1583
+ ...this.#renderState,
1584
+ renderContext: { ...renderContext, rawDetails: rawPerFileResults?.[i] },
1585
+ };
1572
1586
  const resultComponent = renderer.renderResult(
1573
1587
  { content: [], details: fileResult, isError: fileResult.isError },
1574
- this.#renderState,
1588
+ fileOptions,
1575
1589
  theme,
1576
1590
  );
1577
1591
  if (resultComponent) {
@@ -1695,7 +1709,7 @@ export class ToolExecutionComponent extends Container {
1695
1709
 
1696
1710
  if (this.#result) {
1697
1711
  const imageBlocks = this.#getAllImageBlocks();
1698
- const sourcePath = readMediaPath(this.#result.details);
1712
+ const sourcePath = readMediaPath(this.#rawDetails);
1699
1713
 
1700
1714
  for (let i = 0; i < imageBlocks.length; i++) {
1701
1715
  const img = imageBlocks[i];
@@ -1802,6 +1816,9 @@ export class ToolExecutionComponent extends Container {
1802
1816
  return Math.max(1, Math.min(maxSeconds, value));
1803
1817
  };
1804
1818
 
1819
+ if (this.#renderer && !this.#tool?.renderResult && this.#rawDetails !== undefined) {
1820
+ context.rawDetails = this.#rawDetails;
1821
+ }
1805
1822
  if (this.#toolName === "bash") {
1806
1823
  // Bash needs render context even before a result exists. The renderer uses the pending-call args
1807
1824
  // plus this context to keep the inline command preview visible while tool-call JSON is still streaming.
@@ -1882,7 +1899,7 @@ export class ToolExecutionComponent extends Container {
1882
1899
  const dims = img.data ? (getImageDimensions(img.data, mimeType) ?? undefined) : undefined;
1883
1900
  const placeholder = imageFallback(mimeType, dims);
1884
1901
  return img.data
1885
- ? linkedImageText(placeholder, { data: img.data, mimeType }, readMediaPath(this.#result?.details))
1902
+ ? linkedImageText(placeholder, { data: img.data, mimeType }, readMediaPath(this.#rawDetails))
1886
1903
  : placeholder;
1887
1904
  })
1888
1905
  .join("\n");
@@ -4,6 +4,7 @@ import {
4
4
  encodeSixelNow,
5
5
  getCellDimensions,
6
6
  getImageDimensions,
7
+ getTerminalBackgroundColor,
7
8
  type ImageDimensions,
8
9
  ImageProtocol,
9
10
  imageFallback,
@@ -41,6 +42,13 @@ export interface ImageOptions {
41
42
  requestRender?: () => void;
42
43
  }
43
44
 
45
+ interface SixelEncode {
46
+ widthPx: number;
47
+ heightPx: number;
48
+ backgroundRgb: number;
49
+ sequence?: string | null;
50
+ }
51
+
44
52
  /** Renders in progress whose rows are bound for native scrollback. */
45
53
  let scrollbackRenderDepth = 0;
46
54
 
@@ -655,6 +663,7 @@ export class Image implements Component {
655
663
  #cachedImageProtocol: typeof TERMINAL.imageProtocol = null;
656
664
  #cachedCellWidthPx = 0;
657
665
  #cachedCellHeightPx = 0;
666
+ #cachedSixelBackground: number | undefined;
658
667
  #cachedKittyUnicodePlaceholders = false;
659
668
  #cachedLinked = false;
660
669
  // Tallest graphic placement this image has rendered. The text fallback
@@ -663,8 +672,8 @@ export class Image implements Component {
663
672
  #renderedGraphicRows = 0;
664
673
  /** Described node and the link policy it was built under (an `off` switch drops its `href`). */
665
674
  #native?: { linked: boolean; node: NativeNode };
666
- /** Newest SIXEL encode: its target size and, once settled, the sequence (`null` on failure). */
667
- #sixel?: { widthPx: number; heightPx: number; sequence?: string | null };
675
+ /** Newest SIXEL encode: target size, compositing background, and settled sequence. */
676
+ #sixel?: SixelEncode;
668
677
 
669
678
  constructor(
670
679
  base64Data: string,
@@ -704,33 +713,38 @@ export class Image implements Component {
704
713
  }
705
714
 
706
715
  /**
707
- * SIXEL sequence for a target size. A new size starts the encode off the JS
716
+ * SIXEL sequence for a target size and background. A change starts the encode off the JS
708
717
  * thread and answers `undefined` until it settles; the settled encode
709
718
  * invalidates the cached lines and requests a repaint. A render bound for
710
719
  * scrollback cannot wait, so it encodes synchronously.
711
720
  */
712
- #sixelSequence(widthPx: number, heightPx: number): string | null | undefined {
721
+ #sixelSequence(widthPx: number, heightPx: number, backgroundRgb: number): string | null | undefined {
713
722
  const current = this.#sixel;
714
- const sameSize = current !== undefined && current.widthPx === widthPx && current.heightPx === heightPx;
715
- if (sameSize && current.sequence !== undefined) return current.sequence;
723
+ const sameInput =
724
+ current !== undefined &&
725
+ current.widthPx === widthPx &&
726
+ current.heightPx === heightPx &&
727
+ current.backgroundRgb === backgroundRgb;
728
+ if (sameInput && current.sequence !== undefined) return current.sequence;
716
729
  if (scrollbackRenderDepth > 0) {
717
730
  // Replacing the entry makes a still-pending async encode settle as stale.
718
- const sequence = encodeSixelNow(this.#base64Data, widthPx, heightPx);
719
- this.#sixel = { widthPx, heightPx, sequence };
731
+ const sequence = encodeSixelNow(this.#base64Data, widthPx, heightPx, backgroundRgb);
732
+ this.#sixel = { widthPx, heightPx, backgroundRgb, sequence };
720
733
  return sequence;
721
734
  }
722
- if (sameSize) return undefined;
723
- const request: { widthPx: number; heightPx: number; sequence?: string | null } = { widthPx, heightPx };
735
+ if (sameInput) return undefined;
736
+ const request: SixelEncode = { widthPx, heightPx, backgroundRgb };
724
737
  this.#sixel = request;
725
738
  const settle = (sequence: string | null): void => {
726
739
  request.sequence = sequence;
727
- if (this.#sixel !== request) return;
740
+ if (this.#sixel !== request || getTerminalBackgroundColor() !== backgroundRgb) return;
728
741
  this.invalidate();
729
742
  if (this.#options.requestRender) this.#options.requestRender();
730
743
  else this.#budget?.requestRender();
731
744
  };
732
- encodeSixelAsync(new Uint8Array(Buffer.from(this.#base64Data, "base64")), widthPx, heightPx).then(settle, () =>
733
- settle(null),
745
+ encodeSixelAsync(new Uint8Array(Buffer.from(this.#base64Data, "base64")), widthPx, heightPx, backgroundRgb).then(
746
+ settle,
747
+ () => settle(null),
734
748
  );
735
749
  return undefined;
736
750
  }
@@ -768,6 +782,7 @@ export class Image implements Component {
768
782
  const hasProtocol = imageProtocol != null;
769
783
  const cellDimensions = getCellDimensions();
770
784
  const kittyUnicodePlaceholders = getKittyGraphics().unicodePlaceholders;
785
+ const sixelBackground = imageProtocol === ImageProtocol.Sixel ? getTerminalBackgroundColor() : undefined;
771
786
  // observe() must run on every pass — even a cache hit — so the image keeps
772
787
  // its display-order slot in the budget. Only graphics-capable frames count
773
788
  // toward (and are demoted by) the budget; without a protocol every image is
@@ -789,6 +804,7 @@ export class Image implements Component {
789
804
  this.#cachedImageProtocol === imageProtocol &&
790
805
  this.#cachedCellWidthPx === cellDimensions.widthPx &&
791
806
  this.#cachedCellHeightPx === cellDimensions.heightPx &&
807
+ this.#cachedSixelBackground === sixelBackground &&
792
808
  this.#cachedKittyUnicodePlaceholders === kittyUnicodePlaceholders &&
793
809
  // The text fallback embeds a link only under an enabled hyperlink policy.
794
810
  this.#cachedLinked === linked &&
@@ -813,7 +829,7 @@ export class Image implements Component {
813
829
  maxHeightCells: this.#options.maxHeightCells,
814
830
  imageId: this.#imageId,
815
831
  includeTransmit: needsTransmit,
816
- sixel: (widthPx, heightPx) => this.#sixelSequence(widthPx, heightPx),
832
+ sixel: (widthPx, heightPx) => this.#sixelSequence(widthPx, heightPx, sixelBackground!),
817
833
  });
818
834
 
819
835
  if (result?.transmit && this.#imageId != null && this.#budget !== undefined) {
@@ -861,6 +877,7 @@ export class Image implements Component {
861
877
  this.#cachedImageProtocol = imageProtocol;
862
878
  this.#cachedCellWidthPx = cellDimensions.widthPx;
863
879
  this.#cachedCellHeightPx = cellDimensions.heightPx;
880
+ this.#cachedSixelBackground = sixelBackground;
864
881
  this.#cachedKittyUnicodePlaceholders = kittyUnicodePlaceholders;
865
882
  this.#cachedLinked = linked;
866
883
 
@@ -1779,6 +1779,33 @@ function loosenListItem(item: Tokens.ListItem): Tokens.ListItem {
1779
1779
  };
1780
1780
  }
1781
1781
 
1782
+ const REFERENCE_LABEL_RE = /\[[^[\]]*[^\s[\]][^[\]]*\]/;
1783
+ const LABEL_FREE_TOKENS = new Set(["checkbox", "code", "codespan", "escape", "html", "image", "math"]);
1784
+
1785
+ function labelSource(tokens: readonly Token[]): string {
1786
+ let source = "";
1787
+ for (const token of tokens) {
1788
+ if (token.type === "list") {
1789
+ for (const item of (token as Tokens.List).items) source += `${labelSource(item.tokens)}\n\n`;
1790
+ } else if (token.type === "table") {
1791
+ const table = token as TableToken;
1792
+ for (const cell of [...table.header, ...table.rows.flat()]) source += `${labelSource(cell.tokens ?? [])}\n\n`;
1793
+ } else if ("tokens" in token && token.tokens) {
1794
+ source += labelSource(token.tokens);
1795
+ } else {
1796
+ source += LABEL_FREE_TOKENS.has(token.type) ? " " : token.raw;
1797
+ }
1798
+ }
1799
+ return source;
1800
+ }
1801
+
1802
+ /** Whether a reference definition arriving later could still turn part of `item` into a link. */
1803
+ function hasUnresolvedReferenceLabel(item: Tokens.ListItem): boolean {
1804
+ return labelSource(item.tokens)
1805
+ .split(BLANK_LINE_RE)
1806
+ .some(block => REFERENCE_LABEL_RE.test(block));
1807
+ }
1808
+
1782
1809
  function formatHyperlink(text: string, target: string): string {
1783
1810
  if (!TERMINAL.hyperlinks || !target) {
1784
1811
  return text;
@@ -2145,6 +2172,7 @@ interface StreamingListCache {
2145
2172
  start: number;
2146
2173
  tailStart: number;
2147
2174
  stableCount: number;
2175
+ referenceStop?: number;
2148
2176
  list: Tokens.List;
2149
2177
  }
2150
2178
  /**
@@ -2232,6 +2260,13 @@ export class Markdown implements Component {
2232
2260
  // The frozen prefix's leading run in front of the first of those openers:
2233
2261
  // no append can change its tokens, so getLastRenderStableText publishes it.
2234
2262
  #streamSettledText?: string;
2263
+ // The text in front of the newest top-level item of a list that is still
2264
+ // streaming as the last block. Once an item's marker line has ended, no
2265
+ // append can add to the items in front of it, and #renderList draws each
2266
+ // item from its own tokens alone, so their rows are final. Kept while later
2267
+ // texts extend it, so the published prefix never shrinks when the list
2268
+ // stops being the last block.
2269
+ #streamListSettledText?: string;
2235
2270
  // The prefix before a rewind that only a closer on the still-growing last
2236
2271
  // line caused. Once a later text holds no closer line after it, that
2237
2272
  // prefix is right again, so #lexTokens puts it back.
@@ -2411,14 +2446,18 @@ export class Markdown implements Component {
2411
2446
  }
2412
2447
 
2413
2448
  /**
2414
- * Width-independent source prefix of the last render ending at a frozen
2415
- * Markdown block boundary that no append can move: it stops in front of a
2416
- * display-math opener whose block an append could still close. Only
2449
+ * Width-independent source prefix of the last render that no append can
2450
+ * change: up to a frozen Markdown block boundary, stopping in front of a
2451
+ * display-math opener whose block an append could still close, or, while a
2452
+ * list streams as the last block, up to its newest top-level item. Only
2417
2453
  * meaningful while streaming (transient render cache on); grows
2418
2454
  * monotonically under append-only `setText`.
2419
2455
  */
2420
2456
  getLastRenderStableText(): string {
2421
- return this.#transientRenderCache ? (this.#streamSettledText ?? "") : "";
2457
+ if (!this.#transientRenderCache) return "";
2458
+ const settled = this.#streamSettledText ?? "";
2459
+ const list = this.#streamListSettledText;
2460
+ return list !== undefined && list.length > settled.length ? list : settled;
2422
2461
  }
2423
2462
 
2424
2463
  get transientRenderCache(): boolean {
@@ -2589,6 +2628,9 @@ export class Markdown implements Component {
2589
2628
  #checkpointStreamingList(text: string, tokens: Token[]): void {
2590
2629
  const previous = this.#streamingListCache;
2591
2630
  this.#streamingListCache = undefined;
2631
+ if (this.#streamListSettledText !== undefined && !text.startsWith(this.#streamListSettledText)) {
2632
+ this.#streamListSettledText = undefined;
2633
+ }
2592
2634
  let index = tokens.length - 1;
2593
2635
  while (tokens[index]?.type === "space") index--;
2594
2636
  const token = tokens[index];
@@ -2603,6 +2645,7 @@ export class Markdown implements Component {
2603
2645
  let cursor = canResume ? previous.tailStart : start;
2604
2646
  let tailStart = start;
2605
2647
  let stableCount = 0;
2648
+ let referenceStop = canResume ? previous.referenceStop : undefined;
2606
2649
  const indent = /^ */.exec(list.raw)![0].length;
2607
2650
  for (let i = canResume ? previous.stableCount : 0; i < list.items.length; i++) {
2608
2651
  const item = list.items[i]!;
@@ -2614,9 +2657,34 @@ export class Markdown implements Component {
2614
2657
  tailStart = position;
2615
2658
  stableCount = i;
2616
2659
  }
2660
+ if (referenceStop === undefined && hasUnresolvedReferenceLabel(item)) referenceStop = tailStart;
2617
2661
  cursor = position + item.raw.length;
2618
2662
  }
2619
- this.#streamingListCache = { text, before: tokens.slice(0, index), start, tailStart, stableCount, list };
2663
+ // Items from stableCount on are scanned again next time and may still
2664
+ // resolve their own labels, so only a finished item's label is kept.
2665
+ if (referenceStop !== undefined && referenceStop >= tailStart) referenceStop = undefined;
2666
+ this.#streamingListCache = {
2667
+ text,
2668
+ before: tokens.slice(0, index),
2669
+ start,
2670
+ tailStart,
2671
+ stableCount,
2672
+ referenceStop,
2673
+ list,
2674
+ };
2675
+ // No append can add to an item once a later one has begun, but a later
2676
+ // reference definition can still restyle a bracketed label in it. The
2677
+ // list must also start right at the settled boundary: in front of it may
2678
+ // sit a display-math opener whose closer, arriving after the list, would
2679
+ // turn the whole run, list included, into one math block.
2680
+ const settledEnd = referenceStop ?? tailStart;
2681
+ if (
2682
+ settledEnd > start &&
2683
+ (this.#streamSettledText?.length ?? 0) === start &&
2684
+ settledEnd > (this.#streamListSettledText?.length ?? 0)
2685
+ ) {
2686
+ this.#streamListSettledText = text.slice(0, settledEnd);
2687
+ }
2620
2688
  }
2621
2689
 
2622
2690
  /**
@@ -2629,6 +2697,7 @@ export class Markdown implements Component {
2629
2697
  this.#streamPrefixTokens = undefined;
2630
2698
  this.#streamPrefixOpeners = NO_OPENERS;
2631
2699
  this.#streamSettledText = undefined;
2700
+ this.#streamListSettledText = undefined;
2632
2701
  this.#streamPrefixLineCache = undefined;
2633
2702
  this.#tailRowCache = undefined;
2634
2703
  this.#listItemRowsCache = undefined;