mlx-bun 0.0.10 → 0.0.12

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 (216) hide show
  1. package/README.md +107 -32
  2. package/THIRD_PARTY_LICENSES.md +10 -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/colibri-glm52-port.md +1479 -0
  10. package/docs/design/compat-cli-surface-design.md +4 -3
  11. package/docs/design/decode-speed-program.md +172 -0
  12. package/docs/design/docs-reorg-plan.md +1 -1
  13. package/docs/design/dspark-serving-program.md +371 -0
  14. package/docs/design/dspark-speculative-decoding.md +167 -5
  15. package/docs/design/faithful-l1-consolidation.md +310 -0
  16. package/docs/design/generic-model-support.md +8 -5
  17. package/docs/design/grammar-spec-batching-integration.md +301 -0
  18. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  19. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  20. package/docs/design/omlx-adoption-map.md +14 -6
  21. package/docs/design/orpo-training.md +34 -30
  22. package/docs/design/paged-kv-cache.md +214 -0
  23. package/docs/design/parallel-slots.md +82 -49
  24. package/docs/design/parity-tier-dag.md +43 -33
  25. package/docs/design/pre-colibri-stabilization.md +329 -0
  26. package/docs/design/repo-cleanup-plan.md +189 -0
  27. package/docs/design/runtime-isolation.md +19 -3
  28. package/docs/design/segmented-backward-training.md +3 -2
  29. package/docs/design/ssd-kv-cold-tier.md +130 -0
  30. package/docs/design/structured-output.md +484 -0
  31. package/docs/design/the-dreaming-master-plan.md +2 -3
  32. package/docs/design/turboquant-kv.md +235 -0
  33. package/docs/design/unified-engine-frontier-plan.md +774 -0
  34. package/docs/design/web-chat-beat-matrix.md +315 -0
  35. package/docs/design/web-chat-redesign.md +1112 -0
  36. package/docs/investigations/colibri-oracle-pin.md +1284 -0
  37. package/docs/investigations/dspark-handoff.md +182 -16
  38. package/docs/investigations/indexshare-performance-spike.md +357 -0
  39. package/docs/investigations/lab-build-journal.md +2 -2
  40. package/docs/investigations/openwiki-evaluation.md +249 -0
  41. package/docs/planning/IDEAS.md +14 -0
  42. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  43. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  44. package/docs/planning/chat-ui-vision.md +50 -0
  45. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  46. package/docs/planning/release-notes-v0.0.11.md +239 -0
  47. package/docs/planning/release-notes-v0.0.12.md +80 -0
  48. package/docs/reference/cli.md +63 -18
  49. package/docs/reference/distribution.md +37 -6
  50. package/docs/reference/embedding.md +40 -16
  51. package/docs/reference/features-matrix.md +99 -0
  52. package/docs/reference/library-api.md +69 -20
  53. package/docs/reference/memory.md +26 -6
  54. package/docs/reference/models.md +60 -3
  55. package/docs/reference/orpo-quickstart.md +3 -4
  56. package/docs/reference/server-api.md +717 -24
  57. package/docs/reference/server-config.md +265 -129
  58. package/docs/reference/training.md +24 -16
  59. package/package.json +5 -3
  60. package/src/anthropic.ts +67 -44
  61. package/src/audio/conformer.ts +647 -0
  62. package/src/audio/decode.ts +133 -0
  63. package/src/audio/features.ts +202 -0
  64. package/src/audio/transcode.ts +67 -0
  65. package/src/chat-template.ts +155 -10
  66. package/src/cli.ts +483 -113
  67. package/src/config.ts +116 -24
  68. package/src/diffusion/diffusion-generate.ts +14 -8
  69. package/src/download.ts +143 -12
  70. package/src/eval/README.md +7 -6
  71. package/src/eval/ifeval.ts +63 -120
  72. package/src/eval/kl.ts +59 -3
  73. package/src/eval/quality-db.ts +2 -2
  74. package/src/eval/runner.ts +17 -8
  75. package/src/eval/tasks/ifeval.ts +167 -31
  76. package/src/expert-io.ts +391 -0
  77. package/src/expert-offload.ts +15 -2
  78. package/src/expert-residency.ts +1134 -0
  79. package/src/expert-usage.ts +415 -0
  80. package/src/flags.ts +13 -0
  81. package/src/generate.ts +438 -35
  82. package/src/grammar.ts +585 -0
  83. package/src/hf-push.ts +1 -1
  84. package/src/hub-rest.ts +242 -0
  85. package/src/index.ts +14 -2
  86. package/src/jobs/runner.ts +37 -18
  87. package/src/kv-store.ts +586 -93
  88. package/src/lora.ts +85 -14
  89. package/src/md-modules.d.ts +42 -0
  90. package/src/media-fetch.ts +426 -0
  91. package/src/memory/rest.ts +245 -0
  92. package/src/memory/vault.ts +74 -0
  93. package/src/mlx/array.ts +149 -19
  94. package/src/mlx/autograd.ts +53 -16
  95. package/src/mlx/checkpoint.ts +46 -13
  96. package/src/mlx/custom-vjp.ts +83 -35
  97. package/src/mlx/ffi.ts +27 -3
  98. package/src/mlx/metal-kernel.ts +7 -4
  99. package/src/mlx/ops.ts +153 -23
  100. package/src/mlx/turboquant-ops.ts +831 -0
  101. package/src/mlx/turboquant-tables.ts +237 -0
  102. package/src/model/batched-mask.ts +62 -1
  103. package/src/model/batched-quant.ts +206 -0
  104. package/src/model/batched-rotating-quant.ts +346 -0
  105. package/src/model/batched-rotating.ts +46 -1
  106. package/src/model/compiled-decode.ts +4 -15
  107. package/src/model/factory.ts +103 -6
  108. package/src/model/gemma4-base.ts +395 -39
  109. package/src/model/gemma4.ts +122 -33
  110. package/src/model/generated/gemma4-12b.ts +9 -13
  111. package/src/model/generated/gemma4-26b.ts +10 -5
  112. package/src/model/generated/gemma4-e4b.ts +14 -9
  113. package/src/model/glm52-atlas.ts +511 -0
  114. package/src/model/glm52-cache.ts +706 -0
  115. package/src/model/glm52-config.ts +262 -0
  116. package/src/model/glm52-container.ts +305 -0
  117. package/src/model/glm52-coupling.ts +388 -0
  118. package/src/model/glm52-dsa.ts +602 -0
  119. package/src/model/glm52-expert-layout.ts +253 -0
  120. package/src/model/glm52-layout.ts +152 -0
  121. package/src/model/glm52-memory.ts +473 -0
  122. package/src/model/glm52-mla.ts +786 -0
  123. package/src/model/glm52-moe.ts +223 -0
  124. package/src/model/glm52-pilot.ts +537 -0
  125. package/src/model/glm52-reference.ts +445 -0
  126. package/src/model/glm52-residency.ts +557 -0
  127. package/src/model/glm52-resident-weights.ts +135 -0
  128. package/src/model/glm52-streamed-experts.ts +453 -0
  129. package/src/model/glm52-streamed-metal.ts +610 -0
  130. package/src/model/glm52-weights.ts +503 -0
  131. package/src/model/glm52.ts +1153 -0
  132. package/src/model/minicpm5.ts +58 -102
  133. package/src/model/paged-kv.ts +253 -0
  134. package/src/model/qwen3-delta.ts +76 -15
  135. package/src/model/qwen3-moe.ts +404 -0
  136. package/src/model/qwen3.ts +9 -8
  137. package/src/model/qwen3_5.ts +88 -23
  138. package/src/model/support.ts +19 -1
  139. package/src/model/universal/dense.ts +40 -5
  140. package/src/model/universal/rope.ts +36 -21
  141. package/src/native/expert_io.c +523 -0
  142. package/src/native-pack.ts +6 -4
  143. package/src/pi-web.ts +1299 -82
  144. package/src/preflight.ts +27 -8
  145. package/src/prompt-cache.ts +293 -33
  146. package/src/quantize/quantizer.ts +16 -1
  147. package/src/registry.ts +57 -2
  148. package/src/responses.ts +62 -49
  149. package/src/serve/batch-scheduler.ts +1050 -78
  150. package/src/serve/generation-gateway.ts +252 -24
  151. package/src/serve/isolate.ts +417 -0
  152. package/src/serve/lane-registry.ts +44 -0
  153. package/src/serve/session-search.ts +326 -0
  154. package/src/server.ts +1701 -175
  155. package/src/spec/assistant-source.ts +150 -0
  156. package/src/spec/deepspec-source.ts +143 -0
  157. package/src/spec/dflash-source.ts +124 -0
  158. package/src/spec/dspark/ab-stats.ts +136 -0
  159. package/src/spec/dspark/calibration.ts +112 -0
  160. package/src/spec/dspark/deepspec-module.ts +801 -0
  161. package/src/spec/dspark/generate-dflash.ts +72 -20
  162. package/src/spec/dspark/loader.ts +23 -0
  163. package/src/spec/dspark/module-dflash.ts +278 -36
  164. package/src/spec/dspark/quantize-drafter.ts +91 -0
  165. package/src/spec/generate.ts +59 -25
  166. package/src/spec/glm52-mtp-source.ts +392 -0
  167. package/src/spec/ngram-source.ts +132 -0
  168. package/src/spec/serve-loop.ts +617 -0
  169. package/src/spec/source.ts +118 -0
  170. package/src/spec/two-model.ts +171 -0
  171. package/src/ssd-cache.ts +111 -35
  172. package/src/tokenizer.ts +288 -2
  173. package/src/tool-approvals.ts +131 -0
  174. package/src/tool-call.ts +438 -22
  175. package/src/train/flash-cce.ts +2 -2
  176. package/src/train/job.ts +0 -12
  177. package/src/train/lora-params.ts +17 -6
  178. package/src/train/trainer.ts +60 -30
  179. package/src/vision/prompt.ts +217 -48
  180. package/src/web/app.html +1197 -1791
  181. package/src/web/app.js +5694 -0
  182. package/src/web/icon.svg +13 -0
  183. package/src/web/manifest.webmanifest +24 -0
  184. package/src/web/src/adapters-panel.ts +288 -0
  185. package/src/web/src/api.ts +57 -0
  186. package/src/web/src/assistant.ts +485 -0
  187. package/src/web/src/chat.ts +1050 -0
  188. package/src/web/src/composer.ts +959 -0
  189. package/src/web/src/dataset.ts +132 -0
  190. package/src/web/src/finetune.ts +267 -0
  191. package/src/web/src/hub.ts +428 -0
  192. package/src/web/src/main.ts +98 -0
  193. package/src/web/src/markdown.ts +0 -0
  194. package/src/web/src/memory-panel.ts +572 -0
  195. package/src/web/src/model-picker.ts +159 -0
  196. package/src/web/src/palette.ts +346 -0
  197. package/src/web/src/protocol.ts +62 -0
  198. package/src/web/src/quantize.ts +167 -0
  199. package/src/web/src/rag.ts +274 -0
  200. package/src/web/src/sessions.ts +469 -0
  201. package/src/web/src/shell.ts +804 -0
  202. package/src/web/src/status.ts +300 -0
  203. package/src/web/src/ui-catalog.ts +92 -0
  204. package/src/web/sw.js +71 -0
  205. package/src/web/vendor/README.md +61 -0
  206. package/src/web/vendor/hljs-LICENSE +29 -0
  207. package/src/web/vendor/hljs-theme.css +59 -0
  208. package/src/web/vendor/hljs.js +26 -0
  209. package/src/web-tools.ts +54 -2
  210. package/src/weights.ts +82 -19
  211. package/src/model/fused-decode-kernel.ts +0 -312
  212. package/src/model/fused-geglu-kernel.ts +0 -198
  213. package/src/model/fused-mlp-kernel.ts +0 -169
  214. package/src/model/fused-swiglu-kernel.ts +0 -240
  215. package/src/model/steel-linear-kernel.ts +0 -96
  216. package/src/status-page.html +0 -497
