mlx-bun 0.0.10 → 0.0.11

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 (173) hide show
  1. package/README.md +66 -21
  2. package/THIRD_PARTY_LICENSES.md +1 -0
  3. package/docs/archive/mlx-bun-lab-report.html +275 -0
  4. package/docs/archive/wwdc-mlx-bun.html +848 -0
  5. package/docs/design/audio-input-plan.md +356 -0
  6. package/docs/design/batching-perf-path.md +58 -35
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/compat-cli-surface-design.md +4 -3
  10. package/docs/design/decode-speed-program.md +172 -0
  11. package/docs/design/docs-reorg-plan.md +1 -1
  12. package/docs/design/dspark-serving-program.md +371 -0
  13. package/docs/design/dspark-speculative-decoding.md +167 -5
  14. package/docs/design/faithful-l1-consolidation.md +310 -0
  15. package/docs/design/generic-model-support.md +8 -5
  16. package/docs/design/grammar-spec-batching-integration.md +301 -0
  17. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  18. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  19. package/docs/design/omlx-adoption-map.md +14 -6
  20. package/docs/design/orpo-training.md +34 -30
  21. package/docs/design/paged-kv-cache.md +214 -0
  22. package/docs/design/parallel-slots.md +82 -49
  23. package/docs/design/parity-tier-dag.md +43 -33
  24. package/docs/design/repo-cleanup-plan.md +189 -0
  25. package/docs/design/runtime-isolation.md +19 -3
  26. package/docs/design/segmented-backward-training.md +3 -2
  27. package/docs/design/ssd-kv-cold-tier.md +130 -0
  28. package/docs/design/structured-output.md +484 -0
  29. package/docs/design/the-dreaming-master-plan.md +2 -3
  30. package/docs/design/turboquant-kv.md +235 -0
  31. package/docs/design/unified-engine-frontier-plan.md +774 -0
  32. package/docs/design/web-chat-beat-matrix.md +315 -0
  33. package/docs/design/web-chat-redesign.md +1112 -0
  34. package/docs/investigations/dspark-handoff.md +182 -16
  35. package/docs/investigations/lab-build-journal.md +2 -2
  36. package/docs/investigations/openwiki-evaluation.md +249 -0
  37. package/docs/planning/IDEAS.md +14 -0
  38. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  39. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  40. package/docs/planning/chat-ui-vision.md +50 -0
  41. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  42. package/docs/planning/release-notes-v0.0.11.md +239 -0
  43. package/docs/reference/cli.md +35 -16
  44. package/docs/reference/distribution.md +13 -6
  45. package/docs/reference/embedding.md +35 -14
  46. package/docs/reference/features-matrix.md +97 -0
  47. package/docs/reference/library-api.md +30 -9
  48. package/docs/reference/memory.md +26 -6
  49. package/docs/reference/models.md +11 -1
  50. package/docs/reference/orpo-quickstart.md +3 -4
  51. package/docs/reference/server-api.md +634 -22
  52. package/docs/reference/server-config.md +250 -121
  53. package/docs/reference/training.md +14 -15
  54. package/package.json +5 -3
  55. package/src/anthropic.ts +13 -0
  56. package/src/audio/conformer.ts +647 -0
  57. package/src/audio/decode.ts +133 -0
  58. package/src/audio/features.ts +202 -0
  59. package/src/audio/transcode.ts +67 -0
  60. package/src/cli.ts +356 -105
  61. package/src/config.ts +49 -2
  62. package/src/download.ts +40 -0
  63. package/src/eval/README.md +7 -6
  64. package/src/eval/kl.ts +59 -3
  65. package/src/eval/quality-db.ts +2 -2
  66. package/src/eval/runner.ts +3 -3
  67. package/src/expert-offload.ts +15 -2
  68. package/src/flags.ts +13 -0
  69. package/src/generate.ts +330 -24
  70. package/src/grammar.ts +585 -0
  71. package/src/hub-rest.ts +242 -0
  72. package/src/kv-store.ts +442 -88
  73. package/src/lora.ts +8 -0
  74. package/src/md-modules.d.ts +42 -0
  75. package/src/media-fetch.ts +284 -0
  76. package/src/memory/rest.ts +245 -0
  77. package/src/memory/vault.ts +74 -0
  78. package/src/mlx/array.ts +106 -19
  79. package/src/mlx/autograd.ts +2 -2
  80. package/src/mlx/ffi.ts +27 -3
  81. package/src/mlx/metal-kernel.ts +7 -4
  82. package/src/mlx/ops.ts +153 -23
  83. package/src/mlx/turboquant-ops.ts +831 -0
  84. package/src/mlx/turboquant-tables.ts +237 -0
  85. package/src/model/batched-mask.ts +62 -1
  86. package/src/model/batched-quant.ts +206 -0
  87. package/src/model/batched-rotating-quant.ts +346 -0
  88. package/src/model/batched-rotating.ts +46 -1
  89. package/src/model/compiled-decode.ts +4 -15
  90. package/src/model/factory.ts +10 -4
  91. package/src/model/gemma4-base.ts +363 -39
  92. package/src/model/gemma4.ts +122 -33
  93. package/src/model/generated/gemma4-12b.ts +9 -13
  94. package/src/model/generated/gemma4-26b.ts +10 -5
  95. package/src/model/generated/gemma4-e4b.ts +14 -9
  96. package/src/model/minicpm5.ts +58 -102
  97. package/src/model/paged-kv.ts +253 -0
  98. package/src/model/qwen3-delta.ts +76 -15
  99. package/src/model/qwen3-moe.ts +404 -0
  100. package/src/model/qwen3.ts +9 -8
  101. package/src/model/qwen3_5.ts +88 -23
  102. package/src/model/support.ts +12 -1
  103. package/src/model/universal/dense.ts +40 -5
  104. package/src/model/universal/rope.ts +36 -21
  105. package/src/pi-web.ts +1299 -82
  106. package/src/preflight.ts +27 -8
  107. package/src/prompt-cache.ts +293 -33
  108. package/src/quantize/quantizer.ts +16 -1
  109. package/src/registry.ts +57 -2
  110. package/src/serve/batch-scheduler.ts +965 -72
  111. package/src/serve/generation-gateway.ts +214 -11
  112. package/src/serve/isolate.ts +417 -0
  113. package/src/serve/lane-registry.ts +44 -0
  114. package/src/serve/session-search.ts +326 -0
  115. package/src/server.ts +1380 -135
  116. package/src/spec/assistant-source.ts +150 -0
  117. package/src/spec/deepspec-source.ts +143 -0
  118. package/src/spec/dflash-source.ts +124 -0
  119. package/src/spec/dspark/ab-stats.ts +136 -0
  120. package/src/spec/dspark/calibration.ts +112 -0
  121. package/src/spec/dspark/deepspec-module.ts +801 -0
  122. package/src/spec/dspark/generate-dflash.ts +72 -20
  123. package/src/spec/dspark/loader.ts +23 -0
  124. package/src/spec/dspark/module-dflash.ts +278 -36
  125. package/src/spec/dspark/quantize-drafter.ts +91 -0
  126. package/src/spec/generate.ts +59 -25
  127. package/src/spec/ngram-source.ts +132 -0
  128. package/src/spec/serve-loop.ts +536 -0
  129. package/src/spec/source.ts +93 -0
  130. package/src/spec/two-model.ts +171 -0
  131. package/src/ssd-cache.ts +107 -35
  132. package/src/tokenizer.ts +288 -2
  133. package/src/tool-approvals.ts +131 -0
  134. package/src/tool-call.ts +408 -20
  135. package/src/train/flash-cce.ts +2 -2
  136. package/src/train/job.ts +0 -2
  137. package/src/train/trainer.ts +1 -4
  138. package/src/vision/prompt.ts +217 -48
  139. package/src/web/app.html +1184 -1789
  140. package/src/web/app.js +5667 -0
  141. package/src/web/icon.svg +13 -0
  142. package/src/web/manifest.webmanifest +24 -0
  143. package/src/web/src/adapters-panel.ts +288 -0
  144. package/src/web/src/api.ts +57 -0
  145. package/src/web/src/assistant.ts +485 -0
  146. package/src/web/src/chat.ts +1050 -0
  147. package/src/web/src/composer.ts +959 -0
  148. package/src/web/src/dataset.ts +132 -0
  149. package/src/web/src/finetune.ts +267 -0
  150. package/src/web/src/hub.ts +428 -0
  151. package/src/web/src/main.ts +98 -0
  152. package/src/web/src/markdown.ts +0 -0
  153. package/src/web/src/memory-panel.ts +572 -0
  154. package/src/web/src/model-picker.ts +159 -0
  155. package/src/web/src/palette.ts +346 -0
  156. package/src/web/src/protocol.ts +62 -0
  157. package/src/web/src/quantize.ts +167 -0
  158. package/src/web/src/rag.ts +274 -0
  159. package/src/web/src/sessions.ts +469 -0
  160. package/src/web/src/shell.ts +804 -0
  161. package/src/web/src/status.ts +252 -0
  162. package/src/web/src/ui-catalog.ts +92 -0
  163. package/src/web/sw.js +71 -0
  164. package/src/web/vendor/README.md +61 -0
  165. package/src/web/vendor/hljs-LICENSE +29 -0
  166. package/src/web/vendor/hljs-theme.css +59 -0
  167. package/src/web/vendor/hljs.js +26 -0
  168. package/src/model/fused-decode-kernel.ts +0 -312
  169. package/src/model/fused-geglu-kernel.ts +0 -198
  170. package/src/model/fused-mlp-kernel.ts +0 -169
  171. package/src/model/fused-swiglu-kernel.ts +0 -240
  172. package/src/model/steel-linear-kernel.ts +0 -96
  173. package/src/status-page.html +0 -497
