@cardstack/boxel-cli 0.11.0-unstable.2 → 0.11.0-unstable.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/bundled-realms/pretui/components/agent-mark.gts +98 -0
  2. package/bundled-realms/pretui/components/agent-question.gts +340 -37
  3. package/bundled-realms/pretui/components/answer-footer.gts +249 -0
  4. package/bundled-realms/pretui/components/card-embed.gts +523 -0
  5. package/bundled-realms/pretui/components/card-pills.gts +725 -0
  6. package/bundled-realms/pretui/components/code-block.gts +87 -45
  7. package/bundled-realms/pretui/components/collapsed-turn.gts +133 -12
  8. package/bundled-realms/pretui/components/compaction-chip.gts +81 -67
  9. package/bundled-realms/pretui/components/composer-cap.gts +516 -0
  10. package/bundled-realms/pretui/components/context-ring.gts +108 -0
  11. package/bundled-realms/pretui/components/diff-block.gts +1002 -31
  12. package/bundled-realms/pretui/components/diff-stat.gts +102 -0
  13. package/bundled-realms/pretui/components/jump-chip.gts +86 -53
  14. package/bundled-realms/pretui/components/markdown-stream.gts +799 -0
  15. package/bundled-realms/pretui/components/markdown-surface.gts +71 -0
  16. package/bundled-realms/pretui/components/plan-mark.gts +147 -0
  17. package/bundled-realms/pretui/components/prose.gts +185 -8
  18. package/bundled-realms/pretui/components/prose.md +6 -8
  19. package/bundled-realms/pretui/components/quick-look.gts +377 -0
  20. package/bundled-realms/pretui/components/reactions.gts +136 -0
  21. package/bundled-realms/pretui/components/recommendation.gts +67 -36
  22. package/bundled-realms/pretui/components/result-card.gts +58 -22
  23. package/bundled-realms/pretui/components/segmented-control.gts +13 -8
  24. package/bundled-realms/pretui/components/session-prep.gts +12 -11
  25. package/bundled-realms/pretui/components/sliding-highlight.gts +8 -3
  26. package/bundled-realms/pretui/components/speaker-label.gts +132 -0
  27. package/bundled-realms/pretui/components/state-glyph.gts +121 -0
  28. package/bundled-realms/pretui/components/step-list.gts +62 -60
  29. package/bundled-realms/pretui/components/stick-to-bottom.gts +1150 -0
  30. package/bundled-realms/pretui/components/system-line.gts +143 -0
  31. package/bundled-realms/pretui/components/task-checklist.gts +184 -0
  32. package/bundled-realms/pretui/components/thinking.gts +63 -94
  33. package/bundled-realms/pretui/components/tool-change-table.gts +285 -0
  34. package/bundled-realms/pretui/components/tool-gate.gts +462 -0
  35. package/bundled-realms/pretui/components/tool-group.gts +920 -0
  36. package/bundled-realms/pretui/components/tool-icon.ts +64 -0
  37. package/bundled-realms/pretui/components/tool-meta.gts +82 -0
  38. package/bundled-realms/pretui/components/tool-plan.gts +197 -0
  39. package/bundled-realms/pretui/components/tool-row.gts +2087 -0
  40. package/bundled-realms/pretui/components/turn-changes.gts +227 -0
  41. package/bundled-realms/pretui/components/user-bubble.gts +575 -0
  42. package/bundled-realms/pretui/components/work-item.gts +185 -48
  43. package/bundled-realms/pretui/docs/scroll-anchoring-protocol.md +136 -0
  44. package/bundled-realms/pretui/internal/markdown-projection.ts +438 -0
  45. package/bundled-realms/pretui/internal/mount-queue.ts +79 -0
  46. package/bundled-realms/pretui/internal/tool-phrases.ts +168 -0
  47. package/bundled-realms/pretui/package.json +2 -1
  48. package/bundled-test-harness/assets/{ai-module-creation-test-beRNwH-e.js → ai-module-creation-test-B6FQKqIl.js} +1 -1
  49. package/bundled-test-harness/assets/{app-CtezUCip.js → app-KpPqdyX3.js} +4 -4
  50. package/bundled-test-harness/assets/{binary-upload-dedupe-test-bI8T0QLl.js → binary-upload-dedupe-test-DN00y1tM.js} +1 -1
  51. package/bundled-test-harness/assets/{can-invoke-test-DVaU_yRT.js → can-invoke-test-Bl-EUFM3.js} +1 -1
  52. package/bundled-test-harness/assets/{card-chooser-paging-test-DnxMnXKR.js → card-chooser-paging-test-BNuZRHX3.js} +1 -1
  53. package/bundled-test-harness/assets/{card-chooser-test-DUSnLr-u.js → card-chooser-test-wKfPOOT4.js} +1 -1
  54. package/bundled-test-harness/assets/{card-def-field-def-relationships-test-GBUr3tFK.js → card-def-field-def-relationships-test-CduQ6zan.js} +1 -1
  55. package/bundled-test-harness/assets/{card-delete-test-CQveO1VC.js → card-delete-test-DKxolj5o.js} +1 -1
  56. package/bundled-test-harness/assets/{check-correctness-test-D2M2p66N.js → check-correctness-test-B-kgHztp.js} +1 -1
  57. package/bundled-test-harness/assets/{codeblocks-test-AdolhdCA.js → codeblocks-test-BDD7qK_V.js} +1 -1
  58. package/bundled-test-harness/assets/{compaction-test-DvyAQq56.js → compaction-test-BweolkhG.js} +1 -1
  59. package/bundled-test-harness/assets/{container-DIF_9lZ9.css → container-B5B6u-xm.css} +29 -22
  60. package/bundled-test-harness/assets/{container-DvvqmNpg.js → container-BnJjIM6J.js} +9 -6
  61. package/bundled-test-harness/assets/{create-listing-modal-test-D95JfQab.js → create-listing-modal-test-FvcAKpIa.js} +1 -1
  62. package/bundled-test-harness/assets/{debug-message-test-BcNtj7Wr.js → debug-message-test-CJ18Evt7.js} +1 -1
  63. package/bundled-test-harness/assets/{externals-DCnB4XSS.js → externals-BWjhmCD4.js} +1 -1
  64. package/bundled-test-harness/assets/{fallback-banner-live-delete-test-5lnN1pv2.js → fallback-banner-live-delete-test-C2H3BrT-.js} +1 -1
  65. package/bundled-test-harness/assets/{fallback-banner-test-DFb_m_Kq.js → fallback-banner-test-C10aYjkj.js} +1 -1
  66. package/bundled-test-harness/assets/{fallback-models-test-p3YIwj1L.js → fallback-models-test-BxoPiaGF.js} +1 -1
  67. package/bundled-test-harness/assets/{file-attachment-test-CmyjNWRR.js → file-attachment-test-DeKyzYaz.js} +1 -1
  68. package/bundled-test-harness/assets/{general-test-CX-TFDuX.js → general-test-BewzrpoB.js} +1 -1
  69. package/bundled-test-harness/assets/{index-query-engine-test-CY2tAkot.js → index-query-engine-test-BtI8Y2LR.js} +1 -1
  70. package/bundled-test-harness/assets/{instance-filter-matcher-test-CMBTh8xn.js → instance-filter-matcher-test-BHXHGHjE.js} +1 -1
  71. package/bundled-test-harness/assets/{main-BOZbZVem.js → main-CqAHZ2kK.js} +1 -1
  72. package/bundled-test-harness/assets/{message-tool-test-CfjRSJvt.js → message-tool-test-NMaPJFsd.js} +1 -1
  73. package/bundled-test-harness/assets/{model-cost-tier-test-mZG84Uso.js → model-cost-tier-test-BgbfN2LH.js} +1 -1
  74. package/bundled-test-harness/assets/{operation-lowering-test-CHnjKSJb.js → operation-lowering-test-C4IaOjsA.js} +1 -1
  75. package/bundled-test-harness/assets/{operator-mode-card-chooser-test-KWS8lq16.js → operator-mode-card-chooser-test-Bwe47Rn7.js} +1 -1
  76. package/bundled-test-harness/assets/{operator-mode-links-test-CViFt0Ws.js → operator-mode-links-test-BU4jkC-B.js} +1 -1
  77. package/bundled-test-harness/assets/{operator-mode-test-D0gZM2yw.js → operator-mode-test-Bh0rGM9z.js} +1 -1
  78. package/bundled-test-harness/assets/{operator-mode-ui-test-CEvHJ9DS.js → operator-mode-ui-test-whgUw0ju.js} +1 -1
  79. package/bundled-test-harness/assets/{overlay-menu-items-test-Pw80RXsr.js → overlay-menu-items-test-Cwn1vr8q.js} +1 -1
  80. package/bundled-test-harness/assets/{past-sessions-test-CW0-Bjav.js → past-sessions-test-Bh24N-Kz.js} +1 -1
  81. package/bundled-test-harness/assets/{permission-checking-realm-test-D5uXQypS.js → permission-checking-realm-test-DKnnAy0f.js} +1 -1
  82. package/bundled-test-harness/assets/{realm-config-policy-standing-test-Btatheni.js → realm-config-policy-standing-test-DngdYypq.js} +1 -1
  83. package/bundled-test-harness/assets/{realm-config-routing-rule-edit-test-Dh5zW13o.js → realm-config-routing-rule-edit-test-_rkFU8SN.js} +1 -1
  84. package/bundled-test-harness/assets/{realm-config-settings-test-BJa8gv_C.js → realm-config-settings-test-CR9NH0Zn.js} +1 -1
  85. package/bundled-test-harness/assets/{reasoning-test-2Zp37cQB.js → reasoning-test-0lH10kRl.js} +1 -1
  86. package/bundled-test-harness/assets/{room-message-test-BsrON13e.js → room-message-test-C-PddE4Y.js} +1 -1
  87. package/bundled-test-harness/assets/{run-realm-code-BxktQf6R.js → run-realm-code-C8N7DtN-.js} +1 -1
  88. package/bundled-test-harness/assets/{run-realm-code-test-7grpTos-.js → run-realm-code-test-DBdp147q.js} +1 -1
  89. package/bundled-test-harness/assets/{schema-generation-test-CM9ggQji.js → schema-generation-test-CB1Hb7GQ.js} +1 -1
  90. package/bundled-test-harness/assets/{scrolling-test-BER00htB.js → scrolling-test-3PhVin8h.js} +1 -1
  91. package/bundled-test-harness/assets/{searchable-option-test-Ctf8r3HZ.js → searchable-option-test-BAdOiL4U.js} +1 -1
  92. package/bundled-test-harness/assets/{sending-test-BJGobs2E.js → sending-test-KSmu1tSJ.js} +1 -1
  93. package/bundled-test-harness/assets/{skills-test-GPOsHRN6.js → skills-test-D-dlcFpd.js} +1 -1
  94. package/bundled-test-harness/assets/{store-test-QvvHimzH.js → store-test-CEW7xQjZ.js} +1 -1
  95. package/bundled-test-harness/assets/{tests-DoTEpvcF.js → tests-QYqqa6nv.js} +47 -47
  96. package/bundled-test-harness/assets/{text-input-validator-test-CYYH2N0L.js → text-input-validator-test-D8iNOWep.js} +1 -1
  97. package/bundled-test-harness/assets/{to-device-streaming-test-Ctqa0bVp.js → to-device-streaming-test-trCX-505.js} +1 -1
  98. package/bundled-test-harness/assets/{token-usage-test-BQVbK3X0.js → token-usage-test-C6sULooH.js} +1 -1
  99. package/bundled-test-harness/assets/{tools-DU8Rc15s.js → tools-UJfD-_al.js} +1 -1
  100. package/bundled-test-harness/assets/{tools-test-B13yvEKE.js → tools-test-Brav31AV.js} +1 -1
  101. package/bundled-test-harness/assets/{worker-DHBEBihX.js → worker-9ph9_Lpv.js} +31 -26
  102. package/bundled-test-harness/index.html +8 -8
  103. package/bundled-test-harness/tests/index.html +8 -8
  104. package/bundled-types/host-app/lib/realm-runner/worker.ts +8 -51
  105. package/bundled-types/runtime-common/realm-runner-script.d.ts +3 -0
  106. package/package.json +1 -1
