privateboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/dist/cli.js +10502 -0
  4. package/dist/cli.js.map +1 -0
  5. package/package.json +63 -0
  6. package/public/adjourn-overlay.css +253 -0
  7. package/public/agent-overlay.css +444 -0
  8. package/public/agent-overlay.js +604 -0
  9. package/public/agent-profile.css +3230 -0
  10. package/public/agent-profile.js +3329 -0
  11. package/public/app.js +6629 -0
  12. package/public/auto-hide-scroll.js +90 -0
  13. package/public/avatar-skill.js +793 -0
  14. package/public/avatars/chair.svg +98 -0
  15. package/public/avatars/first-principles.svg +122 -0
  16. package/public/avatars/long-horizon.svg +147 -0
  17. package/public/avatars/open_ai.png +0 -0
  18. package/public/avatars/phenomenologist.svg +130 -0
  19. package/public/avatars/socrates.svg +187 -0
  20. package/public/avatars/user-empathy.svg +117 -0
  21. package/public/avatars/value-investor.svg +117 -0
  22. package/public/favicon.svg +10 -0
  23. package/public/fonts/agent-Italic.woff2 +0 -0
  24. package/public/fonts/human-sans.woff2 +0 -0
  25. package/public/icons.css +103 -0
  26. package/public/models-cache.js +57 -0
  27. package/public/new-agent.css +1359 -0
  28. package/public/new-agent.js +675 -0
  29. package/public/onboarding.css +628 -0
  30. package/public/onboarding.js +782 -0
  31. package/public/prototype-dashboard.html +7596 -0
  32. package/public/report/spines/a16z-thesis.css +1055 -0
  33. package/public/report/spines/anthropic-essay.css +556 -0
  34. package/public/report/spines/boardroom-dark.css +1082 -0
  35. package/public/report/spines/gartner-note.css +538 -0
  36. package/public/report/spines/mckinsey-deck.css +523 -0
  37. package/public/report/spines/openai-paper.css +516 -0
  38. package/public/report.html +1417 -0
  39. package/public/room-settings.css +895 -0
  40. package/public/room-settings.js +1039 -0
  41. package/public/themes.css +338 -0
  42. package/public/user-settings.css +1236 -0
  43. package/public/user-settings.js +1291 -0
