@mlx-bun/module-chat 0.0.0-stage → 1.0.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.
@@ -0,0 +1,547 @@
1
+ // Part of the chat panel (`@mlx-bun/module-chat`), bundled into the app's browser build.
2
+ //
3
+ // The hand-rolled markdown renderer + streaming block-memoization +
4
+ // syntax-highlighting glue. Behavior-identical port of the original inline
5
+ // <script> in app.html. This module is the streaming-parity oracle for
6
+ // packages/module-chat/tests/panel.test.ts: renderBlocksIncremental fed char-by-char must
7
+ // converge to the exact same innerHTML as one-shot mdToHtml.
8
+
9
+ /** Escape the four HTML-significant characters. Used on every interpolation
10
+ * site across the app — the single discipline that keeps user/model text
11
+ * from ever being interpreted as markup. */
12
+ export const esc = (s: unknown): string =>
13
+ String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" } as Record<string, string>)[c]!);
14
+
15
+ /** Allow only safe link targets (http/https/mailto/anchor/relative). */
16
+ export function mdSafeUrl(u: unknown): string | null {
17
+ const s = String(u).trim();
18
+ return /^(https?:\/\/|mailto:|#|\/|\.{1,2}\/)/i.test(s) ? s : null;
19
+ }
20
+
21
+ /** Inline markdown -> HTML. Escapes first, then shields code spans from
22
+ * further markup so backticked text is never mangled or unsafe. */
23
+ export function mdInline(t: unknown): string {
24
+ const codes: string[] = [];
25
+ // Pull code spans out first (any run of backticks), then escape the rest.
26
+ let src = String(t).replace(/(`+)([\s\S]+?)\1/g, (_m, _ticks, c: string) => {
27
+ codes.push(c.replace(/^ | $/g, ""));
28
+ return "\0C" + (codes.length - 1) + "\0";
29
+ });
30
+ let s = esc(src);
31
+ // [text](url "title") — title optional and ignored.
32
+ s = s.replace(/\[([^\]]+)\]\(\s*([^)\s]+)(?:\s+&quot;[^)]*&quot;)?\s*\)/g, (m, text: string, url: string) => {
33
+ const safe = mdSafeUrl(url);
34
+ return safe ? '<a href="' + safe + '" target="_blank" rel="noopener noreferrer">' + text + "</a>" : m;
35
+ });
36
+ s = s
37
+ .replace(/\*\*\*([^*]+)\*\*\*/g, "<strong><em>$1</em></strong>")
38
+ .replace(/\*\*([^*]+?)\*\*/g, "<strong>$1</strong>")
39
+ .replace(/(^|[^_\w])__([^_]+?)__(?![\w])/g, "$1<strong>$2</strong>")
40
+ .replace(/(^|[^*\w])\*([^*\s][^*]*?)\*/g, "$1<em>$2</em>")
41
+ .replace(/(^|[^_\w])_([^_\s][^_]*?)_(?![\w])/g, "$1<em>$2</em>")
42
+ .replace(/~~([^~]+?)~~/g, "<del>$1</del>");
43
+ // Autolink bare URLs that markdown didn't already wrap.
44
+ s = s.replace(/(^|[\s(])(https?:\/\/[^\s<)]+[^\s<).,;:!?'"])/g,
45
+ (_m, pre: string, url: string) => pre + '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + url + "</a>");
46
+ return s.replace(/\0C(\d+)\0/g, (_m, i: string) => "<code>" + esc(codes[+i]) + "</code>");
47
+ }
48
+
49
+ /** Pure: does this fence's language tag qualify for the Canvas v1
50
+ * Preview|Source toggle? Only html/svg — js/ts/etc. fences never get a
51
+ * toggle (running arbitrary script output isn't the feature; rendering a
52
+ * markup/vector document is). Exported so packages/module-chat/tests/panel.test.ts can check
53
+ * the detection logic directly without a DOM. */
54
+ export function isCanvasFence(lang: string): boolean {
55
+ return /^(html?|svg)$/i.test(lang.trim());
56
+ }
57
+
58
+ export function mdCodeBlock(lang: string, code: string): string {
59
+ // language-<lang> on <code> is the de-facto convention hljs's own
60
+ // language-detection-from-class reads (see highlightIn() below); an empty
61
+ // fence info string just omits the class and highlightIn() skips it.
62
+ const langClass = lang ? ' class="language-' + esc(lang) + '"' : "";
63
+ const canvas = isCanvasFence(lang);
64
+ // Canvas v1 (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
65
+ // beat-matrix Axis 2): a Preview|Source toggle
66
+ // next to Copy for completed html/svg fences. The toggle markup is always
67
+ // emitted for a qualifying fence (even while the block is still streaming
68
+ // — mdCodeBlock only ever runs on a CLOSED fence per mdToHtml/splitBlocks,
69
+ // so "qualifying" already implies "complete"); the iframe itself is never
70
+ // created here — see wireCanvasToggle's lazy srcdoc creation below, so an
71
+ // unopened Preview never executes model-generated script.
72
+ const toggleHtml = canvas
73
+ ? '<span class="cbtoggle" role="group" aria-label="View">' +
74
+ '<button class="cbview cbview-source active" type="button" aria-pressed="true">Source</button>' +
75
+ '<button class="cbview cbview-preview" type="button" aria-pressed="false">Preview</button>' +
76
+ "</span>"
77
+ : "";
78
+ // The raw fence text (pre-escape) is stashed verbatim in a hidden <div>
79
+ // sibling so wireCanvasToggle can read it back via .textContent (the
80
+ // browser does entity-decoding for .textContent on ordinary elements, so
81
+ // no manual unescape needed) without re-parsing the highlighted/escaped
82
+ // <code> body. NOT a <script> tag: script/style are HTML "raw text"
83
+ // elements whose content the parser does NOT entity-decode (textContent
84
+ // would come back as the literal "&lt;h1&gt;" instead of "<h1>") — a plain
85
+ // element sidesteps that without needing any special-case unescaping, and
86
+ // `hidden` (not display:none, matching convention elsewhere in this file)
87
+ // keeps it out of layout and never executed either way.
88
+ const srcStash = canvas ? '<div class="cbsrc" hidden>' + esc(code) + "</div>" : "";
89
+ return '<div class="codeblock' + (canvas ? " has-canvas" : "") + '"><div class="cbhead"><span class="cblang">' + esc(lang || "") +
90
+ "</span>" + toggleHtml + '<button class="cbcopy" type="button">Copy</button></div>' +
91
+ "<pre><code" + langClass + ">" + esc(code) + "</code></pre>" + srcStash +
92
+ (canvas ? '<div class="cbcanvas" hidden></div>' : "") + "</div>";
93
+ }
94
+
95
+ /* ────────────────────────────────────────────────────────────────────
96
+ CANVAS V1 (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
97
+ beat-matrix Axis 2 — "I miss optiq's Canvas"
98
+ is a disallowed outcome): completed html/svg fenced code blocks get a
99
+ Preview|Source toggle in the existing .cbhead bar. Preview renders the
100
+ fence content in a sandboxed <iframe>.
101
+
102
+ SECURITY: the iframe sandbox is `allow-scripts` ONLY — never
103
+ `allow-same-origin` together with `allow-scripts`. Combining them would
104
+ let a model-generated <script> read this iframe's effective origin,
105
+ which the browser resolves to the PARENT document's origin for a
106
+ srcdoc/about:blank frame with allow-same-origin — i.e. the sandboxed
107
+ content could reach window.parent's DOM, localStorage, and the live
108
+ WebSocket. allow-scripts alone keeps the frame in a unique opaque
109
+ origin: scripts run, but same-origin-gated APIs (parent access,
110
+ storage, cookies) are denied by the browser regardless of what the
111
+ content tries. This is the one non-negotiable line in this feature.
112
+
113
+ Lazy: the iframe element is only created on the first "Preview" click
114
+ (never auto-run) — a model that emits an html fence should not get a
115
+ free script execution just because a message rendered.
116
+ ──────────────────────────────────────────────────────────────────── */
117
+
118
+ /** Wire the Preview/Source toggle + Copy button for every codeblock inside
119
+ * `container` via ONE delegated click listener — safe to call once on a
120
+ * stable ancestor (chat's `thread()` element, the memory panel's
121
+ * `mem-body`) whose innerHTML gets replaced/extended repeatedly; delegation
122
+ * means newly rendered codeblocks are wired for free, no per-render
123
+ * re-attachment needed. Copy-button wiring already lives inline in
124
+ * chat.ts's own delegated listener — this function only owns the Canvas
125
+ * toggle so it can be shared between chat.ts and memory-panel.ts without
126
+ * duplicating the Copy logic in two places. */
127
+ export function wireCanvasToggle(container: HTMLElement): void {
128
+ container.addEventListener("click", (e) => {
129
+ const btn = (e.target as HTMLElement).closest?.(".cbview") as HTMLElement | null;
130
+ if (!btn || !container.contains(btn)) return;
131
+ const block = btn.closest(".codeblock");
132
+ if (!block) return;
133
+ const showPreview = btn.classList.contains("cbview-preview");
134
+ setCanvasView(block as HTMLElement, showPreview ? "preview" : "source");
135
+ });
136
+ }
137
+
138
+ /** Capture which Canvas-eligible codeblocks (in document order) are
139
+ * currently showing Preview, keyed by their positional index within
140
+ * `container` — NOT by fence content, since two identical fences would
141
+ * collide on a content key and streamed text can still be mutating right
142
+ * up to the caller's re-render. Positional index is stable across a
143
+ * finishStreaming-style "replace innerHTML with a fresh render of the
144
+ * same final text" pass because mdToHtml walks the same fences in the
145
+ * same order both times. Returns an empty array cheaply when nothing is
146
+ * in Preview (the common case) — callers should skip the restore pass
147
+ * entirely when the array is empty. */
148
+ export function captureCanvasViewStates(container: HTMLElement): number[] {
149
+ const previewIdx: number[] = [];
150
+ const blocks = container.querySelectorAll(".codeblock.has-canvas");
151
+ blocks.forEach((block, i) => {
152
+ if (block.querySelector(".cbview-preview")?.classList.contains("active")) previewIdx.push(i);
153
+ });
154
+ return previewIdx;
155
+ }
156
+
157
+ /** Inverse of captureCanvasViewStates: re-apply Preview to the codeblocks
158
+ * at the given positional indices after a full re-render replaced the
159
+ * container's innerHTML (which always resets every qualifying fence back
160
+ * to its default Source view — see mdCodeBlock). A no-op for any index
161
+ * past the end of the freshly rendered set (final text had fewer
162
+ * qualifying fences than the mid-stream snapshot did — nothing sensible
163
+ * to restore, so it's silently skipped rather than throwing). */
164
+ export function restoreCanvasViewStates(container: HTMLElement, previewIdx: readonly number[]): void {
165
+ if (!previewIdx.length) return;
166
+ const blocks = container.querySelectorAll(".codeblock.has-canvas");
167
+ for (const i of previewIdx) {
168
+ const block = blocks[i] as HTMLElement | undefined;
169
+ if (block) setCanvasView(block, "preview");
170
+ }
171
+ }
172
+
173
+ function setCanvasView(block: HTMLElement, view: "preview" | "source"): void {
174
+ const sourceBtn = block.querySelector(".cbview-source");
175
+ const previewBtn = block.querySelector(".cbview-preview");
176
+ const pre = block.querySelector("pre");
177
+ const canvasEl = block.querySelector(".cbcanvas") as HTMLElement | null;
178
+ if (!sourceBtn || !previewBtn || !pre || !canvasEl) return;
179
+ sourceBtn.classList.toggle("active", view === "source");
180
+ sourceBtn.setAttribute("aria-pressed", String(view === "source"));
181
+ previewBtn.classList.toggle("active", view === "preview");
182
+ previewBtn.setAttribute("aria-pressed", String(view === "preview"));
183
+ if (view === "source") {
184
+ canvasEl.hidden = true;
185
+ (pre as HTMLElement).hidden = false;
186
+ return;
187
+ }
188
+ (pre as HTMLElement).hidden = true;
189
+ canvasEl.hidden = false;
190
+ // Lazy iframe creation: only build it the first time Preview is opened,
191
+ // never on render. srcdoc (not src=data:) keeps everything same-tab, no
192
+ // network hop, and no history entry.
193
+ if (!canvasEl.querySelector("iframe")) {
194
+ const srcEl = block.querySelector(".cbsrc");
195
+ const code = srcEl ? srcEl.textContent || "" : "";
196
+ const frame = document.createElement("iframe");
197
+ frame.className = "cbframe";
198
+ // allow-scripts ONLY — see the file-level comment above this section.
199
+ frame.setAttribute("sandbox", "allow-scripts");
200
+ frame.setAttribute("loading", "lazy");
201
+ frame.title = "Canvas preview";
202
+ // A neutral light background regardless of app theme: model-authored
203
+ // HTML/SVG rarely sets its own background, and an unstyled fragment on
204
+ // a dark app background reads as broken; a plain white canvas is the
205
+ // one assumption that's safe for both arbitrary markup and dark/light
206
+ // app themes alike (this iframe is content the model wrote, not app
207
+ // chrome, so it doesn't need to follow the app's theme toggle).
208
+ frame.srcdoc = code;
209
+ canvasEl.appendChild(frame);
210
+ }
211
+ }
212
+
213
+ /* ────────────────────────────────────────────────────────────────────
214
+ CHAT-WITH-FILES RAG v1 — [n] citation markers (`02d723a:docs/design/web-chat-redesign.md`
215
+ Appendix A, beat-matrix Axis 5): when a turn's prompt carried retrieved chunks
216
+ (composer.ts's buildMessageText, retrieval-mode branch), the model is
217
+ asked to cite them inline as `[n]`. This turns each SURVIVING `[n]`
218
+ marker in the assistant's rendered HTML into a clickable superscript
219
+ link that expands/scrolls the Sources panel (chat.ts) — but ONLY when a
220
+ citation map for that exact n was actually attached to this message
221
+ (never linkify `[n]` in an ordinary reply, e.g. "see note [1] below" in
222
+ a message with no attachments at all — that would be a false citation).
223
+ ──────────────────────────────────────────────────────────────────── */
224
+
225
+ /** Pure: rewrite bracketed `[n]` markers in already-rendered HTML text into
226
+ * clickable citation links, but only for n's present in `validNs` — every
227
+ * other `[...]` run (including `[n]` for an n outside the cited set) is
228
+ * left untouched. Operates on HTML (post mdToHtml/mdInline), not raw
229
+ * markdown source, so it must NOT rewrite inside tag markup — an `<a
230
+ * href="...[1]...">` (a URL that happens to contain a literal "[1]" in its
231
+ * path, or any other attribute value) would otherwise get a `<button>`
232
+ * spliced into the middle of the attribute string, corrupting the tag.
233
+ * Splits the string on tag boundaries first and only replaces `[n]` runs
234
+ * in the text-node segments between tags. */
235
+ export function linkifyCitations(html: string, validNs: ReadonlySet<number>): string {
236
+ if (!validNs.size) return html;
237
+ const replaceMarkers = (text: string): string =>
238
+ text.replace(/\[(\d{1,3})\]/g, (m, d: string) => {
239
+ const n = Number(d);
240
+ if (!validNs.has(n)) return m;
241
+ return '<button type="button" class="cite-mark" data-cite="' + n + '" aria-label="Source ' + n + '">' + n + "</button>";
242
+ });
243
+ // Split into alternating [text, tag, text, tag, ...] segments — capturing
244
+ // group in split() keeps the tag delimiters in the output array — and
245
+ // only rewrite the (even-indexed) text segments.
246
+ return html.split(/(<[^>]*>)/g).map((seg, i) => (i % 2 === 0 ? replaceMarkers(seg) : seg)).join("");
247
+ }
248
+
249
+ function mdTableSep(s: string): boolean { return /^[\s|:-]+$/.test(s) && /-/.test(s) && /\|/.test(s); }
250
+ function mdSplitRow(s: string): string[] { return s.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()); }
251
+ function mdBlockStart(s: string): boolean {
252
+ return /^\s*(`{3,}|~{3,})/.test(s) || /^(#{1,6})\s/.test(s) || /^\s*>/.test(s) ||
253
+ /^\s*[-*+]\s+/.test(s) || /^\s*\d+[.)]\s+/.test(s) || /^\s*([-*_])\s*(\1\s*){2,}$/.test(s);
254
+ }
255
+
256
+ /** Safe markdown renderer: code fences (w/ copy), headings, lists, task
257
+ * lists, tables, blockquotes, rules, links, emphasis, inline code. */
258
+ export function mdToHtml(src: unknown): string {
259
+ const lines = String(src).replace(/\r\n?/g, "\n").split("\n");
260
+ let html = "", i = 0, list: string | null = null;
261
+ const closeList = () => { if (list) { html += "</" + list + ">"; list = null; } };
262
+
263
+ while (i < lines.length) {
264
+ const ln = lines[i]!;
265
+
266
+ // fenced code block
267
+ const fence = ln.match(/^\s*(`{3,}|~{3,})\s*([\w+#.-]*)/);
268
+ if (fence) {
269
+ closeList();
270
+ const tick = fence[1]![0], lang = fence[2] || "", buf: string[] = [];
271
+ const closeRe = new RegExp("^\\s*" + (tick === "`" ? "`{3,}" : "~{3,}") + "\\s*$");
272
+ i++;
273
+ while (i < lines.length && !closeRe.test(lines[i]!)) { buf.push(lines[i]!); i++; }
274
+ i++; // consume closing fence (or run off the end mid-stream)
275
+ html += mdCodeBlock(lang, buf.join("\n"));
276
+ continue;
277
+ }
278
+
279
+ // GFM table: a row with pipes followed by a separator row
280
+ if (ln.includes("|") && i + 1 < lines.length && mdTableSep(lines[i + 1]!)) {
281
+ closeList();
282
+ const header = mdSplitRow(ln);
283
+ const aligns = mdSplitRow(lines[i + 1]!).map((c) => {
284
+ const l = c.startsWith(":"), r = c.endsWith(":");
285
+ return l && r ? "center" : r ? "right" : l ? "left" : "";
286
+ });
287
+ i += 2;
288
+ const rows: string[][] = [];
289
+ while (i < lines.length && lines[i]!.includes("|") && lines[i]!.trim() !== "") { rows.push(mdSplitRow(lines[i]!)); i++; }
290
+ const al = (k: number) => (aligns[k] ? ' style="text-align:' + aligns[k] + '"' : "");
291
+ let tbl = '<div class="md-tablewrap"><table class="md-table"><thead><tr>';
292
+ header.forEach((c, k) => { tbl += "<th" + al(k) + ">" + mdInline(c) + "</th>"; });
293
+ tbl += "</tr></thead><tbody>";
294
+ rows.forEach((r) => {
295
+ tbl += "<tr>";
296
+ for (let k = 0; k < header.length; k++) tbl += "<td" + al(k) + ">" + mdInline(r[k] || "") + "</td>";
297
+ tbl += "</tr>";
298
+ });
299
+ html += tbl + "</tbody></table></div>";
300
+ continue;
301
+ }
302
+
303
+ // heading
304
+ const h = ln.match(/^(#{1,6})\s+(.*)$/);
305
+ if (h) { closeList(); const n = h[1]!.length; html += "<h" + n + ">" + mdInline(h[2]!.replace(/\s+#+\s*$/, "")) + "</h" + n + ">"; i++; continue; }
306
+
307
+ // horizontal rule
308
+ if (/^\s*([-*_])\s*(\1\s*){2,}$/.test(ln)) { closeList(); html += "<hr>"; i++; continue; }
309
+
310
+ // blockquote (consecutive '>' lines, rendered recursively)
311
+ if (/^\s*>/.test(ln)) {
312
+ closeList();
313
+ const buf: string[] = [];
314
+ while (i < lines.length && /^\s*>/.test(lines[i]!)) { buf.push(lines[i]!.replace(/^\s*>\s?/, "")); i++; }
315
+ html += "<blockquote>" + mdToHtml(buf.join("\n")) + "</blockquote>";
316
+ continue;
317
+ }
318
+
319
+ // task list / unordered / ordered list items
320
+ const task = ln.match(/^\s*[-*+]\s+\[([ xX])\]\s+(.*)$/);
321
+ const ul = ln.match(/^\s*[-*+]\s+(.*)$/);
322
+ const ol = ln.match(/^\s*\d+[.)]\s+(.*)$/);
323
+ if (task) {
324
+ if (list !== "ul") { closeList(); html += "<ul>"; list = "ul"; }
325
+ html += '<li class="task"><input type="checkbox" disabled' + (task[1]!.toLowerCase() === "x" ? " checked" : "") + "> " + mdInline(task[2]!) + "</li>";
326
+ i++; continue;
327
+ }
328
+ if (ul) { if (list !== "ul") { closeList(); html += "<ul>"; list = "ul"; } html += "<li>" + mdInline(ul[1]!) + "</li>"; i++; continue; }
329
+ if (ol) { if (list !== "ol") { closeList(); html += "<ol>"; list = "ol"; } html += "<li>" + mdInline(ol[1]!) + "</li>"; i++; continue; }
330
+
331
+ if (ln.trim() === "") { closeList(); i++; continue; }
332
+
333
+ // paragraph: merge following plain lines (soft breaks -> <br>)
334
+ closeList();
335
+ const para = [ln];
336
+ i++;
337
+ while (i < lines.length && lines[i]!.trim() !== "" && !mdBlockStart(lines[i]!)) { para.push(lines[i]!); i++; }
338
+ html += "<p>" + para.map(mdInline).join("<br>") + "</p>";
339
+ }
340
+ closeList();
341
+ return html;
342
+ }
343
+
344
+ /* ────────────────────────────────────────────────────────────────────
345
+ STREAMING BLOCK MEMOIZATION (`02d723a:docs/design/web-chat-redesign.md` §2.2)
346
+ ────────────────────────────────────────────────────────────────────
347
+ Naively doing `textNode.innerHTML = mdToHtml(fullText)` on every token
348
+ is O(n^2) over a response: every delta re-parses and re-renders the
349
+ *entire* message from scratch, and innerHTML-replace also blows away
350
+ and recreates every DOM node (losing e.g. text selection) each time.
351
+
352
+ The fix, ported from the Streamdown/t3.chat "block memoization"
353
+ pattern into this vanilla renderer: split accumulated text at stable
354
+ block boundaries (blank lines; a fenced code block counts as one
355
+ block and only becomes "stable" once its closing fence has arrived).
356
+ Every block except the last is done streaming and gets parsed with
357
+ mdToHtml exactly ONCE — its DOM node is then left completely alone.
358
+ Only the last ("live tail") block gets re-parsed on each delta, and
359
+ even that is throttled to once per animation frame so a burst of
360
+ fast tokens (e.g. spec-decode) coalesces into one reflow instead of
361
+ one per token.
362
+
363
+ INVARIANT (this is the correctness test for this whole scheme, and the
364
+ thing packages/module-chat/tests/panel.test.ts's streaming-parity fixtures check): the
365
+ memoized incremental render must be pixel-for-pixel/byte-for-byte
366
+ identical to a single `mdToHtml(fullText)` pass once the message is
367
+ complete. We guarantee this by (a) never touching a block's DOM once
368
+ it's marked done, using the same mdToHtml on that exact substring
369
+ that a full pass would have used, and (b) doing one full non-
370
+ memoized mdToHtml(fullText) pass at turn-end (see finishStreaming in
371
+ chat.ts) to paper over any edge case where incremental block splitting
372
+ disagrees with whole-text parsing (e.g. a list that only "closes"
373
+ because a later blank line arrives, or a paragraph the tail-block
374
+ join glued differently than the full parse would). That final pass
375
+ is the ground truth; the incremental renders are a fast-path
376
+ approximation of it while streaming is in flight. */
377
+
378
+ export interface MdBlock {
379
+ text: string;
380
+ done: boolean;
381
+ node?: HTMLElement;
382
+ }
383
+
384
+ export interface BlockState {
385
+ blocks: MdBlock[];
386
+ }
387
+
388
+ /** Split text into blocks at blank lines. A fenced code block (opened by
389
+ * a ``` or ~~~ fence) is always kept as a single block and is only
390
+ * "closed" (safe to memoize) once its matching closing fence has been
391
+ * seen — while the fence is still open we keep extending the same
392
+ * block on every call, exactly like mdToHtml's own fence scanner does.
393
+ * Returns [{text, done}]; every element except possibly the last has
394
+ * done:true (the last block is "done" too if the text itself ends
395
+ * with a blank line — i.e. the block truly can't grow anymore given
396
+ * what's arrived so far). */
397
+ export function splitBlocks(src: unknown): MdBlock[] {
398
+ const lines = String(src).replace(/\r\n?/g, "\n").split("\n");
399
+ const blocks: MdBlock[] = [];
400
+ let buf: string[] = [], i = 0;
401
+ const flush = (done: boolean) => { if (buf.length) { blocks.push({ text: buf.join("\n"), done }); buf = []; } };
402
+
403
+ while (i < lines.length) {
404
+ const ln = lines[i]!;
405
+ const fence = ln.match(/^\s*(`{3,}|~{3,})/);
406
+ if (fence) {
407
+ // A fence starts a code block: flush whatever came before it as its
408
+ // own (done) block, then greedily consume lines until the matching
409
+ // closing fence — the whole fenced region is one block.
410
+ flush(true);
411
+ const tick = fence[1]![0];
412
+ const closeRe = new RegExp("^\\s*" + (tick === "`" ? "`{3,}" : "~{3,}") + "\\s*$");
413
+ buf.push(ln); i++;
414
+ let closed = false;
415
+ while (i < lines.length) {
416
+ buf.push(lines[i]!);
417
+ if (closeRe.test(lines[i]!)) { closed = true; i++; break; }
418
+ i++;
419
+ }
420
+ flush(closed); // still-open fence -> not done; more deltas may extend it
421
+ continue;
422
+ }
423
+ if (ln.trim() === "") { flush(true); i++; continue; }
424
+ buf.push(ln); i++;
425
+ }
426
+ // Trailing buffered lines with no closing blank line yet: this is the
427
+ // live tail, not done — it may still grow on the next delta.
428
+ flush(false);
429
+ return blocks;
430
+ }
431
+
432
+ /**
433
+ * Incrementally (re)render an accumulated markdown string into `container`
434
+ * using block memoization. `state` is a small cache object stashed on the
435
+ * caller (e.g. `a.blockState`) shaped `{ blocks: [{text, done, node}] }`,
436
+ * created fresh per message. Completed blocks whose text hasn't changed
437
+ * since last render are left untouched (their DOM node is reused as-is);
438
+ * only new/changed/live-tail blocks get an mdToHtml call.
439
+ *
440
+ * Completed block nodes get `class="md-block done"` and, once rendered,
441
+ * have any fenced code inside them run through highlightIn() (syntax
442
+ * highlighting only ever touches finished blocks — never the streaming
443
+ * live tail, to avoid re-highlighting on every token).
444
+ */
445
+ export function renderBlocksIncremental(container: HTMLElement, text: string, state: BlockState): void {
446
+ const next = splitBlocks(text);
447
+ const prev = state.blocks || [];
448
+
449
+ for (let k = 0; k < next.length; k++) {
450
+ const nb = next[k]!;
451
+ const pb = prev[k];
452
+ if (pb && pb.done && pb.text === nb.text && pb.node) {
453
+ // Unchanged completed block: reuse the existing node untouched.
454
+ nb.node = pb.node;
455
+ continue;
456
+ }
457
+ // (Re)render this block. Reuse the DOM node in place if one already
458
+ // exists at this index (keeps node identity stable across re-renders
459
+ // of the live tail instead of thrashing the DOM), otherwise create it.
460
+ const node = pb && pb.node ? pb.node : document.createElement("div");
461
+ node.className = "md-block" + (nb.done ? " done" : "");
462
+ node.innerHTML = mdToHtml(nb.text);
463
+ nb.node = node;
464
+ if (!node.parentNode) container.appendChild(node);
465
+ if (nb.done) highlightIn(node);
466
+ }
467
+ // Drop any now-stale trailing nodes (text shrank — shouldn't normally
468
+ // happen during streaming, but keep the DOM honest if it ever does).
469
+ for (let k = next.length; k < prev.length; k++) {
470
+ const pk = prev[k];
471
+ if (pk && pk.node && pk.node.parentNode) pk.node.remove();
472
+ }
473
+ state.blocks = next;
474
+ }
475
+
476
+ /* ────────────────────────────────────────────────────────────────────
477
+ SYNTAX HIGHLIGHTING
478
+ ────────────────────────────────────────────────────────────────────
479
+ Vendored highlight.js (apps/mlx-bun/src/web/public/vendor/hljs.js, no CDN — see that file's
480
+ header) applied to fenced code blocks. mdCodeBlock() above stamps
481
+ `class="language-<fence-info-string>"` onto the <code> element when the
482
+ fence declares one (```ts, ```python, ...); highlight.js reads that class
483
+ itself (its own `language-xxx` convention), so no lookup table is needed
484
+ here — an empty/unrecognized language just falls through to hljs's
485
+ plaintext handling.
486
+
487
+ Called from exactly three places:
488
+ 1. renderBlocksIncremental(), only on blocks freshly marked `done`
489
+ (the live streaming tail is never highlighted — that would mean
490
+ re-highlighting on every token, the same O(n^2) shape the block-
491
+ memoization pass just fixed).
492
+ 2. finishStreaming() in chat.ts, the end-of-turn full mdToHtml pass
493
+ (ground truth — catches any block whose "done" transition happened
494
+ without a fresh render, e.g. the last block if it ends exactly on
495
+ a blank line).
496
+ 3. renderAssistantStatic() in chat.ts, for replayed history.
497
+ Idempotent via hljs's own `data-highlighted` marker (set on the <code>
498
+ element after its first highlightElement() call) — highlightElement()
499
+ itself throws "Element previously highlighted" if called twice on the
500
+ same node without clearing that marker, so we check for it explicitly
501
+ rather than relying on the library to no-op. */
502
+
503
+ /** The vendored highlight.js global (apps/mlx-bun/src/web/public/vendor/hljs.js), loaded via a
504
+ * plain <script defer src="/assets/hljs.js">, not an ES module — so it's
505
+ * a `window` global, not an import. Declared loosely (not the full hljs
506
+ * typing) since this app only calls the one method. */
507
+ declare const hljs: { highlightElement(el: Element): void } | undefined;
508
+
509
+ export function highlightIn(container: HTMLElement | null): void {
510
+ if (typeof hljs === "undefined" || !container) return;
511
+ const blocks = container.querySelectorAll("pre code");
512
+ for (const block of blocks) {
513
+ const el = block as HTMLElement;
514
+ if (el.dataset.highlighted) continue; // already done — idempotent
515
+ // Skip auto-detect on huge blocks: hljs's language guesser runs every
516
+ // registered grammar against the text, which is fine for a fenced
517
+ // ```lang block (single grammar) but expensive with no declared
518
+ // language on a very large block. A declared language always runs
519
+ // (fixed grammar, no guessing), regardless of size.
520
+ const hasLang = /\blanguage-/.test(el.className);
521
+ if (!hasLang && (el.textContent || "").length > 20_000) continue;
522
+ try { hljs!.highlightElement(el); } catch { /* leave as plain text */ }
523
+ }
524
+ }
525
+
526
+ /** rAF-throttle `render` so a burst of calls within one frame collapses into
527
+ * a single DOM update. `shouldStick`/`doStick` (both optional) let a caller
528
+ * autoscroll correctly across the coalesced burst: `shouldStick` is sampled
529
+ * fresh at the START of each queued frame (i.e. right before `render` runs,
530
+ * against whatever scrollHeight the LAST completed render left behind —
531
+ * never the stale pre-burst height a synchronous caller-side check would
532
+ * see), and `doStick` runs immediately after `render` grows the DOM in that
533
+ * same frame, so the pin decision and the scroll it acts on are always the
534
+ * correct before/after pair regardless of how many calls got coalesced. */
535
+ export function makeFrameScheduler(render: () => void, shouldStick?: () => boolean, doStick?: () => void): () => void {
536
+ let queued = false;
537
+ return function schedule() {
538
+ if (queued) return;
539
+ queued = true;
540
+ requestAnimationFrame(() => {
541
+ queued = false;
542
+ const stickAfter = shouldStick ? shouldStick() : false;
543
+ render();
544
+ if (stickAfter && doStick) doStick();
545
+ });
546
+ };
547
+ }
@@ -0,0 +1,17 @@
1
+ // The panel's open popovers, for the host's Escape sweep: each registers how to close itself when its controls are
2
+ // wired, and `closeTopPopover` closes the first one open (in the order the host page's sweep always used).
3
+ const ORDER = ["chat-sampling-pop", "chat-sysprompt-pop", "adapters-overlay"] as const;
4
+ type PopoverId = (typeof ORDER)[number];
5
+ const closers: Partial<Record<PopoverId, () => void>> = {};
6
+
7
+ export function setPopoverClose(id: PopoverId, close: () => void): void { closers[id] = close; }
8
+
9
+ const isOpen = (id: PopoverId): boolean => !!closers[id] && !!document.getElementById(id)?.classList.contains("open");
10
+
11
+ export function hasOpenPopover(): boolean { return ORDER.some(isOpen); }
12
+
13
+ /** Closes the first open popover; false when none was open. */
14
+ export function closeTopPopover(): boolean {
15
+ for (const id of ORDER) if (isOpen(id)) { closers[id]!(); return true; }
16
+ return false;
17
+ }