@mlx-bun/module-chat 0.0.0-stage → 1.0.0

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