@@ -0,0 +1,804 @@
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
+ // App shell: DOM helpers, toast, HF settings modal, focus trap, theme,
5
+ // keyboard shortcut sheet, mobile drawer, router, connection pill + model
6
+ // identity polling, and the global keydown sweep. Behavior-identical port
7
+ // of the original inline <script> in app.html. Owns the `controllers`
8
+ // registry that the per-tab modules populate (see main.ts for the exact
9
+ // registration order, which callers must preserve).
10
+
11
+ import { api } from "./api";
12
+ import type { ApiEnvelope } from "./protocol";
13
+
14
+ /* ════════════════════════════════════════════════════════════════════
15
+ SHARED HELPERS
16
+ ════════════════════════════════════════════════════════════════════ */
17
+ export const $ = (id: string): HTMLElement => document.getElementById(id) as HTMLElement;
18
+ export const gb = (b: number | null | undefined): string => b == null ? "—" : (b / 2 ** 30).toFixed(2) + " GB";
19
+ export const mb = (b: number | null | undefined): string => b == null ? "—" : (b / 2 ** 20).toFixed(1) + " MB";
20
+ export const num = (n: number | null | undefined): string => n == null ? "—" : Math.round(n).toLocaleString();
21
+
22
+ /** Build a DOM node quickly. */
23
+ export function el<K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, parent?: Element | null): HTMLElementTagNameMap[K] {
24
+ const e = document.createElement(tag);
25
+ if (cls) e.className = cls;
26
+ if (parent) parent.appendChild(e);
27
+ return e;
28
+ }
29
+
30
+ /** Toast notification. kind: "" | "ok" | "err". */
31
+ export function toast(msg: string, kind = "", ms = 4200): void {
32
+ const t = el("div", "toast " + kind, $("toasts"));
33
+ t.textContent = msg;
34
+ setTimeout(() => { t.style.transition = "opacity .4s,transform .4s"; t.style.opacity = "0"; t.style.transform = "translateY(10px)"; setTimeout(() => t.remove(), 420); }, ms);
35
+ }
36
+
37
+ const injectedStyleIds = new Set<string>();
38
+
39
+ /** Append a `<style>` block to `<head>` exactly once per `id`, keyed
40
+ * independent of call order/count (idempotent — safe to call from a
41
+ * module's own top-level init every boot). Used by modules that build
42
+ * their DOM entirely via createElement (no app.html markup of their own)
43
+ * and therefore have nowhere else to put CSS for their new classes — the
44
+ * command palette (palette.ts) and the sidebar's full-text-search /
45
+ * export-menu additions (sessions.ts) both use this instead of touching
46
+ * app.html's <style> block, which is out of scope for the modules that
47
+ * introduce them. All rules should reference the existing `:root` design
48
+ * tokens (var(--card), var(--hairline), etc.) so injected chrome matches
49
+ * the current theme (dark/light/auto) automatically — those custom
50
+ * properties cascade regardless of where in the document this tag lands. */
51
+ export function injectStyles(id: string, css: string): void {
52
+ if (injectedStyleIds.has(id)) return;
53
+ injectedStyleIds.add(id);
54
+ const style = document.createElement("style");
55
+ style.id = id;
56
+ style.textContent = css;
57
+ document.head.appendChild(style);
58
+ }
59
+
60
+ /* ════════════════════════════════════════════════════════════════════
61
+ HUGGING FACE — shared token settings + push-to-hub flow
62
+ ════════════════════════════════════════════════════════════════════ */
63
+
64
+ /** Reflect saved-token state on the nav gear. */
65
+ export async function refreshHfGear(): Promise<boolean> {
66
+ try {
67
+ const d = await api("/api/settings/hf-token");
68
+ const saved = !!(d && d.ok && (d as { hasToken?: boolean }).hasToken);
69
+ $("nav-hf").classList.toggle("saved", saved);
70
+ $("nav-hf").title = saved ? "Hugging Face token saved · click to replace" : "Hugging Face token settings";
71
+ return saved;
72
+ } catch { return false; }
73
+ }
74
+
75
+ let hfOverlayTrap: FocusTrap | null = null; // set by initHfSettings() below
76
+
77
+ function openHfSettings(): void {
78
+ const ov = $("hf-overlay"); ov.classList.add("open");
79
+ if (hfOverlayTrap) hfOverlayTrap.capture();
80
+ ($("hf-token-input") as HTMLInputElement).value = ""; $("hf-settings-msg").innerHTML = "";
81
+ $("hf-state").textContent = "Checking for a saved token…";
82
+ api("/api/settings/hf-token").then((d) => {
83
+ const saved = !!(d && d.ok && (d as { hasToken?: boolean }).hasToken);
84
+ $("hf-state").innerHTML = saved
85
+ ? "A write token is <strong>saved</strong>. Enter a new one below to replace it."
86
+ : "No token saved yet. Add a write token to push models and datasets to the Hub.";
87
+ }).catch(() => { $("hf-state").textContent = "Could not reach the server."; });
88
+ setTimeout(() => $("hf-token-input").focus(), 50);
89
+ }
90
+ function closeHfSettings(): void {
91
+ $("hf-overlay").classList.remove("open");
92
+ if (hfOverlayTrap) hfOverlayTrap.restore();
93
+ }
94
+
95
+ async function saveHfToken(): Promise<void> {
96
+ const tokenInput = $("hf-token-input") as HTMLInputElement;
97
+ const token = tokenInput.value.trim();
98
+ const msg = $("hf-settings-msg");
99
+ if (!token) { msg.innerHTML = '<div class="flash err">Enter a token first.</div>'; return; }
100
+ const btn = $("hf-save") as HTMLButtonElement; btn.disabled = true;
101
+ const d = await api("/api/settings/hf-token", { method: "POST", body: { token } }).catch((): ApiEnvelope => ({ ok: false, error: "request failed" }));
102
+ btn.disabled = false;
103
+ if (!d.ok) { msg.innerHTML = '<div class="flash err">' + escHtml(d.error || "could not save token") + "</div>"; return; }
104
+ msg.innerHTML = '<div class="flash ok">Token saved to <code>~/.mlx-bun/hf.json</code>.</div>';
105
+ tokenInput.value = "";
106
+ refreshHfGear();
107
+ toast("Hugging Face token saved", "ok");
108
+ }
109
+
110
+ // Local esc() copy to avoid a circular import with markdown.ts (shell.ts
111
+ // only needs escaping, not the full markdown surface) — identical logic to
112
+ // markdown.ts's esc().
113
+ function escHtml(s: unknown): string {
114
+ return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" } as Record<string, string>)[c]!);
115
+ }
116
+
117
+ export interface PushToHubOpts {
118
+ kind: "quantize" | "finetune" | "dataset";
119
+ job_id?: string;
120
+ source_path?: string;
121
+ }
122
+
123
+ /**
124
+ * Shared push-to-hub flow, rendered inline inside a done-step card.
125
+ * pushToHub(panelEl, { kind, job_id?, source_path? })
126
+ * kind: "quantize" | "finetune" | "dataset". Posts to /api/{kind}/push.
127
+ * Walks: ensure token -> repo_id + private -> POST -> success/error flash.
128
+ */
129
+ export async function pushToHub(panel: HTMLElement, opts: PushToHubOpts): Promise<void> {
130
+ const { kind, job_id, source_path } = opts;
131
+ panel.innerHTML = '<div class="pushpanel"><div class="flash"><span class="shimmer">checking Hugging Face token…</span></div></div>';
132
+ const wrap = panel.firstChild as HTMLElement;
133
+ let hasToken = false;
134
+ try { const s = await api("/api/settings/hf-token"); hasToken = !!(s && s.ok && (s as { hasToken?: boolean }).hasToken); } catch { /* treat as no token */ }
135
+
136
+ const renderForm = () => {
137
+ wrap.innerHTML =
138
+ (!hasToken
139
+ ? '<div class="field"><label>Hugging Face write token</label>' +
140
+ '<input type="password" class="p-token" placeholder="hf_…" autocomplete="off" spellcheck="false">' +
141
+ '<div class="hint">A <strong>write</strong> token from huggingface.co/settings/tokens. Stored locally at <code>~/.mlx-bun/hf.json</code>.</div></div>'
142
+ : "") +
143
+ '<div class="field"><label>Repository id</label>' +
144
+ '<input type="text" class="p-repo" placeholder="you/my-' + escHtml(kind === "dataset" ? "dataset" : "model") + '" autocomplete="off"></div>' +
145
+ '<div class="field"><label class="chk"><input type="checkbox" class="p-priv">Private repository</label></div>' +
146
+ '<div class="p-msg"></div>' +
147
+ '<div class="btnrow" style="margin-top:6px"><button class="btn primary sm p-go">' +
148
+ (hasToken ? "Push" : "Save token &amp; push") + "</button></div>";
149
+ (wrap.querySelector(".p-go") as HTMLButtonElement).onclick = go;
150
+ const focusEl = wrap.querySelector(hasToken ? ".p-repo" : ".p-token") as HTMLElement | null;
151
+ if (focusEl) focusEl.focus();
152
+ };
153
+
154
+ async function go(): Promise<void> {
155
+ const msg = wrap.querySelector(".p-msg") as HTMLElement;
156
+ const repo = ((wrap.querySelector(".p-repo") as HTMLInputElement).value || "").trim();
157
+ const priv = (wrap.querySelector(".p-priv") as HTMLInputElement).checked;
158
+ if (!repo) { msg.innerHTML = '<div class="flash err">Enter a repository id (e.g. <code>you/my-model</code>).</div>'; return; }
159
+ const btn = wrap.querySelector(".p-go") as HTMLButtonElement; btn.disabled = true;
160
+
161
+ if (!hasToken) {
162
+ const token = ((wrap.querySelector(".p-token") as HTMLInputElement).value || "").trim();
163
+ if (!token) { msg.innerHTML = '<div class="flash err">Enter a write token first.</div>'; btn.disabled = false; return; }
164
+ const sv = await api("/api/settings/hf-token", { method: "POST", body: { token } }).catch((): ApiEnvelope => ({ ok: false, error: "request failed" }));
165
+ if (!sv.ok) { msg.innerHTML = '<div class="flash err">' + escHtml(sv.error || "could not save token") + "</div>"; btn.disabled = false; return; }
166
+ hasToken = true; refreshHfGear();
167
+ }
168
+
169
+ msg.innerHTML = '<div class="flash"><span class="shimmer">pushing to ' + escHtml(repo) + "…</span></div>";
170
+ const body: Record<string, unknown> = { repo_id: repo, private: priv };
171
+ if (job_id != null) body.job_id = job_id;
172
+ if (source_path != null) body.source_path = source_path;
173
+ const d = await api("/api/" + kind + "/push", { method: "POST", body }).catch((): ApiEnvelope => ({ ok: false, error: "request failed" }));
174
+ btn.disabled = false;
175
+ if (!d.ok) { msg.innerHTML = '<div class="flash err">' + escHtml(d.error || "push failed") + "</div>"; return; }
176
+ const url = (d as { url?: string }).url || ("https://huggingface.co/" + repo);
177
+ wrap.innerHTML = '<div class="flash ok">Pushed to <a href="' + escHtml(url) + '" target="_blank" rel="noopener" style="text-decoration:underline">' + escHtml(url) + "</a></div>";
178
+ toast("Pushed to Hugging Face", "ok");
179
+ }
180
+
181
+ renderForm();
182
+ }
183
+
184
+ /* wire the nav gear + modal once at boot. The modal now also hosts the
185
+ * Agent tools section (plan §5.4/§6.5/§9 Phase 2) — see the block below;
186
+ * kept in this same init function since it's one modal, one open/close
187
+ * lifecycle, one focus trap. */
188
+ export function initHfSettings(): void {
189
+ $("nav-hf").onclick = openHfSettings;
190
+ $("hf-close").onclick = closeHfSettings;
191
+ $("hf-save").onclick = saveHfToken;
192
+ $("hf-overlay").addEventListener("click", (e) => { if (e.target === $("hf-overlay")) closeHfSettings(); });
193
+ $("hf-token-input").addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter") saveHfToken(); });
194
+ // Escape is handled globally by closeTopOverlay (plan §7 item 5 — one
195
+ // Escape mechanism for every popover/overlay, not a bespoke one per modal).
196
+ hfOverlayTrap = trapFocus($("hf-overlay"), () => $("hf-overlay").classList.contains("open"));
197
+ refreshHfGear();
198
+ initCodingToolsToggle();
199
+ }
200
+
201
+ /* ════════════════════════════════════════════════════════════════════
202
+ AGENT TOOLS SETTINGS (plan §5.4/§6.5/§9 Phase 2) — the codingTools
203
+ opt-in toggle + the durable "always allow" list. State of record for
204
+ BOTH is server-side (the toggle's enforcement, and the tool-approvals
205
+ config file); this module only reflects what the server last reported
206
+ (coding_tools/tool_approvals ServerMessages, relayed here by chat.ts —
207
+ shell.ts owns no WebSocket) plus the localStorage mirror that lets the
208
+ checkbox restore its last-requested state before the first `ready`
209
+ frame arrives.
210
+ ════════════════════════════════════════════════════════════════════ */
211
+ const CODING_TOOLS_KEY = "mlxbun.codingTools";
212
+
213
+ /** Last requested state (this browser). The server is the actual source of
214
+ * truth for enforcement — this is only UI restoration so the checkbox
215
+ * doesn't flash unchecked while waiting on the first `ready`/`coding_tools`
216
+ * frame after a reload. */
217
+ export function storedCodingToolsPreference(): boolean {
218
+ return localStorage.getItem(CODING_TOOLS_KEY) === "1";
219
+ }
220
+
221
+ function setStoredCodingToolsPreference(on: boolean): void {
222
+ localStorage.setItem(CODING_TOOLS_KEY, on ? "1" : "0");
223
+ }
224
+
225
+ /** Reflect the server's coding_tools frame {active, pending} into the
226
+ * checkbox + the honest status note. `active` is what THIS session's tool
227
+ * surface actually contains; `pending` is the last request, which only
228
+ * takes effect on the next new/opened/forked chat — the note says so
229
+ * explicitly rather than implying the toggle is live immediately. */
230
+ export function renderCodingToolsState(active: boolean, pending: boolean): void {
231
+ const cb = $("settings-coding-tools") as HTMLInputElement | null;
232
+ if (cb) cb.checked = pending;
233
+ const note = $("settings-coding-tools-note");
234
+ if (!note) return;
235
+ if (pending && !active) {
236
+ note.style.display = "";
237
+ note.textContent = "Will apply starting with your next new chat (this chat keeps its current tools).";
238
+ } else if (!pending && active) {
239
+ note.style.display = "";
240
+ note.textContent = "Turning this off won't remove tools from the CURRENT chat — start a new chat to fully disable.";
241
+ } else {
242
+ note.style.display = "none";
243
+ note.textContent = "";
244
+ }
245
+ }
246
+
247
+ /** Reflect the durable always-allow set (tool_approvals frame) into the
248
+ * settings list, each row with a "forget" button. `onForget` is supplied
249
+ * by chat.ts (it owns the WebSocket) — this module never sends frames
250
+ * itself, matching pushToHub/refreshHfGear's api()-only pattern above. */
251
+ export function renderToolApprovals(tools: readonly string[], onForget: (tool: string) => void): void {
252
+ const list = $("settings-approvals-list");
253
+ if (!list) return;
254
+ if (tools.length === 0) {
255
+ list.innerHTML = '<div class="settings-approvals-empty">No tools are set to always-allow yet.</div>';
256
+ return;
257
+ }
258
+ list.innerHTML = tools.map((t) =>
259
+ '<div class="settings-approval-row"><span class="satool">' + escHtml(t) +
260
+ '</span><button class="saforget" data-tool="' + escHtml(t) + '">Forget</button></div>'
261
+ ).join("");
262
+ list.querySelectorAll<HTMLButtonElement>(".saforget").forEach((btn) => {
263
+ btn.onclick = () => onForget(btn.dataset.tool || "");
264
+ });
265
+ }
266
+
267
+ /** Wire the checkbox; the actual set_coding_tools WS send is delegated to
268
+ * controllers.chat.setCodingTools (registered by chat.ts's init — same
269
+ * cross-controller-call pattern as newChat/copyLastResponse above), since
270
+ * shell.ts owns no WebSocket. controllers.chat is always populated by the
271
+ * time a user can reach this modal (main.ts's boot order registers it
272
+ * before any UI is interactive); the guard below is defensive only — if
273
+ * it's ever missing, localStorage still records the preference (it's
274
+ * re-sent on the next `ready` frame regardless), it just silently skips
275
+ * the immediate WS send rather than throwing. */
276
+ function initCodingToolsToggle(): void {
277
+ const cb = $("settings-coding-tools") as HTMLInputElement | null;
278
+ if (!cb) return;
279
+ cb.checked = storedCodingToolsPreference();
280
+ cb.onchange = () => {
281
+ const on = cb.checked;
282
+ setStoredCodingToolsPreference(on);
283
+ const setCodingTools = controllers.chat && controllers.chat.setCodingTools as ((enabled: boolean) => void) | undefined;
284
+ if (setCodingTools) setCodingTools(on);
285
+ };
286
+ }
287
+
288
+ /* ════════════════════════════════════════════════════════════════════
289
+ POPOVER / OVERLAY A11Y SWEEP (plan §7 item 5)
290
+ A single reusable focus-trap: Tab/Shift+Tab cycle within the container
291
+ while `isOpen()` is true, and focus returns to whatever triggered the
292
+ overlay when it closes. Applied uniformly to the HF modal, the
293
+ shortcut sheet, the sampling popover, and the drawer — one mechanism,
294
+ not four bespoke ones.
295
+ ════════════════════════════════════════════════════════════════════ */
296
+ export interface FocusTrap {
297
+ /** Call right before opening: remembers the trigger to restore focus to. */
298
+ capture(): void;
299
+ /** Call right after closing. */
300
+ restore(): void;
301
+ }
302
+
303
+ export function trapFocus(container: HTMLElement, isOpen: () => boolean): FocusTrap {
304
+ let lastFocused: HTMLElement | null = null;
305
+ const focusables = (): HTMLElement[] => [...container.querySelectorAll(
306
+ 'a[href],button:not([disabled]),textarea,input:not([disabled]),select,[tabindex]:not([tabindex="-1"])'
307
+ )].filter((e) => (e as HTMLElement).offsetParent !== null || e === document.activeElement) as HTMLElement[];
308
+ container.addEventListener("keydown", (e) => {
309
+ const ke = e as KeyboardEvent;
310
+ if (ke.key !== "Tab" || !isOpen()) return;
311
+ const items = focusables();
312
+ if (!items.length) return;
313
+ const first = items[0]!, last = items[items.length - 1]!;
314
+ if (ke.shiftKey && document.activeElement === first) { ke.preventDefault(); last.focus(); }
315
+ else if (!ke.shiftKey && document.activeElement === last) { ke.preventDefault(); first.focus(); }
316
+ });
317
+ return {
318
+ capture() { lastFocused = document.activeElement as HTMLElement | null; },
319
+ restore() { if (lastFocused && lastFocused.focus) lastFocused.focus(); lastFocused = null; },
320
+ };
321
+ }
322
+
323
+ /* ════════════════════════════════════════════════════════════════════
324
+ THEME (plan §7): auto/dark/light, persisted, honors prefers-color-scheme
325
+ when the choice is "auto" (the default). Ambient bloom/shimmer already
326
+ respect prefers-reduced-motion via CSS above — this only handles color.
327
+ ════════════════════════════════════════════════════════════════════ */
328
+ const THEME_KEY = "mlxbun.theme"; // "auto" | "dark" | "light"
329
+ const themeMedia = window.matchMedia("(prefers-color-scheme: light)");
330
+
331
+ function effectiveTheme(choice: string): "dark" | "light" {
332
+ if (choice === "dark" || choice === "light") return choice;
333
+ return themeMedia.matches ? "light" : "dark";
334
+ }
335
+ function applyTheme(choice: string): void {
336
+ document.documentElement.setAttribute("data-theme", effectiveTheme(choice));
337
+ document.querySelectorAll<HTMLButtonElement>("#theme-toggle button").forEach((b) =>
338
+ b.classList.toggle("active", b.dataset.themeChoice === choice));
339
+ }
340
+ export function setTheme(choice: string): void {
341
+ localStorage.setItem(THEME_KEY, choice);
342
+ applyTheme(choice);
343
+ }
344
+ export function initTheme(): void {
345
+ const saved = localStorage.getItem(THEME_KEY) || "auto";
346
+ applyTheme(saved);
347
+ document.querySelectorAll<HTMLButtonElement>("#theme-toggle button").forEach((b) =>
348
+ b.addEventListener("click", () => setTheme(b.dataset.themeChoice || "auto")));
349
+ // Live-follow the OS when the user's choice is "auto" (default).
350
+ themeMedia.addEventListener("change", () => {
351
+ if ((localStorage.getItem(THEME_KEY) || "auto") === "auto") applyTheme("auto");
352
+ });
353
+ }
354
+
355
+ /* ════════════════════════════════════════════════════════════════════
356
+ KEYBOARD SHORTCUT SHEET (Cmd/Ctrl+/) + global bindings (plan §7)
357
+ ════════════════════════════════════════════════════════════════════ */
358
+ let skTrap: FocusTrap;
359
+
360
+ /** Exported (not just used internally) so palette.ts's "Open shortcut
361
+ * sheet" action can call it directly — same registered-entry-point
362
+ * posture as openMemPanel/openHubPanel below, just a plain export since
363
+ * this one takes no arguments and lives in the same module as its own
364
+ * close/trap state. */
365
+ export function openShortcutSheet(): void {
366
+ skTrap.capture();
367
+ $("shortcut-overlay").classList.add("open");
368
+ $("nav-shortcuts").setAttribute("aria-expanded", "true");
369
+ setTimeout(() => $("sk-close").focus(), 30);
370
+ }
371
+ function closeShortcutSheet(): void {
372
+ $("shortcut-overlay").classList.remove("open");
373
+ $("nav-shortcuts").setAttribute("aria-expanded", "false");
374
+ skTrap.restore();
375
+ }
376
+ export function initShortcutSheet(): void {
377
+ skTrap = trapFocus($("shortcut-overlay"), () => $("shortcut-overlay").classList.contains("open"));
378
+ const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
379
+ document.querySelectorAll("#sk-mod-label, .sk-mod").forEach((e) => { e.textContent = isMac ? "⌘" : "Ctrl"; });
380
+ $("nav-shortcuts").onclick = () => {
381
+ $("shortcut-overlay").classList.contains("open") ? closeShortcutSheet() : openShortcutSheet();
382
+ };
383
+ $("sk-close").onclick = closeShortcutSheet;
384
+ $("shortcut-overlay").addEventListener("click", (e) => { if (e.target === $("shortcut-overlay")) closeShortcutSheet(); });
385
+ }
386
+
387
+ /* ════════════════════════════════════════════════════════════════════
388
+ MOBILE DRAWER (plan §5.7/§7/§9 Phase 1 item 1) — chat sidebar becomes a
389
+ slide-over on narrow viewports instead of vanishing outright. Chat-route
390
+ only: the hamburger button is CSS-hidden >=760px and JS-hidden off /chat
391
+ (router() below), so open/close are only ever reachable when relevant.
392
+ ════════════════════════════════════════════════════════════════════ */
393
+ let drawerTrap: FocusTrap;
394
+
395
+ export function openDrawer(): void {
396
+ drawerTrap.capture();
397
+ $("chat-sidebar").classList.add("drawer-open");
398
+ $("chat-drawer-backdrop").classList.add("open");
399
+ $("chat-hamburger").setAttribute("aria-expanded", "true");
400
+ setTimeout(() => { const s = $("chat-sess-search"); if (s) s.focus(); }, 30);
401
+ }
402
+ export function closeDrawer(): void {
403
+ $("chat-sidebar").classList.remove("drawer-open");
404
+ $("chat-drawer-backdrop").classList.remove("open");
405
+ $("chat-hamburger").setAttribute("aria-expanded", "false");
406
+ drawerTrap.restore();
407
+ }
408
+ export function initDrawer(): void {
409
+ drawerTrap = trapFocus($("chat-sidebar"), () => $("chat-sidebar").classList.contains("drawer-open"));
410
+ $("chat-hamburger").onclick = () => {
411
+ $("chat-sidebar").classList.contains("drawer-open") ? closeDrawer() : openDrawer();
412
+ };
413
+ $("chat-drawer-backdrop").addEventListener("click", closeDrawer);
414
+ }
415
+
416
+ /** Set by controllers.chat's initSampling() once the popover exists; let
417
+ * closeTopOverlay's global Escape sweep close it without a second,
418
+ * divergent Escape listener living inside the controller. */
419
+ export let samplingPopoverClose: (() => void) | null = null;
420
+ export function setSamplingPopoverClose(fn: (() => void) | null): void { samplingPopoverClose = fn; }
421
+
422
+ /** Same registered-callback pattern as samplingPopoverClose above, used by
423
+ * memory-panel.ts's initMemoryPanel() — avoids a circular import (shell.ts
424
+ * is imported BY memory-panel.ts for $/trapFocus/etc., so shell.ts can't
425
+ * import memory-panel.ts's closeMemPanel back). */
426
+ export let memPanelClose: (() => void) | null = null;
427
+ export function setMemPanelClose(fn: (() => void) | null): void { memPanelClose = fn; }
428
+
429
+ /** Same pattern, for the adapter routing table overlay (adapters-panel.ts,
430
+ * plan §5.6/§9 Phase 2) — same circular-import reason as memPanelClose. */
431
+ export let adaptersPanelClose: (() => void) | null = null;
432
+ export function setAdaptersPanelClose(fn: (() => void) | null): void { adaptersPanelClose = fn; }
433
+
434
+ /** Same pattern, for the model picker popover (model-picker.ts). */
435
+ export let modelPopClose: (() => void) | null = null;
436
+ export function setModelPopClose(fn: (() => void) | null): void { modelPopClose = fn; }
437
+
438
+ /** Same pattern, for the Model Hub panel (hub.ts, plan §9 Phase 3) —
439
+ * opened from the model picker's "Browse models…" action. */
440
+ export let hubPanelClose: (() => void) | null = null;
441
+ export function setHubPanelClose(fn: (() => void) | null): void { hubPanelClose = fn; }
442
+
443
+ /** Registered by hub.ts's initHubPanel(); called by model-picker.ts's
444
+ * "Browse models…" button. Its own body is re-rendered via innerHTML on
445
+ * every popover open (see refreshModelPop()), so the button can't hold a
446
+ * handler bound once at boot the way the overlay-close callbacks above
447
+ * do — model-picker.ts re-wires the click to this registered function
448
+ * each time it re-renders instead. */
449
+ export let openHubFromModelPicker: (() => void) | null = null;
450
+ export function setOpenHubFromModelPicker(fn: (() => void) | null): void { openHubFromModelPicker = fn; }
451
+
452
+ /** Same pattern, for the system-prompt popover (composer.ts's
453
+ * initSystemPrompt() — presets v1, plan §9 Phase 2). */
454
+ export let sysPromptPopoverClose: (() => void) | null = null;
455
+ export function setSysPromptPopoverClose(fn: (() => void) | null): void { sysPromptPopoverClose = fn; }
456
+
457
+ /** Same pattern, for the command palette (palette.ts, plan §9 Phase 3,
458
+ * beat-matrix Axis 10 "Command palette" row). Its own module builds 100%
459
+ * of its DOM via createElement (no app.html markup to look up), so
460
+ * isPaletteOpen()/openPalette() are registered functions rather than
461
+ * `$(id)`-based lookups like the overlays above. */
462
+ export let paletteClose: (() => void) | null = null;
463
+ export function setPaletteClose(fn: (() => void) | null): void { paletteClose = fn; }
464
+ export let paletteIsOpen: (() => boolean) | null = null;
465
+ export function setPaletteIsOpen(fn: (() => boolean) | null): void { paletteIsOpen = fn; }
466
+ export let paletteOpen: (() => void) | null = null;
467
+ export function setPaletteOpen(fn: (() => void) | null): void { paletteOpen = fn; }
468
+
469
+ /** Any open popover/drawer/sheet this Escape binding knows how to close,
470
+ * checked in a fixed priority order (most-recently-opened-ish first).
471
+ * Returns true if it closed something, so callers can stop there. */
472
+ function closeTopOverlay(): boolean {
473
+ if ($("shortcut-overlay").classList.contains("open")) { closeShortcutSheet(); return true; }
474
+ if ($("hf-overlay").classList.contains("open")) { closeHfSettings(); return true; }
475
+ const samplePop = $("chat-sampling-pop");
476
+ if (samplePop && samplePop.classList.contains("open") && samplingPopoverClose) { samplingPopoverClose(); return true; }
477
+ const sysPop = $("chat-sysprompt-pop");
478
+ if (sysPop && sysPop.classList.contains("open") && sysPromptPopoverClose) { sysPromptPopoverClose(); return true; }
479
+ const memOverlay = $("mem-overlay");
480
+ if (memOverlay && memOverlay.classList.contains("open") && memPanelClose) { memPanelClose(); return true; }
481
+ const adaptersOverlay = $("adapters-overlay");
482
+ if (adaptersOverlay && adaptersOverlay.classList.contains("open") && adaptersPanelClose) { adaptersPanelClose(); return true; }
483
+ const modelPop = $("model-pop");
484
+ if (modelPop && modelPop.classList.contains("open") && modelPopClose) { modelPopClose(); return true; }
485
+ const hubOverlay = $("hub-overlay");
486
+ if (hubOverlay && hubOverlay.classList.contains("open") && hubPanelClose) { hubPanelClose(); return true; }
487
+ if (paletteIsOpen && paletteIsOpen() && paletteClose) { paletteClose(); return true; }
488
+ if ($("chat-sidebar").classList.contains("drawer-open")) { closeDrawer(); return true; }
489
+ return false;
490
+ }
491
+
492
+ export function initGlobalKeydown(): void {
493
+ document.addEventListener("keydown", (e) => {
494
+ const mod = e.metaKey || e.ctrlKey;
495
+ // Cmd/Ctrl+K — command palette (beat-matrix Axis 10). Checked before
496
+ // Cmd/Ctrl+/ below since they're different keys but both live here;
497
+ // order doesn't matter functionally, kept first as the newer addition.
498
+ if (mod && (e.key === "k" || e.key === "K")) {
499
+ e.preventDefault();
500
+ if (paletteIsOpen && paletteIsOpen()) { if (paletteClose) paletteClose(); }
501
+ else if (paletteOpen) paletteOpen();
502
+ return;
503
+ }
504
+ // Cmd/Ctrl+/ — shortcut sheet. Not a browser-reserved combo.
505
+ if (mod && e.key === "/") { e.preventDefault(); $("shortcut-overlay").classList.contains("open") ? closeShortcutSheet() : openShortcutSheet(); return; }
506
+ // Cmd/Ctrl+Shift+O — new chat. (Ctrl+Shift+O is free in every major
507
+ // browser; Cmd+Shift+O has no macOS Safari/Chrome reservation either.)
508
+ if (mod && e.shiftKey && (e.key === "O" || e.key === "o")) {
509
+ if (currentRoute() !== "chat") return; // no-op off the chat route
510
+ e.preventDefault();
511
+ const newChat = controllers.chat && controllers.chat.newChat as (() => void) | undefined;
512
+ newChat && newChat();
513
+ return;
514
+ }
515
+ // Cmd/Ctrl+Shift+C — copy last response. Reuses the wave-3 copy action.
516
+ if (mod && e.shiftKey && (e.key === "C" || e.key === "c")) {
517
+ if (currentRoute() !== "chat") return;
518
+ e.preventDefault();
519
+ const copyLastResponse = controllers.chat && controllers.chat.copyLastResponse as (() => void) | undefined;
520
+ copyLastResponse && copyLastResponse();
521
+ return;
522
+ }
523
+ // Shift+Escape — focus the composer (skipped while already typing
524
+ // somewhere else that isn't the composer, so it doesn't steal focus
525
+ // from e.g. the HF token field — but Escape's own overlay-close below
526
+ // still fires first via closeTopOverlay for the "any open thing" case).
527
+ if (e.shiftKey && e.key === "Escape") {
528
+ if (currentRoute() !== "chat") return;
529
+ e.preventDefault();
530
+ $("chat-box").focus();
531
+ return;
532
+ }
533
+ // Escape alone — close whatever overlay is open, with focus restored to
534
+ // its trigger. Never shadows a plain Escape when nothing is open (e.g.
535
+ // the composer's own Escape-to-cancel-edit keeps working, since
536
+ // closeTopOverlay simply returns false and this handler does nothing else).
537
+ if (e.key === "Escape" && !e.shiftKey && !mod) {
538
+ closeTopOverlay();
539
+ }
540
+ });
541
+ }
542
+
543
+ /* ════════════════════════════════════════════════════════════════════
544
+ DEVELOPER TOGGLE (plan §8/§9 Phase 2) — Chat is the product; Quantize /
545
+ Fine-tune / Build Dataset / Status / Curves / Routes are developer tools
546
+ that collapse behind one nav switch. Persisted; default OFF for a fresh
547
+ browser, but default ON (once) when ANY pre-existing mlxbun.* localStorage
548
+ key is found — an existing user never has tabs yanked out from under
549
+ them. Deep links still work: entering a dev route with the toggle off
550
+ flips it on (capability is never unreachable, just not the default view).
551
+ ════════════════════════════════════════════════════════════════════ */
552
+ const DEV_KEY = "mlxbun.developer";
553
+ /** Dev-only tabs, matching data-tab on the <a class="tab"> elements in nav
554
+ * (routes' visibility is further gated by the /dag probe below). "curves"
555
+ * is listed for applyDeveloperMode's DOM sweep even though it can never be
556
+ * a `Route` — it's a full-page href, not hash-routed, so
557
+ * ensureDeveloperModeFor(route: Route) below can never match it; a direct
558
+ * /curves visit reaches a real page load, not the SPA router, so there's
559
+ * no deep-link case to flip the toggle for. */
560
+ const DEV_TABS = ["quantize", "finetune", "dataset", "status", "curves", "routes"] as const;
561
+
562
+ /** True if any OTHER mlxbun.* key already exists — i.e. this is a returning
563
+ * user of the app, not a fresh browser profile. Checked BEFORE writing the
564
+ * developer key itself, so it can't self-detect on a later load. */
565
+ function hasExistingMlxbunState(): boolean {
566
+ for (let i = 0; i < localStorage.length; i++) {
567
+ const k = localStorage.key(i);
568
+ if (k && k.startsWith("mlxbun.") && k !== DEV_KEY) return true;
569
+ }
570
+ return false;
571
+ }
572
+
573
+ export function isDeveloperMode(): boolean {
574
+ const saved = localStorage.getItem(DEV_KEY);
575
+ if (saved != null) return saved === "1";
576
+ // First-ever read: decide + persist the one-time default so it's stable
577
+ // across reloads (a fresh browser stays OFF; an existing user's tabs
578
+ // don't flicker between "on" and "off" from the detection re-running).
579
+ const on = hasExistingMlxbunState();
580
+ localStorage.setItem(DEV_KEY, on ? "1" : "0");
581
+ return on;
582
+ }
583
+
584
+ /** Reflect developer mode into the DOM: dev-tab visibility + the toggle's
585
+ * own pressed state. Safe to call before /dag has been probed — the
586
+ * routes tab's extra hide (see initRoutesProbe) is independent and only
587
+ * ever hides further, never re-shows a tab this function would show. */
588
+ function applyDeveloperMode(on: boolean): void {
589
+ document.querySelectorAll<HTMLElement>("nav .tab[data-dev]").forEach((t) => {
590
+ // Once /dag has been probed and failed, routes stays hidden regardless
591
+ // of developer mode (dataset routesUnavailable === "1" marks that).
592
+ if (t.dataset.tab === "routes" && t.dataset.routesUnavailable === "1") { t.style.display = "none"; return; }
593
+ t.style.display = on ? "" : "none";
594
+ });
595
+ const btn = $("nav-developer");
596
+ if (btn) btn.setAttribute("aria-checked", on ? "true" : "false");
597
+ const dot = document.getElementById("nav-developer-dot");
598
+ if (dot) dot.classList.toggle("on", on);
599
+ updateTabFades();
600
+ }
601
+
602
+ export function setDeveloperMode(on: boolean): void {
603
+ localStorage.setItem(DEV_KEY, on ? "1" : "0");
604
+ applyDeveloperMode(on);
605
+ }
606
+
607
+ /** Called by the router when a deep link (or brand-new hash) lands on a
608
+ * dev-only route while developer mode is off — capability must never be
609
+ * unreachable just because the toggle defaulted off (plan §8). Flips the
610
+ * preference on (persisted, not a one-time peek) so the tab row itself
611
+ * stops looking broken/mismatched with what's showing. */
612
+ function ensureDeveloperModeFor(route: Route): void {
613
+ if ((DEV_TABS as readonly string[]).includes(route) && !isDeveloperMode()) setDeveloperMode(true);
614
+ }
615
+
616
+ export function initDeveloperToggle(): void {
617
+ applyDeveloperMode(isDeveloperMode());
618
+ const btn = $("nav-developer");
619
+ if (btn) btn.onclick = () => setDeveloperMode(!isDeveloperMode());
620
+ }
621
+
622
+ /* ── Routes tab feature-detection (web-ui-pass-plan.md #17) ──
623
+ /dag readFileSync's a repo-relative doc that's absent from compiled
624
+ binaries/npm installs. Probe once at boot with a HEAD request; on 404
625
+ hide the Routes tab entirely (rather than leaving a dead link that
626
+ iframes a raw 404) and, if the user is already sitting on #/routes, swap
627
+ in a graceful in-app note instead of the broken iframe. Could instead
628
+ embed the artifact server-side like app.html's /curves fallback does —
629
+ left as server.ts territory, not touched here. */
630
+ export async function initRoutesProbe(): Promise<void> {
631
+ const tab = document.querySelector<HTMLElement>('nav .tab[data-tab="routes"]');
632
+ let ok = true;
633
+ try {
634
+ const r = await fetch("/dag", { method: "HEAD" });
635
+ ok = r.ok;
636
+ } catch { ok = false; }
637
+ if (ok) return;
638
+ if (tab) { tab.dataset.routesUnavailable = "1"; tab.style.display = "none"; }
639
+ const section = $("s-routes");
640
+ if (section) {
641
+ section.innerHTML =
642
+ '<div class="wrap" style="max-width:640px;margin:60px auto;text-align:center;color:var(--dim)">' +
643
+ "<h2>Routes map unavailable</h2>" +
644
+ "<p>The training/inference route diagram ships alongside the repo checkout " +
645
+ "and isn't bundled into this build.</p></div>";
646
+ }
647
+ // If a deep link landed here directly, bounce to chat rather than sit on
648
+ // an orphaned nav state with no visible tab pointing at it.
649
+ if (currentRoute() === "routes") location.replace("#/chat");
650
+ }
651
+
652
+ /* ════════════════════════════════════════════════════════════════════
653
+ ROUTER — toggles section[data-route]; lazily inits each controller.
654
+ ════════════════════════════════════════════════════════════════════ */
655
+ export const ROUTES = ["chat", "quantize", "finetune", "dataset", "status", "routes"] as const;
656
+ export type Route = typeof ROUTES[number];
657
+
658
+ /** A tab controller: init() runs once (lazy, on first enter); enter()/leave()
659
+ * run every time the route is (de)activated. Extra fields (refreshAdapters,
660
+ * newChat, copyLastResponse, refreshLibrary) are controller-specific
661
+ * cross-calls other controllers use — kept loose (unknown) here since the
662
+ * registry is shared infrastructure, not a single controller's contract. */
663
+ export interface Controller {
664
+ init?(): void;
665
+ enter?(): void;
666
+ leave?(): void;
667
+ [extra: string]: unknown;
668
+ }
669
+
670
+ const inited: Partial<Record<Route, boolean>> = {};
671
+ /** name -> { init, enter, leave }. Populated by main.ts in the exact order
672
+ * the original inline script declared controllers.chat/.quantize/.finetune/
673
+ * .dataset/.status — load-bearing for the module-init side effects each
674
+ * IIFE ran at declaration time (e.g. chat's samplingPopoverClose wiring). */
675
+ export const controllers: Partial<Record<Route, Controller>> = {};
676
+
677
+ export function currentRoute(): Route {
678
+ const h = (location.hash || "").replace(/^#\/?/, "").split("?")[0] || "";
679
+ return (ROUTES as readonly string[]).includes(h) ? (h as Route) : "chat";
680
+ }
681
+
682
+ export function router(): void {
683
+ const route = currentRoute();
684
+ ensureDeveloperModeFor(route); // deep link to a dev tab always flips the toggle on
685
+ document.querySelectorAll<HTMLElement>("section[data-route]").forEach((s) => {
686
+ const on = s.dataset.route === route;
687
+ if (on && !s.classList.contains("active")) {
688
+ s.classList.add("active");
689
+ const c = controllers[route];
690
+ if (c) { if (!inited[route]) { inited[route] = true; c.init && c.init(); } c.enter && c.enter(); }
691
+ } else if (!on && s.classList.contains("active")) {
692
+ s.classList.remove("active");
693
+ const c = controllers[s.dataset.route as Route];
694
+ if (c && c.leave) c.leave();
695
+ }
696
+ });
697
+ document.querySelectorAll<HTMLElement>("nav .tab").forEach((t) => t.classList.toggle("active", t.dataset.tab === route));
698
+ // dim the ambient bloom slightly in the chat workspace so text reads cleanly
699
+ $("bloom").style.opacity = route === "chat" ? "0.55" : "1";
700
+ // The mobile drawer hamburger only makes sense on /chat (it opens the
701
+ // recent-chats sidebar, which only exists there) — CSS already hides it
702
+ // >=760px; this hides it off-route regardless of viewport width.
703
+ $("chat-hamburger").style.display = route === "chat" ? "" : "none";
704
+ if (route !== "chat") closeDrawer();
705
+ }
706
+
707
+ /* Nav-tab scroll-edge fades (2026-07-06 jank fix): the tab row scrolls with
708
+ its scrollbar hidden, so overflow read as clipped/broken text. Toggle
709
+ .fade-r/.fade-l so the CSS mask signals "more this way" only when true. */
710
+ export function updateTabFades(): void {
711
+ const t = $("tabs");
712
+ const over = t.scrollWidth - t.clientWidth > 1;
713
+ t.classList.toggle("fade-r", over && t.scrollLeft + t.clientWidth < t.scrollWidth - 1);
714
+ t.classList.toggle("fade-l", over && t.scrollLeft > 1);
715
+ }
716
+
717
+ export function initRouter(): void {
718
+ window.addEventListener("hashchange", router);
719
+ $("tabs").addEventListener("scroll", updateTabFades, { passive: true });
720
+ window.addEventListener("resize", updateTabFades);
721
+ updateTabFades();
722
+ }
723
+
724
+ /* ════════════════════════════════════════════════════════════════════
725
+ PWA SERVICE WORKER REGISTRATION (plan §9 Phase 3, beat-matrix Axis 10)
726
+ Shell-only cache-first worker (src/web/sw.js) — installability + instant
727
+ shell paint, explicitly NOT offline chat (the app is useless without the
728
+ local model server running, see sw.js's own header comment). Guarded so
729
+ it never registers somewhere a service worker can't run at all
730
+ (`'serviceWorker' in navigator`) or where the browser would refuse it
731
+ anyway (secure-context requirement: https, or localhost/127.0.0.1 for
732
+ local dev — a plain `http://<lan-ip>` load would silently no-op without
733
+ this check, so failing the guard is preferable to a console error on
734
+ every load in that case).
735
+ ════════════════════════════════════════════════════════════════════ */
736
+ export function initServiceWorker(): void {
737
+ if (!("serviceWorker" in navigator)) return;
738
+ const host = location.hostname;
739
+ const isLocalhost = host === "localhost" || host === "127.0.0.1" || host === "::1";
740
+ if (location.protocol !== "https:" && !isLocalhost) return;
741
+ window.addEventListener("load", () => {
742
+ navigator.serviceWorker.register("/sw.js").catch(() => {
743
+ // Registration failure isn't user-actionable (browser policy, private
744
+ // browsing, etc.) — the app works identically without it, so this is
745
+ // silent rather than a toast.
746
+ });
747
+ });
748
+ }
749
+
750
+ /* ════════════════════════════════════════════════════════════════════
751
+ GLOBAL CONNECTION PILL + MODEL ID (polled lightly, always on)
752
+ ════════════════════════════════════════════════════════════════════ */
753
+ export let activeModelId: string | null = null;
754
+
755
+ export function setConn(state: string, text: string): void {
756
+ const p = $("nav-conn");
757
+ p.className = "pill " + (state || "");
758
+ $("nav-conn-text").textContent = text;
759
+ }
760
+
761
+ let defaultHelloSub: string | null = null;
762
+
763
+ export async function pollIdentity(): Promise<void> {
764
+ try {
765
+ const [models, dl] = await Promise.all([
766
+ fetch("/v1/models").then((r) => r.json()),
767
+ fetch("/downloads").then((r) => r.json()).catch(() => ({ downloads: [] })),
768
+ ]);
769
+ activeModelId = models.data && models.data[0] ? models.data[0].id : null;
770
+ $("nav-model").textContent = activeModelId || "no model";
771
+ setConn("ok", "live · localhost");
772
+ updateDownloadIndicator((dl && dl.downloads) || []);
773
+ } catch {
774
+ $("nav-model").textContent = "server unreachable";
775
+ setConn("bad", "unreachable — retrying");
776
+ }
777
+ }
778
+
779
+ interface DownloadInfo {
780
+ state: string;
781
+ repoId: string;
782
+ totalBytes?: number;
783
+ receivedBytes?: number;
784
+ }
785
+
786
+ /** Surface a background model download — the "bigger model arriving while you
787
+ * chat on the starter" case — as a live nav pill plus a download-aware
788
+ * greeting in the empty chat state. Hidden when nothing else is downloading. */
789
+ export function updateDownloadIndicator(downloads: DownloadInfo[]): void {
790
+ const sub = $("chat-hello-sub");
791
+ if (sub && defaultHelloSub === null) defaultHelloSub = sub.textContent;
792
+ const incoming = downloads.find((d) => d.state === "active" && d.repoId !== activeModelId);
793
+ const pill = $("nav-download");
794
+ if (incoming) {
795
+ const pct = incoming.totalBytes ? Math.floor(((incoming.receivedBytes || 0) / incoming.totalBytes) * 100) : 0;
796
+ const name = incoming.repoId.split("/").pop();
797
+ $("nav-download-text").textContent = "↓ " + name + " · " + pct + "%";
798
+ pill.style.display = "";
799
+ if (sub) sub.textContent = "You're on a small, fast starter model so you can chat right now — a more capable one (" + name + ") is downloading and takes over next launch. Ask me anything, or about mlx-bun itself.";
800
+ } else {
801
+ pill.style.display = "none";
802
+ if (sub && defaultHelloSub !== null) sub.textContent = defaultHelloSub;
803
+ }
804
+ }