@@ -0,0 +1,799 @@
1
+ // Pretui — MarkdownStream: assistant prose that arrives token by token, rendered so each update costs the tail, not the whole message. Finished blocks freeze and keep their DOM; only the last block re-renders; an open code fence grows by appending.
2
+ import Component from '@glimmer/component';
3
+ import { tracked } from '@glimmer/tracking';
4
+ import { htmlSafe } from '@ember/template';
5
+ import type { SafeString } from '@ember/template';
6
+ import { modifier } from 'ember-modifier';
7
+ import { CodeBlock } from './code-block';
8
+ import { MarkdownSurface } from './markdown-surface';
9
+ import {
10
+ collectDefinitions,
11
+ heal,
12
+ healBfm,
13
+ isExternalHref,
14
+ isPathLike,
15
+ project,
16
+ renderPlain,
17
+ } from '../internal/markdown-projection';
18
+ import type {
19
+ BlockMode,
20
+ Directive,
21
+ MarkdownFlavor,
22
+ ProjectedBlock,
23
+ Projection,
24
+ } from '../internal/markdown-projection';
25
+
26
+ export type {
27
+ Directive,
28
+ MarkdownFlavor,
29
+ } from '../internal/markdown-projection';
30
+
31
+ /** What `@render` is asked to turn into HTML: one block's markdown. */
32
+ export interface MarkdownSource {
33
+ /** stable across updates */
34
+ key: string;
35
+ /** the block's markdown, healed when it is the live tail */
36
+ src: string;
37
+ flavor: MarkdownFlavor;
38
+ /** every link-reference and footnote definition in the whole document,
39
+ * one per line, for a renderer to append: a block parsed on its own cannot
40
+ * otherwise see a definition that lives in another block */
41
+ definitions: string;
42
+ mode: BlockMode;
43
+ }
44
+
45
+ /** A link `@resolveLink` confirmed. Anything else the caller needs to act
46
+ * on it (a card id, a line range) rides in `target`. */
47
+ export interface ResolvedLink {
48
+ href: string;
49
+ kind: 'url' | 'path';
50
+ target?: unknown;
51
+ }
52
+
53
+ export interface MarkdownCode {
54
+ key: string;
55
+ /** the block's position among the message's code blocks, from 0 */
56
+ index: number;
57
+ source: string;
58
+ language?: string;
59
+ /** whether the closing fence has arrived */
60
+ complete: boolean;
61
+ }
62
+
63
+ export interface MarkdownDirective extends Directive {
64
+ key: string;
65
+ }
66
+
67
+ /** A BFM card or file reference, inline (`:card[url]`) or block
68
+ * (`::card[url | size]`). */
69
+ export interface MarkdownRef {
70
+ /** stable while the reference's block is unchanged */
71
+ key: string;
72
+ type: 'card' | 'file';
73
+ url: string;
74
+ inline: boolean;
75
+ /** block refs: the raw `|` arguments (format or size), for the host's own
76
+ * parser. Inline refs: empty */
77
+ args: string[];
78
+ /** from the renderer's placeholder attributes, when it emitted them */
79
+ format?: string;
80
+ width?: string;
81
+ height?: string;
82
+ /** false while a block ref's closing `]` has not arrived: show a
83
+ * placeholder, not a card */
84
+ complete: boolean;
85
+ }
86
+
87
+ export interface MarkdownStreamSignature {
88
+ Args: {
89
+ /** the full accumulated text so far */
90
+ text: string;
91
+ /** true while tokens are still arriving */
92
+ streaming?: boolean;
93
+ /** block markdown → SANITIZED HTML, synchronously or as a promise. The
94
+ * component inserts what this returns; sanitizing is the renderer's job.
95
+ * Default: the text escaped into paragraphs. */
96
+ render?: (source: MarkdownSource) => string | Promise<string>;
97
+ /** repair the live tail's unclosed inline markup before rendering it.
98
+ * Default true */
99
+ heal?: boolean;
100
+ /** a relative link or path-like inline code becomes a link only when
101
+ * this confirms it. External http(s) links need no lookup. */
102
+ resolveLink?: (
103
+ href: string,
104
+ kind: 'url' | 'path',
105
+ ) => Promise<ResolvedLink | null | undefined>;
106
+ /** a confirmed link was activated */
107
+ onLinkActivate?: (
108
+ link: ResolvedLink,
109
+ event: MouseEvent | KeyboardEvent,
110
+ ) => void;
111
+ /** read politely once the stream completes. Silent by default */
112
+ announceComplete?: string;
113
+ /** 'markdown' (default) or 'bfm', Boxel Flavored Markdown: display math
114
+ * stays one block and never renders half-written, partial card and file
115
+ * references show as pending, an unclosed `$` stays literal, references
116
+ * render through <:ref>, and finished Mermaid fences through <:diagram> */
117
+ flavor?: MarkdownFlavor;
118
+ /** bfm: fence languages that are diagrams. Default ['mermaid'] */
119
+ diagramLanguages?: string[];
120
+ };
121
+ Blocks: {
122
+ code: [MarkdownCode];
123
+ directive: [MarkdownDirective];
124
+ ref: [MarkdownRef];
125
+ diagram: [MarkdownCode];
126
+ };
127
+ Element: HTMLDivElement;
128
+ }
129
+
130
+ interface RenderedEntry {
131
+ /** the src the HTML on screen was rendered from */
132
+ src: string;
133
+ html: string;
134
+ /** the request number the HTML on screen came from: a result from an
135
+ * older request than this one is dropped */
136
+ seq: number;
137
+ /** reused while the HTML is unchanged, so Glimmer leaves the DOM (and any
138
+ * selection in it) alone and the link scan does not re-run */
139
+ safe?: SafeString;
140
+ /** the src the newest pending promise was asked for, so the same src is
141
+ * not requested twice */
142
+ requested?: string;
143
+ }
144
+
145
+ interface ViewBlock {
146
+ key: string;
147
+ kind: ProjectedBlock['kind'];
148
+ mode: BlockMode;
149
+ html?: SafeString;
150
+ code?: MarkdownCode;
151
+ /** bfm: a finished diagram fence */
152
+ diagram?: boolean;
153
+ directive?: MarkdownDirective;
154
+ /** bfm: a block card or file reference */
155
+ ref?: MarkdownRef;
156
+ /** bfm: display math still open; shown as its source, never rendered */
157
+ pendingMath?: string;
158
+ /** the streaming tail: the caret sits at the end of its text */
159
+ caret?: boolean;
160
+ }
161
+
162
+ interface RefSlot {
163
+ id: string;
164
+ blockKey: string;
165
+ element: Element;
166
+ ref: MarkdownRef;
167
+ }
168
+
169
+ const REF_SELECTOR = '[data-boxel-bfm-inline-ref], [data-boxel-bfm-block-ref]';
170
+
171
+ function refFromPlaceholder(
172
+ element: Element,
173
+ key: string,
174
+ ): MarkdownRef | undefined {
175
+ let inlineUrl = element.getAttribute('data-boxel-bfm-inline-ref');
176
+ let url = inlineUrl ?? element.getAttribute('data-boxel-bfm-block-ref');
177
+ let type = element.getAttribute('data-boxel-bfm-type');
178
+ if (!url || (type !== 'card' && type !== 'file')) {
179
+ return undefined;
180
+ }
181
+ return {
182
+ key,
183
+ type,
184
+ url,
185
+ inline: inlineUrl !== null,
186
+ args: [],
187
+ format: element.getAttribute('data-boxel-bfm-format') ?? undefined,
188
+ width: element.getAttribute('data-boxel-bfm-width') ?? undefined,
189
+ height: element.getAttribute('data-boxel-bfm-height') ?? undefined,
190
+ complete: true,
191
+ };
192
+ }
193
+
194
+ const LINK_STATE = 'data-markdown-link';
195
+ const resolvedLinks = new WeakMap<Element, ResolvedLink>();
196
+
197
+ /** Runs `linkify` on insert and whenever the block's HTML changes. Keyed on
198
+ * the HTML so an unchanged block is not re-scanned. */
199
+ const linkified = modifier(
200
+ (
201
+ element: HTMLElement,
202
+ [linkify, _html, key]: [
203
+ (el: HTMLElement, key: string) => void,
204
+ SafeString | undefined,
205
+ string,
206
+ ],
207
+ ) => {
208
+ linkify(element, key);
209
+ },
210
+ );
211
+
212
+ /** Both truthy: a template helper, since Glimmer has no `and`. */
213
+ const bothTrue = (a: unknown, b: unknown): boolean => Boolean(a) && Boolean(b);
214
+
215
+ // Node-type numbers rather than Node.TEXT_NODE: inside a realm module the
216
+ // name `Node` is not the browser's DOM Node, and its constants are absent.
217
+ const TEXT_NODE = 3;
218
+ const ELEMENT_NODE = 1;
219
+
220
+ function lastTextNode(node: Node): Text | null {
221
+ for (let i = node.childNodes.length - 1; i >= 0; i--) {
222
+ let child = node.childNodes[i]!;
223
+ if (child.nodeType === TEXT_NODE) {
224
+ if ((child.textContent ?? '').trim() !== '') {
225
+ return child as Text;
226
+ }
227
+ continue;
228
+ }
229
+ if (child.nodeType === ELEMENT_NODE) {
230
+ let found = lastTextNode(child);
231
+ if (found) {
232
+ return found;
233
+ }
234
+ }
235
+ }
236
+ return null;
237
+ }
238
+
239
+ /** Puts the streaming caret right after the last word of the live block,
240
+ * inside whatever element holds it (a paragraph, a list item, a cell), so it
241
+ * reads as the end of the sentence rather than a line of its own. Re-placed
242
+ * whenever the block's HTML changes; removed when the block freezes. */
243
+ const caretAtEnd = modifier(
244
+ (
245
+ element: HTMLElement,
246
+ [_html, show]: [SafeString | undefined, boolean | undefined],
247
+ ) => {
248
+ element.querySelector('[data-markdown-caret]')?.remove();
249
+ if (!show) {
250
+ return;
251
+ }
252
+ let caret = document.createElement('span');
253
+ caret.className = 'pretui-md-caret';
254
+ caret.setAttribute('aria-hidden', 'true');
255
+ caret.setAttribute('data-markdown-caret', '');
256
+ caret.setAttribute('data-test-pretui-markdown-caret', '');
257
+ // The block's HTML can land after this modifier installs, and an async
258
+ // renderer swaps it later still, so the caret follows the content:
259
+ // placed now, and re-placed whenever the block's children change.
260
+ // Placing is idempotent, so its own move settles the observer.
261
+ let place = () => {
262
+ let text = lastTextNode(element);
263
+ if (text) {
264
+ if (text.nextSibling !== caret) {
265
+ text.after(caret);
266
+ }
267
+ } else if (caret.parentNode !== element || element.lastChild !== caret) {
268
+ element.appendChild(caret);
269
+ }
270
+ };
271
+ place();
272
+ let observer = new MutationObserver(place);
273
+ observer.observe(element, { childList: true, subtree: true });
274
+ return () => {
275
+ observer.disconnect();
276
+ caret.remove();
277
+ };
278
+ },
279
+ );
280
+
281
+ const listen = modifier(
282
+ (
283
+ element: HTMLElement,
284
+ [onClick, onKeyDown]: [
285
+ (event: MouseEvent) => void,
286
+ (event: KeyboardEvent) => void,
287
+ ],
288
+ ) => {
289
+ element.addEventListener('click', onClick);
290
+ element.addEventListener('keydown', onKeyDown);
291
+ return () => {
292
+ element.removeEventListener('click', onClick);
293
+ element.removeEventListener('keydown', onKeyDown);
294
+ };
295
+ },
296
+ );
297
+
298
+ /**
299
+ * Streaming markdown.
300
+ *
301
+ * ```hbs
302
+ * <MarkdownStream @text={{message.body}} @streaming={{message.isStreaming}}
303
+ * @render={{this.renderMarkdown}} @resolveLink={{this.resolve}}
304
+ * @onLinkActivate={{this.open}}>
305
+ * <:directive as |d|><CardEmbed @url={{d.body}} @format={{get d.args 0}} /></:directive>
306
+ * </MarkdownStream>
307
+ * ```
308
+ *
309
+ * Better than re-rendering the whole message per token (what the current
310
+ * host assistant does): frozen blocks call the renderer once, the live tail
311
+ * is healed so half-written emphasis and links render as intended, and a
312
+ * selection made mid-stream survives completion because a block whose HTML
313
+ * does not change is not touched.
314
+ */
315
+ export class MarkdownStream extends Component<MarkdownStreamSignature> {
316
+ /** bumped when an async render resolves, so the view re-reads the cache */
317
+ @tracked private version = 0;
318
+ /** bfm references found in rendered blocks, each rendered in place
319
+ * through <:ref> */
320
+ @tracked refSlots: RefSlot[] = [];
321
+ private projection: Projection | undefined;
322
+ private rendered = new Map<string, RenderedEntry>();
323
+ /** numbers each render request, so results apply in request order */
324
+ private renderSeq = 0;
325
+
326
+ get streaming(): boolean {
327
+ return Boolean(this.args.streaming);
328
+ }
329
+ get flavor(): MarkdownFlavor {
330
+ return this.args.flavor ?? 'markdown';
331
+ }
332
+ get isBfm(): boolean {
333
+ return this.flavor === 'bfm';
334
+ }
335
+ private isDiagram(language: string | undefined): boolean {
336
+ if (!this.isBfm || !language) {
337
+ return false;
338
+ }
339
+ let languages = this.args.diagramLanguages ?? ['mermaid'];
340
+ return languages.includes(language.toLowerCase());
341
+ }
342
+
343
+ get blocks(): ViewBlock[] {
344
+ // consume the version so a resolved async render re-renders
345
+ void this.version;
346
+ let text = this.args.text ?? '';
347
+ this.projection = project(
348
+ this.projection,
349
+ text,
350
+ this.streaming,
351
+ this.flavor,
352
+ );
353
+ let definitions = collectDefinitions(text);
354
+ let live = new Set<string>();
355
+ let codeIndex = 0;
356
+ let view = this.projection.blocks.map((block): ViewBlock => {
357
+ live.add(block.key);
358
+ if (block.kind === 'code') {
359
+ let index = codeIndex++;
360
+ return {
361
+ key: block.key,
362
+ kind: 'code',
363
+ mode: block.mode,
364
+ code: {
365
+ key: block.key,
366
+ index,
367
+ source: block.source ?? '',
368
+ language: block.language,
369
+ complete: Boolean(block.complete),
370
+ },
371
+ diagram: Boolean(block.complete) && this.isDiagram(block.language),
372
+ };
373
+ }
374
+ if (block.kind === 'directive') {
375
+ let directive = block.directive!;
376
+ let isRef =
377
+ this.isBfm &&
378
+ (directive.name === 'card' || directive.name === 'file');
379
+ return {
380
+ key: block.key,
381
+ kind: 'directive',
382
+ mode: block.mode,
383
+ directive: { key: block.key, ...directive },
384
+ ref: isRef
385
+ ? {
386
+ key: block.key,
387
+ type: directive.name as 'card' | 'file',
388
+ url: directive.body,
389
+ inline: false,
390
+ args: directive.args,
391
+ complete: directive.complete,
392
+ }
393
+ : undefined,
394
+ };
395
+ }
396
+ if (block.pendingMath) {
397
+ return {
398
+ key: block.key,
399
+ kind: 'text',
400
+ mode: block.mode,
401
+ pendingMath: block.raw,
402
+ };
403
+ }
404
+ let src = block.raw;
405
+ if (block.mode === 'live' && (this.args.heal ?? true)) {
406
+ src = heal(this.isBfm ? healBfm(src) : src);
407
+ }
408
+ return {
409
+ key: block.key,
410
+ kind: 'text',
411
+ mode: block.mode,
412
+ caret: block.mode === 'live',
413
+ html: this.safeFor(
414
+ block.key,
415
+ this.htmlFor(block.key, src, block.mode, definitions),
416
+ ),
417
+ };
418
+ });
419
+ // forget blocks that no longer exist (a reset, a regenerate)
420
+ for (let key of this.rendered.keys()) {
421
+ if (!live.has(key)) {
422
+ this.rendered.delete(key);
423
+ }
424
+ }
425
+ return view;
426
+ }
427
+
428
+ /** The block's HTML, rendering only when its source changed. An async
429
+ * renderer keeps the previous HTML on screen until a result lands. Every
430
+ * request is numbered, and a result replaces the HTML on screen whenever
431
+ * it comes from a newer request than that HTML did, even when a still
432
+ * newer request is pending: under a steady stream with a slow renderer,
433
+ * the tail then advances with each result instead of waiting for the
434
+ * stream to pause. A result older than the HTML on screen is dropped. */
435
+ private htmlFor(
436
+ key: string,
437
+ blockSrc: string,
438
+ mode: BlockMode,
439
+ definitions: string,
440
+ ): string {
441
+ // the cache is keyed on what the renderer sees: a definition added later
442
+ // in the document re-renders the blocks that could use it
443
+ let src = definitions ? `${blockSrc}\u0000${definitions}` : blockSrc;
444
+ let entry = this.rendered.get(key);
445
+ if (entry && entry.src === src) {
446
+ return entry.html;
447
+ }
448
+ if (entry && entry.requested === src) {
449
+ return entry.html;
450
+ }
451
+ let render = this.args.render;
452
+ let seq = ++this.renderSeq;
453
+ if (!render) {
454
+ let html = renderPlain(blockSrc);
455
+ this.rendered.set(key, { src, html, seq });
456
+ return html;
457
+ }
458
+ let result = render({
459
+ key,
460
+ src: blockSrc,
461
+ mode,
462
+ flavor: this.flavor,
463
+ definitions,
464
+ });
465
+ if (typeof result === 'string') {
466
+ this.rendered.set(key, { src, html: result, seq });
467
+ return result;
468
+ }
469
+ let previous = entry?.html ?? '';
470
+ this.rendered.set(key, {
471
+ src: entry?.src ?? '',
472
+ html: previous,
473
+ seq: entry?.seq ?? 0,
474
+ safe: entry?.safe,
475
+ requested: src,
476
+ });
477
+ let land = (html: string) => {
478
+ let current = this.rendered.get(key);
479
+ if (!current || seq <= current.seq || this.isDestroyed) {
480
+ return; // the block is gone, or newer HTML is already on screen
481
+ }
482
+ this.rendered.set(key, {
483
+ src,
484
+ html,
485
+ seq,
486
+ safe: current.safe,
487
+ // a newer request still pending stays pending
488
+ requested: current.requested === src ? undefined : current.requested,
489
+ });
490
+ this.version++;
491
+ };
492
+ result.then(land, () => land(renderPlain(blockSrc)));
493
+ return previous;
494
+ }
495
+
496
+ private safeFor(key: string, html: string): SafeString {
497
+ let entry = this.rendered.get(key);
498
+ if (entry?.safe && entry.safe.toString() === html) {
499
+ return entry.safe;
500
+ }
501
+ let safe = htmlSafe(html);
502
+ if (entry) {
503
+ entry.safe = safe;
504
+ }
505
+ return safe;
506
+ }
507
+
508
+ get announcement(): string {
509
+ return this.streaming ? '' : (this.args.announceComplete ?? '');
510
+ }
511
+
512
+ // ── links ──────────────────────────────────────────────────────────────
513
+
514
+ /** Runs whenever a text block's HTML is (re)inserted: marks external
515
+ * links to open in a new tab, and asks @resolveLink about relative links
516
+ * and path-like inline code. Unconfirmed, they stay plain text. */
517
+ linkify = (element: HTMLElement, blockKey: string) => {
518
+ if (this.isBfm) {
519
+ this.collectRefs(element, blockKey);
520
+ }
521
+ for (let anchor of Array.from(element.querySelectorAll('a[href]'))) {
522
+ let href = anchor.getAttribute('href') ?? '';
523
+ if (isExternalHref(href)) {
524
+ anchor.setAttribute('target', '_blank');
525
+ anchor.setAttribute('rel', 'noopener noreferrer');
526
+ continue;
527
+ }
528
+ this.pend(anchor, href, 'url');
529
+ }
530
+ for (let code of Array.from(element.querySelectorAll('code'))) {
531
+ if (code.closest('pre')) {
532
+ continue;
533
+ }
534
+ let text = code.textContent ?? '';
535
+ if (isPathLike(text)) {
536
+ this.pend(code, text.trim(), 'path');
537
+ }
538
+ }
539
+ };
540
+
541
+ /** Register the block's reference placeholders as slots. Deferred past
542
+ * the render that inserted them: refSlots was read in that render. */
543
+ private collectRefs(element: HTMLElement, blockKey: string) {
544
+ let found: RefSlot[] = [];
545
+ Array.from(element.querySelectorAll(REF_SELECTOR)).forEach(
546
+ (placeholder, index) => {
547
+ let id = `${blockKey}#${index}`;
548
+ let ref = refFromPlaceholder(placeholder, id);
549
+ if (ref) {
550
+ found.push({ id, blockKey, element: placeholder, ref });
551
+ }
552
+ },
553
+ );
554
+ // refSlots is read only inside the microtask: reading it here would make
555
+ // this modifier depend on it, and the write below would re-run the
556
+ // modifier forever
557
+ queueMicrotask(() => {
558
+ if (this.isDestroying || this.isDestroyed) {
559
+ return;
560
+ }
561
+ let hadAny = this.refSlots.some((slot) => slot.blockKey === blockKey);
562
+ if (!found.length && !hadAny) {
563
+ return;
564
+ }
565
+ this.refSlots = [
566
+ ...this.refSlots.filter(
567
+ (slot) => slot.blockKey !== blockKey && slot.element.isConnected,
568
+ ),
569
+ ...found,
570
+ ];
571
+ });
572
+ }
573
+
574
+ private pend(el: Element, href: string, kind: 'url' | 'path') {
575
+ let resolve = this.args.resolveLink;
576
+ el.setAttribute(LINK_STATE, 'pending');
577
+ if (!resolve) {
578
+ return;
579
+ }
580
+ resolve(href, kind).then(
581
+ (link) => {
582
+ if (!link || !el.isConnected) {
583
+ return;
584
+ }
585
+ resolvedLinks.set(el, link);
586
+ el.setAttribute(LINK_STATE, 'resolved');
587
+ if (el.tagName !== 'A') {
588
+ el.setAttribute('role', 'link');
589
+ el.setAttribute('tabindex', '0');
590
+ }
591
+ },
592
+ () => undefined,
593
+ );
594
+ }
595
+
596
+ private activate(event: MouseEvent | KeyboardEvent) {
597
+ let target = event.target instanceof Element ? event.target : null;
598
+ let el = target?.closest(`[${LINK_STATE}]`);
599
+ if (!el) {
600
+ return;
601
+ }
602
+ if (el.getAttribute(LINK_STATE) !== 'resolved') {
603
+ // an unconfirmed relative link goes nowhere
604
+ event.preventDefault();
605
+ return;
606
+ }
607
+ let link = resolvedLinks.get(el);
608
+ if (link) {
609
+ event.preventDefault();
610
+ this.args.onLinkActivate?.(link, event);
611
+ }
612
+ }
613
+
614
+ onClick = (event: MouseEvent) => this.activate(event);
615
+ onKeyDown = (event: KeyboardEvent) => {
616
+ if (event.key === 'Enter') {
617
+ this.activate(event);
618
+ }
619
+ };
620
+
621
+ <template>
622
+ <div
623
+ class='pretui-md'
624
+ aria-busy={{if this.streaming 'true'}}
625
+ data-streaming={{if this.streaming 'true' 'false'}}
626
+ {{listen this.onClick this.onKeyDown}}
627
+ data-test-pretui-markdown-stream
628
+ ...attributes
629
+ >
630
+ <MarkdownSurface class='pretui-md-prose'>
631
+ {{#each this.blocks key='key' as |block|}}
632
+ {{#if block.code}}
633
+ <div
634
+ class='pretui-md-block'
635
+ data-scroll-anchor
636
+ data-kind='code'
637
+ data-mode={{block.mode}}
638
+ data-complete={{if block.code.complete 'true' 'false'}}
639
+ data-test-pretui-markdown-block='code'
640
+ >
641
+ {{#if (bothTrue block.diagram (has-block 'diagram'))}}
642
+ {{yield block.code to='diagram'}}
643
+ {{else if (has-block 'code')}}
644
+ {{yield block.code to='code'}}
645
+ {{else}}
646
+ <CodeBlock
647
+ @code={{block.code.source}}
648
+ @language={{block.code.language}}
649
+ @maxLines={{24}}
650
+ data-scroll-region
651
+ />
652
+ {{/if}}
653
+ </div>
654
+ {{else if block.directive}}
655
+ <div
656
+ class='pretui-md-block'
657
+ data-scroll-anchor
658
+ data-kind='directive'
659
+ data-mode={{block.mode}}
660
+ data-test-pretui-markdown-block='directive'
661
+ >
662
+ {{#if (bothTrue block.ref (has-block 'ref'))}}
663
+ {{yield block.ref to='ref'}}
664
+ {{else if (has-block 'directive')}}
665
+ {{yield block.directive to='directive'}}
666
+ {{else}}
667
+ <p>::{{block.directive.name}}[{{block.directive.body}}]</p>
668
+ {{/if}}
669
+ </div>
670
+ {{else if block.pendingMath}}
671
+ <div
672
+ class='pretui-md-block'
673
+ data-scroll-anchor
674
+ data-kind='text'
675
+ data-mode={{block.mode}}
676
+ data-test-pretui-markdown-block='text'
677
+ ><pre
678
+ class='pretui-md-math-pending'
679
+ data-pending-math
680
+ data-test-pretui-markdown-pending-math
681
+ >{{block.pendingMath}}</pre></div>
682
+ {{else}}
683
+ <div
684
+ class='pretui-md-block'
685
+ data-scroll-anchor
686
+ data-kind='text'
687
+ data-mode={{block.mode}}
688
+ {{linkified this.linkify block.html block.key}}
689
+ {{caretAtEnd block.html block.caret}}
690
+ data-test-pretui-markdown-block='text'
691
+ >{{block.html}}</div>
692
+ {{/if}}
693
+ {{/each}}
694
+ {{#if (has-block 'ref')}}
695
+ {{#each this.refSlots key='id' as |slot|}}
696
+ {{#in-element slot.element}}
697
+ {{yield slot.ref to='ref'}}
698
+ {{/in-element}}
699
+ {{/each}}
700
+ {{/if}}
701
+ </MarkdownSurface>
702
+ <span
703
+ class='pretui-md-live'
704
+ role='status'
705
+ aria-live='polite'
706
+ data-test-pretui-markdown-announce
707
+ >{{this.announcement}}</span>
708
+ </div>
709
+ <style scoped>
710
+ @layer PretComponent, PretComposite;
711
+ @layer PretComposite {
712
+ /* the answer's typography is MarkdownSurface's: the reading voice,
713
+ the heading ladder, code and tables; this keeps the blocks' own
714
+ rhythm and the stream's marks */
715
+ .pretui-md {
716
+ min-width: 0;
717
+ }
718
+ .pretui-md-block {
719
+ min-width: 0;
720
+ }
721
+ /* Each block wraps the HTML its renderer returned and adds no
722
+ spacing of its own: the block has no padding or border, so its
723
+ children's margins collapse through it and Prose's margins
724
+ between paragraphs and around headings hold exactly as in a
725
+ static render. Prose zeroes the outer edges of its own first and
726
+ last child, which here are blocks, so the same edges are zeroed
727
+ one level down. */
728
+ .pretui-md-block:first-child > :deep(:first-child) {
729
+ margin-block-start: 0;
730
+ }
731
+ .pretui-md-block:last-child > :deep(:last-child) {
732
+ margin-block-end: 0;
733
+ }
734
+ .pretui-md-block[data-kind='code'] {
735
+ margin-block: var(--boxel-sp-sm);
736
+ }
737
+ .pretui-md-block[data-kind='code']:first-child {
738
+ margin-block-start: 0;
739
+ }
740
+ .pretui-md-block[data-kind='code']:last-child {
741
+ margin-block-end: 0;
742
+ }
743
+ /* an unconfirmed relative link reads as the plain text it is */
744
+ .pretui-md-block :deep([data-markdown-link='pending']) {
745
+ color: inherit;
746
+ text-decoration: none;
747
+ cursor: text;
748
+ }
749
+ .pretui-md-block :deep(code[data-markdown-link='resolved']) {
750
+ cursor: pointer;
751
+ text-decoration: underline;
752
+ text-decoration-color: var(--muted-foreground);
753
+ text-underline-offset: 0.18em;
754
+ }
755
+ .pretui-md-block
756
+ :deep(code[data-markdown-link='resolved']:focus-visible) {
757
+ outline: 2px solid var(--ring);
758
+ outline-offset: 2px;
759
+ }
760
+ .pretui-md-block :deep(.pretui-md-caret) {
761
+ display: inline-block;
762
+ width: 0.5em;
763
+ height: 1em;
764
+ margin-inline-start: var(--boxel-sp-6xs);
765
+ vertical-align: text-bottom;
766
+ border-radius: var(--boxel-border-radius-2xs);
767
+ background-color: color-mix(in oklch, var(--foreground) 60%, transparent);
768
+ animation: pretui-md-caret 1s steps(2, start) infinite;
769
+ }
770
+ @keyframes pretui-md-caret {
771
+ to {
772
+ visibility: hidden;
773
+ }
774
+ }
775
+ /* math still arriving is its source, a pasteable string, in the
776
+ quieter ink rather than faded */
777
+ .pretui-md-math-pending {
778
+ font-family: var(--font-mono);
779
+ font-size: var(--boxel-font-size-xs);
780
+ line-height: var(--boxel-line-height-xs);
781
+ color: var(--muted-foreground);
782
+ }
783
+ .pretui-md-live {
784
+ position: absolute;
785
+ width: 1px;
786
+ height: 1px;
787
+ overflow: hidden;
788
+ clip-path: inset(50%);
789
+ white-space: nowrap;
790
+ }
791
+ @media (prefers-reduced-motion: reduce) {
792
+ .pretui-md-block :deep(.pretui-md-caret) {
793
+ animation: none;
794
+ }
795
+ }
796
+ }
797
+ </style>
798
+ </template>
799
+ }