@@ -0,0 +1,1291 @@
1
+ /* ═══════════════════════════════════════════
2
+ USER SETTINGS OVERLAY · 2-column layout
3
+ ═══════════════════════════════════════════
4
+ Left rail: User · Theme · API Key
5
+ Right panel: section content for the selected rail item
6
+ Triggered by any element with [data-user-settings-trigger].
7
+
8
+ Persistence:
9
+ theme → localStorage (UI-only preference, no need to round-trip)
10
+ user (name/intro/avatarSeed) → /api/prefs (SQLite-backed)
11
+ api keys → localStorage (will move to /api/keys in M2/M3)
12
+
13
+ The keys object is exposed on window.boardroomKeys() so other modules
14
+ (new-agent.js) can show provider configuration status next to model rows.
15
+ */
16
+ (function () {
17
+ const THEME_KEY = "boardroom.theme";
18
+
19
+ // /api/prefs is async; cache the latest value at module bootstrap so the
20
+ // synchronous render code below stays simple. saveUser writes through.
21
+ let _prefsCache = { name: "You", intro: "", avatarSeed: null };
22
+
23
+ const THEMES = [
24
+ { slug: "regent", name: "Regent", desc: "warm gold on dark · default · the boardroom premium",
25
+ swatches: ["#0A0A0A","#131312","#C9A46B","#9A7B40","#A57843","#B5706A","#6A9B97","#C8C5BE"] },
26
+ { slug: "eastwood", name: "Eastwood", desc: "calm forest green",
27
+ swatches: ["#0A0A0A","#131312","#6FB572","#427A48","#B59560","#B5706A","#6A9B97","#C8C5BE"] },
28
+ { slug: "atrium", name: "Atrium", desc: "warm paper · light · the only daylight theme",
29
+ swatches: ["#FBFBF7","#F4F2EC","#2E7D32","#1B5E20","#A86C2A","#A8403D","#2E7D7A","#1F1E1A"] },
30
+ { slug: "pinterest", name: "Pinterest", desc: "clean white · Pinterest red · light",
31
+ swatches: ["#FFFFFF","#FAFAFA","#E60023","#AD081B","#F4A100","#E60023","#2E7D7A","#111111"] },
32
+ { slug: "apple", name: "Apple", desc: "pure white · system blue · Apple.com aesthetic · light",
33
+ swatches: ["#FFFFFF","#F5F5F7","#0071E3","#0051A8","#FF9500","#FF3B30","#5AC8FA","#1D1D1F"] },
34
+ { slug: "alanpeabody", name: "Alan Peabody", desc: "cool blue · git-green accents",
35
+ swatches: ["#0E1419","#131A21","#6BAFE0","#3F7AAA","#C8A463","#D67373","#6FB5A8","#C8D0DA"] },
36
+ { slug: "amuse", name: "Amuse", desc: "magenta + cyan · playful",
37
+ swatches: ["#1A0E14","#21121A","#D67BC0","#9C4884","#DCBE5D","#E07F84","#6FBFC2","#DECBD2"] },
38
+ { slug: "jtriley", name: "JTriley", desc: "bright lime + yellow · punchy",
39
+ swatches: ["#0A0F0A","#131914","#B5DA40","#6E8E27","#F0CC4E","#D67762","#6FBE9A","#C8D6BE"] },
40
+ { slug: "nebirhos", name: "Nebirhos", desc: "teal · warm orange highlights",
41
+ swatches: ["#0A1414","#11201F","#5EB1A6","#357770","#DD9258","#D87060","#6FBEC2","#B8D4D0"] },
42
+ { slug: "wedisagree", name: "We Disagree", desc: "argumentative orange · subtle green",
43
+ swatches: ["#14110E","#1F1A14","#DD7B40","#A8521E","#E6B872","#E26060","#6FB28A","#D8CBBC"] }
44
+ ];
45
+
46
+ const PROVIDERS = [
47
+ { id: "openrouter", label: "OpenRouter", hint: "default · routes any model · sk-or-…", placeholder: "sk-or-v1-…", group: "llm" },
48
+ { id: "anthropic", label: "Anthropic", hint: "Claude · Sonnet 4.6, Opus 4.7, Haiku 4.5", placeholder: "sk-ant-…", group: "llm" },
49
+ { id: "openai", label: "OpenAI", hint: "GPT · gpt-5, gpt-5 mini, gpt-4o", placeholder: "sk-…", group: "llm" },
50
+ { id: "google", label: "Google", hint: "Gemini · 2.5 Pro, 2.5 Flash", placeholder: "AIza…", group: "llm" },
51
+ { id: "xai", label: "xAI", hint: "Grok · grok-4.3, grok-4.1 fast", placeholder: "xai-…", group: "llm" },
52
+ // ── Skill Services (not LLM providers, but the same encrypted key store) ──
53
+ { id: "brave", label: "Brave Search", hint: "powers the Web Search system skill · ≈ $5 / 1000 queries · privacy-respecting",
54
+ placeholder: "BSA…", group: "skill" },
55
+ ];
56
+
57
+ function escape(s) {
58
+ return String(s).replace(/[&<>"']/g, (c) => ({
59
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
60
+ }[c]));
61
+ }
62
+
63
+ /* ── Storage helpers ───────────────────────────────────────── */
64
+ function getTheme() { try { return localStorage.getItem(THEME_KEY) || "regent"; } catch (e) { return "regent"; } }
65
+ function setTheme(slug) {
66
+ const theme = slug || "regent";
67
+ document.documentElement.setAttribute("data-theme", theme);
68
+ try { localStorage.setItem(THEME_KEY, theme); } catch (e) {}
69
+ }
70
+
71
+ async function fetchPrefs() {
72
+ try {
73
+ const r = await fetch("/api/prefs");
74
+ if (!r.ok) return;
75
+ const data = await r.json();
76
+ _prefsCache = {
77
+ name: typeof data.name === "string" ? data.name : "You",
78
+ intro: typeof data.intro === "string" ? data.intro : "",
79
+ avatarSeed: data.avatarSeed ?? null
80
+ };
81
+ } catch (e) {
82
+ // Network or server hiccup — fall back to whatever's already cached.
83
+ }
84
+ }
85
+
86
+ // Sync read from cache (populated at bootstrap below).
87
+ function getUser() { return _prefsCache; }
88
+
89
+ // Write-through: update cache immediately, persist to server in background.
90
+ // We don't await — the UI doesn't block on the round-trip.
91
+ function saveUser(u) {
92
+ _prefsCache = { ...(_prefsCache || {}), ...u };
93
+ fetch("/api/prefs", {
94
+ method: "PUT",
95
+ headers: { "content-type": "application/json" },
96
+ body: JSON.stringify({
97
+ name: u.name,
98
+ intro: u.intro,
99
+ avatarSeed: u.avatarSeed
100
+ })
101
+ }).catch(() => { /* offline → cache stays, retry on next edit */ });
102
+ }
103
+
104
+ // Provider keys live in the SQLite-backed /api/keys. Plaintext is never
105
+ // returned by the server — only meta. We cache the meta map locally so
106
+ // sync code (and window.boardroomKeys) keeps working.
107
+ // _keysMeta: { openrouter: { configured: true, updatedAt }, anthropic: {...}, ... }
108
+ let _keysMeta = {};
109
+
110
+ async function fetchKeyMeta() {
111
+ try {
112
+ const r = await fetch("/api/keys");
113
+ if (!r.ok) return;
114
+ const data = await r.json();
115
+ const next = {};
116
+ for (const row of (data.keys || [])) next[row.provider] = row;
117
+ _keysMeta = next;
118
+ } catch (e) { /* keep last cache on offline */ }
119
+ }
120
+
121
+ // Sync read used by new-agent.js: returns a map { provider: truthy } where
122
+ // 'truthy' is the meta object so existence-check (`if (keys[p])`) still works.
123
+ function getKeys() {
124
+ const out = {};
125
+ for (const [p, m] of Object.entries(_keysMeta)) {
126
+ if (m && m.configured) out[p] = m;
127
+ }
128
+ return out;
129
+ }
130
+
131
+ // Available-models snapshot · shape from /api/models. We don't keep
132
+ // a private copy any more — the singleton in models-cache.js owns
133
+ // the canonical state. Local read goes through the global, so all
134
+ // pickers (composer / agent profile / new-agent / this default
135
+ // selector) stay in sync after a key write.
136
+ function modelsSnapshot() {
137
+ return (typeof window.boardroomModels === "function") ? window.boardroomModels() : null;
138
+ }
139
+ function refreshModels() {
140
+ if (typeof window.boardroomModelsRefresh === "function") return window.boardroomModelsRefresh();
141
+ return Promise.resolve(null);
142
+ }
143
+
144
+ async function saveDefaultModel(modelV) {
145
+ try {
146
+ await fetch("/api/prefs", {
147
+ method: "PUT",
148
+ headers: { "content-type": "application/json" },
149
+ body: JSON.stringify({ defaultModelV: modelV }),
150
+ });
151
+ // Patch the shared cache in place so the next read sees the
152
+ // user's choice without round-tripping. Other tabs / pickers
153
+ // pick it up on their next refresh.
154
+ const snap = modelsSnapshot();
155
+ if (snap) snap.defaultModelV = modelV;
156
+ } catch (e) { /* swallow · UI is optimistic */ }
157
+ }
158
+
159
+ /** Pick a provider's "primary" model · used when the user clicks
160
+ * "Set as default" on a provider row. We prefer the user's already-
161
+ * picked model for that provider (if reachable), otherwise we fall
162
+ * back to a curated primary, otherwise the first reachable model
163
+ * from that provider in the registry. */
164
+ const PRIMARY_BY_PROVIDER = {
165
+ anthropic: "opus-4-7",
166
+ openai: "gpt-5-5",
167
+ google: "gemini-3-flash",
168
+ xai: "grok-4-3",
169
+ deepseek: "deepseek-v4-pro",
170
+ openrouter: "opus-4-7",
171
+ };
172
+ function primaryModelForProvider(provider) {
173
+ const snap = modelsSnapshot();
174
+ const reachable = (snap && snap.reachable) || [];
175
+ // 1. Curated primary, if reachable.
176
+ const curated = PRIMARY_BY_PROVIDER[provider];
177
+ if (curated && reachable.find((m) => m.modelV === curated && m.provider === provider)) {
178
+ return curated;
179
+ }
180
+ // 2. First reachable model from that provider.
181
+ const first = reachable.find((m) => m.provider === provider);
182
+ return first ? first.modelV : null;
183
+ }
184
+ /** Look up which provider currently owns the saved default model. */
185
+ function currentDefaultProvider() {
186
+ const snap = modelsSnapshot();
187
+ if (!snap || !snap.defaultModelV) return null;
188
+ const m = (snap.reachable || []).find((x) => x.modelV === snap.defaultModelV);
189
+ return m ? m.provider : null;
190
+ }
191
+ /** Click handler for the per-row "Set as default" button. Picks the
192
+ * provider's primary model + persists it as defaultModelV. The row
193
+ * re-renders so the badge moves. */
194
+ async function setProviderAsDefault(provider) {
195
+ const modelV = primaryModelForProvider(provider);
196
+ if (!modelV) return;
197
+ await saveDefaultModel(modelV);
198
+ // Re-render the keys section so every LLM row updates its badge.
199
+ if (currentSection === "keys") renderSection("keys");
200
+ }
201
+
202
+ // Set / clear a single provider key. The server applies the trim+empty-=delete
203
+ // semantic; we mirror the resulting meta into our local cache.
204
+ async function setProviderKey(provider, value) {
205
+ try {
206
+ const trimmed = (value || "").trim();
207
+ const r = await fetch("/api/keys/" + encodeURIComponent(provider), {
208
+ method: trimmed ? "PUT" : "DELETE",
209
+ headers: trimmed ? { "content-type": "application/json" } : undefined,
210
+ body: trimmed ? JSON.stringify({ key: trimmed }) : undefined
211
+ });
212
+ if (!r.ok) return null;
213
+ const meta = await r.json();
214
+ _keysMeta[provider] = meta;
215
+ return meta;
216
+ } catch (e) { return null; }
217
+ }
218
+
219
+ // Public — other modules read provider configuration via this
220
+ window.boardroomKeys = getKeys;
221
+
222
+ // Apply theme on script init (FOUC fallback)
223
+ setTheme(getTheme());
224
+
225
+ /* ── Section content renderers ────────────────────────────── */
226
+ function userSectionHTML() {
227
+ const u = getUser();
228
+ return `
229
+ <div class="us-pane-head">
230
+ <div class="us-pane-tag">▸ User</div>
231
+ <div class="us-pane-deck">how the boardroom addresses you and what it knows about your context.</div>
232
+ </div>
233
+
234
+ <div class="us-pane-body">
235
+ <div class="us-row">
236
+ <div class="us-row-label">Avatar</div>
237
+ <div class="us-row-field us-avatar-row">
238
+ <div class="us-avatar-frame" data-us-avatar></div>
239
+ <button type="button" class="us-mini-btn" data-us-regen-avatar>
240
+ <span class="us-mini-btn-mark">◆</span>
241
+ <span>generate 8-bit avatar</span>
242
+ </button>
243
+ </div>
244
+ </div>
245
+
246
+ <div class="us-row">
247
+ <div class="us-row-label">Name</div>
248
+ <div class="us-row-field">
249
+ <div class="us-input-wrap">
250
+ <input type="text" class="us-input" data-us-name placeholder="Kay" maxlength="32" value="${escape(u.name || "")}">
251
+ </div>
252
+ </div>
253
+ </div>
254
+
255
+ <div class="us-row">
256
+ <div class="us-row-label">About you</div>
257
+ <div class="us-row-field">
258
+ <div class="us-input-wrap tall">
259
+ <textarea class="us-input" data-us-intro maxlength="320" placeholder="A line or two about your role, what you tend to think about, what you're working on. Directors will keep this in mind across rooms.">${escape(u.intro || "")}</textarea>
260
+ </div>
261
+ <div class="us-row-meta"><span data-us-intro-count>0</span> / 320 chars</div>
262
+ </div>
263
+ </div>
264
+ </div>
265
+ `;
266
+ }
267
+
268
+ function themeSectionHTML() {
269
+ const current = getTheme();
270
+ const swatchSpans = (cs) => cs.map((c) => `<span style="background:${c}"></span>`).join("");
271
+ return `
272
+ <div class="us-pane-head">
273
+ <div class="us-pane-tag">▸ Theme</div>
274
+ <div class="us-pane-deck">global · zsh-inspired palettes. Applied instantly across all rooms.</div>
275
+ </div>
276
+
277
+ <div class="us-pane-body">
278
+ <div class="us-theme-grid">
279
+ ${THEMES.map((t) => `
280
+ <a href="#" class="us-theme${t.slug === current ? " active" : ""}" data-theme-slug="${t.slug}">
281
+ <span class="us-theme-swatch">${swatchSpans(t.swatches)}</span>
282
+ <span class="us-theme-info">
283
+ <span class="us-theme-name">${escape(t.name)}</span>
284
+ <span class="us-theme-desc">${escape(t.desc)}</span>
285
+ </span>
286
+ <span class="us-theme-check"></span>
287
+ </a>
288
+ `).join("")}
289
+ </div>
290
+ </div>
291
+ `;
292
+ }
293
+
294
+ /* ── Usage section ────────────────────────────────────────── */
295
+ // Provider → CSS-variable color slot. Lets each model bar/swatch
296
+ // pick up the right accent from the active theme without baking
297
+ // color values here.
298
+ const PROVIDER_COLOR_VAR = {
299
+ anthropic: "--lime",
300
+ openai: "--cyan",
301
+ google: "--amber",
302
+ xai: "--magenta",
303
+ deepseek: "--red",
304
+ unknown: "--text-soft",
305
+ };
306
+
307
+ function fmtTokens(n) {
308
+ if (!Number.isFinite(n) || n <= 0) return "0";
309
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 1 : 2) + "M";
310
+ if (n >= 1_000) return (n / 1_000).toFixed(n >= 10_000 ? 0 : 1) + "k";
311
+ return String(Math.round(n));
312
+ }
313
+
314
+ function usageSectionHTML() {
315
+ return `
316
+ <div class="us-pane-head">
317
+ <div class="us-pane-tag">▸ Usage</div>
318
+ <div class="us-pane-deck">cumulative LLM-call accounting · billed across every director / chair turn since this boardroom was opened.</div>
319
+ </div>
320
+
321
+ <div class="us-pane-body">
322
+ <div class="us-usage" data-usage-pane>
323
+ <div class="us-usage-loading">measuring…</div>
324
+ </div>
325
+ </div>
326
+ `;
327
+ }
328
+
329
+ function renderUsagePane(s) {
330
+ const pane = paneEl.querySelector("[data-usage-pane]");
331
+ if (!pane) return;
332
+ if (!s || s.totalTokens === 0) {
333
+ pane.innerHTML = `
334
+ <div class="us-usage-empty">
335
+ <div class="us-usage-empty-num">0</div>
336
+ <div class="us-usage-empty-text">no LLM calls billed yet · open a room and let the directors speak.</div>
337
+ </div>
338
+ `;
339
+ return;
340
+ }
341
+
342
+ const total = s.totalTokens;
343
+ const segments = s.byModel.map((m) => {
344
+ const pct = (m.tokens / total) * 100;
345
+ const color = PROVIDER_COLOR_VAR[m.provider] || PROVIDER_COLOR_VAR.unknown;
346
+ return `<span class="us-usage-seg" style="width:${pct.toFixed(2)}%;background:var(${color})" title="${escape(m.displayName)} · ${fmtTokens(m.tokens)}"></span>`;
347
+ }).join("");
348
+
349
+ const modelRows = s.byModel.map((m) => {
350
+ const pct = (m.tokens / total) * 100;
351
+ const color = PROVIDER_COLOR_VAR[m.provider] || PROVIDER_COLOR_VAR.unknown;
352
+ return `
353
+ <div class="us-model-row">
354
+ <div class="us-model-info">
355
+ <span class="us-model-dot" style="background:var(${color})"></span>
356
+ <span class="us-model-name">${escape(m.displayName)}</span>
357
+ <span class="us-model-provider">${escape(m.provider)}</span>
358
+ </div>
359
+ <div class="us-model-bar">
360
+ <span style="width:${pct.toFixed(2)}%;background:var(${color})"></span>
361
+ </div>
362
+ <div class="us-model-stats">
363
+ <span class="us-model-tokens">${fmtTokens(m.tokens)}</span>
364
+ <span class="us-model-pct">${pct.toFixed(1)}%</span>
365
+ <span class="us-model-agents">${m.agents} agent${m.agents === 1 ? "" : "s"}</span>
366
+ </div>
367
+ </div>
368
+ `;
369
+ }).join("");
370
+
371
+ // Top consumers (top 6 by tokens, skip silent agents).
372
+ const topAgents = s.byAgent.filter((a) => a.tokens > 0).slice(0, 6);
373
+ const agentRows = topAgents.map((a) => {
374
+ const pct = (a.tokens / total) * 100;
375
+ const color = PROVIDER_COLOR_VAR[a.provider] || PROVIDER_COLOR_VAR.unknown;
376
+ const role = a.roleKind === "moderator" ? "chair" : "director";
377
+ return `
378
+ <div class="us-agent-row">
379
+ <div class="us-agent-name-col">
380
+ <span class="us-agent-name">${escape(a.name)}</span>
381
+ <span class="us-agent-role">${role}</span>
382
+ </div>
383
+ <div class="us-agent-model" style="color:var(${color})">${escape(a.displayName)}</div>
384
+ <div class="us-agent-bar">
385
+ <span style="width:${Math.max(pct, 1).toFixed(2)}%;background:var(${color})"></span>
386
+ </div>
387
+ <div class="us-agent-tokens">${fmtTokens(a.tokens)}</div>
388
+ </div>
389
+ `;
390
+ }).join("");
391
+
392
+ const silentCount = s.byAgent.length - topAgents.length;
393
+ const silentNote = silentCount > 0
394
+ ? `<div class="us-agent-silent">+ ${silentCount} agent${silentCount === 1 ? "" : "s"} not yet billed</div>`
395
+ : "";
396
+
397
+ // Retired-agents footer · custom directors that the user has
398
+ // deleted. Their per-agent identity is gone but the tokens were
399
+ // real, so we surface a small footer note acknowledging that the
400
+ // model-level totals above include their share. Hidden when no
401
+ // agents have ever been deleted with consumed tokens.
402
+ const retired = s.retired || { tokens: 0, agents: 0 };
403
+ const retiredNote = retired.tokens > 0
404
+ ? `
405
+ <div class="us-usage-retired">
406
+ <span class="us-usage-retired-mark">↓</span>
407
+ <span class="us-usage-retired-text">
408
+ <strong>${retired.agents}</strong> retired
409
+ agent${retired.agents === 1 ? "" : "s"} ·
410
+ <strong>${fmtTokens(retired.tokens)}</strong> tokens preserved across deletions and folded into the model totals above.
411
+ </span>
412
+ </div>
413
+ `
414
+ : "";
415
+
416
+ pane.innerHTML = `
417
+ <div class="us-usage-head">
418
+ <div class="us-usage-total">
419
+ <div class="us-usage-total-num">${fmtTokens(total)}</div>
420
+ <div class="us-usage-total-raw">${total.toLocaleString()} tokens</div>
421
+ </div>
422
+ <div class="us-usage-meta">
423
+ <div class="us-usage-meta-row">
424
+ <span class="us-usage-meta-label">Models</span>
425
+ <span class="us-usage-meta-value">${s.byModel.length}</span>
426
+ </div>
427
+ <div class="us-usage-meta-row">
428
+ <span class="us-usage-meta-label">Agents</span>
429
+ <span class="us-usage-meta-value">${s.agentCount}</span>
430
+ </div>
431
+ <div class="us-usage-meta-row">
432
+ <span class="us-usage-meta-label">Active</span>
433
+ <span class="us-usage-meta-value">${s.byAgent.filter((a) => a.tokens > 0).length}</span>
434
+ </div>
435
+ </div>
436
+ </div>
437
+
438
+ <div class="us-usage-bar" aria-label="Token distribution by model">${segments}</div>
439
+
440
+ <div class="us-usage-section">
441
+ <div class="us-usage-section-tag">By model</div>
442
+ <div class="us-model-list">${modelRows}</div>
443
+ </div>
444
+
445
+ ${topAgents.length ? `
446
+ <div class="us-usage-section">
447
+ <div class="us-usage-section-tag">Top consumers</div>
448
+ <div class="us-agent-list">${agentRows}</div>
449
+ ${silentNote}
450
+ </div>
451
+ ` : ""}
452
+
453
+ ${retiredNote}
454
+ `;
455
+ }
456
+
457
+ async function wireUsageSection() {
458
+ try {
459
+ const r = await fetch("/api/usage/summary");
460
+ if (!r.ok) throw new Error("HTTP " + r.status);
461
+ const s = await r.json();
462
+ renderUsagePane(s);
463
+ } catch (e) {
464
+ const pane = paneEl.querySelector("[data-usage-pane]");
465
+ if (pane) pane.innerHTML = `<div class="us-usage-empty"><div class="us-usage-empty-text">couldn't fetch usage stats. ${escape(String(e && e.message || e))}</div></div>`;
466
+ }
467
+ }
468
+
469
+ // LLM-provider IDs only (excludes Skill Services like Brave — those
470
+ // live in their own pinned subgroup and aren't subject to the
471
+ // "+ add provider" flow).
472
+ const LLM_PROVIDER_IDS = PROVIDERS.filter((p) => p.group === "llm").map((p) => p.id);
473
+ const SKILL_PROVIDER_IDS = PROVIDERS.filter((p) => p.group === "skill").map((p) => p.id);
474
+
475
+ function ensureActiveProviders() {
476
+ if (activeProviders === null) {
477
+ // Only show rows for providers the user has actually configured.
478
+ // Unconfigured providers (incl. OpenRouter) live behind the
479
+ // "+ add provider" chips so the panel reflects what's really
480
+ // wired up rather than projecting an empty OpenRouter row onto
481
+ // a user who never picked it during onboarding.
482
+ activeProviders = LLM_PROVIDER_IDS.filter(
483
+ (k) => _keysMeta[k] && _keysMeta[k].configured,
484
+ );
485
+ }
486
+ }
487
+
488
+ function renderKeyRow(p, removable) {
489
+ const meta = _keysMeta[p.id];
490
+ const has = !!(meta && meta.configured);
491
+ // The server never returns plaintext, but it does return a 4+4
492
+ // masked preview of the stored key (e.g. "sk-or…YjNH"). Surfacing
493
+ // it as the placeholder lets the user verify which key is in which
494
+ // slot — a real failure mode we hit when the OpenRouter slot
495
+ // silently held a Brave key. When configured we show the preview
496
+ // alone (no "paste to replace" hint — the row is clearly populated
497
+ // and pasting overwrites by default); when empty we show the
498
+ // provider's normal hint.
499
+ const preview = has && meta.preview ? meta.preview : null;
500
+ const placeholder = has
501
+ ? (preview || "••••••••")
502
+ : p.placeholder;
503
+ // Default-provider tag · only meaningful for LLM providers.
504
+ // We compute it from the saved defaultModelV's owning provider.
505
+ // The "Set as default" CTA only appears on LLM rows that are
506
+ // configured AND not the current default. Skill rows (Brave) are
507
+ // never default-eligible — they're search, not a model.
508
+ const isLlm = p.group === "llm";
509
+ const isDefault = isLlm && currentDefaultProvider() === p.id;
510
+ const canSetDefault = isLlm && has && !isDefault && !!primaryModelForProvider(p.id);
511
+ const labelExtras = isDefault
512
+ ? ' <span class="badge us-key-default-badge">default</span>'
513
+ : "";
514
+ return `
515
+ <div class="us-key-row${isDefault ? " is-default" : ""}" data-provider="${p.id}">
516
+ <div class="us-key-head">
517
+ <div class="us-key-label">${escape(p.label)}${labelExtras}</div>
518
+ <div class="us-key-status ${has ? "on" : "off"}" data-status>${has ? "● configured" : "○ not set"}</div>
519
+ ${canSetDefault ? `<button type="button" class="us-key-set-default" data-set-default-provider="${p.id}" title="Use ${escape(p.label)} as the default model provider for new agents">set as default</button>` : ""}
520
+ ${removable ? `<button type="button" class="us-key-remove" data-remove-provider="${p.id}" title="Remove">✕</button>` : ""}
521
+ </div>
522
+ <div class="us-key-hint">${escape(p.hint)}</div>
523
+ <div class="us-input-wrap">
524
+ <input
525
+ type="password"
526
+ class="us-input${has ? " has-preview" : ""}"
527
+ data-key-input
528
+ name="bk-${p.id}"
529
+ placeholder="${escape(placeholder)}"
530
+ value=""
531
+ autocomplete="new-password"
532
+ data-lpignore="true"
533
+ data-1p-ignore="true"
534
+ data-form-type="other"
535
+ spellcheck="false">
536
+ <button type="button" class="us-key-eye" data-key-eye title="Show / hide">◉</button>
537
+ </div>
538
+ </div>
539
+ `;
540
+ }
541
+
542
+ function keysSectionHTML() {
543
+ ensureActiveProviders();
544
+ // Anthropic is temporarily excluded from the "+ add provider"
545
+ // chips · only sonnet-4-6 is direct-routable on the Anthropic SDK
546
+ // right now (opus / haiku are openrouterOnly), so adding an
547
+ // Anthropic key alone unlocks just one model — confusing UX. Once
548
+ // the registry has ≥ 2 direct-routable Claude models, drop the
549
+ // exclusion. Existing users who already configured Anthropic still
550
+ // see their row (activeProviders preserves them). */
551
+ const HIDDEN_FROM_ADD = new Set(["anthropic"]);
552
+ const addable = PROVIDERS.filter(
553
+ (p) => p.group === "llm" && !HIDDEN_FROM_ADD.has(p.id) && !activeProviders.includes(p.id),
554
+ );
555
+ const skillProviders = PROVIDERS.filter((p) => p.group === "skill");
556
+
557
+ return `
558
+ <div class="us-pane-head">
559
+ <div class="us-pane-tag">▸ API Key</div>
560
+ <div class="us-pane-deck">stored locally, never uploaded. add a key for any provider — a single key is enough to get started. Skill services power optional capabilities like web search.</div>
561
+ </div>
562
+
563
+ <div class="us-pane-body">
564
+
565
+ <div class="us-key-group">
566
+ <div class="us-key-group-tag">LLM Providers</div>
567
+ ${activeProviders.map((id) => {
568
+ const p = PROVIDERS.find((x) => x.id === id);
569
+ if (!p) return "";
570
+ return renderKeyRow(p, true);
571
+ }).join("")}
572
+ ${addable.length > 0 ? `
573
+ <div class="us-key-add">
574
+ <span class="us-key-add-label">+ add provider:</span>
575
+ <div class="us-key-add-chips">
576
+ ${addable.map((p) => `
577
+ <button type="button" class="us-key-add-chip" data-add-provider="${p.id}">${escape(p.label)}</button>
578
+ `).join("")}
579
+ </div>
580
+ </div>
581
+ ` : ""}
582
+ </div>
583
+
584
+ ${skillProviders.length > 0 ? `
585
+ <div class="us-key-group us-key-group-skill">
586
+ <div class="us-key-group-tag">Skill Services</div>
587
+ <div class="us-key-group-deck">enables system skills that need an outside service. Each agent can opt in or out per-profile.</div>
588
+ ${skillProviders.map((p) => renderKeyRow(p, !!(_keysMeta[p.id] && _keysMeta[p.id].configured))).join("")}
589
+ </div>
590
+ ` : ""}
591
+
592
+ <div data-models-summary>${modelsSummaryHTML()}</div>
593
+
594
+ </div>
595
+ `;
596
+ }
597
+
598
+ /* ── Available models · summary + default picker ─────────────
599
+ Lives at the bottom of the API Key section. Hidden when the
600
+ user has no keys configured. Re-fetched after every key
601
+ write so the route badges and reachable count stay accurate. */
602
+ const PROVIDER_ORDER = ["anthropic", "openai", "google", "xai", "deepseek", "openrouter"];
603
+ const PROVIDER_LABEL = {
604
+ anthropic: "Anthropic",
605
+ openai: "OpenAI",
606
+ google: "Google",
607
+ xai: "xAI",
608
+ deepseek: "DeepSeek",
609
+ openrouter:"OpenRouter",
610
+ };
611
+ function providerLabel(p) { return PROVIDER_LABEL[p] || p; }
612
+
613
+ function routeBadgeHTML(m) {
614
+ const d = !!(m.routes && m.routes.direct);
615
+ const o = !!(m.routes && m.routes.openrouter);
616
+ if (d && o) return `<span class="us-models-route">direct · OR</span>`;
617
+ if (d) return `<span class="us-models-route">direct</span>`;
618
+ if (o) return `<span class="us-models-route">OR</span>`;
619
+ return "";
620
+ }
621
+
622
+ function modelsSummaryHTML() {
623
+ const cache = modelsSnapshot();
624
+ if (!cache) {
625
+ return `<div class="us-key-group us-key-group-models">
626
+ <div class="us-key-group-tag">Available models</div>
627
+ <div class="us-models-loading">measuring reach…</div>
628
+ </div>`;
629
+ }
630
+ if (!cache.hasAnyKey) return "";
631
+ const reachable = (cache.reachable || []);
632
+ if (reachable.length === 0) return "";
633
+
634
+ const byProvider = new Map();
635
+ for (const m of reachable) {
636
+ if (!byProvider.has(m.provider)) byProvider.set(m.provider, []);
637
+ byProvider.get(m.provider).push(m);
638
+ }
639
+ const providers = Array.from(byProvider.keys()).sort((a, b) => {
640
+ const ai = PROVIDER_ORDER.indexOf(a), bi = PROVIDER_ORDER.indexOf(b);
641
+ return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
642
+ });
643
+
644
+ const blocks = providers.map((p) => {
645
+ const models = byProvider.get(p);
646
+ return `
647
+ <div class="us-models-provider">
648
+ <div class="us-models-provider-tag">${escape(providerLabel(p))}</div>
649
+ <div class="us-models-rows">
650
+ ${models.map((m) => `
651
+ <div class="us-models-row">
652
+ <span class="us-models-name">${escape(m.displayName)}</span>
653
+ <span class="us-models-deck">${escape(m.deck || "")}</span>
654
+ ${routeBadgeHTML(m)}
655
+ </div>
656
+ `).join("")}
657
+ </div>
658
+ </div>
659
+ `;
660
+ }).join("");
661
+
662
+ let defaultBlock = "";
663
+ const def = cache.defaultModelV;
664
+ if (reachable.length >= 2) {
665
+ const optgroups = providers.map((p) => {
666
+ const models = byProvider.get(p);
667
+ return `<optgroup label="${escape(providerLabel(p))}">${
668
+ models.map((m) => `<option value="${escape(m.modelV)}"${m.modelV === def ? " selected" : ""}>${escape(m.displayName)}</option>`).join("")
669
+ }</optgroup>`;
670
+ }).join("");
671
+ defaultBlock = `
672
+ <div class="us-models-default">
673
+ <div class="us-models-default-label">Default model</div>
674
+ <div class="us-models-default-hint">new agents inherit this. when an agent's model goes unreachable, it falls back here too.</div>
675
+ <div class="us-input-wrap us-models-default-wrap">
676
+ <select class="us-input us-models-default-select" data-default-model>${optgroups}</select>
677
+ </div>
678
+ </div>
679
+ `;
680
+ } else {
681
+ const m = reachable[0];
682
+ defaultBlock = `
683
+ <div class="us-models-default">
684
+ <div class="us-models-default-label">Default model</div>
685
+ <div class="us-models-default-static">
686
+ <span class="us-models-default-name">${escape(m.displayName)}</span>
687
+ <span class="us-models-default-note">only reachable model</span>
688
+ </div>
689
+ </div>
690
+ `;
691
+ }
692
+
693
+ return `
694
+ <div class="us-key-group us-key-group-models">
695
+ <div class="us-key-group-tag">Available models</div>
696
+ <div class="us-key-group-deck">${reachable.length} model${reachable.length === 1 ? "" : "s"} reachable across ${providers.length} provider${providers.length === 1 ? "" : "s"}. <code>direct</code> uses the provider key, <code>OR</code> routes through OpenRouter.</div>
697
+ <div class="us-models-list">${blocks}</div>
698
+ ${defaultBlock}
699
+ </div>
700
+ `;
701
+ }
702
+
703
+ /* ── Default Model section ────────────────────────────────────
704
+ A dedicated rail tab for picking the default model the rest of
705
+ the system inherits (new agents, fallback for unreachable
706
+ stale-modelV agents, brief flagship tier). The same dropdown
707
+ also lives at the bottom of the API Key section as a quick
708
+ toggle, but here it gets a focused page with grouping by
709
+ provider + a deck per model row + the active route badge. */
710
+ function defaultModelSectionHTML() {
711
+ const cache = modelsSnapshot();
712
+ if (!cache || !cache.hasAnyKey) {
713
+ return `
714
+ <div class="us-pane-head">
715
+ <div class="us-pane-tag">▸ Default Model</div>
716
+ <div class="us-pane-deck">no LLM key configured yet — add one in <a href="#" data-jump-keys class="us-link">API Key</a> first, then come back to pick a default.</div>
717
+ </div>
718
+ `;
719
+ }
720
+ const reachable = cache.reachable || [];
721
+ if (reachable.length === 0) {
722
+ return `
723
+ <div class="us-pane-head">
724
+ <div class="us-pane-tag">▸ Default Model</div>
725
+ <div class="us-pane-deck">your configured keys don't reach any model right now. Check the key values, or add another carrier in <a href="#" data-jump-keys class="us-link">API Key</a>.</div>
726
+ </div>
727
+ `;
728
+ }
729
+
730
+ // Group reachable models by provider, ordered by PROVIDER_ORDER
731
+ // (anthropic / openai / google / xai / deepseek / openrouter).
732
+ const byProvider = new Map();
733
+ for (const m of reachable) {
734
+ if (!byProvider.has(m.provider)) byProvider.set(m.provider, []);
735
+ byProvider.get(m.provider).push(m);
736
+ }
737
+ const providers = Array.from(byProvider.keys()).sort((a, b) => {
738
+ const ai = PROVIDER_ORDER.indexOf(a), bi = PROVIDER_ORDER.indexOf(b);
739
+ return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
740
+ });
741
+ const def = cache.defaultModelV;
742
+
743
+ const blocks = providers.map((p) => {
744
+ const models = byProvider.get(p);
745
+ const rows = models.map((m) => {
746
+ const isActive = m.modelV === def;
747
+ return `
748
+ <button type="button"
749
+ class="us-default-row${isActive ? " active" : ""}"
750
+ data-default-pick="${escape(m.modelV)}">
751
+ <span class="us-default-row-mark">${isActive ? "●" : "○"}</span>
752
+ <span class="us-default-row-text">
753
+ <span class="us-default-row-name">${escape(m.displayName)}</span>
754
+ <span class="us-default-row-deck">${escape(m.deck || "")}</span>
755
+ </span>
756
+ ${routeBadgeHTML(m)}
757
+ </button>
758
+ `;
759
+ }).join("");
760
+ return `
761
+ <div class="us-default-provider">
762
+ <div class="us-default-provider-tag">${escape(providerLabel(p))}</div>
763
+ <div class="us-default-rows">${rows}</div>
764
+ </div>
765
+ `;
766
+ }).join("");
767
+
768
+ return `
769
+ <div class="us-pane-head">
770
+ <div class="us-pane-tag">▸ Default Model</div>
771
+ <div class="us-pane-deck">new agents inherit this. when an agent's saved model becomes unreachable (key removed / model retired), it falls back here too. brief flagship tier uses this as the deep-write model.</div>
772
+ </div>
773
+
774
+ <div class="us-pane-body">
775
+ <div class="us-default-list">${blocks}</div>
776
+ </div>
777
+ `;
778
+ }
779
+
780
+ function wireDefaultModelSection() {
781
+ if (!paneEl) return;
782
+ // Jump links to the API Key section when the user has no keys.
783
+ paneEl.querySelectorAll("[data-jump-keys]").forEach((a) => {
784
+ a.addEventListener("click", (e) => {
785
+ e.preventDefault();
786
+ renderSection("keys");
787
+ });
788
+ });
789
+ // Click a row · save defaultModelV, repaint the section so the
790
+ // active-row marker moves. Refresh the shared models cache too so
791
+ // every other picker (composer / agent profile) picks up the new
792
+ // value on its next read.
793
+ paneEl.querySelectorAll("[data-default-pick]").forEach((btn) => {
794
+ btn.addEventListener("click", async (e) => {
795
+ e.preventDefault();
796
+ const v = btn.getAttribute("data-default-pick");
797
+ if (!v) return;
798
+ await saveDefaultModel(v);
799
+ await refreshModels();
800
+ if (currentSection === "default") renderSection("default");
801
+ });
802
+ });
803
+ }
804
+
805
+ function refreshModelsSummary() {
806
+ if (!paneEl) return;
807
+ const slot = paneEl.querySelector("[data-models-summary]");
808
+ if (!slot) return;
809
+ slot.innerHTML = modelsSummaryHTML();
810
+ // Also refresh each LLM row's default-state UI · the "set as
811
+ // default" button only appears once a model from that provider
812
+ // becomes reachable, which happens after the user pastes a key
813
+ // and refreshModels() resolves. Walk the rows and patch the
814
+ // default-related controls in place so we don't disturb the
815
+ // input field the user is typing into.
816
+ const defaultProvider = currentDefaultProvider();
817
+ paneEl.querySelectorAll(".us-key-row").forEach((row) => {
818
+ const id = row.dataset.provider;
819
+ const p = PROVIDERS.find((x) => x.id === id);
820
+ if (!p || p.group !== "llm") return;
821
+ const meta = _keysMeta[id];
822
+ const has = !!(meta && meta.configured);
823
+ const isDefault = defaultProvider === id;
824
+ const canSet = has && !isDefault && !!primaryModelForProvider(id);
825
+ // Toggle .is-default on the row.
826
+ row.classList.toggle("is-default", isDefault);
827
+ // Sync the badge in the label.
828
+ const label = row.querySelector(".us-key-label");
829
+ if (label) {
830
+ const existing = label.querySelector(".us-key-default-badge");
831
+ if (isDefault && !existing) {
832
+ label.insertAdjacentHTML("beforeend", ' <span class="badge us-key-default-badge">default</span>');
833
+ } else if (!isDefault && existing) {
834
+ existing.remove();
835
+ // Cleanup adjacent whitespace text node so we don't accumulate
836
+ // spaces over time.
837
+ const next = label.lastChild;
838
+ if (next && next.nodeType === 3 && /\s+/.test(next.nodeValue || "")) next.remove();
839
+ }
840
+ }
841
+ // Sync the "set as default" button.
842
+ const head = row.querySelector(".us-key-head");
843
+ if (head) {
844
+ const existing = head.querySelector("[data-set-default-provider]");
845
+ if (canSet && !existing) {
846
+ // Insert just before the remove button (or at the end).
847
+ const btn = document.createElement("button");
848
+ btn.type = "button";
849
+ btn.className = "us-key-set-default";
850
+ btn.dataset.setDefaultProvider = id;
851
+ btn.title = `Use ${p.label} as the default model provider for new agents`;
852
+ btn.textContent = "set as default";
853
+ btn.addEventListener("click", async (e) => {
854
+ e.preventDefault();
855
+ await setProviderAsDefault(id);
856
+ });
857
+ const removeBtn = head.querySelector("[data-remove-provider]");
858
+ if (removeBtn) head.insertBefore(btn, removeBtn);
859
+ else head.appendChild(btn);
860
+ } else if (!canSet && existing) {
861
+ existing.remove();
862
+ }
863
+ }
864
+ });
865
+ }
866
+
867
+ /* Avatar generation · same flow as the agent profile's regenerate
868
+ button (see agent-profile.js / regenerateProfileAvatar): each
869
+ click pulls a fresh seed from AvatarSkill.randomSeed(), saves it
870
+ to the user prefs (`avatarSeed`), and re-paints. The SVG is
871
+ rendered from that seed via AvatarSkill.generate(). */
872
+ function generateAvatar(seed) {
873
+ return window.AvatarSkill.generate(seed);
874
+ }
875
+
876
+ /* ── Modal shell ──────────────────────────────────────────── */
877
+ function modalHTML() {
878
+ return `
879
+ <div class="user-settings-overlay" id="user-settings-overlay" role="dialog" aria-modal="true" aria-hidden="true">
880
+ <div class="user-settings-modal" role="document">
881
+
882
+ <div class="us-classification">
883
+ <span><span class="dot">●</span> user · settings</span>
884
+ <span class="right">// local</span>
885
+ </div>
886
+
887
+ <header class="us-head">
888
+ <div class="us-title">Preference</div>
889
+ <button type="button" class="us-close" aria-label="Close">✕</button>
890
+ </header>
891
+
892
+ <div class="us-frame">
893
+ <nav class="us-nav" role="tablist">
894
+ <a href="#" class="us-nav-item active" data-section="user" role="tab" aria-selected="true">User</a>
895
+ <a href="#" class="us-nav-item" data-section="theme" role="tab" aria-selected="false">Theme</a>
896
+ <a href="#" class="us-nav-item" data-section="usage" role="tab" aria-selected="false">Usage</a>
897
+ <a href="#" class="us-nav-item" data-section="keys" role="tab" aria-selected="false">API Key</a>
898
+ <a href="#" class="us-nav-item" data-section="default" role="tab" aria-selected="false">Default Model</a>
899
+ </nav>
900
+
901
+ <div class="us-pane" data-us-pane></div>
902
+ </div>
903
+
904
+ <footer class="us-foot">
905
+ <span class="saved">changes save automatically</span>
906
+ <button type="button" class="us-done">[ Done ]</button>
907
+ </footer>
908
+
909
+ </div>
910
+ </div>
911
+ `;
912
+ }
913
+
914
+ let overlay, modal, paneEl, currentSection = "user";
915
+ let activeProviders = null; // populated lazily from saved keys; reset on each open
916
+
917
+ function renderSection(id) {
918
+ currentSection = id;
919
+ if (id === "user") paneEl.innerHTML = userSectionHTML();
920
+ else if (id === "theme") paneEl.innerHTML = themeSectionHTML();
921
+ else if (id === "usage") paneEl.innerHTML = usageSectionHTML();
922
+ else if (id === "keys") paneEl.innerHTML = keysSectionHTML();
923
+ else if (id === "default") paneEl.innerHTML = defaultModelSectionHTML();
924
+
925
+ // Section-specific wiring
926
+ if (id === "user") wireUserSection();
927
+ if (id === "keys") wireKeysSection();
928
+ if (id === "usage") wireUsageSection();
929
+ if (id === "default") wireDefaultModelSection();
930
+
931
+ // Active rail item
932
+ modal.querySelectorAll(".us-nav-item").forEach((el) => {
933
+ const on = el.dataset.section === id;
934
+ el.classList.toggle("active", on);
935
+ el.setAttribute("aria-selected", on ? "true" : "false");
936
+ });
937
+ }
938
+
939
+ function paintUserAvatar() {
940
+ const frame = paneEl.querySelector("[data-us-avatar]");
941
+ if (!frame) return;
942
+ const u = getUser();
943
+ // Mirror the agent profile flow · the avatar is whatever seed is
944
+ // saved on the user prefs. If none has ever been generated, mint
945
+ // one now so the avatar is stable across reloads.
946
+ let seed = u.avatarSeed;
947
+ if (!seed && window.AvatarSkill) {
948
+ seed = window.AvatarSkill.randomSeed();
949
+ saveUser({ avatarSeed: seed });
950
+ // Cascade the freshly-minted seed to app.prefs so the sidebar
951
+ // foot picks it up on the same paint.
952
+ if (window.app) {
953
+ window.app.prefs = { ...(window.app.prefs || {}), avatarSeed: seed };
954
+ if (typeof window.app.renderUserBlock === "function") window.app.renderUserBlock();
955
+ }
956
+ }
957
+ frame.innerHTML = generateAvatar(seed || "default");
958
+ }
959
+
960
+ function wireUserSection() {
961
+ const nameInput = paneEl.querySelector("[data-us-name]");
962
+ const introInput = paneEl.querySelector("[data-us-intro]");
963
+ const introCount = paneEl.querySelector("[data-us-intro-count]");
964
+
965
+ function persist() {
966
+ const u = { name: nameInput.value.trim() || "Kay", intro: introInput.value };
967
+ saveUser(u);
968
+ // Refresh app state + redraw sidebar foot via the central renderer.
969
+ if (window.app) {
970
+ window.app.prefs = { ...(window.app.prefs || {}), ...u };
971
+ if (typeof window.app.renderUserBlock === "function") window.app.renderUserBlock();
972
+ } else {
973
+ document.querySelectorAll(".sidebar-foot .user-name").forEach((el) => { el.textContent = (u.name || "Kay").toUpperCase(); });
974
+ document.querySelectorAll(".sidebar-foot .user-menu .name").forEach((el) => { el.textContent = u.name || "Kay"; });
975
+ }
976
+ }
977
+
978
+ nameInput.addEventListener("input", persist);
979
+ introInput.addEventListener("input", () => {
980
+ introCount.textContent = introInput.value.length;
981
+ persist();
982
+ });
983
+ introCount.textContent = introInput.value.length;
984
+
985
+ // Regenerate avatar · same pattern as agent-profile's
986
+ // regenerateProfileAvatar: pull a fresh randomSeed, persist it to
987
+ // the user prefs, repaint. No counter, no name/intro composition —
988
+ // the seed is the only thing that determines the avatar.
989
+ paneEl.querySelector("[data-us-regen-avatar]").addEventListener("click", (e) => {
990
+ e.preventDefault();
991
+ if (!window.AvatarSkill) return;
992
+ const seed = window.AvatarSkill.randomSeed();
993
+ saveUser({ avatarSeed: seed });
994
+ paintUserAvatar();
995
+ // Push the new seed into app.prefs so the sidebar foot's user
996
+ // avatar repaints with the same SVG. Without this, the settings
997
+ // overlay shows the new face but the sidebar keeps the old one
998
+ // until the next reload.
999
+ if (window.app) {
1000
+ window.app.prefs = { ...(window.app.prefs || {}), avatarSeed: seed };
1001
+ if (typeof window.app.renderUserBlock === "function") window.app.renderUserBlock();
1002
+ }
1003
+ });
1004
+
1005
+ paintUserAvatar();
1006
+ }
1007
+
1008
+ function rerenderKeysSection() {
1009
+ paneEl.innerHTML = keysSectionHTML();
1010
+ wireKeysSection();
1011
+ }
1012
+
1013
+ function wireKeysSection() {
1014
+ paneEl.querySelectorAll("[data-key-eye]").forEach((btn) => {
1015
+ btn.addEventListener("click", (e) => {
1016
+ e.preventDefault();
1017
+ const input = btn.parentElement.querySelector("input");
1018
+ if (input) input.type = input.type === "password" ? "text" : "password";
1019
+ });
1020
+ });
1021
+
1022
+ // Add a provider row
1023
+ paneEl.querySelectorAll("[data-add-provider]").forEach((btn) => {
1024
+ btn.addEventListener("click", (e) => {
1025
+ e.preventDefault();
1026
+ const id = btn.dataset.addProvider;
1027
+ if (!activeProviders.includes(id)) activeProviders.push(id);
1028
+ rerenderKeysSection();
1029
+ });
1030
+ });
1031
+
1032
+ // Remove a provider row (server-side delete clears its key too).
1033
+ paneEl.querySelectorAll("[data-remove-provider]").forEach((btn) => {
1034
+ btn.addEventListener("click", async (e) => {
1035
+ e.preventDefault();
1036
+ const id = btn.dataset.removeProvider;
1037
+ activeProviders = activeProviders.filter((p) => p !== id);
1038
+ await setProviderKey(id, ""); // clears server-side
1039
+ await refreshModels();
1040
+ rerenderKeysSection();
1041
+ });
1042
+ });
1043
+
1044
+ // Set this provider as the default · picks the provider's primary
1045
+ // model and persists it as defaultModelV. The badge moves to the
1046
+ // newly-default row on re-render.
1047
+ paneEl.querySelectorAll("[data-set-default-provider]").forEach((btn) => {
1048
+ btn.addEventListener("click", async (e) => {
1049
+ e.preventDefault();
1050
+ const id = btn.dataset.setDefaultProvider;
1051
+ await setProviderAsDefault(id);
1052
+ });
1053
+ });
1054
+
1055
+ // Default-model picker · persists to /api/prefs.
1056
+ paneEl.querySelectorAll("[data-default-model]").forEach((sel) => {
1057
+ sel.addEventListener("change", () => { saveDefaultModel(sel.value); });
1058
+ });
1059
+
1060
+ // Auto-save: every keystroke / paste persists immediately, no Save button.
1061
+ // We debounce slightly so we don't fire a server PUT on every character —
1062
+ // 220ms after the user stops typing. Empty value is a no-op (NOT a
1063
+ // delete) — browser autofill / blur events occasionally fire `input`
1064
+ // with v="", and we never want that to wipe a real key. Explicit
1065
+ // removal goes through the ✕ button.
1066
+ const debounceMap = new WeakMap();
1067
+ function persistRow(row) {
1068
+ const provider = row.dataset.provider;
1069
+ const input = row.querySelector("[data-key-input]");
1070
+ const v = input.value;
1071
+ const trimmed = v.trim();
1072
+
1073
+ // No-op on empty · never DELETE via the input field. The ✕ button
1074
+ // is the only path that clears a key. This protects against
1075
+ // autofill races + accidental select-all+delete.
1076
+ if (!trimmed) return;
1077
+
1078
+ // Optimistic local UI update on non-empty input.
1079
+ const status = row.querySelector("[data-status]");
1080
+ status.classList.add("on");
1081
+ status.classList.remove("off");
1082
+ status.textContent = "● configured";
1083
+
1084
+ // Debounced server write
1085
+ const prev = debounceMap.get(row);
1086
+ if (prev) clearTimeout(prev);
1087
+ const timer = setTimeout(async () => {
1088
+ await setProviderKey(provider, v);
1089
+ await refreshModels();
1090
+ refreshModelsSummary();
1091
+ }, 220);
1092
+ debounceMap.set(row, timer);
1093
+ }
1094
+
1095
+ paneEl.querySelectorAll(".us-key-row").forEach((row) => {
1096
+ const input = row.querySelector("[data-key-input]");
1097
+ if (!input) return;
1098
+ input.addEventListener("input", () => persistRow(row));
1099
+ // Paste handler — input fires after paste too, but this is explicit
1100
+ // and lets us snap-update the status pill on the same tick.
1101
+ input.addEventListener("paste", () => {
1102
+ // paste mutates value asynchronously; defer one tick
1103
+ setTimeout(() => persistRow(row), 0);
1104
+ });
1105
+ });
1106
+
1107
+ // First render of the section · the shared cache may already
1108
+ // have a snapshot (models-cache.js fetches at module load). If
1109
+ // not, kick off a refresh and update the summary when it lands.
1110
+ // Either way, subsequent key-write paths refresh the cache, and
1111
+ // the inline pill refresh below picks up the new state.
1112
+ if (!modelsSnapshot()) {
1113
+ refreshModels().then(refreshModelsSummary);
1114
+ }
1115
+
1116
+ // Always re-fetch /api/keys on every keys-tab open. The bootstrap
1117
+ // fetchKeyMeta runs once at page load, but the user can land on
1118
+ // settings before that resolves, OR can open settings a long time
1119
+ // after — either way `_keysMeta` may be empty / stale and the
1120
+ // status pills come up "○ not set" even when the server has a key.
1121
+ // The cheap GET keeps this tab honest. We update the pills + the
1122
+ // input placeholder INLINE (no full rerender) so the user's
1123
+ // typing-in-progress isn't disturbed and so we don't loop through
1124
+ // wireKeysSection again.
1125
+ fetchKeyMeta().then(() => {
1126
+ if (currentSection !== "keys") return;
1127
+ paneEl.querySelectorAll(".us-key-row").forEach((row) => {
1128
+ const provider = row.dataset.provider;
1129
+ const meta = _keysMeta[provider];
1130
+ const has = !!(meta && meta.configured);
1131
+ const status = row.querySelector("[data-status]");
1132
+ if (!status) return;
1133
+ if (has) {
1134
+ status.classList.add("on");
1135
+ status.classList.remove("off");
1136
+ status.textContent = "● configured";
1137
+ } else {
1138
+ status.classList.add("off");
1139
+ status.classList.remove("on");
1140
+ status.textContent = "○ not set";
1141
+ }
1142
+ // Refresh placeholder using the masked preview · "sk-or…YjNH ·
1143
+ // paste to replace" when configured, the provider's hint when
1144
+ // not. Skip if user has typed something so we don't clobber
1145
+ // their in-progress edit. Toggle the .has-preview class so the
1146
+ // placeholder picks up the "real-value" colour rather than the
1147
+ // dim hint colour — the user reads it as their stored key,
1148
+ // not as missing text.
1149
+ const input = row.querySelector("[data-key-input]");
1150
+ if (input && !input.value) {
1151
+ const provDef = PROVIDERS.find((p) => p.id === provider);
1152
+ const preview = has && meta.preview ? meta.preview : null;
1153
+ input.placeholder = has
1154
+ ? (preview || "••••••••")
1155
+ : (provDef ? provDef.placeholder : "");
1156
+ input.classList.toggle("has-preview", has);
1157
+ }
1158
+ });
1159
+ });
1160
+ }
1161
+
1162
+ /* ── Open / close ─────────────────────────────────────────── */
1163
+ function open() {
1164
+ if (!overlay) return;
1165
+ activeProviders = null; // re-derive from saved keys on next render
1166
+ renderSection(currentSection || "user");
1167
+ overlay.classList.add("open");
1168
+ overlay.setAttribute("aria-hidden", "false");
1169
+ document.body.style.overflow = "hidden";
1170
+ }
1171
+ function close() {
1172
+ if (!overlay) return;
1173
+ overlay.classList.remove("open");
1174
+ overlay.setAttribute("aria-hidden", "true");
1175
+ document.body.style.overflow = "";
1176
+ // Sync app.keys with whatever the user just configured · the
1177
+ // requireModelKey gate reads from app.keys and would otherwise
1178
+ // see stale state until the next page reload.
1179
+ if (window.app && typeof window.app.refreshKeys === "function") {
1180
+ window.app.refreshKeys();
1181
+ }
1182
+ }
1183
+
1184
+ function init() {
1185
+ if (document.getElementById("user-settings-overlay")) return;
1186
+ const wrap = document.createElement("div");
1187
+ wrap.innerHTML = modalHTML().trim();
1188
+ document.body.appendChild(wrap.firstChild);
1189
+ overlay = document.getElementById("user-settings-overlay");
1190
+ modal = overlay.querySelector(".user-settings-modal");
1191
+ paneEl = modal.querySelector("[data-us-pane]");
1192
+
1193
+ // Close interactions
1194
+ overlay.querySelector(".us-close").addEventListener("click", close);
1195
+ modal.querySelector(".us-done").addEventListener("click", (e) => { e.preventDefault(); close(); });
1196
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
1197
+ document.addEventListener("keydown", (e) => {
1198
+ if (e.key === "Escape" && overlay.classList.contains("open")) {
1199
+ e.stopImmediatePropagation();
1200
+ close();
1201
+ }
1202
+ });
1203
+
1204
+ // Trigger anywhere on the page
1205
+ document.addEventListener("click", (e) => {
1206
+ if (e.target.closest("[data-user-settings-trigger]")) {
1207
+ e.preventDefault();
1208
+ open();
1209
+ }
1210
+ });
1211
+
1212
+ // Rail nav
1213
+ modal.addEventListener("click", (e) => {
1214
+ const item = e.target.closest(".us-nav-item");
1215
+ if (!item) return;
1216
+ e.preventDefault();
1217
+ renderSection(item.dataset.section);
1218
+ });
1219
+
1220
+ // Theme rows (delegated since pane re-renders)
1221
+ modal.addEventListener("click", (e) => {
1222
+ const row = e.target.closest(".us-theme");
1223
+ if (!row) return;
1224
+ e.preventDefault();
1225
+ e.stopPropagation();
1226
+ const slug = row.dataset.themeSlug;
1227
+ paneEl.querySelectorAll(".us-theme").forEach((el) => el.classList.remove("active"));
1228
+ row.classList.add("active");
1229
+ setTheme(slug);
1230
+ });
1231
+
1232
+ // Cross-tab theme sync
1233
+ window.addEventListener("storage", (e) => {
1234
+ if (e.key === THEME_KEY && e.newValue) {
1235
+ setTheme(e.newValue);
1236
+ if (paneEl && currentSection === "theme") {
1237
+ paneEl.querySelectorAll(".us-theme").forEach((el) => {
1238
+ el.classList.toggle("active", el.dataset.themeSlug === e.newValue);
1239
+ });
1240
+ }
1241
+ }
1242
+ });
1243
+
1244
+ // Initial pane
1245
+ renderSection("user");
1246
+ }
1247
+
1248
+ // Public
1249
+ window.openUserSettings = function (opts) {
1250
+ if (!overlay) init();
1251
+ open();
1252
+ // Optional deep-link · jump to a section + focus a key row.
1253
+ // Used by agent-profile's "Configure key" link on the web-search row.
1254
+ if (opts && typeof opts === "object") {
1255
+ if (typeof opts.section === "string") {
1256
+ renderSection(opts.section);
1257
+ if (opts.section === "keys" && typeof opts.focusProvider === "string") {
1258
+ // Defer one frame so the section's DOM is in place.
1259
+ setTimeout(() => {
1260
+ const row = paneEl.querySelector(`.us-key-row[data-provider="${opts.focusProvider}"]`);
1261
+ if (!row) return;
1262
+ row.scrollIntoView({ behavior: "smooth", block: "center" });
1263
+ row.classList.add("us-key-row-flash");
1264
+ setTimeout(() => row.classList.remove("us-key-row-flash"), 1500);
1265
+ const input = row.querySelector("input[data-key-input]");
1266
+ if (input) input.focus();
1267
+ }, 60);
1268
+ }
1269
+ }
1270
+ }
1271
+ };
1272
+ window.closeUserSettings = close;
1273
+ window.applyTheme = setTheme;
1274
+
1275
+ // Bootstrap: prefetch prefs and key meta so the first render has real
1276
+ // values, then init.
1277
+ async function bootstrap() {
1278
+ await Promise.all([fetchPrefs(), fetchKeyMeta()]);
1279
+ init();
1280
+ // Mirror name into sidebar foot once we know it.
1281
+ document.querySelectorAll(".sidebar-foot .user-name").forEach((el) => {
1282
+ el.textContent = (_prefsCache.name || "You").toUpperCase();
1283
+ });
1284
+ }
1285
+
1286
+ if (document.readyState === "loading") {
1287
+ document.addEventListener("DOMContentLoaded", bootstrap);
1288
+ } else {
1289
+ bootstrap();
1290
+ }
1291
+ })();