@@ -0,0 +1,959 @@
1
+ // GENERATED-ADJACENT source module — part of the src/web/src/* split (plan
2
+ // §7/§9 Phase 2). Built into src/web/app.js by scripts/build-web.ts.
3
+ //
4
+ // The composer's non-thread surface: attachments (files + images), the
5
+ // sampling popover (full parameter set incl. Advanced), the LoRA adapter
6
+ // selector, the reasoning-toggle presentation, and the perf strip (tok/s,
7
+ // TTFT, context-fill, lane badge). Extracted from the original monolithic
8
+ // controllers.chat closure in app.html — behavior identical. Owns its own
9
+ // module-level state (there's only ever one composer on screen, same as
10
+ // the original single IIFE), but every WS send goes through a callback
11
+ // passed in from chat.ts, which owns the actual WebSocket.
12
+
13
+ import type { ReadyGenDefaults, SamplingOverrides } from "../../pi-web";
14
+ import type { Lane } from "../../serve/lane-registry";
15
+ import { api } from "./api";
16
+ import type { ApiEnvelope } from "./protocol";
17
+ import {
18
+ $, el, toast, trapFocus, type FocusTrap, setSamplingPopoverClose, setSysPromptPopoverClose,
19
+ } from "./shell";
20
+ import { buildContextBlock, retrieve, shouldRetrieve, type Citation } from "./rag";
21
+
22
+ /* ────────────────────────────────────────────────────────────────────
23
+ Attachments (files + images)
24
+ ──────────────────────────────────────────────────────────────────── */
25
+
26
+ export interface Attachment {
27
+ id: number;
28
+ kind: "image" | "text";
29
+ name: string;
30
+ mimeType?: string;
31
+ data?: string;
32
+ text?: string;
33
+ truncated?: boolean;
34
+ }
35
+
36
+ const MAX_TEXT_BYTES = 256 * 1024;
37
+ const TEXT_EXTS = /\.(txt|md|markdown|csv|tsv|json|jsonl|ya?ml|toml|ini|cfg|conf|log|xml|html?|css|scss|js|mjs|cjs|ts|tsx|jsx|py|rs|go|java|c|h|cpp|hpp|cc|cs|rb|php|sh|bash|zsh|fish|sql|swift|kt|lua|r|jl|tex|svg|env|gitignore|dockerfile|makefile)$/i;
38
+
39
+ function isTextFile(f: File): boolean {
40
+ if (f.type.startsWith("text/")) return true;
41
+ if (/(json|xml|javascript|yaml|x-sh|x-toml|csv|markdown)/i.test(f.type)) return true;
42
+ return TEXT_EXTS.test(f.name || "");
43
+ }
44
+ function readDataUrl(file: File): Promise<string> {
45
+ return new Promise((res, rej) => {
46
+ const r = new FileReader(); r.onerror = rej;
47
+ r.onload = () => { const s = String(r.result); const i = s.indexOf(","); res(i >= 0 ? s.slice(i + 1) : s); };
48
+ r.readAsDataURL(file);
49
+ });
50
+ }
51
+ function readText(file: File): Promise<string> {
52
+ return new Promise((res, rej) => { const r = new FileReader(); r.onerror = rej; r.onload = () => res(String(r.result)); r.readAsText(file); });
53
+ }
54
+
55
+ export class ComposerState {
56
+ attachments: Attachment[] = [];
57
+ visionCapable = false; // from the `ready` frame; gates image attachments
58
+ thinkingCapable = false; // from `ready`; gates the reasoning toggle
59
+ // default on: with tools (read/web_search) the reasoning step gives the
60
+ // small model far better tool judgment (answers math/writing itself,
61
+ // only searches when needed). The thinking box defaults collapsed.
62
+ thinkingOn = true;
63
+ attachSeq = 0;
64
+
65
+ // Per-request sampling. Each field is either a user override (a number)
66
+ // or null = "use the recommended default" (resolved server-side).
67
+ sampling: Required<SamplingOverrides> = {
68
+ temperature: null, top_p: null, top_k: null, min_p: null,
69
+ xtc_probability: null, xtc_threshold: null, repetition_penalty: null,
70
+ repetition_context_size: null, presence_penalty: null, frequency_penalty: null,
71
+ seed: null,
72
+ };
73
+ // Populated from the `ready` frame (ReadyGenDefaults); null entries fall
74
+ // back to the built-in constants below until a ready frame arrives.
75
+ genDefaults: ReadyGenDefaults = { temperature: null, topP: null, topK: null };
76
+
77
+ // Per-message sampling scope (plan §9 Phase 3, beat matrix Axis 4): "session"
78
+ // (default) behaves exactly as today — overrides persist until changed.
79
+ // "next_turn" is a one-shot: the NEXT change the user makes is sent with
80
+ // `scope:"next_turn"` and the server reverts to the prior session-level
81
+ // sampling once that turn completes (turn_end). armedOneShot mirrors the
82
+ // server's one-shot state locally purely for the indicator chip/dot — it's
83
+ // cleared on turn_end (see clearOneShotArmed in chat.ts's handle()).
84
+ samplingScope: "session" | "next_turn" = "session";
85
+ oneShotArmed = false;
86
+
87
+ // Per-chat custom system prompt (plan §9 Phase 2 item, beat matrix Axis 4),
88
+ // layered onto the built-in surface prompt server-side (see pi-web.ts's
89
+ // injectSystemPrompt/installSystemPromptHook). null/"" = none set.
90
+ systemPrompt: string | null = null;
91
+
92
+ recTemp(): number { return this.genDefaults.temperature != null ? this.genDefaults.temperature : (this.thinkingOn ? 0.9 : 0.7); }
93
+ recTopP(): number { return this.genDefaults.topP != null ? this.genDefaults.topP : 0.95; }
94
+ recTopK(): number { return this.genDefaults.topK != null ? this.genDefaults.topK : 0; }
95
+ }
96
+
97
+ export async function addFiles(state: ComposerState, files: File[]): Promise<void> {
98
+ for (const f of files) {
99
+ if (f.type.startsWith("image/")) {
100
+ if (!state.visionCapable) { toast("This model can't see images — serve a vision-capable model (look for the vision tag in the Library).", "err"); continue; }
101
+ try { state.attachments.push({ id: ++state.attachSeq, kind: "image", name: f.name || "image", mimeType: f.type, data: await readDataUrl(f) }); }
102
+ catch { toast("Couldn't read " + (f.name || "image"), "err"); }
103
+ } else if (isTextFile(f)) {
104
+ try {
105
+ let text = await readText(f); const truncated = text.length > MAX_TEXT_BYTES; if (truncated) text = text.slice(0, MAX_TEXT_BYTES);
106
+ state.attachments.push({ id: ++state.attachSeq, kind: "text", name: f.name || "file", text, truncated });
107
+ } catch { toast("Couldn't read " + (f.name || "file"), "err"); }
108
+ } else {
109
+ toast("Unsupported file type: " + (f.name || "file"), "err");
110
+ }
111
+ }
112
+ renderAttachments(state);
113
+ }
114
+ export function renderAttachments(state: ComposerState): void {
115
+ const box = $("chat-attach");
116
+ box.innerHTML = "";
117
+ box.style.display = state.attachments.length ? "flex" : "none";
118
+ // Retrieval-mode tag (task brief item 4, transparency principle): whether
119
+ // ANY text file currently gets RAG'd is a property of the WHOLE attached
120
+ // set (buildMessageText sums all text-file chars together), so every text
121
+ // chip shows the tag together, not per-file — a file isn't individually
122
+ // "over threshold", the attachment set is.
123
+ const totalTextChars = state.attachments.reduce((n, a) => n + (a.kind === "text" ? (a.text || "").length : 0), 0);
124
+ const retrievalMode = shouldRetrieve(totalTextChars);
125
+ for (const a of state.attachments) {
126
+ const chip = el("div", "attach-chip", box);
127
+ chip.dataset.attId = String(a.id); // mention-picker pulse target (composer.ts's initMentionPicker)
128
+ if (a.kind === "image") { const im = el("img", "", chip); im.src = "data:" + a.mimeType + ";base64," + a.data; im.alt = a.name; }
129
+ else el("span", "att-ico", chip).textContent = "📄";
130
+ el("span", "att-name", chip).textContent = a.name + (a.truncated ? " (truncated)" : "");
131
+ if (a.kind === "text" && retrievalMode) {
132
+ const tag = el("span", "att-tag", chip);
133
+ tag.textContent = "retrieval";
134
+ tag.title = "This file is large enough that only the most relevant excerpts are sent, not the whole file";
135
+ }
136
+ const x = el("button", "att-x", chip); x.type = "button"; x.textContent = "✕";
137
+ x.onclick = () => { state.attachments = state.attachments.filter((z) => z.id !== a.id); renderAttachments(state); };
138
+ }
139
+ }
140
+ export function clearAttachments(state: ComposerState): void { state.attachments = []; renderAttachments(state); }
141
+
142
+ /** Result of buildMessageText: the outgoing text the model sees, plus (when
143
+ * retrieval mode fired) the citation list chat.ts needs to attach a Sources
144
+ * panel to the just-sent turn. `citations` is empty for the common small-
145
+ * attachment case — inline mode has nothing to cite, matching today's
146
+ * behavior exactly (task brief item 2: "zero behavior change"). */
147
+ export interface BuiltMessage {
148
+ text: string;
149
+ citations: Citation[];
150
+ }
151
+
152
+ /** Chat-with-files RAG v1 (plan §9 Phase 3, beat matrix Axis 5 — optiq
153
+ * Lab's dependency-free BM25 bar). Size-aware retrieval over attached text
154
+ * files, decided per send:
155
+ * - total attached text <= INLINE_THRESHOLD_CHARS: inline every file
156
+ * verbatim exactly as before this feature existed (LM Studio's
157
+ * transparent "fits context -> just inline it" half of the dual mode).
158
+ * - above threshold: retrieve the top-K chunks (src/web/src/rag.ts's
159
+ * BM25 index) scored against the outgoing message text, and inject a
160
+ * numbered [1]..[K] context block instead of the raw files. The index
161
+ * is rebuilt fresh on every send (attachments can change between
162
+ * turns) — cheap for the few-dozen-chunk scale this targets.
163
+ * The user's visible message (what addUserMsg renders) is untouched
164
+ * either way — the injection composes into the outgoing WS text only,
165
+ * same as plain file-inlining does today. */
166
+ export function buildMessageText(state: ComposerState, userText: string): BuiltMessage {
167
+ const files = state.attachments.filter((a): a is Attachment & { text: string } => a.kind === "text" && a.text != null);
168
+ if (!files.length) return { text: userText, citations: [] };
169
+ const totalChars = files.reduce((n, a) => n + a.text.length, 0);
170
+ if (!shouldRetrieve(totalChars)) {
171
+ let pre = "";
172
+ for (const a of files) pre += "Attached file: " + a.name + "\n```\n" + a.text + "\n```\n\n";
173
+ return { text: pre + userText, citations: [] };
174
+ }
175
+ const citations = retrieve(files.map((a) => ({ id: a.id, name: a.name, text: a.text })), userText);
176
+ if (!citations.length) return { text: userText, citations: [] }; // no term overlap at all — nothing useful to inject
177
+ return { text: buildContextBlock(citations) + userText, citations };
178
+ }
179
+ export function updateAttachHint(state: ComposerState): void {
180
+ const btn = $("chat-attach-btn");
181
+ if (btn) btn.title = state.visionCapable ? "Attach files or images" : "Attach files (this model can't see images)";
182
+ }
183
+
184
+ /* ────────────────────────────────────────────────────────────────────
185
+ LoRA adapter selector
186
+ ──────────────────────────────────────────────────────────────────── */
187
+
188
+ export interface AdapterInfo {
189
+ id: string;
190
+ path: string;
191
+ rank?: number;
192
+ mounted?: boolean;
193
+ compatible?: boolean;
194
+ base_model?: string;
195
+ }
196
+
197
+ function escHtml(s: unknown): string {
198
+ return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" } as Record<string, string>)[c]!);
199
+ }
200
+
201
+ /** Pure: builds the <option> markup for the adapter <select> from the
202
+ * /v1/adapters/available list. Split out from refreshAdapters() (which
203
+ * does the fetch + DOM write) so tests/web-app.test.ts can exercise the
204
+ * esc() discipline on this template directly without a DOM or network —
205
+ * every interpolated field (id, rank, path, base_model) must be escaped,
206
+ * since adapter ids/paths are user-controlled (on-disk directory names). */
207
+ export function renderAdapterOptionsHtml(list: AdapterInfo[]): string {
208
+ return '<option value="">no adapter</option>' + list.map((a) => {
209
+ const label = escHtml(a.id) + (a.rank ? " · r" + escHtml(a.rank) : "") + (a.mounted ? " · mounted" : "");
210
+ if (a.compatible) return `<option value="${escHtml(a.id)}" data-path="${escHtml(a.path)}">${label}</option>`;
211
+ const why = a.base_model
212
+ ? `trained for ${escHtml(a.base_model)}, not the currently-served model`
213
+ : "not compatible with the currently-served model";
214
+ return `<option value="${escHtml(a.id)}" disabled title="${why}">${label} (incompatible)</option>`;
215
+ }).join("");
216
+ }
217
+
218
+ // — LoRA adapter selector. Pi-native: app.html lists + mounts adapters, then
219
+ // tells pi-web which one is active; the before_provider_request hook injects
220
+ // it into every provider request. Default "" = none (base model). Still a
221
+ // single-select <select> this phase (the full three-state routing table is
222
+ // Phase 2) — the presentation upgrade here is showing every adapter found
223
+ // on disk (not just ones that fit the served model) with incompatible
224
+ // entries grayed via the `compatible` flag /v1/adapters/available now
225
+ // returns, each with a title explaining why it's unusable right now. —
226
+ export async function refreshAdapters(): Promise<void> {
227
+ const sel = $("chat-adapter") as HTMLSelectElement | null;
228
+ if (!sel) return;
229
+ let list: AdapterInfo[] = [];
230
+ try { const d = await api("/v1/adapters/available"); list = (d as { adapters?: AdapterInfo[] }).adapters || []; } catch { /* leave list empty */ }
231
+ const cur = sel.value;
232
+ sel.innerHTML = renderAdapterOptionsHtml(list);
233
+ if (cur && list.some((a) => a.id === cur)) sel.value = cur; // keep prior selection
234
+ }
235
+
236
+ export async function onSelectAdapter(sel: HTMLSelectElement, send: (obj: unknown) => boolean): Promise<void> {
237
+ const id = sel.value;
238
+ if (!id) { send({ type: "set_adapter", id: null }); return; } // none -> base model
239
+ const opt = sel.options[sel.selectedIndex];
240
+ const path = opt && opt.getAttribute("data-path");
241
+ if (path) { // auto-load on select; mount() is idempotent server-side
242
+ const r = await api("/v1/adapters", { method: "POST", body: { id, path } });
243
+ if (r && (r.error || r.ok === false)) {
244
+ toast("adapter: " + (r.error || "mount failed"), "err");
245
+ sel.value = ""; send({ type: "set_adapter", id: null }); return;
246
+ }
247
+ }
248
+ send({ type: "set_adapter", id });
249
+ // Adapter-namespaced KV means a swap can never reuse the outgoing
250
+ // adapter's cache — make that concrete instead of implicit (plan §5.2).
251
+ toast("adapter mounted — new turns start a fresh KV segment", "ok");
252
+ }
253
+
254
+ export function updateThinkingToggle(state: ComposerState): void {
255
+ const b = $("chat-think");
256
+ if (!b) return;
257
+ b.style.display = state.thinkingCapable ? "inline-flex" : "none"; // only for thinking-capable models
258
+ b.classList.toggle("think-on", state.thinkingOn);
259
+ b.title = state.thinkingOn ? "Reasoning on — click to answer directly" : "Reasoning off — click to let the model think";
260
+ b.setAttribute("aria-pressed", String(state.thinkingOn));
261
+ refreshSamplingRecs(state); // recommended temperature follows the thinking state
262
+ }
263
+
264
+ /* ────────────────────────────────────────────────────────────────────
265
+ Sampling popover
266
+ ──────────────────────────────────────────────────────────────────── */
267
+
268
+ type SamplingField = keyof ComposerState["sampling"];
269
+
270
+ interface SampFieldDef {
271
+ id: string;
272
+ field: SamplingField;
273
+ valId: string;
274
+ rec?: (state: ComposerState) => number;
275
+ fmt: (v: number) => string;
276
+ }
277
+
278
+ // Each row is a slider + a live value readout. A field is "auto" (null
279
+ // override -> the server uses its mode-aware default; temperature/top_p/top_k
280
+ // recommendations come from genDefaults, resolved per-model) until the user
281
+ // drags it, which records an explicit override. Reset returns every field to
282
+ // auto. The three primary fields have a live "rec" (a meaningful recommended
283
+ // number always shown dimmed); the Advanced fields have no natural default
284
+ // number to display, so they just show "off" until overridden.
285
+ const SAMP_FIELDS: SampFieldDef[] = [
286
+ { id: "samp-temp", field: "temperature", valId: "samp-temp-val", rec: (s) => s.recTemp(), fmt: (v) => v.toFixed(2) },
287
+ { id: "samp-topp", field: "top_p", valId: "samp-topp-val", rec: (s) => s.recTopP(), fmt: (v) => v.toFixed(2) },
288
+ { id: "samp-topk", field: "top_k", valId: "samp-topk-val", rec: (s) => s.recTopK(), fmt: (v) => (v >= 1 ? String(Math.round(v)) : "off") },
289
+ ];
290
+ // Advanced fields: no meaningful "recommended" number — auto just reads "off".
291
+ const SAMP_ADV_FIELDS: SampFieldDef[] = [
292
+ { id: "samp-minp", field: "min_p", valId: "samp-minp-val", fmt: (v) => v.toFixed(2) },
293
+ { id: "samp-xtcp", field: "xtc_probability", valId: "samp-xtcp-val", fmt: (v) => v.toFixed(2) },
294
+ { id: "samp-xtct", field: "xtc_threshold", valId: "samp-xtct-val", fmt: (v) => v.toFixed(2) },
295
+ { id: "samp-reppen", field: "repetition_penalty", valId: "samp-reppen-val", fmt: (v) => v.toFixed(2) },
296
+ { id: "samp-repctx", field: "repetition_context_size", valId: "samp-repctx-val", fmt: (v) => String(Math.round(v)) },
297
+ { id: "samp-prespen", field: "presence_penalty", valId: "samp-prespen-val", fmt: (v) => v.toFixed(2) },
298
+ { id: "samp-freqpen", field: "frequency_penalty", valId: "samp-freqpen-val", fmt: (v) => v.toFixed(2) },
299
+ ];
300
+ const SAMP_SLIDER_FIELDS = SAMP_FIELDS.concat(SAMP_ADV_FIELDS);
301
+
302
+ // Sync every slider + readout to current state: an overridden field shows its
303
+ // set value (bright readout); an auto primary field shows the recommended
304
+ // value (dimmed) and tracks it live — e.g. temperature when the thinking
305
+ // toggle flips; an auto Advanced field just shows "off". Also syncs the seed
306
+ // text input. Called on thinking-toggle flips, ready-frame arrival, popover
307
+ // open, and reset.
308
+ export function refreshSamplingRecs(state: ComposerState): void {
309
+ for (const f of SAMP_FIELDS) {
310
+ const slider = $(f.id) as HTMLInputElement | null, val = $(f.valId); if (!slider || !val) continue;
311
+ const over = state.sampling[f.field];
312
+ const v = over != null ? over : f.rec!(state);
313
+ slider.value = String(v);
314
+ val.textContent = f.fmt(v);
315
+ val.classList.toggle("auto", over == null);
316
+ }
317
+ for (const f of SAMP_ADV_FIELDS) {
318
+ const slider = $(f.id) as HTMLInputElement | null, val = $(f.valId); if (!slider || !val) continue;
319
+ const over = state.sampling[f.field];
320
+ slider.value = String(over != null ? over : slider.min);
321
+ val.textContent = over != null ? f.fmt(over) : "off";
322
+ val.classList.toggle("auto", over == null);
323
+ }
324
+ const seedInput = $("samp-seed") as HTMLInputElement | null;
325
+ if (seedInput) seedInput.value = state.sampling.seed != null ? String(state.sampling.seed) : "";
326
+ }
327
+
328
+ // User dragged a slider -> record the explicit override, brighten the readout,
329
+ // and push the whole sampling state to the server.
330
+ function onSlide(state: ComposerState, f: SampFieldDef, send: (obj: unknown) => boolean): void {
331
+ const slider = $(f.id) as HTMLInputElement | null; if (!slider) return;
332
+ const v = Number(slider.value);
333
+ (state.sampling[f.field] as number) = v;
334
+ const val = $(f.valId); if (val) { val.textContent = f.fmt(v); val.classList.remove("auto"); }
335
+ updateSamplingUi(state);
336
+ pushSampling(state, send);
337
+ }
338
+ function onSeedInput(state: ComposerState, send: (obj: unknown) => boolean): void {
339
+ const input = $("samp-seed") as HTMLInputElement | null; if (!input) return;
340
+ const raw = input.value.trim();
341
+ let seed: number | null = raw === "" ? null : Number(raw);
342
+ if (seed != null && !Number.isFinite(seed)) seed = null;
343
+ state.sampling.seed = seed;
344
+ updateSamplingUi(state);
345
+ pushSampling(state, send);
346
+ }
347
+ function pushSampling(state: ComposerState, send: (obj: unknown) => boolean): void {
348
+ // Session mode omits `scope` entirely — identical wire shape to before
349
+ // this feature existed ("session mode behaves exactly as today," task
350
+ // brief item 2). Only next-turn mode adds the field, and only arms the
351
+ // one-shot indicator once there's actually an override to apply for it
352
+ // (sending scope:"next_turn" with every field null would arm a chip for
353
+ // an override that doesn't exist).
354
+ if (state.samplingScope === "next_turn") {
355
+ send({ type: "set_sampling", scope: "next_turn", ...state.sampling });
356
+ state.oneShotArmed = Object.values(state.sampling).some((v) => v != null);
357
+ } else {
358
+ send({ type: "set_sampling", ...state.sampling });
359
+ state.oneShotArmed = false;
360
+ }
361
+ updateSamplingUi(state);
362
+ }
363
+
364
+ // The pill shows a lit dot whenever any field is overridden, and a distinct
365
+ // "armed" dot state while a one-shot next-turn override is queued but hasn't
366
+ // applied yet (task brief item 2: "the Sampling pill's dot shows a distinct
367
+ // state while a one-shot override is armed").
368
+ function updateSamplingUi(state: ComposerState): void {
369
+ const dirty = Object.values(state.sampling).some((v) => v != null);
370
+ const pill = $("chat-sampling");
371
+ if (pill) {
372
+ pill.classList.toggle("dirty", dirty && !state.oneShotArmed);
373
+ pill.classList.toggle("armed", state.oneShotArmed);
374
+ pill.title = state.oneShotArmed
375
+ ? "Sampling override armed for your next message only"
376
+ : dirty
377
+ ? "Sampling overridden — click to edit or reset"
378
+ : "Sampling controls (temperature · top_p · top_k · Advanced)";
379
+ }
380
+ const chip = $("samp-oneshot-chip");
381
+ if (chip) chip.style.display = state.oneShotArmed ? "inline-flex" : "none";
382
+ const scopeSelect = $("samp-scope") as HTMLSelectElement | null;
383
+ if (scopeSelect && scopeSelect.value !== state.samplingScope) scopeSelect.value = state.samplingScope;
384
+ }
385
+
386
+ /** Called from chat.ts on every `turn_end` — the server applies a one-shot
387
+ * next_turn override for exactly one turn then reverts, so the client-side
388
+ * indicator must clear in lockstep rather than guessing a timeout. A no-op
389
+ * when no one-shot was armed (the common case: session-mode sampling, or no
390
+ * overrides set at all). Does NOT touch state.sampling — the server is the
391
+ * source of truth for what the NEXT turn's session-level values revert to;
392
+ * this only clears the local "armed" indicator and flips scope back to
393
+ * session so the popover doesn't silently keep applying next-turn-only
394
+ * semantics to values the user may tweak next without re-selecting scope. */
395
+ export function clearOneShotArmed(state: ComposerState): void {
396
+ if (!state.oneShotArmed) return;
397
+ state.oneShotArmed = false;
398
+ state.samplingScope = "session";
399
+ updateSamplingUi(state);
400
+ }
401
+
402
+ export function resetSampling(state: ComposerState, send: (obj: unknown) => boolean): void {
403
+ for (const k of Object.keys(state.sampling) as SamplingField[]) (state.sampling[k] as number | null) = null;
404
+ state.samplingScope = "session";
405
+ refreshSamplingRecs(state); updateSamplingUi(state);
406
+ pushSampling(state, send);
407
+ }
408
+
409
+ export function initSampling(state: ComposerState, send: (obj: unknown) => boolean): void {
410
+ const pill = $("chat-sampling"), pop = $("chat-sampling-pop");
411
+ if (!pill || !pop) return;
412
+ const trap: FocusTrap = trapFocus(pop, () => pop.classList.contains("open"));
413
+ const setOpen = (open: boolean) => {
414
+ const was = pop.classList.contains("open");
415
+ if (open === was) return;
416
+ if (open) trap.capture();
417
+ pop.classList.toggle("open", open);
418
+ pill.classList.toggle("on", open);
419
+ pill.setAttribute("aria-expanded", String(open));
420
+ if (open) refreshSamplingRecs(state);
421
+ else trap.restore();
422
+ };
423
+ // Exposed so the global Escape sweep (closeTopOverlay) can close this
424
+ // popover uniformly instead of keeping a second Escape listener.
425
+ setSamplingPopoverClose(() => setOpen(false));
426
+ const toggle = () => setOpen(!pop.classList.contains("open"));
427
+ pill.onclick = (e) => { e.stopPropagation(); toggle(); };
428
+ pill.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter" || (e as KeyboardEvent).key === " ") { e.preventDefault(); toggle(); } });
429
+ pop.addEventListener("click", (e) => e.stopPropagation());
430
+ // Click-away closes the popover (Escape is handled globally, see
431
+ // closeTopOverlay — kept out of this listener to avoid a second,
432
+ // divergent Escape path).
433
+ document.addEventListener("click", () => setOpen(false));
434
+ for (const f of SAMP_SLIDER_FIELDS) {
435
+ const e = $(f.id); if (!e) continue;
436
+ e.addEventListener("input", () => onSlide(state, f, send));
437
+ }
438
+ const seedInput = $("samp-seed");
439
+ if (seedInput) seedInput.addEventListener("input", () => onSeedInput(state, send));
440
+ const reset = $("samp-reset"); if (reset) reset.onclick = (e) => { e.stopPropagation(); resetSampling(state, send); };
441
+ // Scope control (task brief item 2): switching to "next message only"
442
+ // doesn't itself send anything — it just changes what the NEXT slider
443
+ // drag/seed edit means. Switching back to "this session" while a one-shot
444
+ // was armed but not yet consumed just clears the local indicator; the
445
+ // server-side revert already only ever happens at turn_end, so there's
446
+ // nothing to un-arm server-side from a scope flip alone (no changed value
447
+ // was ever sent for it to apply to session-persistently and no frame
448
+ // corresponds to that alone).
449
+ const scopeSelect = $("samp-scope") as HTMLSelectElement | null;
450
+ if (scopeSelect) scopeSelect.addEventListener("change", (e) => {
451
+ e.stopPropagation();
452
+ state.samplingScope = scopeSelect.value === "next_turn" ? "next_turn" : "session";
453
+ if (state.samplingScope === "session") { state.oneShotArmed = false; }
454
+ updateSamplingUi(state);
455
+ });
456
+ updateSamplingUi(state); refreshSamplingRecs(state);
457
+ }
458
+
459
+ /* ────────────────────────────────────────────────────────────────────
460
+ System prompt (plan §9 Phase 2, beat matrix Axis 4) + presets v1
461
+ ──────────────────────────────────────────────────────────────────── */
462
+
463
+ const SYS_PROMPT_MAX = 4000;
464
+
465
+ /**
466
+ * One saved preset bundle: a name, the system-prompt text, and a sampling
467
+ * snapshot. Shaped so Phase 5's persona/bundle concept (system prompt +
468
+ * adapter selection + tool allowlist + memory scope, plan §5.7/§6) is an
469
+ * ADDITIVE extension of this same object — new optional fields, no
470
+ * migration of the fields already here. Saved to localStorage, never sent
471
+ * to the server (presets are a client-only convenience over the existing
472
+ * set_system_prompt/set_sampling frames).
473
+ */
474
+ export interface Preset {
475
+ name: string;
476
+ systemPrompt: string | null;
477
+ sampling: SamplingOverrides;
478
+ // Phase 5 additive fields (not read/written by v1, documented here so the
479
+ // shape doesn't need revisiting): adapterId?: string | null;
480
+ // toolAllowlist?: string[]; memoryScope?: "off" | "read" | "read+write".
481
+ }
482
+
483
+ const PRESETS_KEY = "mlxbun.presets";
484
+
485
+ /** Load saved presets from localStorage. Corrupt/missing storage -> empty
486
+ * list (never throws — a settings-picker feature must never break the
487
+ * composer it lives next to). */
488
+ export function loadPresets(): Preset[] {
489
+ try {
490
+ const raw = localStorage.getItem(PRESETS_KEY);
491
+ if (!raw) return [];
492
+ const parsed = JSON.parse(raw);
493
+ if (!Array.isArray(parsed)) return [];
494
+ return parsed.filter((p): p is Preset =>
495
+ !!p && typeof p === "object" && typeof (p as Preset).name === "string");
496
+ } catch {
497
+ return [];
498
+ }
499
+ }
500
+
501
+ function savePresets(presets: Preset[]): void {
502
+ try { localStorage.setItem(PRESETS_KEY, JSON.stringify(presets)); } catch { /* storage full/unavailable — best effort */ }
503
+ }
504
+
505
+ /** Build a preset snapshot from the current composer state. Pure. */
506
+ export function presetFromState(name: string, state: ComposerState): Preset {
507
+ return { name, systemPrompt: state.systemPrompt, sampling: { ...state.sampling } };
508
+ }
509
+
510
+ /** Insert-or-replace by name (case-sensitive), so re-saving "Concise" updates
511
+ * it in place rather than piling up duplicates. Pure — returns the new
512
+ * array, doesn't touch storage (callers persist via savePresets). */
513
+ export function upsertPreset(presets: Preset[], preset: Preset): Preset[] {
514
+ const i = presets.findIndex((p) => p.name === preset.name);
515
+ if (i === -1) return [...presets, preset];
516
+ const out = presets.slice();
517
+ out[i] = preset;
518
+ return out;
519
+ }
520
+
521
+ /** Remove a preset by name. Pure. */
522
+ export function removePreset(presets: Preset[], name: string): Preset[] {
523
+ return presets.filter((p) => p.name !== name);
524
+ }
525
+
526
+ /** Apply a preset's system prompt + sampling to the composer state and push
527
+ * both through the EXISTING set_system_prompt/set_sampling frames (no new
528
+ * wire shape for presets — they're a client-side convenience over frames
529
+ * that already exist). */
530
+ export function applyPreset(state: ComposerState, preset: Preset, send: (obj: unknown) => boolean): void {
531
+ state.systemPrompt = preset.systemPrompt ?? null;
532
+ state.sampling = {
533
+ temperature: null, top_p: null, top_k: null, min_p: null,
534
+ xtc_probability: null, xtc_threshold: null, repetition_penalty: null,
535
+ repetition_context_size: null, presence_penalty: null, frequency_penalty: null,
536
+ seed: null,
537
+ ...preset.sampling,
538
+ };
539
+ refreshSystemPromptUi(state);
540
+ pushSystemPrompt(state, send);
541
+ refreshSamplingRecs(state);
542
+ updateSamplingUi(state);
543
+ pushSampling(state, send);
544
+ }
545
+
546
+ function pushSystemPrompt(state: ComposerState, send: (obj: unknown) => boolean): void {
547
+ send({ type: "set_system_prompt", text: state.systemPrompt });
548
+ }
549
+
550
+ /** Sync the textarea, character count, and indicator pill to current state.
551
+ * Called on popover open and after every edit/clear/preset-apply. */
552
+ export function refreshSystemPromptUi(state: ComposerState): void {
553
+ const ta = $("sysprompt-text") as HTMLTextAreaElement | null;
554
+ const count = $("sysprompt-count");
555
+ const text = state.systemPrompt ?? "";
556
+ if (ta && ta.value !== text) ta.value = text;
557
+ if (count) count.textContent = text.length + " / " + SYS_PROMPT_MAX;
558
+ // The pill must always visibly show a custom prompt is shaping replies —
559
+ // a trust rule, not a nicety (task brief item 2).
560
+ const active = text.trim().length > 0;
561
+ const pill = $("chat-sysprompt");
562
+ if (pill) {
563
+ pill.classList.toggle("dirty", active);
564
+ pill.title = active
565
+ ? "Custom system prompt active — click to edit or clear"
566
+ : "System prompt (shapes how the assistant replies in this chat)";
567
+ }
568
+ }
569
+
570
+ function onSystemPromptInput(state: ComposerState, send: (obj: unknown) => boolean): void {
571
+ const ta = $("sysprompt-text") as HTMLTextAreaElement | null;
572
+ if (!ta) return;
573
+ const text = ta.value.slice(0, SYS_PROMPT_MAX);
574
+ if (text !== ta.value) ta.value = text; // hard cap, mirrors the live count
575
+ state.systemPrompt = text.length ? text : null;
576
+ const count = $("sysprompt-count");
577
+ if (count) count.textContent = text.length + " / " + SYS_PROMPT_MAX;
578
+ const pill = $("chat-sysprompt");
579
+ if (pill) {
580
+ const active = text.trim().length > 0;
581
+ pill.classList.toggle("dirty", active);
582
+ }
583
+ pushSystemPrompt(state, send);
584
+ }
585
+
586
+ export function clearSystemPrompt(state: ComposerState, send: (obj: unknown) => boolean): void {
587
+ state.systemPrompt = null;
588
+ refreshSystemPromptUi(state);
589
+ pushSystemPrompt(state, send);
590
+ }
591
+
592
+ /** Pure: builds the <option> markup for the presets <select>. Split out for
593
+ * the same reason renderAdapterOptionsHtml is — testable esc() discipline
594
+ * without a DOM. Preset names are user-authored, so every interpolation
595
+ * must be escaped. */
596
+ export function renderPresetOptionsHtml(presets: Preset[]): string {
597
+ return '<option value="">— presets —</option>' +
598
+ presets.map((p) => `<option value="${escHtml(p.name)}">${escHtml(p.name)}</option>`).join("");
599
+ }
600
+
601
+ function renderPresetSelect(): void {
602
+ const sel = $("sysprompt-preset-select") as HTMLSelectElement | null;
603
+ if (!sel) return;
604
+ const cur = sel.value;
605
+ sel.innerHTML = renderPresetOptionsHtml(loadPresets());
606
+ if (cur && [...sel.options].some((o) => o.value === cur)) sel.value = cur;
607
+ }
608
+
609
+ export function initSystemPrompt(state: ComposerState, send: (obj: unknown) => boolean): void {
610
+ const pill = $("chat-sysprompt"), pop = $("chat-sysprompt-pop");
611
+ if (!pill || !pop) return;
612
+ const trap: FocusTrap = trapFocus(pop, () => pop.classList.contains("open"));
613
+ const setOpen = (open: boolean) => {
614
+ const was = pop.classList.contains("open");
615
+ if (open === was) return;
616
+ if (open) trap.capture();
617
+ pop.classList.toggle("open", open);
618
+ pill.classList.toggle("on", open);
619
+ pill.setAttribute("aria-expanded", String(open));
620
+ if (open) { refreshSystemPromptUi(state); renderPresetSelect(); }
621
+ else trap.restore();
622
+ };
623
+ setSysPromptPopoverClose(() => setOpen(false));
624
+ const toggle = () => setOpen(!pop.classList.contains("open"));
625
+ pill.onclick = (e) => { e.stopPropagation(); toggle(); };
626
+ pill.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter" || (e as KeyboardEvent).key === " ") { e.preventDefault(); toggle(); } });
627
+ pop.addEventListener("click", (e) => e.stopPropagation());
628
+ document.addEventListener("click", () => setOpen(false));
629
+
630
+ const ta = $("sysprompt-text");
631
+ if (ta) ta.addEventListener("input", () => onSystemPromptInput(state, send));
632
+ const clearBtn = $("sysprompt-clear");
633
+ if (clearBtn) clearBtn.onclick = (e) => { e.stopPropagation(); clearSystemPrompt(state, send); };
634
+
635
+ // — Presets v1: save-current / apply / delete over the select above. —
636
+ const presetSelect = $("sysprompt-preset-select") as HTMLSelectElement | null;
637
+ if (presetSelect) presetSelect.addEventListener("change", (e) => {
638
+ e.stopPropagation();
639
+ const name = presetSelect.value;
640
+ if (!name) return;
641
+ const preset = loadPresets().find((p) => p.name === name);
642
+ if (preset) { applyPreset(state, preset, send); toast('Applied preset "' + name + '"', "ok"); }
643
+ });
644
+ const saveBtn = $("sysprompt-preset-save");
645
+ if (saveBtn) saveBtn.onclick = (e) => {
646
+ e.stopPropagation();
647
+ const name = (window.prompt("Save current system prompt + sampling as a preset named:") || "").trim();
648
+ if (!name) return;
649
+ const presets = upsertPreset(loadPresets(), presetFromState(name, state));
650
+ savePresets(presets);
651
+ renderPresetSelect();
652
+ if (presetSelect) presetSelect.value = name;
653
+ toast('Saved preset "' + name + '"', "ok");
654
+ };
655
+ const delBtn = $("sysprompt-preset-delete");
656
+ if (delBtn) delBtn.onclick = (e) => {
657
+ e.stopPropagation();
658
+ const name = presetSelect?.value;
659
+ if (!name) { toast("Select a preset to delete first", "err"); return; }
660
+ savePresets(removePreset(loadPresets(), name));
661
+ renderPresetSelect();
662
+ toast('Deleted preset "' + name + '"', "ok");
663
+ };
664
+
665
+ refreshSystemPromptUi(state);
666
+ }
667
+
668
+ /* ────────────────────────────────────────────────────────────────────
669
+ Perf strip: tok/s, TTFT, context-fill, lane badge
670
+ ──────────────────────────────────────────────────────────────────── */
671
+
672
+ export function fmtTok(n: number | null | undefined): string {
673
+ if (n == null) return "—";
674
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
675
+ if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "k";
676
+ return String(n);
677
+ }
678
+
679
+ export interface ContextFrame {
680
+ tokens: number | null;
681
+ contextWindow: number;
682
+ percent: number | null;
683
+ }
684
+
685
+ export function renderContext(m: ContextFrame): void {
686
+ const node = $("pf-fill");
687
+ if (!node || !m.contextWindow) return;
688
+ const pct = m.percent;
689
+ const pctStr = pct == null ? "" : " (" + Math.round(pct) + "%)";
690
+ node.textContent = "ctx " + fmtTok(m.tokens) + "/" + fmtTok(m.contextWindow) + pctStr;
691
+ node.style.color = pct == null ? "var(--dimmer)" : pct >= 85 ? "var(--orange)" : pct >= 70 ? "var(--yellow)" : "var(--dimmer)";
692
+ }
693
+
694
+ // Lane badge (plan §5.2/§6.4, risk #5): server-driven only, from the
695
+ // turn_end frame's optional `lane` field (src/serve/lane-registry.ts +
696
+ // pi-web.ts). Absent on turns that never resolved a lane (aborted before
697
+ // any model call) — the badge just stays hidden rather than guessing.
698
+ const LANE_LABEL: Record<Lane, string> = { serial: "serial", "serial+spec": "spec-decode active", batched: "batched" };
699
+ const LANE_CLASS: Record<Lane, string> = { serial: "serial", "serial+spec": "spec", batched: "batched" };
700
+
701
+ export function renderLane(lane: Lane | undefined): void {
702
+ const laneEl = $("pf-lane"), label = $("pf-lane-label");
703
+ if (!laneEl || !label) return;
704
+ if (!lane) { laneEl.style.display = "none"; return; }
705
+ laneEl.className = "pf-lane " + (LANE_CLASS[lane] || "");
706
+ label.textContent = LANE_LABEL[lane] || lane;
707
+ laneEl.style.display = "inline-flex";
708
+ }
709
+
710
+ /* ────────────────────────────────────────────────────────────────────
711
+ Unified "#" retrieval mention (plan §5.2/§9 Phase 2): typing "#" opens a
712
+ picker over BOTH currently-attached files and vault articles (via
713
+ /api/memory/search) as one retrieval gesture — Open WebUI's `#`-mention
714
+ pattern, extended so "attach a file" and "search my memory" are the same
715
+ interaction. Selecting a file pulses its existing attach chip (the file
716
+ is already attached — nothing to insert); selecting an article inserts
717
+ `[[Article Name]]` at the query span (the wikilink syntax
718
+ src/memory/vault.ts's resolveWikilinkToStem/extractWikilinkTargets
719
+ already resolves, so the agent's memory tools pick it up naturally, same
720
+ as in the vault's own prose). No vault -> files-only picker. Pure
721
+ query-detection/insertion helpers below are unit-tested directly
722
+ (tests/web-app.test.ts); DOM wiring (initMentionPicker) owns the
723
+ picker's open/close/keyboard-nav state.
724
+ ──────────────────────────────────────────────────────────────────── */
725
+
726
+ export interface MentionQuery {
727
+ /** Index (in the textarea value) where the "#" itself sits. */
728
+ hashIndex: number;
729
+ /** The text typed after "#", up to the caret (no spaces — a space ends
730
+ * the mention span, matching Open WebUI/Slack-style "#" pickers). */
731
+ query: string;
732
+ }
733
+
734
+ /** Pure: does the caret currently sit inside an open "#mention" span? Scans
735
+ * backward from the caret for a "#" that isn't preceded by a word
736
+ * character (so "C#" or "issue#12" don't trigger it) and has no whitespace
737
+ * between it and the caret. Returns null when there's no open mention. */
738
+ export function detectMentionQuery(text: string, caret: number): MentionQuery | null {
739
+ if (caret < 0 || caret > text.length) return null;
740
+ let i = caret - 1;
741
+ while (i >= 0) {
742
+ const ch = text[i]!;
743
+ if (ch === "#") {
744
+ const before = i > 0 ? text[i - 1]! : "";
745
+ if (/[A-Za-z0-9_]/.test(before)) return null; // "C#", "issue#12" — not a mention
746
+ return { hashIndex: i, query: text.slice(i + 1, caret) };
747
+ }
748
+ if (/\s/.test(ch)) return null; // whitespace ends the mention span
749
+ i--;
750
+ }
751
+ return null;
752
+ }
753
+
754
+ export type MentionItem =
755
+ | { kind: "file"; id: number; name: string }
756
+ | { kind: "article"; name: string; excerpt?: string };
757
+
758
+ const MAX_MENTION_FILE_RESULTS = 6;
759
+ const MAX_MENTION_ARTICLE_RESULTS = 8;
760
+
761
+ /** Pure: filters currently-attached files by the mention query (case-
762
+ * insensitive substring on name; empty query matches everything, so
763
+ * opening the picker with just "#" shows all attachments first). */
764
+ export function filterFileMentions(attachments: Attachment[], query: string): MentionItem[] {
765
+ const q = query.trim().toLowerCase();
766
+ const matches = attachments.filter((a) => !q || a.name.toLowerCase().includes(q));
767
+ return matches.slice(0, MAX_MENTION_FILE_RESULTS).map((a) => ({ kind: "file", id: a.id, name: a.name }));
768
+ }
769
+
770
+ /** Pure: combines file matches (always first — already-attached context is
771
+ * the cheapest/most relevant hit) with article search hits into one flat
772
+ * list for keyboard nav. Article list is capped independently of files so
773
+ * a vault with many hits doesn't crowd out attachments. */
774
+ export function buildMentionItems(fileMatches: MentionItem[], articleNames: string[]): MentionItem[] {
775
+ const articles: MentionItem[] = articleNames.slice(0, MAX_MENTION_ARTICLE_RESULTS).map((name) => ({ kind: "article", name }));
776
+ return [...fileMatches, ...articles];
777
+ }
778
+
779
+ /** Pure: replaces the open "#query" span with the mention's insertion text
780
+ * and returns the new full text + where the caret should land. Files
781
+ * insert nothing (the file is already attached — the span is just
782
+ * deleted); articles insert `[[Name]] ` (trailing space so typing
783
+ * continues naturally, matching resolveWikilinkToStem's bracket syntax). */
784
+ export function applyMention(text: string, q: MentionQuery, item: MentionItem, caret: number): { text: string; caret: number } {
785
+ const insertion = item.kind === "article" ? "[[" + item.name + "]] " : "";
786
+ const before = text.slice(0, q.hashIndex);
787
+ const after = text.slice(caret);
788
+ const newText = before + insertion + after;
789
+ return { text: newText, caret: before.length + insertion.length };
790
+ }
791
+
792
+ function escMention(s: unknown): string { return escHtml(s); }
793
+
794
+ /** Pure: builds the picker's inner HTML for a given item list + selected
795
+ * index. Split out for esc()-discipline testing without a DOM, same
796
+ * reasoning as renderAdapterOptionsHtml/renderPresetOptionsHtml above —
797
+ * file/article names are user/vault data and must be escaped. */
798
+ export function renderMentionListHtml(items: MentionItem[], selected: number, memoryEnabled: boolean): string {
799
+ if (!items.length) {
800
+ return '<div class="mention-empty">' +
801
+ (memoryEnabled ? "No matching files or memory articles." : "No matching attached files.") +
802
+ "</div>";
803
+ }
804
+ let filesHtml = "", articlesHtml = "";
805
+ items.forEach((item, i) => {
806
+ const active = i === selected ? " active" : "";
807
+ const row = item.kind === "file"
808
+ ? '<div class="mention-row' + active + '" data-idx="' + i + '" role="option">' +
809
+ '<span class="mention-ico" aria-hidden="true">📄</span>' +
810
+ '<span class="mention-label">' + escMention(item.name) + "</span>" +
811
+ '<span class="mention-tag">attached</span></div>'
812
+ : '<div class="mention-row' + active + '" data-idx="' + i + '" role="option">' +
813
+ '<span class="mention-ico" aria-hidden="true">◆</span>' +
814
+ '<span class="mention-label">' + escMention(item.name.replace(/_/g, " ")) + "</span>" +
815
+ '<span class="mention-tag">memory</span></div>';
816
+ if (item.kind === "file") filesHtml += row; else articlesHtml += row;
817
+ });
818
+ const sections: string[] = [];
819
+ if (filesHtml) sections.push('<div class="mention-sec-title">Attached files</div>' + filesHtml);
820
+ if (articlesHtml) sections.push('<div class="mention-sec-title">Memory</div>' + articlesHtml);
821
+ return sections.join("");
822
+ }
823
+
824
+ type MentionSearchResp = ApiEnvelope & { summaries?: { article: string }[]; enabled?: boolean };
825
+
826
+ /** DOM wiring for the picker: opens on "#", filters attachments locally +
827
+ * debounces a /api/memory/search round trip, keyboard nav (arrows/Enter/
828
+ * Escape), click-to-select, and returns focus to the textarea at the
829
+ * right caret position on every path (selection, Escape, click-away). */
830
+ export function initMentionPicker(state: ComposerState, box: HTMLTextAreaElement, opts: { onFileSelected: (a: Attachment) => void }): void {
831
+ const pop = $("chat-mention-pop");
832
+ if (!pop) return;
833
+ let items: MentionItem[] = [];
834
+ let selected = 0;
835
+ let activeQuery: MentionQuery | null = null;
836
+ let memoryEnabled = true; // optimistic; a failed/empty search just yields no article rows
837
+ let debounce: ReturnType<typeof setTimeout> | undefined;
838
+ let searchSeq = 0;
839
+
840
+ function render(): void {
841
+ pop.innerHTML = renderMentionListHtml(items, selected, memoryEnabled);
842
+ pop.querySelectorAll<HTMLElement>(".mention-row").forEach((row) => {
843
+ row.addEventListener("mousedown", (e) => { e.preventDefault(); const idx = Number(row.dataset.idx); if (Number.isFinite(idx)) select(idx); });
844
+ });
845
+ }
846
+
847
+ function close(): void {
848
+ pop.classList.remove("open");
849
+ activeQuery = null;
850
+ items = [];
851
+ selected = 0;
852
+ }
853
+
854
+ function select(idx: number): void {
855
+ const item = items[idx];
856
+ if (!item || !activeQuery) { close(); return; }
857
+ if (item.kind === "file") {
858
+ const att = state.attachments.find((a) => a.id === item.id);
859
+ const q = activeQuery;
860
+ // File mentions don't rewrite the text — the file is already attached
861
+ // context — just clear the typed "#query" span and pulse its chip.
862
+ const before = box.value.slice(0, q.hashIndex);
863
+ const after = box.value.slice(box.selectionStart ?? box.value.length);
864
+ box.value = before + after;
865
+ box.selectionStart = box.selectionEnd = before.length;
866
+ if (att) opts.onFileSelected(att);
867
+ } else {
868
+ const q = activeQuery;
869
+ const caret = box.selectionStart ?? box.value.length;
870
+ const result = applyMention(box.value, q, item, caret);
871
+ box.value = result.text;
872
+ box.selectionStart = box.selectionEnd = result.caret;
873
+ }
874
+ close();
875
+ box.focus();
876
+ box.dispatchEvent(new Event("input")); // resize + any other input-driven UI stays in sync
877
+ }
878
+
879
+ async function runSearch(query: string): Promise<void> {
880
+ const seq = ++searchSeq;
881
+ if (!query.trim()) { updateItems([]); return; }
882
+ try {
883
+ const d = await api<MentionSearchResp>("/api/memory/search?q=" + encodeURIComponent(query));
884
+ if (seq !== searchSeq) return; // stale response — a newer keystroke already superseded it
885
+ // enabled:false is the REST layer's explicit "no vault yet" signal
886
+ // (src/memory/rest.ts's noVault()) — only THAT latches the picker into
887
+ // files-only mode; any other failure (a transient search error) just
888
+ // yields an empty article list for this keystroke, not a permanent
889
+ // downgrade for the rest of the picker session.
890
+ if (d.enabled === false) { memoryEnabled = false; updateItems([]); return; }
891
+ if (d.ok === false) { updateItems([]); return; }
892
+ updateItems((d.summaries || []).map((s) => s.article));
893
+ } catch {
894
+ if (seq !== searchSeq) return;
895
+ updateItems([]);
896
+ }
897
+ }
898
+
899
+ function updateItems(articleNames: string[]): void {
900
+ if (!activeQuery) return;
901
+ const fileMatches = filterFileMentions(state.attachments, activeQuery.query);
902
+ items = buildMentionItems(fileMatches, articleNames);
903
+ selected = 0;
904
+ render();
905
+ }
906
+
907
+ function openFor(q: MentionQuery): void {
908
+ activeQuery = q;
909
+ pop.classList.add("open");
910
+ const fileMatches = filterFileMentions(state.attachments, q.query);
911
+ items = buildMentionItems(fileMatches, []);
912
+ selected = 0;
913
+ render();
914
+ clearTimeout(debounce);
915
+ debounce = setTimeout(() => runSearch(q.query), 180);
916
+ }
917
+
918
+ box.addEventListener("input", () => {
919
+ const caret = box.selectionStart ?? box.value.length;
920
+ const q = detectMentionQuery(box.value, caret);
921
+ if (!q) { close(); return; }
922
+ if (!activeQuery || activeQuery.hashIndex !== q.hashIndex) { openFor(q); return; }
923
+ activeQuery = q;
924
+ const fileMatches = filterFileMentions(state.attachments, q.query);
925
+ items = buildMentionItems(fileMatches, items.filter((i) => i.kind === "article").map((i) => (i as { name: string }).name));
926
+ selected = 0;
927
+ render();
928
+ clearTimeout(debounce);
929
+ debounce = setTimeout(() => runSearch(q.query), 180);
930
+ });
931
+
932
+ box.addEventListener("keydown", (e) => {
933
+ if (!pop.classList.contains("open") || !items.length) return;
934
+ if (e.key === "ArrowDown") { e.preventDefault(); selected = Math.min(selected + 1, items.length - 1); render(); }
935
+ else if (e.key === "ArrowUp") { e.preventDefault(); selected = Math.max(selected - 1, 0); render(); }
936
+ else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); select(selected); }
937
+ else if (e.key === "Escape") { e.preventDefault(); close(); }
938
+ });
939
+
940
+ box.addEventListener("blur", () => {
941
+ // Delay so a click on a .mention-row (which also blurs the textarea)
942
+ // still registers via its mousedown handler before we tear the popover
943
+ // down — mousedown fires before blur, but keep this defensive.
944
+ setTimeout(() => { if (document.activeElement !== box) close(); }, 120);
945
+ });
946
+ }
947
+
948
+ export function renderQueue(q: { steering?: readonly string[]; followUp?: readonly string[] }): void {
949
+ const bar = $("chat-queue");
950
+ const parts: string[] = [];
951
+ // steering/followUp are arrays (pi-web queue_update) — an empty array is
952
+ // truthy, so gate on .length; show the latest steering note and one pill
953
+ // per queued follow-up.
954
+ const steering = q.steering || [];
955
+ if (steering.length) parts.push('<span class="qtag">steering: <b>' + escHtml(String(steering[steering.length - 1]).slice(0, 60)) + "</b></span>");
956
+ for (const f of q.followUp || []) parts.push('<span class="qtag">queued: <b>' + escHtml(String(f).slice(0, 60)) + "</b></span>");
957
+ bar.innerHTML = parts.join("");
958
+ bar.style.display = parts.length ? "flex" : "none";
959
+ }