reasonix 0.18.0 → 0.19.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.
package/dashboard/app.js DELETED
@@ -1,4768 +0,0 @@
1
- // Reasonix dashboard SPA — Preact 10 + HTM, no build step.
2
- //
3
- // CDN imports use esm.sh (provides ESM bundles of npm packages with
4
- // caching). We pin minor versions; bumping is a deliberate choice.
5
-
6
- import hljs from "https://esm.sh/highlight.js@11.10.0/lib/common";
7
- import htm from "https://esm.sh/htm@3.1.1";
8
- import { Marked, marked } from "https://esm.sh/marked@12.0.2";
9
- import { Component, h, render } from "https://esm.sh/preact@10.22.0";
10
- import {
11
- useCallback,
12
- useEffect,
13
- useMemo,
14
- useRef,
15
- useState,
16
- } from "https://esm.sh/preact@10.22.0/hooks";
17
-
18
- const html = htm.bind(h);
19
-
20
- // ---------- Markdown rendering ----------
21
- //
22
- // Rules:
23
- // - GFM (tables, strikethrough, autolinks).
24
- // - Code blocks with a known language → highlight.js. Auto-detect
25
- // when the fence has no language. The CSS in app.css picks colors
26
- // from our palette so the result reads as Reasonix's TUI, not as
27
- // GitHub's.
28
- // - Code blocks that look like Reasonix's edit_file SEARCH/REPLACE
29
- // or a unified diff → custom diff renderer with red/green lines
30
- // matching the TUI's edit-block view.
31
-
32
- function escapeHtml(s) {
33
- // Defensive: marked occasionally hands the renderer `text === undefined`
34
- // for empty / malformed fences (and the token-vs-positional ambiguity
35
- // around v12 makes it easier to crash than not). Coerce to string
36
- // instead of letting `.replace` blow up.
37
- if (s == null) return "";
38
- return String(s)
39
- .replace(/&/g, "&")
40
- .replace(/</g, "&lt;")
41
- .replace(/>/g, "&gt;")
42
- .replace(/"/g, "&quot;")
43
- .replace(/'/g, "&#39;");
44
- }
45
-
46
- const SEARCH_REPLACE_RE = /<{7}\s*SEARCH\s*\n([\s\S]*?)\n={7}\s*\n([\s\S]*?)\n>{7}\s*REPLACE/;
47
-
48
- function renderSearchReplace(search, replace, file) {
49
- const safeSearch = typeof search === "string" ? search : String(search ?? "");
50
- const safeReplace = typeof replace === "string" ? replace : String(replace ?? "");
51
- const oldLines = safeSearch
52
- .split("\n")
53
- .map((l) => `<span class="diff-line del">- ${escapeHtml(l)}</span>`)
54
- .join("\n");
55
- const newLines = safeReplace
56
- .split("\n")
57
- .map((l) => `<span class="diff-line ins">+ ${escapeHtml(l)}</span>`)
58
- .join("\n");
59
- const header = file ? `<span class="diff-line hunk">▸ edit ${escapeHtml(file)}</span>\n` : "";
60
- return `<pre class="diff-block">${header}${oldLines}\n${newLines}</pre>`;
61
- }
62
-
63
- function renderUnifiedDiff(text) {
64
- const safe = typeof text === "string" ? text : String(text ?? "");
65
- const lines = safe
66
- .split("\n")
67
- .map((l) => {
68
- if (l.startsWith("+++") || l.startsWith("---")) {
69
- return `<span class="diff-line meta">${escapeHtml(l)}</span>`;
70
- }
71
- if (l.startsWith("+")) {
72
- return `<span class="diff-line ins">${escapeHtml(l)}</span>`;
73
- }
74
- if (l.startsWith("-")) {
75
- return `<span class="diff-line del">${escapeHtml(l)}</span>`;
76
- }
77
- if (l.startsWith("@@")) {
78
- return `<span class="diff-line hunk">${escapeHtml(l)}</span>`;
79
- }
80
- return escapeHtml(l);
81
- })
82
- .join("\n");
83
- return `<pre class="diff-block">${lines}</pre>`;
84
- }
85
-
86
- const renderer = new marked.Renderer();
87
- // Accept BOTH calling conventions so we don't crash if marked changes
88
- // shape between versions:
89
- // - v12+ token object: renderer.code({ type, raw, lang, text, ... })
90
- // - legacy positional: renderer.code(text, lang, escaped)
91
- // Either path normalizes to `{ text, lang }`. Empty / nullable text is
92
- // coerced to "" (escapeHtml does the same) so an empty fence renders
93
- // as an empty code block rather than throwing.
94
- renderer.code = function reasonixCode(arg1, arg2 /* legacy lang */) {
95
- let text;
96
- let lang;
97
- if (arg1 && typeof arg1 === "object" && !Array.isArray(arg1)) {
98
- text = arg1.text;
99
- lang = arg1.lang;
100
- } else {
101
- text = arg1;
102
- lang = arg2;
103
- }
104
- if (text == null) text = "";
105
- if (typeof text !== "string") text = String(text);
106
- // Reasonix's edit_file marker block. Show as red/green diff with a
107
- // small "▸ edit <file>" header lifted from the language tag (e.g.
108
- // ```edit:src/foo.ts → file = src/foo.ts).
109
- const sr = SEARCH_REPLACE_RE.exec(text);
110
- if (sr) {
111
- const file = typeof lang === "string" && lang.startsWith("edit:") ? lang.slice(5) : "";
112
- return renderSearchReplace(sr[1], sr[2], file);
113
- }
114
- if (lang === "diff") {
115
- return renderUnifiedDiff(text);
116
- }
117
- // Standard highlight.js path — explicit language wins, otherwise auto.
118
- if (lang && typeof lang === "string" && hljs.getLanguage(lang)) {
119
- try {
120
- const h = hljs.highlight(text, { language: lang, ignoreIllegals: true }).value;
121
- return `<pre><code class="hljs language-${lang}">${h}</code></pre>`;
122
- } catch {
123
- /* fall through to auto */
124
- }
125
- }
126
- try {
127
- const auto = hljs.highlightAuto(text);
128
- return `<pre><code class="hljs">${auto.value}</code></pre>`;
129
- } catch {
130
- return `<pre><code>${escapeHtml(text)}</code></pre>`;
131
- }
132
- };
133
-
134
- marked.use({ renderer, gfm: true, breaks: false, pedantic: false });
135
-
136
- // Separate Marked instance for the editor's markdown preview. The chat
137
- // renderer above does fancy SEARCH/REPLACE diff blocks and stamps every
138
- // code fence through hljs — useful inside an assistant message, but
139
- // disruptive when the user is just previewing a normal README and
140
- // expects standard `<pre><code>` blocks. Vanilla rendering also avoids
141
- // any chance our custom token-shape sniffing breaks on real markdown.
142
- const previewMarked = new Marked({ gfm: true, breaks: false, pedantic: false });
143
- previewMarked.use({
144
- renderer: {
145
- code(...args) {
146
- const first = args[0];
147
- const arg = first && typeof first === "object" ? first : { text: first, lang: args[1] };
148
- const text = arg.text == null ? "" : String(arg.text);
149
- const lang = typeof arg.lang === "string" ? arg.lang : "";
150
- try {
151
- const out =
152
- lang && hljs.getLanguage(lang)
153
- ? hljs.highlight(text, { language: lang, ignoreIllegals: true })
154
- : hljs.highlightAuto(text);
155
- const cls = lang ? `hljs language-${lang}` : "hljs";
156
- return `<pre><code class="${cls}">${out.value}</code></pre>`;
157
- } catch {
158
- return `<pre><code>${escapeHtml(text)}</code></pre>`;
159
- }
160
- },
161
- },
162
- });
163
-
164
- // ---------- bootstrapping ----------
165
-
166
- const TOKEN = document.querySelector('meta[name="reasonix-token"]')?.getAttribute("content") ?? "";
167
- const MODE =
168
- document.querySelector('meta[name="reasonix-mode"]')?.getAttribute("content") ?? "standalone";
169
-
170
- // Helper: every fetch tacks the token onto the URL (reads) and the
171
- // header (mutations). Server logic in src/server/index.ts requires
172
- // the header form for any non-GET.
173
- async function api(path, opts = {}) {
174
- const method = opts.method ?? "GET";
175
- const url = `/api${path}${path.includes("?") ? "&" : "?"}token=${TOKEN}`;
176
- const headers = { ...(opts.headers ?? {}) };
177
- headers["X-Reasonix-Token"] = TOKEN;
178
- if (opts.body !== undefined) headers["Content-Type"] = "application/json";
179
- const res = await fetch(url, {
180
- method,
181
- headers,
182
- body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
183
- });
184
- const text = await res.text();
185
- let parsed = null;
186
- try {
187
- parsed = text ? JSON.parse(text) : null;
188
- } catch {
189
- parsed = { error: text };
190
- }
191
- if (!res.ok) {
192
- const err = new Error(parsed?.error ?? `${res.status} ${res.statusText}`);
193
- err.status = res.status;
194
- err.body = parsed;
195
- throw err;
196
- }
197
- return parsed;
198
- }
199
-
200
- // usePoll: re-fetch a GET endpoint every `intervalMs`, returning
201
- // `{ data, error, loading, refresh }`. v0.13 swaps this for SSE.
202
- function usePoll(path, intervalMs = 2000) {
203
- const [data, setData] = useState(null);
204
- const [error, setError] = useState(null);
205
- const [loading, setLoading] = useState(true);
206
-
207
- const refresh = useCallback(async () => {
208
- try {
209
- const next = await api(path);
210
- setData(next);
211
- setError(null);
212
- } catch (err) {
213
- setError(err);
214
- } finally {
215
- setLoading(false);
216
- }
217
- }, [path]);
218
-
219
- useEffect(() => {
220
- let cancelled = false;
221
- let timer = null;
222
- const tick = async () => {
223
- if (cancelled) return;
224
- await refresh();
225
- if (cancelled) return;
226
- timer = setTimeout(tick, intervalMs);
227
- };
228
- tick();
229
- return () => {
230
- cancelled = true;
231
- if (timer) clearTimeout(timer);
232
- };
233
- }, [refresh, intervalMs]);
234
-
235
- return { data, error, loading, refresh };
236
- }
237
-
238
- // ---------- formatting helpers ----------
239
-
240
- function fmtUsd(n) {
241
- if (n === null || n === undefined) return "—";
242
- if (n === 0) return "$0";
243
- return `$${n.toFixed(n < 0.01 ? 6 : 4)}`;
244
- }
245
-
246
- function fmtPct(n) {
247
- if (n === null || n === undefined) return "—";
248
- return `${(n * 100).toFixed(1)}%`;
249
- }
250
-
251
- function fmtNum(n) {
252
- if (n === null || n === undefined) return "—";
253
- return n.toLocaleString();
254
- }
255
-
256
- function fmtBytes(n) {
257
- if (n === null || n === undefined) return "—";
258
- if (n < 1024) return `${n} B`;
259
- if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
260
- if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
261
- return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
262
- }
263
-
264
- function fmtRelativeTime(iso) {
265
- if (!iso) return "—";
266
- const ms = typeof iso === "number" ? iso : Date.parse(iso);
267
- if (!Number.isFinite(ms)) return "—";
268
- const dSec = (Date.now() - ms) / 1000;
269
- if (dSec < 60) return "just now";
270
- if (dSec < 3600) return `${Math.floor(dSec / 60)}m ago`;
271
- if (dSec < 86400) return `${Math.floor(dSec / 3600)}h ago`;
272
- if (dSec < 30 * 86400) return `${Math.floor(dSec / 86400)}d ago`;
273
- return new Date(ms).toISOString().slice(0, 10);
274
- }
275
-
276
- // ---------- panels ----------
277
-
278
- function OverviewPanel() {
279
- const { data, error, loading } = usePoll("/overview", 2000);
280
- if (loading && !data) return html`<div class="boot">loading overview…</div>`;
281
- if (error) return html`<div class="notice err">overview failed: ${error.message}</div>`;
282
- const o = data;
283
-
284
- return html`
285
- <div>
286
- <div class="panel-header">
287
- <h2 class="panel-title">Live Cockpit</h2>
288
- <span class="panel-subtitle">${o.mode === "attached" ? "attached to running session" : "standalone (read-only disk view)"}</span>
289
- </div>
290
-
291
- ${o.mode === "standalone" ? html`<div class="notice">Standalone mode — start <code>/dashboard</code> from inside <code>reasonix code</code> for live session state, MCP, and tools.</div>` : null}
292
-
293
- <div class="metric-grid">
294
- ${MetricCard("Reasonix", o.version, o.latestVersion && o.latestVersion !== o.version ? `latest: ${o.latestVersion}` : "current")}
295
- ${MetricCard("Session", o.session ?? "—", o.session === null ? "ephemeral or disconnected" : null)}
296
- ${MetricCard("Model", o.model ?? "—", o.model ? "active" : null)}
297
- ${MetricCard("Edit mode", o.editMode ?? "—", o.editMode === "yolo" ? "all prompts bypassed" : null, o.editMode === "yolo" ? "warn" : null)}
298
- ${MetricCard("Plan mode", o.planMode === null ? "—" : o.planMode ? "ON" : "off", o.planMode ? "writes gated" : null)}
299
- ${MetricCard("Pending edits", fmtNum(o.pendingEdits), o.pendingEdits ? "awaiting /apply" : null)}
300
- ${MetricCard("MCP servers", fmtNum(o.mcpServerCount), null)}
301
- ${MetricCard("Tools", fmtNum(o.toolCount), null)}
302
- </div>
303
-
304
- <div class="section-title">Working directory</div>
305
- <div class="card">
306
- <div class="card-value mono" style="font-size: 14px;">${o.cwd ?? "—"}</div>
307
- </div>
308
- </div>
309
- `;
310
- }
311
-
312
- function MetricCard(title, value, hint, pillVariant) {
313
- const muted = value === "—" || value === null || value === undefined;
314
- return html`
315
- <div class="card">
316
- <div class="card-title">${title}</div>
317
- <div class="card-value ${muted ? "muted" : ""}">${value}</div>
318
- ${
319
- hint
320
- ? pillVariant
321
- ? html`<div class="card-hint"><span class="pill pill-${pillVariant}">${hint}</span></div>`
322
- : html`<div class="card-hint">${hint}</div>`
323
- : null
324
- }
325
- </div>
326
- `;
327
- }
328
-
329
- function UsagePanel() {
330
- const { data, error, loading } = usePoll("/usage", 5000);
331
- if (loading && !data) return html`<div class="boot">loading usage…</div>`;
332
- if (error) return html`<div class="notice err">usage failed: ${error.message}</div>`;
333
- const u = data;
334
-
335
- return html`
336
- <div>
337
- <div class="panel-header">
338
- <h2 class="panel-title">Usage</h2>
339
- <span class="panel-subtitle">${u.recordCount.toLocaleString()} records · ${u.logSize}</span>
340
- </div>
341
-
342
- ${
343
- u.recordCount === 0
344
- ? html`<div class="empty">No usage data yet — run a turn in <code>reasonix chat</code> / <code>code</code> / <code>run</code> and refresh.</div>`
345
- : html`
346
- <table>
347
- <thead>
348
- <tr>
349
- <th></th>
350
- <th class="numeric">turns</th>
351
- <th class="numeric">cache hit</th>
352
- <th class="numeric">cost (USD)</th>
353
- <th class="numeric">cache saved</th>
354
- <th class="numeric">vs Claude</th>
355
- <th class="numeric">saved</th>
356
- </tr>
357
- </thead>
358
- <tbody>
359
- ${u.buckets.map((b) => {
360
- const hitRatio =
361
- b.cacheHitTokens + b.cacheMissTokens > 0
362
- ? b.cacheHitTokens / (b.cacheHitTokens + b.cacheMissTokens)
363
- : 0;
364
- const claudeSavings = b.claudeEquivUsd > 0 ? 1 - b.costUsd / b.claudeEquivUsd : 0;
365
- return html`
366
- <tr>
367
- <td>${b.label}</td>
368
- <td class="numeric">${fmtNum(b.turns)}</td>
369
- <td class="numeric">${b.turns > 0 ? fmtPct(hitRatio) : "—"}</td>
370
- <td class="numeric">${b.turns > 0 ? fmtUsd(b.costUsd) : "—"}</td>
371
- <td class="numeric">${b.turns > 0 && b.cacheSavingsUsd > 0 ? fmtUsd(b.cacheSavingsUsd) : "—"}</td>
372
- <td class="numeric">${b.turns > 0 ? fmtUsd(b.claudeEquivUsd) : "—"}</td>
373
- <td class="numeric">${b.turns > 0 && claudeSavings > 0 ? fmtPct(claudeSavings) : "—"}</td>
374
- </tr>
375
- `;
376
- })}
377
- </tbody>
378
- </table>
379
- `
380
- }
381
-
382
- ${
383
- u.byModel.length > 0
384
- ? html`
385
- <div class="section-title">Most used models</div>
386
- <table>
387
- <thead><tr><th>model</th><th class="numeric">turns</th></tr></thead>
388
- <tbody>
389
- ${u.byModel.slice(0, 5).map(
390
- (m) => html`
391
- <tr><td><code>${m.model}</code></td><td class="numeric">${fmtNum(m.turns)}</td></tr>
392
- `,
393
- )}
394
- </tbody>
395
- </table>
396
- `
397
- : null
398
- }
399
-
400
- ${
401
- u.subagents
402
- ? html`
403
- <div class="section-title">Subagent activity</div>
404
- <div class="card">
405
- <div class="card-title">Total runs</div>
406
- <div class="card-value">${fmtNum(u.subagents.total)}</div>
407
- <div class="card-hint">${fmtUsd(u.subagents.costUsd)} · ${(u.subagents.totalDurationMs / 1000).toFixed(1)}s total</div>
408
- </div>
409
- `
410
- : null
411
- }
412
- </div>
413
- `;
414
- }
415
-
416
- function ToolsPanel() {
417
- const { data, error, loading } = usePoll("/tools", 4000);
418
- if (loading && !data) return html`<div class="boot">loading tools…</div>`;
419
- if (error?.status === 503) {
420
- return html`<div class="notice">${error.body?.error ?? "live tools view requires an attached session"}</div>`;
421
- }
422
- if (error) return html`<div class="notice err">tools failed: ${error.message}</div>`;
423
- const t = data;
424
-
425
- return html`
426
- <div>
427
- <div class="panel-header">
428
- <h2 class="panel-title">Tools</h2>
429
- <span class="panel-subtitle">${t.total} registered ${t.planMode ? html`<span class="pill pill-warn">plan mode — writes gated</span>` : ""}</span>
430
- </div>
431
- ${
432
- t.tools.length === 0
433
- ? html`<div class="empty">No tools registered.</div>`
434
- : html`
435
- <table>
436
- <thead>
437
- <tr>
438
- <th>name</th>
439
- <th>flags</th>
440
- <th>description</th>
441
- </tr>
442
- </thead>
443
- <tbody>
444
- ${t.tools.map(
445
- (tool) => html`
446
- <tr>
447
- <td><code>${tool.name}</code></td>
448
- <td>
449
- ${tool.readOnly ? html`<span class="pill pill-ok">read-only</span>` : html`<span class="pill pill-accent">write</span>`}
450
- ${tool.flattened ? html` <span class="pill pill-dim">flat</span>` : ""}
451
- </td>
452
- <td>${tool.description ?? ""}</td>
453
- </tr>
454
- `,
455
- )}
456
- </tbody>
457
- </table>
458
- `
459
- }
460
- </div>
461
- `;
462
- }
463
-
464
- function PermissionsPanel() {
465
- const { data, error, loading, refresh } = usePoll("/permissions", 5000);
466
- const [draft, setDraft] = useState("");
467
- const [busy, setBusy] = useState(false);
468
- const [feedback, setFeedback] = useState(null);
469
-
470
- const add = useCallback(async () => {
471
- const prefix = draft.trim();
472
- if (!prefix) return;
473
- setBusy(true);
474
- setFeedback(null);
475
- try {
476
- const res = await api("/permissions", { method: "POST", body: { prefix } });
477
- if (res.alreadyPresent) setFeedback({ kind: "info", text: `${prefix} already in list` });
478
- else setFeedback({ kind: "ok", text: `added: ${prefix}` });
479
- setDraft("");
480
- await refresh();
481
- } catch (err) {
482
- setFeedback({ kind: "err", text: err.message });
483
- } finally {
484
- setBusy(false);
485
- }
486
- }, [draft, refresh]);
487
-
488
- const remove = useCallback(
489
- async (prefix) => {
490
- if (!confirm(`Remove "${prefix}" from this project's allowlist?`)) return;
491
- setBusy(true);
492
- setFeedback(null);
493
- try {
494
- await api("/permissions", { method: "DELETE", body: { prefix } });
495
- setFeedback({ kind: "ok", text: `removed: ${prefix}` });
496
- await refresh();
497
- } catch (err) {
498
- setFeedback({ kind: "err", text: err.message });
499
- } finally {
500
- setBusy(false);
501
- }
502
- },
503
- [refresh],
504
- );
505
-
506
- const clearAll = useCallback(async () => {
507
- if (!confirm("Wipe every project allowlist entry? Builtin entries are unaffected.")) return;
508
- setBusy(true);
509
- setFeedback(null);
510
- try {
511
- const res = await api("/permissions/clear", { method: "POST", body: { confirm: true } });
512
- setFeedback({
513
- kind: "ok",
514
- text: `cleared ${res.dropped} entr${res.dropped === 1 ? "y" : "ies"}`,
515
- });
516
- await refresh();
517
- } catch (err) {
518
- setFeedback({ kind: "err", text: err.message });
519
- } finally {
520
- setBusy(false);
521
- }
522
- }, [refresh]);
523
-
524
- if (loading && !data) return html`<div class="boot">loading permissions…</div>`;
525
- if (error) return html`<div class="notice err">permissions failed: ${error.message}</div>`;
526
- const p = data;
527
-
528
- const banner =
529
- p.editMode === "yolo"
530
- ? html`<div class="notice warn">YOLO mode — every shell command auto-runs, allowlist bypassed. <code>/mode review</code> in TUI re-enables.</div>`
531
- : null;
532
-
533
- return html`
534
- <div>
535
- <div class="panel-header">
536
- <h2 class="panel-title">Permissions</h2>
537
- <span class="panel-subtitle">${p.currentCwd ? `project: ${p.currentCwd}` : "no active project"}</span>
538
- </div>
539
- ${banner}
540
-
541
- ${
542
- p.currentCwd
543
- ? html`
544
- <div class="row">
545
- <input
546
- type="text"
547
- placeholder='add a prefix, e.g. "npm run build" or "deploy.sh"'
548
- value=${draft}
549
- onInput=${(e) => setDraft(e.target.value)}
550
- onKeyDown=${(e) => {
551
- if (e.key === "Enter") add();
552
- }}
553
- disabled=${busy}
554
- />
555
- <button class="primary" onClick=${add} disabled=${busy || !draft.trim()}>Add</button>
556
- <button class="danger" onClick=${clearAll} disabled=${busy || p.project.length === 0}>Clear all</button>
557
- </div>
558
- ${feedback ? html`<div class="notice ${feedback.kind === "err" ? "err" : feedback.kind === "ok" ? "" : "warn"}">${feedback.text}</div>` : null}
559
- `
560
- : html`<div class="notice">Mutations require <code>/dashboard</code> from inside an active <code>reasonix code</code> session — standalone <code>reasonix dashboard</code> can't tell which project's allowlist to edit.</div>`
561
- }
562
-
563
- <div class="section-title">Project allowlist (${p.project.length})</div>
564
- ${
565
- p.project.length === 0
566
- ? html`<div class="empty">Nothing stored yet for this project.</div>`
567
- : html`
568
- <table>
569
- <thead><tr><th>#</th><th>prefix</th><th></th></tr></thead>
570
- <tbody>
571
- ${p.project.map(
572
- (prefix, i) => html`
573
- <tr>
574
- <td class="muted">${i + 1}</td>
575
- <td><code>${prefix}</code></td>
576
- <td class="numeric">${p.currentCwd ? html`<button class="danger" onClick=${() => remove(prefix)} disabled=${busy}>remove</button>` : null}</td>
577
- </tr>
578
- `,
579
- )}
580
- </tbody>
581
- </table>
582
- `
583
- }
584
-
585
- <div class="section-title">Builtin allowlist (${p.builtin.length}) — read-only, baked in</div>
586
- <div class="card mono" style="font-size: 12px; line-height: 1.7;">
587
- ${groupByVerb(p.builtin).map(
588
- ([verb, list]) => html`
589
- <div><span class="pill pill-dim">${verb}</span> ${list.join(" · ")}</div>
590
- `,
591
- )}
592
- </div>
593
- </div>
594
- `;
595
- }
596
-
597
- function groupByVerb(list) {
598
- const groups = new Map();
599
- for (const entry of list) {
600
- const head = entry.split(" ")[0];
601
- if (!groups.has(head)) groups.set(head, []);
602
- const tail = entry.slice(head.length).trim();
603
- groups.get(head).push(tail || "(bare)");
604
- }
605
- return [...groups.entries()];
606
- }
607
-
608
- // ---------- Chat panel ----------
609
-
610
- const ROLE_GLYPH = {
611
- user: "◇",
612
- assistant: "◆",
613
- tool: "▣",
614
- info: "·",
615
- warning: "▲",
616
- error: "✦",
617
- };
618
-
619
- function renderMessageBody(text) {
620
- if (!text) return null;
621
- // marked.parse escapes raw HTML in source by default — so any `<script>`
622
- // in model output gets rendered as literal text, not executed. We can
623
- // safely hand the result straight to dangerouslySetInnerHTML.
624
- const rendered = marked.parse(text);
625
- return html`<div class="md" dangerouslySetInnerHTML=${{ __html: rendered }}></div>`;
626
- }
627
-
628
- // Map common file extensions to highlight.js languages.
629
- const LANG_BY_EXT = {
630
- ts: "typescript",
631
- tsx: "typescript",
632
- js: "javascript",
633
- jsx: "javascript",
634
- mjs: "javascript",
635
- cjs: "javascript",
636
- py: "python",
637
- rs: "rust",
638
- go: "go",
639
- java: "java",
640
- kt: "kotlin",
641
- c: "c",
642
- h: "c",
643
- cpp: "cpp",
644
- cc: "cpp",
645
- hpp: "cpp",
646
- cs: "csharp",
647
- swift: "swift",
648
- rb: "ruby",
649
- php: "php",
650
- sh: "bash",
651
- bash: "bash",
652
- zsh: "bash",
653
- fish: "bash",
654
- ps1: "powershell",
655
- json: "json",
656
- yaml: "yaml",
657
- yml: "yaml",
658
- toml: "ini",
659
- xml: "xml",
660
- html: "xml",
661
- svg: "xml",
662
- css: "css",
663
- scss: "scss",
664
- less: "less",
665
- md: "markdown",
666
- sql: "sql",
667
- vue: "xml",
668
- svelte: "xml",
669
- tex: "latex",
670
- proto: "protobuf",
671
- dockerfile: "dockerfile",
672
- };
673
-
674
- function langFromPath(path) {
675
- if (!path) return null;
676
- const lower = path.toLowerCase();
677
- if (lower.endsWith("dockerfile")) return "dockerfile";
678
- const dot = lower.lastIndexOf(".");
679
- if (dot < 0) return null;
680
- const ext = lower.slice(dot + 1);
681
- return LANG_BY_EXT[ext] ?? null;
682
- }
683
-
684
- function renderHighlightedBlock(text, lang) {
685
- if (!text) return "";
686
- const safeLang = lang && hljs.getLanguage(lang) ? lang : null;
687
- try {
688
- const out = safeLang
689
- ? hljs.highlight(text, { language: safeLang, ignoreIllegals: true })
690
- : hljs.highlightAuto(text);
691
- return `<pre class="md"><code class="hljs ${safeLang ? `language-${safeLang}` : ""}">${out.value}</code></pre>`;
692
- } catch {
693
- return `<pre><code>${escapeHtml(text)}</code></pre>`;
694
- }
695
- }
696
-
697
- function parseToolArgs(raw) {
698
- if (!raw) return null;
699
- try {
700
- return JSON.parse(raw);
701
- } catch {
702
- return null;
703
- }
704
- }
705
-
706
- function ToolCard({ msg }) {
707
- const args = parseToolArgs(msg.toolArgs);
708
- const name = msg.toolName ?? "tool";
709
- // Reasonix's filesystem tools emit the path in args.path; MCP-bridged
710
- // ones may differ but most expose a `path` field too. Normalize.
711
- const path = args?.path ?? args?.file_path ?? args?.filename;
712
-
713
- // edit_file (Reasonix) — search/replace pair → diff view.
714
- if (
715
- (name === "edit_file" || name.endsWith("_edit_file")) &&
716
- args &&
717
- typeof args.search === "string" &&
718
- typeof args.replace === "string"
719
- ) {
720
- const diffHtml = renderSearchReplace(args.search, args.replace, path ?? "");
721
- return html`
722
- <div class="tool-card">
723
- <div class="tool-card-head">
724
- <span class="tool-card-icon">✎</span>
725
- <span class="tool-card-name">edit_file</span>
726
- ${path ? html`<code class="tool-card-path tool-card-path-link" onClick=${() => openFileInEditor(path)} title="open in editor">${path}</code>` : null}
727
- </div>
728
- <div dangerouslySetInnerHTML=${{ __html: diffHtml }}></div>
729
- ${msg.text ? html`<div class="tool-card-result">${msg.text}</div>` : null}
730
- </div>
731
- `;
732
- }
733
-
734
- // write_file — show new content as a code block with path-derived lang.
735
- if (
736
- (name === "write_file" || name.endsWith("_write_file")) &&
737
- args &&
738
- typeof args.content === "string"
739
- ) {
740
- const lang = langFromPath(path);
741
- return html`
742
- <div class="tool-card">
743
- <div class="tool-card-head">
744
- <span class="tool-card-icon">+</span>
745
- <span class="tool-card-name">write_file</span>
746
- ${path ? html`<code class="tool-card-path tool-card-path-link" onClick=${() => openFileInEditor(path)} title="open in editor">${path}</code>` : null}
747
- ${lang ? html`<span class="pill pill-dim">${lang}</span>` : null}
748
- </div>
749
- <div dangerouslySetInnerHTML=${{ __html: renderHighlightedBlock(args.content, lang) }}></div>
750
- ${msg.text ? html`<div class="tool-card-result">${msg.text}</div>` : null}
751
- </div>
752
- `;
753
- }
754
-
755
- // read_file / list_files — content lands in msg.text.
756
- if (name === "read_file" || name.endsWith("_read_file") || name === "filesystem_read_file") {
757
- const lang = langFromPath(path);
758
- return html`
759
- <div class="tool-card">
760
- <div class="tool-card-head">
761
- <span class="tool-card-icon">▤</span>
762
- <span class="tool-card-name">read_file</span>
763
- ${path ? html`<code class="tool-card-path tool-card-path-link" onClick=${() => openFileInEditor(path)} title="open in editor">${path}</code>` : null}
764
- ${lang ? html`<span class="pill pill-dim">${lang}</span>` : null}
765
- </div>
766
- <div dangerouslySetInnerHTML=${{ __html: renderHighlightedBlock(msg.text, lang) }}></div>
767
- </div>
768
- `;
769
- }
770
-
771
- // run_command / run_background — terminal-style.
772
- if (name === "run_command" || name === "run_background") {
773
- const cmd = args?.command;
774
- return html`
775
- <div class="tool-card">
776
- <div class="tool-card-head">
777
- <span class="tool-card-icon">⚡</span>
778
- <span class="tool-card-name">${name === "run_background" ? "run_background" : "run_command"}</span>
779
- </div>
780
- ${
781
- cmd
782
- ? html`<pre class="tool-card-cmd"><span class="tool-card-prompt">$</span> <code>${cmd}</code></pre>`
783
- : null
784
- }
785
- ${msg.text ? html`<pre class="tool-card-output">${msg.text}</pre>` : null}
786
- </div>
787
- `;
788
- }
789
-
790
- // list_files / file_exists / delete_file — show args + result inline.
791
- if (
792
- name === "list_files" ||
793
- name === "file_exists" ||
794
- name === "delete_file" ||
795
- name === "create_directory" ||
796
- name === "delete_directory" ||
797
- name.endsWith("_list_files")
798
- ) {
799
- return html`
800
- <div class="tool-card">
801
- <div class="tool-card-head">
802
- <span class="tool-card-icon">▣</span>
803
- <span class="tool-card-name">${name}</span>
804
- ${path ? html`<code class="tool-card-path tool-card-path-link" onClick=${() => openFileInEditor(path)} title="open in editor">${path}</code>` : null}
805
- </div>
806
- <pre class="tool-card-output">${msg.text}</pre>
807
- </div>
808
- `;
809
- }
810
-
811
- // Default — keep the legacy compact box but add an args preview when
812
- // present so MCP-bridged tools still surface something readable.
813
- return html`
814
- <div class="tool-card">
815
- <div class="tool-card-head">
816
- <span class="tool-card-icon">▣</span>
817
- <span class="tool-card-name">${name}</span>
818
- </div>
819
- ${
820
- args
821
- ? html`<details class="tool-card-args"><summary>arguments</summary><pre>${escapeHtml(JSON.stringify(args, null, 2))}</pre></details>`
822
- : null
823
- }
824
- <pre class="tool-card-output">${msg.text}</pre>
825
- </div>
826
- `;
827
- }
828
-
829
- function ChatMessage({ msg, streaming }) {
830
- const role = msg.role;
831
- const glyph = ROLE_GLYPH[role] ?? "·";
832
- if (role === "tool") {
833
- return html`
834
- <div class="chat-msg tool">
835
- <div class="glyph">${glyph}</div>
836
- <${ToolCard} msg=${msg} />
837
- </div>
838
- `;
839
- }
840
- return html`
841
- <div class="chat-msg ${role}">
842
- <div class="glyph">${glyph}</div>
843
- <div class="body">
844
- ${msg.reasoning ? html`<div class="reasoning">${msg.reasoning}</div>` : null}
845
- ${renderMessageBody(msg.text)}
846
- ${streaming ? html`<span class="chat-streaming-cursor"></span>` : null}
847
- </div>
848
- </div>
849
- `;
850
- }
851
-
852
- // ---------- Modal components mirroring the TUI ----------
853
- //
854
- // Each component renders a card matching the TUI's ModalCard accent
855
- // palette: red for shell (run-now), magenta for choice (branching),
856
- // cyan for plan (decision), green for edits. onResolve pushes to the
857
- // server; the SSE channel will echo back a modal-down that clears the
858
- // local state — both surfaces stay in lockstep without polling.
859
-
860
- function ModalCard({ accent, icon, title, subtitle, children }) {
861
- return html`
862
- <div class="modal-card" style=${`border-left-color: ${accent};`}>
863
- <div class="modal-card-head">
864
- <span class="modal-card-icon" style=${`color: ${accent};`}>${icon}</span>
865
- <div>
866
- <div class="modal-card-title">${title}</div>
867
- ${subtitle ? html`<div class="modal-card-subtitle">${subtitle}</div>` : null}
868
- </div>
869
- </div>
870
- ${children}
871
- </div>
872
- `;
873
- }
874
-
875
- function ShellModal({ modal, onResolve }) {
876
- const isBg = modal.shellKind === "run_background";
877
- return html`
878
- <${ModalCard}
879
- accent="#f87171"
880
- icon=${isBg ? "⏱" : "⚡"}
881
- title=${isBg ? "background process" : "shell command"}
882
- subtitle=${
883
- isBg ? "long-running — keeps running after approval" : "model wants to run a shell command"
884
- }
885
- >
886
- <div class="modal-cmd"><span class="modal-cmd-prompt">$</span> <code>${modal.command}</code></div>
887
- <div class="modal-actions">
888
- <button class="primary" onClick=${() => onResolve("shell", "run_once")}>Run once</button>
889
- <button onClick=${() => onResolve("shell", "always_allow")}>Always allow "${modal.allowPrefix}"</button>
890
- <button class="danger" onClick=${() => onResolve("shell", "deny")}>Deny</button>
891
- </div>
892
- <//>
893
- `;
894
- }
895
-
896
- function ChoiceModal({ modal, onResolve }) {
897
- const [custom, setCustom] = useState("");
898
- const [showCustom, setShowCustom] = useState(false);
899
- return html`
900
- <${ModalCard} accent="#f0abfc" icon="🔀" title="model wants you to pick" subtitle=${modal.question}>
901
- ${modal.options.map(
902
- (opt) => html`
903
- <button
904
- key=${opt.id}
905
- class="modal-choice-row"
906
- onClick=${() => onResolve("choice", { kind: "pick", optionId: opt.id })}
907
- >
908
- <span class="modal-choice-id">${opt.id}</span>
909
- <span class="modal-choice-title">${opt.title}</span>
910
- ${opt.summary ? html`<span class="modal-choice-summary">${opt.summary}</span>` : null}
911
- </button>
912
- `,
913
- )}
914
- ${
915
- modal.allowCustom
916
- ? showCustom
917
- ? html`
918
- <div class="modal-custom">
919
- <textarea
920
- placeholder="Type a free-form answer…"
921
- rows="2"
922
- value=${custom}
923
- onInput=${(e) => setCustom(e.target.value)}
924
- ></textarea>
925
- <div class="modal-actions">
926
- <button class="primary" onClick=${() => onResolve("choice", { kind: "custom", text: custom })} disabled=${!custom.trim()}>Send</button>
927
- <button onClick=${() => {
928
- setShowCustom(false);
929
- setCustom("");
930
- }}>Back</button>
931
- </div>
932
- </div>
933
- `
934
- : html`
935
- <button class="modal-choice-row" onClick=${() => setShowCustom(true)}>
936
- <span class="modal-choice-id">·</span>
937
- <span class="modal-choice-title">Type my own answer</span>
938
- <span class="modal-choice-summary">None of the above fits — write a free-form reply.</span>
939
- </button>
940
- `
941
- : null
942
- }
943
- <button class="modal-choice-row modal-choice-cancel" onClick=${() => onResolve("choice", { kind: "cancel" })}>
944
- <span class="modal-choice-id">×</span>
945
- <span class="modal-choice-title">Cancel</span>
946
- <span class="modal-choice-summary">Drop the question. Model will ask what you actually want.</span>
947
- </button>
948
- <//>
949
- `;
950
- }
951
-
952
- function PlanModal({ modal, onResolve }) {
953
- const [feedback, setFeedback] = useState("");
954
- const [stage, setStage] = useState(null); // null | "approve" | "refine"
955
- const send = () => onResolve("plan", stage, feedback);
956
- return html`
957
- <${ModalCard} accent="#67e8f9" icon="◆" title="plan submitted" subtitle="model proposed a plan; review then pick">
958
- <div class="md modal-plan-body" dangerouslySetInnerHTML=${{ __html: marked.parse(modal.body || "") }}></div>
959
- ${
960
- stage
961
- ? html`
962
- <textarea
963
- placeholder=${
964
- stage === "approve"
965
- ? "Optional last instructions / answers to open questions (Enter to send blank)"
966
- : "What needs to change? Be specific."
967
- }
968
- rows="3"
969
- value=${feedback}
970
- onInput=${(e) => setFeedback(e.target.value)}
971
- ></textarea>
972
- <div class="modal-actions">
973
- <button class="primary" onClick=${send}>${stage === "approve" ? "Approve" : "Send refinement"}</button>
974
- <button onClick=${() => {
975
- setStage(null);
976
- setFeedback("");
977
- }}>Back</button>
978
- </div>
979
- `
980
- : html`
981
- <div class="modal-actions">
982
- <button class="primary" onClick=${() => setStage("approve")}>Approve</button>
983
- <button onClick=${() => setStage("refine")}>Refine</button>
984
- <button class="danger" onClick=${() => onResolve("plan", "cancel")}>Cancel</button>
985
- </div>
986
- `
987
- }
988
- <//>
989
- `;
990
- }
991
-
992
- // Line-level LCS diff. Returns an ordered list of rows; "context" rows
993
- // appear on both sides, "del" only on the left (red), "ins" only on the
994
- // right (green). Adjacent del/ins are paired into one row downstream so
995
- // the change reads "old → new" left-to-right like a git side-by-side.
996
- function lineDiff(aLines, bLines) {
997
- const m = aLines.length;
998
- const n = bLines.length;
999
- const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
1000
- for (let i = 1; i <= m; i++) {
1001
- for (let j = 1; j <= n; j++) {
1002
- if (aLines[i - 1] === bLines[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
1003
- else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
1004
- }
1005
- }
1006
- const out = [];
1007
- let i = m;
1008
- let j = n;
1009
- while (i > 0 || j > 0) {
1010
- if (i > 0 && j > 0 && aLines[i - 1] === bLines[j - 1]) {
1011
- out.push({ kind: "context", text: aLines[i - 1] });
1012
- i--;
1013
- j--;
1014
- } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
1015
- out.push({ kind: "ins", text: bLines[j - 1] });
1016
- j--;
1017
- } else {
1018
- out.push({ kind: "del", text: aLines[i - 1] });
1019
- i--;
1020
- }
1021
- }
1022
- return out.reverse();
1023
- }
1024
-
1025
- // Pair del/ins runs into side-by-side rows. A run of consecutive dels
1026
- // followed by a run of inss collapses into rows of (del[k], ins[k]) so
1027
- // the modified line lines up across the gutter; surplus on either side
1028
- // produces rows with the opposite cell empty.
1029
- function pairDiffRows(diff) {
1030
- const rows = [];
1031
- let k = 0;
1032
- while (k < diff.length) {
1033
- if (diff[k].kind === "context") {
1034
- rows.push({ left: diff[k].text, right: diff[k].text, kind: "context" });
1035
- k++;
1036
- continue;
1037
- }
1038
- const dels = [];
1039
- const inss = [];
1040
- while (k < diff.length && diff[k].kind === "del") {
1041
- dels.push(diff[k].text);
1042
- k++;
1043
- }
1044
- while (k < diff.length && diff[k].kind === "ins") {
1045
- inss.push(diff[k].text);
1046
- k++;
1047
- }
1048
- const pairs = Math.max(dels.length, inss.length);
1049
- for (let p = 0; p < pairs; p++) {
1050
- rows.push({
1051
- left: dels[p] ?? null,
1052
- right: inss[p] ?? null,
1053
- kind: dels[p] != null && inss[p] != null ? "change" : dels[p] != null ? "del" : "ins",
1054
- });
1055
- }
1056
- }
1057
- return rows;
1058
- }
1059
-
1060
- // Highlight a single line via hljs in the file's language; falls back to
1061
- // auto-detect, then escaped plain text. Always returns inline HTML safe
1062
- // to drop into a span.
1063
- function hlLine(text, lang) {
1064
- if (text == null) return "";
1065
- if (text === "") return "";
1066
- try {
1067
- if (lang && hljs.getLanguage(lang)) {
1068
- return hljs.highlight(text, { language: lang, ignoreIllegals: true }).value;
1069
- }
1070
- return hljs.highlightAuto(text).value;
1071
- } catch {
1072
- return escapeHtml(text);
1073
- }
1074
- }
1075
-
1076
- function EditReviewModal({ modal, onResolve }) {
1077
- const search = modal.search ?? "";
1078
- const replace = modal.replace ?? "";
1079
- const lang = langFromPath(modal.path);
1080
- const aLines = search.split("\n");
1081
- const bLines = replace.split("\n");
1082
- const rows = pairDiffRows(lineDiff(aLines, bLines));
1083
-
1084
- return html`
1085
- <${ModalCard}
1086
- accent="#86efac"
1087
- icon="◆"
1088
- title="edit pending review"
1089
- subtitle=${`${modal.path} · ${modal.remaining} of ${modal.total} blocks remaining`}
1090
- >
1091
- <div class="edit-diff-wrap">
1092
- <div class="edit-diff-head">
1093
- <div class="edit-diff-side edit-diff-side-old">
1094
- <span class="edit-diff-marker">−</span> before
1095
- </div>
1096
- <div class="edit-diff-side edit-diff-side-new">
1097
- <span class="edit-diff-marker">+</span> after
1098
- </div>
1099
- </div>
1100
- <div class="edit-diff-body">
1101
- ${rows.map(
1102
- (row, i) => html`
1103
- <div key=${i} class=${`edit-diff-row edit-diff-row-${row.kind}`}>
1104
- <div class="edit-diff-cell edit-diff-cell-old">
1105
- ${
1106
- row.left != null
1107
- ? html`<span
1108
- class="edit-diff-line"
1109
- dangerouslySetInnerHTML=${{ __html: hlLine(row.left, lang) || "&nbsp;" }}
1110
- ></span>`
1111
- : html`<span class="edit-diff-empty">&nbsp;</span>`
1112
- }
1113
- </div>
1114
- <div class="edit-diff-cell edit-diff-cell-new">
1115
- ${
1116
- row.right != null
1117
- ? html`<span
1118
- class="edit-diff-line"
1119
- dangerouslySetInnerHTML=${{ __html: hlLine(row.right, lang) || "&nbsp;" }}
1120
- ></span>`
1121
- : html`<span class="edit-diff-empty">&nbsp;</span>`
1122
- }
1123
- </div>
1124
- </div>
1125
- `,
1126
- )}
1127
- </div>
1128
- </div>
1129
- <div class="modal-actions">
1130
- <button class="primary" onClick=${() => onResolve("edit-review", "apply")}>Apply (y)</button>
1131
- <button onClick=${() => onResolve("edit-review", "reject")}>Reject (n)</button>
1132
- <button onClick=${() => onResolve("edit-review", "apply-rest-of-turn")}>Apply rest (a)</button>
1133
- <button onClick=${() => onResolve("edit-review", "flip-to-auto")}>Flip to AUTO (A)</button>
1134
- </div>
1135
- <//>
1136
- `;
1137
- }
1138
-
1139
- function WorkspaceModal({ modal, onResolve }) {
1140
- return html`
1141
- <${ModalCard}
1142
- accent="#fbbf24"
1143
- icon="◇"
1144
- title="model wants to switch workspace"
1145
- subtitle="every subsequent file / shell / memory tool resolves against the new root"
1146
- >
1147
- <div class="modal-cmd"><span class="modal-cmd-prompt">→</span> <code>${modal.path}</code></div>
1148
- <div class="modal-actions">
1149
- <button class="primary" onClick=${() => onResolve("workspace", "switch")}>Switch (Enter)</button>
1150
- <button class="danger" onClick=${() => onResolve("workspace", "deny")}>Deny (Esc)</button>
1151
- </div>
1152
- <//>
1153
- `;
1154
- }
1155
-
1156
- function CheckpointModal({ modal, onResolve }) {
1157
- const [reviseText, setReviseText] = useState("");
1158
- const [staged, setStaged] = useState(false);
1159
- const label = modal.title ? `${modal.stepId} · ${modal.title}` : modal.stepId;
1160
- const counter = modal.total > 0 ? ` (${modal.completed}/${modal.total})` : "";
1161
- return html`
1162
- <${ModalCard}
1163
- accent="#a5f3fc"
1164
- icon="✓"
1165
- title=${`step complete${counter}`}
1166
- subtitle=${label}
1167
- >
1168
- ${
1169
- staged
1170
- ? html`
1171
- <textarea
1172
- placeholder="What needs to change before the next step? Leave blank to just continue."
1173
- rows="3"
1174
- value=${reviseText}
1175
- onInput=${(e) => setReviseText(e.target.value)}
1176
- ></textarea>
1177
- <div class="modal-actions">
1178
- <button class="primary" onClick=${() => onResolve("checkpoint", "revise", reviseText)}>Send revision</button>
1179
- <button onClick=${() => {
1180
- setStaged(false);
1181
- setReviseText("");
1182
- }}>Back</button>
1183
- </div>
1184
- `
1185
- : html`
1186
- <div class="modal-actions">
1187
- <button class="primary" onClick=${() => onResolve("checkpoint", "continue")}>Continue</button>
1188
- <button onClick=${() => setStaged(true)}>Revise…</button>
1189
- <button class="danger" onClick=${() => onResolve("checkpoint", "stop")}>Stop</button>
1190
- </div>
1191
- `
1192
- }
1193
- <//>
1194
- `;
1195
- }
1196
-
1197
- function RevisionModal({ modal, onResolve }) {
1198
- const riskColor = (r) =>
1199
- r === "high" ? "#f87171" : r === "med" ? "#fbbf24" : r === "low" ? "#86efac" : "#9ca3af";
1200
- return html`
1201
- <${ModalCard}
1202
- accent="#c4b5fd"
1203
- icon="✎"
1204
- title="model proposed a plan revision"
1205
- subtitle=${modal.summary || modal.reason}
1206
- >
1207
- <div class="modal-revise-reason">${modal.reason}</div>
1208
- <ol class="modal-revise-steps">
1209
- ${modal.remainingSteps.map(
1210
- (s) => html`
1211
- <li key=${s.id}>
1212
- <span class="modal-revise-dot" style=${`background:${riskColor(s.risk)}`}></span>
1213
- <span class="modal-revise-id">${s.id}</span>
1214
- <span class="modal-revise-title">${s.title}</span>
1215
- <span class="modal-revise-action">${s.action}</span>
1216
- </li>
1217
- `,
1218
- )}
1219
- </ol>
1220
- <div class="modal-actions">
1221
- <button class="primary" onClick=${() => onResolve("revision", "accept")}>Accept</button>
1222
- <button class="danger" onClick=${() => onResolve("revision", "reject")}>Reject</button>
1223
- </div>
1224
- <//>
1225
- `;
1226
- }
1227
-
1228
- function ChatPanel() {
1229
- const [messages, setMessages] = useState([]);
1230
- const [streaming, setStreaming] = useState(null); // { id, text, reasoning }
1231
- // Tool currently dispatched but not yet returning. Set on `tool_start`,
1232
- // cleared on `tool` / `error`. Drives the in-flight row so the user
1233
- // sees what's running (path, command, char counts) instead of a
1234
- // generic "waiting" — file writes especially feel hung otherwise.
1235
- const [activeTool, setActiveTool] = useState(null); // { id, toolName, args }
1236
- const [busy, setBusy] = useState(false);
1237
- const [input, setInput] = useState("");
1238
- const [error, setError] = useState(null);
1239
- const [bootError, setBootError] = useState(null);
1240
- const [statusLine, setStatusLine] = useState(null);
1241
- // Mirror of the active TUI modal: { kind, ...payload } | null. Set
1242
- // by `modal-up` SSE events, cleared by `modal-down`. Web uses POST
1243
- // /api/modal/resolve to drive resolution; either surface clears the
1244
- // other's modal via the resulting modal-down event.
1245
- const [modal, setModal] = useState(null);
1246
- // Current edit gate (review / auto / yolo). null when not in code
1247
- // mode. Refreshed via /api/overview poll because the mode also
1248
- // flips from TUI Shift+Tab and we want the segmented control to
1249
- // stay in sync without a dedicated event.
1250
- const [editMode, setEditModeLocal] = useState(null);
1251
- // Persisted preset + reasoning_effort, surfaced here so the user
1252
- // can flip them mid-chat without leaving the tab. /api/overview
1253
- // includes both since 0.14.x; the same poll covers all three.
1254
- const [preset, setPresetLocal] = useState(null);
1255
- const [effort, setEffortLocal] = useState(null);
1256
- // Live session stats — cache hit, costs, tokens, balance — from the
1257
- // same /api/overview poll. Renders into a compact status bar below
1258
- // the input area.
1259
- const [stats, setStats] = useState(null);
1260
- const [overviewModel, setOverviewModel] = useState(null);
1261
- // Whether the project has a built semantic index. Null = unknown
1262
- // (poll hasn't landed) or non-attached. False = no index → show the
1263
- // dismissible banner. True = index built → hide it.
1264
- const [semanticIndex, setSemanticIndex] = useState(null);
1265
- const [semanticBannerDismissed, setSemanticBannerDismissed] = useState(() => {
1266
- try {
1267
- return localStorage.getItem("rx.semanticBannerDismissed") === "1";
1268
- } catch {
1269
- return false;
1270
- }
1271
- });
1272
- useEffect(() => {
1273
- try {
1274
- localStorage.setItem("rx.semanticBannerDismissed", semanticBannerDismissed ? "1" : "0");
1275
- } catch {
1276
- /* ignore */
1277
- }
1278
- }, [semanticBannerDismissed]);
1279
- // Wall-clock timestamp the current turn started at — populated when
1280
- // busy flips true, cleared when it flips false. Drives the "elapsed
1281
- // Ns" readout in the in-flight indicator. Refreshed once per second
1282
- // by `nowTick` so the seconds counter ticks visibly even between
1283
- // SSE deltas.
1284
- const [turnStartedAt, setTurnStartedAt] = useState(null);
1285
- const [nowTick, setNowTick] = useState(0);
1286
- useEffect(() => {
1287
- if (!busy) return;
1288
- const id = setInterval(() => setNowTick((n) => n + 1), 500);
1289
- return () => clearInterval(id);
1290
- }, [busy]);
1291
- useEffect(() => {
1292
- if (busy) {
1293
- if (!turnStartedAt) setTurnStartedAt(Date.now());
1294
- } else {
1295
- setTurnStartedAt(null);
1296
- }
1297
- }, [busy, turnStartedAt]);
1298
- // Sticks to bottom only while the user is already near the bottom.
1299
- // Once they scroll up to read older content the streaming deltas no
1300
- // longer yank the view back. Re-armed when they scroll back to the
1301
- // bottom on their own. 80px threshold absorbs sub-pixel rounding.
1302
- const shouldAutoScroll = useRef(true);
1303
- // Ref to the scrollable feed container so we don't have to rely on
1304
- // a global querySelector (which would race the conditional render
1305
- // — `.chat-feed` only mounts when at least one message is present).
1306
- // The feed is now always rendered, so `feedRef.current` is set on
1307
- // first paint and the scroll listener attaches once.
1308
- const feedRef = useRef(null);
1309
-
1310
- // Initial snapshot — messages + busy + any modal already up.
1311
- useEffect(() => {
1312
- let cancelled = false;
1313
- (async () => {
1314
- try {
1315
- const data = await api("/messages");
1316
- if (cancelled) return;
1317
- setMessages(data.messages ?? []);
1318
- setBusy(Boolean(data.busy));
1319
- } catch (err) {
1320
- if (!cancelled) setBootError(err.message);
1321
- }
1322
- try {
1323
- const m = await api("/modal");
1324
- if (!cancelled && m.modal) setModal(m.modal);
1325
- } catch {
1326
- /* skip — modal endpoint optional in standalone */
1327
- }
1328
- })();
1329
- return () => {
1330
- cancelled = true;
1331
- };
1332
- }, []);
1333
-
1334
- // Live event stream.
1335
- useEffect(() => {
1336
- const es = new EventSource(`/api/events?token=${TOKEN}`);
1337
- es.onmessage = (ev) => {
1338
- let dash;
1339
- try {
1340
- dash = JSON.parse(ev.data);
1341
- } catch {
1342
- return;
1343
- }
1344
- if (dash.kind === "ping") return;
1345
- if (dash.kind === "busy-change") {
1346
- setBusy(dash.busy);
1347
- return;
1348
- }
1349
- if (dash.kind === "user") {
1350
- setMessages((prev) => [...prev, { id: dash.id, role: "user", text: dash.text }]);
1351
- return;
1352
- }
1353
- if (dash.kind === "assistant_delta") {
1354
- setStreaming((cur) => {
1355
- const text = (cur?.text ?? "") + (dash.contentDelta ?? "");
1356
- const reasoning = (cur?.reasoning ?? "") + (dash.reasoningDelta ?? "");
1357
- return { id: dash.id, text, reasoning };
1358
- });
1359
- return;
1360
- }
1361
- if (dash.kind === "assistant_final") {
1362
- setStreaming(null);
1363
- setMessages((prev) => [
1364
- ...prev,
1365
- {
1366
- id: dash.id,
1367
- role: "assistant",
1368
- text: dash.text,
1369
- reasoning: dash.reasoning,
1370
- },
1371
- ]);
1372
- return;
1373
- }
1374
- if (dash.kind === "tool_start") {
1375
- // Surface the dispatched tool + its args in the in-flight row.
1376
- // No info-row placeholder: the InFlightRow now renders the
1377
- // detail (path / command / char count) and the result card
1378
- // appears when the `tool` event lands. Two rows for one tool
1379
- // call was redundant noise.
1380
- setActiveTool({ id: dash.id, toolName: dash.toolName, args: dash.args });
1381
- return;
1382
- }
1383
- if (dash.kind === "tool") {
1384
- setActiveTool((cur) => (cur && cur.id === dash.id ? null : cur));
1385
- setMessages((prev) => [
1386
- ...prev,
1387
- {
1388
- id: dash.id,
1389
- role: "tool",
1390
- text: dash.content,
1391
- toolName: dash.toolName,
1392
- toolArgs: dash.args,
1393
- },
1394
- ]);
1395
- return;
1396
- }
1397
- if (dash.kind === "warning" || dash.kind === "error" || dash.kind === "info") {
1398
- if (dash.kind === "error") {
1399
- setActiveTool(null);
1400
- }
1401
- setMessages((prev) => [...prev, { id: dash.id, role: dash.kind, text: dash.text }]);
1402
- return;
1403
- }
1404
- if (dash.kind === "status") {
1405
- setStatusLine(dash.text);
1406
- // Clear the status line shortly so old hints don't pile up.
1407
- setTimeout(() => setStatusLine((cur) => (cur === dash.text ? null : cur)), 5000);
1408
- return;
1409
- }
1410
- if (dash.kind === "modal-up") {
1411
- setModal(dash.modal);
1412
- return;
1413
- }
1414
- if (dash.kind === "modal-down") {
1415
- setModal((cur) => (cur && cur.kind === dash.modalKind ? null : cur));
1416
- return;
1417
- }
1418
- };
1419
- es.onerror = () => {
1420
- // Auto-reconnect by default; surface a brief banner on persistent
1421
- // failure but don't tear down — EventSource retries in the background.
1422
- setError("event stream interrupted — reconnecting…");
1423
- setTimeout(() => setError(null), 3000);
1424
- };
1425
- return () => es.close();
1426
- }, []);
1427
-
1428
- const send = useCallback(async () => {
1429
- const text = input.trim();
1430
- if (!text || busy) return;
1431
- setError(null);
1432
- try {
1433
- const res = await api("/submit", { method: "POST", body: { prompt: text } });
1434
- if (!res.accepted) {
1435
- setError(res.reason ?? "rejected");
1436
- return;
1437
- }
1438
- setInput("");
1439
- } catch (err) {
1440
- setError(err.message);
1441
- }
1442
- }, [input, busy]);
1443
-
1444
- const abort = useCallback(async () => {
1445
- try {
1446
- await api("/abort", { method: "POST" });
1447
- } catch (err) {
1448
- setError(err.message);
1449
- }
1450
- }, []);
1451
-
1452
- // /new wipes context + scrollback (server-side); /clear keeps the
1453
- // log but blanks the visible scroll. Both route through /api/submit
1454
- // because handleSubmit on the TUI side already parses slashes — keeps
1455
- // one source of truth, no special endpoint needed. Local messages
1456
- // state is reset optimistically; an /api/messages refetch reconciles.
1457
- const newConversation = useCallback(async () => {
1458
- if (busy) {
1459
- if (!confirm("A turn is in flight. Abort and start a new conversation?")) return;
1460
- } else if (messages.length > 0 && !confirm("Clear current conversation and start fresh?")) {
1461
- return;
1462
- }
1463
- try {
1464
- await api("/submit", { method: "POST", body: { prompt: "/new" } });
1465
- setMessages([]);
1466
- setStreaming(null);
1467
- setActiveTool(null);
1468
- showToast("new conversation", "info");
1469
- // Refetch to reconcile in case the slash queued an info row.
1470
- setTimeout(async () => {
1471
- try {
1472
- const r = await api("/messages");
1473
- setMessages(r.messages ?? []);
1474
- } catch {
1475
- /* swallow */
1476
- }
1477
- }, 200);
1478
- } catch (err) {
1479
- setError(`/new failed: ${err.message}`);
1480
- }
1481
- }, [busy, messages.length]);
1482
-
1483
- const clearScrollback = useCallback(async () => {
1484
- try {
1485
- await api("/submit", { method: "POST", body: { prompt: "/clear" } });
1486
- setMessages([]);
1487
- setStreaming(null);
1488
- setActiveTool(null);
1489
- showToast("scrollback cleared", "info");
1490
- setTimeout(async () => {
1491
- try {
1492
- const r = await api("/messages");
1493
- setMessages(r.messages ?? []);
1494
- } catch {
1495
- /* swallow */
1496
- }
1497
- }, 200);
1498
- } catch (err) {
1499
- setError(`/clear failed: ${err.message}`);
1500
- }
1501
- }, []);
1502
-
1503
- const onKeyDown = useCallback(
1504
- (e) => {
1505
- // Enter sends, Shift+Enter inserts newline.
1506
- if (e.key === "Enter" && !e.shiftKey) {
1507
- e.preventDefault();
1508
- send();
1509
- }
1510
- },
1511
- [send],
1512
- );
1513
-
1514
- if (bootError) {
1515
- return html`<div class="notice err">chat unavailable: ${bootError}</div>`;
1516
- }
1517
-
1518
- // Track whether the user is parked at the bottom. Update on every
1519
- // scroll event so a single wheel-up flips the auto-scroll guard
1520
- // immediately. The threshold is generous enough that overshoot
1521
- // (smooth-scroll rebound, sub-pixel rounding) doesn't accidentally
1522
- // re-arm tracking when the user is barely above bottom.
1523
- //
1524
- // We also distinguish *user* scroll events from auto-scroll's own
1525
- // programmatic `scrollTop = scrollHeight` writes. Without that gate
1526
- // the auto-scroll effect would briefly snap to bottom, fire its
1527
- // own scroll event, re-set shouldAutoScroll = true, then wonder
1528
- // why the user complained that they couldn't scroll up — because
1529
- // every wheel-up was racing against the next delta's auto-snap.
1530
- // We mark the ref as `auto-scrolling` for one tick around the
1531
- // programmatic write; the listener ignores events it sees during
1532
- // that window.
1533
- const autoScrollInFlight = useRef(false);
1534
- useEffect(() => {
1535
- const el = feedRef.current;
1536
- if (!el) return;
1537
- const onScroll = () => {
1538
- if (autoScrollInFlight.current) return;
1539
- const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
1540
- shouldAutoScroll.current = distFromBottom < 80;
1541
- };
1542
- el.addEventListener("scroll", onScroll, { passive: true });
1543
- return () => el.removeEventListener("scroll", onScroll);
1544
- }, []);
1545
-
1546
- // Auto-scroll only when the user hasn't scrolled away. Streaming
1547
- // deltas no longer yank the view back; manual wheel/drag wins.
1548
- useEffect(() => {
1549
- if (!shouldAutoScroll.current) return;
1550
- const el = feedRef.current;
1551
- if (!el) return;
1552
- autoScrollInFlight.current = true;
1553
- el.scrollTop = el.scrollHeight;
1554
- // Clear the gate after the browser has had a chance to fire the
1555
- // resulting scroll event (microtask-ish — rAF is overkill, a 0ms
1556
- // setTimeout is enough to land after the synchronous handler).
1557
- setTimeout(() => {
1558
- autoScrollInFlight.current = false;
1559
- }, 0);
1560
- }, [messages, streaming]);
1561
-
1562
- const allMessages = streaming
1563
- ? [
1564
- ...messages,
1565
- {
1566
- id: streaming.id,
1567
- role: "assistant",
1568
- text: streaming.text,
1569
- reasoning: streaming.reasoning,
1570
- },
1571
- ]
1572
- : messages;
1573
-
1574
- // Resolve the active modal via POST /api/modal/resolve. The server
1575
- // hands the choice straight to App.tsx's resolveXxx callback, which
1576
- // calls the same handler the TUI button would. The local `modal`
1577
- // state clears the moment the SSE channel echoes `modal-down`.
1578
- const resolveModal = useCallback(async (kind, choice, text) => {
1579
- try {
1580
- await api("/modal/resolve", {
1581
- method: "POST",
1582
- body: text !== undefined ? { kind, choice, text } : { kind, choice },
1583
- });
1584
- } catch (err) {
1585
- setError(`modal resolve failed: ${err.message}`);
1586
- }
1587
- }, []);
1588
-
1589
- // Poll /api/overview for current edit mode. Polling (not SSE) is
1590
- // fine — the gate flips from /mode, Shift+Tab, AND the web button;
1591
- // a 4s poll is good enough to keep the segmented control visually
1592
- // honest without piping yet another event kind.
1593
- useEffect(() => {
1594
- let cancelled = false;
1595
- const tick = async () => {
1596
- try {
1597
- const o = await api("/overview");
1598
- if (cancelled) return;
1599
- setEditModeLocal(o.editMode ?? null);
1600
- setPresetLocal(o.preset ?? null);
1601
- setEffortLocal(o.reasoningEffort ?? null);
1602
- setStats(o.stats ?? null);
1603
- setOverviewModel(o.model ?? null);
1604
- setSemanticIndex(o.semanticIndexExists);
1605
- } catch {
1606
- /* swallow */
1607
- }
1608
- };
1609
- tick();
1610
- const t = setInterval(tick, 2500);
1611
- return () => {
1612
- cancelled = true;
1613
- clearInterval(t);
1614
- };
1615
- }, []);
1616
-
1617
- const setEditMode = useCallback(async (next) => {
1618
- setEditModeLocal(next); // optimistic
1619
- try {
1620
- await api("/edit-mode", { method: "POST", body: { mode: next } });
1621
- } catch (err) {
1622
- setError(`mode switch failed: ${err.message}`);
1623
- try {
1624
- const o = await api("/overview");
1625
- setEditModeLocal(o.editMode ?? null);
1626
- } catch {
1627
- /* swallow */
1628
- }
1629
- }
1630
- }, []);
1631
-
1632
- // Generic settings flipper for preset + effort. Both go through
1633
- // /api/settings (which writes to ~/.reasonix/config.json). preset
1634
- // applies next session, effort applies next turn — the buttons'
1635
- // tooltips remind the user.
1636
- const setSetting = useCallback(async (key, value) => {
1637
- if (key === "preset") setPresetLocal(value);
1638
- if (key === "reasoningEffort") setEffortLocal(value);
1639
- try {
1640
- await api("/settings", { method: "POST", body: { [key]: value } });
1641
- } catch (err) {
1642
- setError(`${key} switch failed: ${err.message}`);
1643
- try {
1644
- const o = await api("/overview");
1645
- setPresetLocal(o.preset ?? null);
1646
- setEffortLocal(o.reasoningEffort ?? null);
1647
- } catch {
1648
- /* swallow */
1649
- }
1650
- }
1651
- }, []);
1652
-
1653
- return html`
1654
- <div class="chat-shell">
1655
- <div class="panel-header" style="margin-bottom: 12px;">
1656
- <h2 class="panel-title">Chat</h2>
1657
- <span class="panel-subtitle">
1658
- mirrors the live ${MODE === "attached" ? "TUI" : "session"} — type here or in the terminal, both surfaces stay in sync
1659
- </span>
1660
- <div class="header-pickers" style="margin-left: auto;">
1661
- ${
1662
- effort
1663
- ? html`
1664
- <div class="mode-picker" title="reasoning_effort — applies next turn">
1665
- ${["high", "max"].map(
1666
- (e) => html`
1667
- <button
1668
- key=${e}
1669
- class="mode-btn ${effort === e ? "active accent" : ""}"
1670
- onClick=${() => setSetting("reasoningEffort", e)}
1671
- title=${e === "max" ? "max (default — best quality)" : "high (cheaper / faster)"}
1672
- >${e}</button>
1673
- `,
1674
- )}
1675
- </div>
1676
- `
1677
- : null
1678
- }
1679
- ${
1680
- preset
1681
- ? html`
1682
- <div class="mode-picker" title="preset — model commitment">
1683
- ${(() => {
1684
- // Anything that isn't one of the three new presets
1685
- // (including legacy fast/smart/max from old configs)
1686
- // highlights as `auto` — the safe default. User can
1687
- // re-pick explicitly if they want flash or pro.
1688
- const KNOWN = ["auto", "flash", "pro"];
1689
- const canonical = KNOWN.includes(preset) ? preset : "auto";
1690
- return ["auto", "flash", "pro"].map(
1691
- (p) => html`
1692
- <button
1693
- key=${p}
1694
- class="mode-btn ${canonical === p ? "active accent" : ""}"
1695
- onClick=${() => setSetting("preset", p)}
1696
- title=${
1697
- p === "auto"
1698
- ? "auto — flash baseline; auto-escalates to pro on hard turns (NEEDS_PRO / failure threshold)"
1699
- : p === "flash"
1700
- ? "flash — always flash; no auto-escalate. /pro still works for one-shot manual"
1701
- : "pro — always pro; ~3× flash cost (5/31 discount). Locks in on hard architecture work."
1702
- }
1703
- >${p}</button>
1704
- `,
1705
- );
1706
- })()}
1707
- </div>
1708
- `
1709
- : null
1710
- }
1711
- ${
1712
- editMode
1713
- ? html`
1714
- <div class="mode-picker" title="edit gate — Shift+Tab cycles in TUI">
1715
- ${["review", "auto", "yolo"].map(
1716
- (m) => html`
1717
- <button
1718
- key=${m}
1719
- class="mode-btn ${editMode === m ? "active" : ""} ${m === "yolo" ? "yolo" : ""}"
1720
- onClick=${() => setEditMode(m)}
1721
- title=${
1722
- m === "review"
1723
- ? "review — both edits and non-allowlisted shell ask first"
1724
- : m === "auto"
1725
- ? "auto — edits auto-apply, shell still asks"
1726
- : "yolo — edits AND shell auto-run, allowlist bypassed"
1727
- }
1728
- >${m}</button>
1729
- `,
1730
- )}
1731
- </div>
1732
- `
1733
- : null
1734
- }
1735
- </div>
1736
- </div>
1737
-
1738
- ${
1739
- !busy && statusLine
1740
- ? html`<div class="chat-status"><span class="muted">${statusLine}</span></div>`
1741
- : null
1742
- }
1743
- ${
1744
- semanticIndex === false && !semanticBannerDismissed
1745
- ? html`<div class="chat-banner">
1746
- <span class="chat-banner-icon">≈</span>
1747
- <span class="chat-banner-text">
1748
- <strong>Semantic search isn't enabled for this project.</strong>
1749
- <span class="muted">
1750
- Build the index once and the model can find code by meaning ("where do we handle auth failures?") instead of grep on exact strings.
1751
- </span>
1752
- </span>
1753
- <button
1754
- class="primary"
1755
- onClick=${() => appBus.dispatchEvent(new CustomEvent("navigate-tab", { detail: { tabId: "semantic" } }))}
1756
- >Build it →</button>
1757
- <button
1758
- class="chat-banner-close"
1759
- onClick=${() => setSemanticBannerDismissed(true)}
1760
- title="dismiss (don't show again)"
1761
- >×</button>
1762
- </div>`
1763
- : null
1764
- }
1765
- ${error ? html`<div class="notice err">${error}</div>` : null}
1766
-
1767
- ${
1768
- modal
1769
- ? modal.kind === "shell"
1770
- ? html`<${ShellModal} modal=${modal} onResolve=${resolveModal} />`
1771
- : modal.kind === "choice"
1772
- ? html`<${ChoiceModal} modal=${modal} onResolve=${resolveModal} />`
1773
- : modal.kind === "plan"
1774
- ? html`<${PlanModal} modal=${modal} onResolve=${resolveModal} />`
1775
- : modal.kind === "edit-review"
1776
- ? html`<${EditReviewModal} modal=${modal} onResolve=${resolveModal} />`
1777
- : modal.kind === "workspace"
1778
- ? html`<${WorkspaceModal} modal=${modal} onResolve=${resolveModal} />`
1779
- : modal.kind === "checkpoint"
1780
- ? html`<${CheckpointModal} modal=${modal} onResolve=${resolveModal} />`
1781
- : modal.kind === "revision"
1782
- ? html`<${RevisionModal} modal=${modal} onResolve=${resolveModal} />`
1783
- : null
1784
- : null
1785
- }
1786
-
1787
- <div class="chat-feed" ref=${feedRef}>
1788
- ${
1789
- allMessages.length === 0
1790
- ? html`<div class="chat-empty">
1791
- No conversation yet. Send a prompt below to begin.
1792
- </div>`
1793
- : allMessages.map(
1794
- (m) => html`
1795
- <${ChatMessage}
1796
- key=${m.id}
1797
- msg=${m}
1798
- streaming=${streaming && streaming.id === m.id}
1799
- />
1800
- `,
1801
- )
1802
- }
1803
- </div>
1804
-
1805
- <div class="chat-input-area">
1806
- <textarea
1807
- placeholder=${busy ? "wait for the current turn to finish…" : "Type a prompt — Enter sends, Shift+Enter for a newline"}
1808
- value=${input}
1809
- onInput=${(e) => setInput(e.target.value)}
1810
- onKeyDown=${onKeyDown}
1811
- disabled=${busy}
1812
- rows="2"
1813
- ></textarea>
1814
- <div style="display: flex; flex-direction: column; gap: 6px; align-self: stretch; justify-content: flex-end;">
1815
- <button
1816
- class="primary"
1817
- onClick=${send}
1818
- disabled=${busy || !input.trim()}
1819
- >Send</button>
1820
- <div style="display: flex; gap: 6px;">
1821
- <button onClick=${newConversation} title="/new — wipe conversation context (loop log + scrollback)">New</button>
1822
- <button onClick=${clearScrollback} title="/clear — wipe just visible scrollback (context kept)">Clear</button>
1823
- </div>
1824
- </div>
1825
- </div>
1826
-
1827
- ${
1828
- busy
1829
- ? html`<${InFlightRow}
1830
- streaming=${streaming}
1831
- activeTool=${activeTool}
1832
- startedAt=${turnStartedAt}
1833
- statusLine=${statusLine}
1834
- onAbort=${abort}
1835
- tick=${nowTick}
1836
- />`
1837
- : null
1838
- }
1839
- <${ChatStatusBar} stats=${stats} model=${overviewModel} />
1840
- </div>
1841
- `;
1842
- }
1843
-
1844
- // Summarize the dispatched tool in one line — what the user wants to
1845
- // know is "is this hung or really doing X". Per-tool projection so a
1846
- // write_file says "→ /path/foo (12,345 ch)" instead of just "tool is
1847
- // running". Returns null for tools we don't have a custom shape for;
1848
- // the row falls back to the bare tool name.
1849
- function summarizeActiveTool(activeTool) {
1850
- if (!activeTool) return null;
1851
- const name = activeTool.toolName ?? "tool";
1852
- const args = parseToolArgs(activeTool.args);
1853
- const path = args?.path ?? args?.file_path ?? args?.filename;
1854
- if (name === "write_file" && path) {
1855
- const len = typeof args?.content === "string" ? args.content.length : null;
1856
- return `${name} → ${path}${len != null ? ` (${len.toLocaleString()} ch)` : ""}`;
1857
- }
1858
- if ((name === "edit_file" || name.endsWith("_edit_file")) && path) {
1859
- return `${name} → ${path}`;
1860
- }
1861
- if ((name === "run_command" || name === "run_background") && typeof args?.command === "string") {
1862
- const c = args.command;
1863
- return `${name} → $ ${c.length > 80 ? `${c.slice(0, 80)}…` : c}`;
1864
- }
1865
- if ((name === "read_file" || name === "list_files" || name === "search_files") && path) {
1866
- return `${name} → ${path}`;
1867
- }
1868
- if (path) return `${name} → ${path}`;
1869
- return name;
1870
- }
1871
-
1872
- // Live "what's the model doing right now" strip. Lives just above the
1873
- // ChatStatusBar so the user's eyes don't have to leave the input area
1874
- // to see whether the turn is alive — ticks every 500ms via the parent's
1875
- // nowTick so the seconds counter shows visible motion even when the
1876
- // SSE stream is silent (model thinking, waiting on a tool, etc).
1877
- function InFlightRow({ streaming, activeTool, startedAt, statusLine, onAbort, tick: _tick }) {
1878
- const elapsedMs = startedAt ? Date.now() - startedAt : 0;
1879
- const elapsed = (elapsedMs / 1000).toFixed(1);
1880
- const reasoningLen = streaming?.reasoning?.length ?? 0;
1881
- const textLen = streaming?.text?.length ?? 0;
1882
- // Tool-running phase wins over text/reasoning since the model is
1883
- // blocked on the tool — even if assistant_delta has fired we want
1884
- // to show the active dispatch.
1885
- const toolSummary = summarizeActiveTool(activeTool);
1886
- const phase = toolSummary
1887
- ? "running"
1888
- : reasoningLen > 0 && textLen === 0
1889
- ? "thinking"
1890
- : textLen > 0
1891
- ? "streaming"
1892
- : "waiting";
1893
- return html`
1894
- <div class="chat-inflight">
1895
- <span class="spinner"></span>
1896
- <span class="chat-inflight-phase">${phase}</span>
1897
- <span class="chat-inflight-sep">·</span>
1898
- <span class="muted">${elapsed}s</span>
1899
- ${
1900
- toolSummary
1901
- ? html`
1902
- <span class="chat-inflight-sep">·</span>
1903
- <span class="chat-inflight-tool" title=${toolSummary}>${toolSummary}</span>
1904
- `
1905
- : null
1906
- }
1907
- ${
1908
- !toolSummary && (textLen > 0 || reasoningLen > 0)
1909
- ? html`
1910
- <span class="chat-inflight-sep">·</span>
1911
- <span class="muted">
1912
- ${reasoningLen > 0 ? html`reasoning ${reasoningLen.toLocaleString()} ch` : null}
1913
- ${reasoningLen > 0 && textLen > 0 ? html`<span> · </span>` : null}
1914
- ${textLen > 0 ? html`out ${textLen.toLocaleString()} ch` : null}
1915
- </span>
1916
- `
1917
- : null
1918
- }
1919
- ${
1920
- statusLine
1921
- ? html`
1922
- <span class="chat-inflight-sep">·</span>
1923
- <span class="muted">${statusLine}</span>
1924
- `
1925
- : null
1926
- }
1927
- <button class="chat-inflight-abort" onClick=${onAbort}>Abort (Esc)</button>
1928
- </div>
1929
- `;
1930
- }
1931
-
1932
- // ---------- Chat status bar ----------
1933
- //
1934
- // Mirrors the TUI's StatsPanel — turn / session cost, cache hit %,
1935
- // ctx token gauge, balance. Sits beneath the input area as a compact
1936
- // monospace strip. Renders as a placeholder ("· · ·") while stats
1937
- // haven't arrived yet so the layout doesn't shift on first paint.
1938
-
1939
- function ChatStatusBar({ stats, model }) {
1940
- if (!stats) {
1941
- return html`
1942
- <div class="chat-statusbar">
1943
- <span class="muted">· · · waiting for live stats</span>
1944
- </div>
1945
- `;
1946
- }
1947
- const ctxPct =
1948
- stats.contextCapTokens > 0 ? (stats.lastPromptTokens / stats.contextCapTokens) * 100 : 0;
1949
- const balance = stats.balance && stats.balance.length > 0 ? stats.balance[0] : null;
1950
- return html`
1951
- <div class="chat-statusbar">
1952
- <span class="status-item">
1953
- <span class="status-label">model</span>
1954
- <code>${model ?? "—"}</code>
1955
- </span>
1956
- <span class="status-item">
1957
- <span class="status-label">ctx</span>
1958
- <span class="status-bar-mini">
1959
- <span class="status-bar-mini-fill" style=${`width: ${Math.min(100, ctxPct).toFixed(1)}%;`}></span>
1960
- </span>
1961
- <span class="muted">${stats.lastPromptTokens.toLocaleString()} / ${(stats.contextCapTokens / 1000).toFixed(0)}K</span>
1962
- </span>
1963
- <span class="status-item">
1964
- <span class="status-label">cache</span>
1965
- <span class=${stats.cacheHitRatio >= 0.9 ? "status-ok" : stats.cacheHitRatio >= 0.6 ? "status-warn" : "status-err"}>
1966
- ${(stats.cacheHitRatio * 100).toFixed(1)}%
1967
- </span>
1968
- </span>
1969
- <span class="status-item">
1970
- <span class="status-label">turn</span>
1971
- <code>${fmtUsd(stats.lastTurnCostUsd)}</code>
1972
- </span>
1973
- <span class="status-item">
1974
- <span class="status-label">session</span>
1975
- <code>${fmtUsd(stats.totalCostUsd)}</code>
1976
- <span class="muted" style="font-size: 10px;">
1977
- (${stats.turns} turn${stats.turns === 1 ? "" : "s"})
1978
- </span>
1979
- </span>
1980
- ${
1981
- balance
1982
- ? html`
1983
- <span class="status-item">
1984
- <span class="status-label">balance</span>
1985
- <code>${balance.total_balance} ${balance.currency}</code>
1986
- </span>
1987
- `
1988
- : null
1989
- }
1990
- </div>
1991
- `;
1992
- }
1993
-
1994
- // ---------- System Health ----------
1995
-
1996
- function SystemPanel() {
1997
- const { data, error, loading } = usePoll("/health", 5000);
1998
- if (loading && !data) return html`<div class="boot">loading health…</div>`;
1999
- if (error) return html`<div class="notice err">health failed: ${error.message}</div>`;
2000
- const h = data;
2001
- const upToDate = h.latestVersion ? h.latestVersion === h.version : null;
2002
- return html`
2003
- <div>
2004
- <div class="panel-header">
2005
- <h2 class="panel-title">System Health</h2>
2006
- <span class="panel-subtitle">disk · version · jobs</span>
2007
- </div>
2008
- <div class="metric-grid">
2009
- ${MetricCard(
2010
- "Reasonix",
2011
- h.version,
2012
- h.latestVersion === null
2013
- ? "version check pending"
2014
- : upToDate
2015
- ? "up to date"
2016
- : `latest: ${h.latestVersion}`,
2017
- upToDate === false ? "warn" : null,
2018
- )}
2019
- ${MetricCard("Sessions", `${fmtNum(h.sessions.count)} files`, fmtBytes(h.sessions.totalBytes))}
2020
- ${MetricCard("Memory", `${fmtNum(h.memory.fileCount)} files`, fmtBytes(h.memory.totalBytes))}
2021
- ${MetricCard(
2022
- "Semantic index",
2023
- h.semantic.exists ? `${fmtNum(h.semantic.fileCount)} files` : "not built",
2024
- h.semantic.exists ? fmtBytes(h.semantic.totalBytes) : "run `reasonix index` to build",
2025
- )}
2026
- ${MetricCard("Usage log", fmtBytes(h.usageLog.bytes), null)}
2027
- ${MetricCard("Background jobs", h.jobs === null ? "—" : fmtNum(h.jobs), h.jobs === null ? "no live session" : null)}
2028
- </div>
2029
- <div class="section-title">Paths</div>
2030
- <div class="card mono" style="font-size: 12px; line-height: 1.8;">
2031
- <div><span class="pill pill-dim">home</span> ${h.reasonixHome}</div>
2032
- <div><span class="pill pill-dim">sessions</span> ${h.sessions.path}</div>
2033
- <div><span class="pill pill-dim">memory</span> ${h.memory.path}</div>
2034
- <div><span class="pill pill-dim">semantic</span> ${h.semantic.path}</div>
2035
- <div><span class="pill pill-dim">usage</span> ${h.usageLog.path}</div>
2036
- </div>
2037
- </div>
2038
- `;
2039
- }
2040
-
2041
- // ---------- Sessions browser ----------
2042
-
2043
- function SessionsPanel() {
2044
- const { data, error, loading } = usePoll("/sessions", 5000);
2045
- const [open, setOpen] = useState(null); // { name, messages } or null
2046
- const [openLoading, setOpenLoading] = useState(false);
2047
-
2048
- const view = useCallback(async (name) => {
2049
- setOpen({ name, messages: null });
2050
- setOpenLoading(true);
2051
- try {
2052
- const detail = await api(`/sessions/${encodeURIComponent(name)}`);
2053
- setOpen({ name, messages: detail.messages });
2054
- } catch (err) {
2055
- setOpen({ name, messages: null, error: err.message });
2056
- } finally {
2057
- setOpenLoading(false);
2058
- }
2059
- }, []);
2060
-
2061
- if (loading && !data) return html`<div class="boot">loading sessions…</div>`;
2062
- if (error) return html`<div class="notice err">sessions failed: ${error.message}</div>`;
2063
- const sessions = data.sessions ?? [];
2064
-
2065
- if (open) {
2066
- return html`
2067
- <div>
2068
- <div class="panel-header">
2069
- <h2 class="panel-title">Session</h2>
2070
- <span class="panel-subtitle">${open.name}</span>
2071
- <button onClick=${() => setOpen(null)} style="margin-left: auto;">← back</button>
2072
- </div>
2073
- ${
2074
- openLoading
2075
- ? html`<div class="boot">loading transcript…</div>`
2076
- : open.error
2077
- ? html`<div class="notice err">${open.error}</div>`
2078
- : open.messages && open.messages.length > 0
2079
- ? html`
2080
- <div class="chat-feed" style="max-height: calc(100vh - 180px); overflow-y: auto;">
2081
- ${open.messages.map(
2082
- (m, i) => html`
2083
- <${ChatMessage}
2084
- key=${i}
2085
- msg=${{
2086
- id: `r-${i}`,
2087
- role:
2088
- m.role === "tool"
2089
- ? "tool"
2090
- : m.role === "assistant"
2091
- ? "assistant"
2092
- : m.role === "user"
2093
- ? "user"
2094
- : "info",
2095
- text: m.content ?? "",
2096
- toolName: m.toolName,
2097
- }}
2098
- streaming=${false}
2099
- />
2100
- `,
2101
- )}
2102
- </div>
2103
- `
2104
- : html`<div class="empty">empty transcript.</div>`
2105
- }
2106
- </div>
2107
- `;
2108
- }
2109
-
2110
- return html`
2111
- <div>
2112
- <div class="panel-header">
2113
- <h2 class="panel-title">Sessions</h2>
2114
- <span class="panel-subtitle">${sessions.length} saved · click to read</span>
2115
- </div>
2116
- ${
2117
- sessions.length === 0
2118
- ? html`<div class="empty">No saved sessions yet.</div>`
2119
- : html`
2120
- <table>
2121
- <thead>
2122
- <tr>
2123
- <th>name</th>
2124
- <th class="numeric">messages</th>
2125
- <th class="numeric">size</th>
2126
- <th class="numeric">last touched</th>
2127
- </tr>
2128
- </thead>
2129
- <tbody>
2130
- ${sessions.map(
2131
- (s) => html`
2132
- <tr key=${s.name} onClick=${() => view(s.name)} style="cursor: pointer;">
2133
- <td><code>${s.name}</code></td>
2134
- <td class="numeric">${fmtNum(s.messageCount)}</td>
2135
- <td class="numeric">${fmtBytes(s.size)}</td>
2136
- <td class="numeric muted">${fmtRelativeTime(s.mtime)}</td>
2137
- </tr>
2138
- `,
2139
- )}
2140
- </tbody>
2141
- </table>
2142
- `
2143
- }
2144
- </div>
2145
- `;
2146
- }
2147
-
2148
- // ---------- Plans archive ----------
2149
-
2150
- function PlansPanel() {
2151
- const { data, error, loading } = usePoll("/plans", 8000);
2152
- const [open, setOpen] = useState(null);
2153
- if (loading && !data) return html`<div class="boot">loading plans…</div>`;
2154
- if (error) return html`<div class="notice err">plans failed: ${error.message}</div>`;
2155
- const plans = data.plans ?? [];
2156
-
2157
- if (open) {
2158
- const completedSet = new Set(open.completedStepIds);
2159
- return html`
2160
- <div>
2161
- <div class="panel-header">
2162
- <h2 class="panel-title">Plan</h2>
2163
- <span class="panel-subtitle">${open.session} · ${fmtRelativeTime(open.completedAt)}</span>
2164
- <button onClick=${() => setOpen(null)} style="margin-left: auto;">← back</button>
2165
- </div>
2166
- ${open.summary ? html`<div class="notice">${open.summary}</div>` : null}
2167
- <div class="card">
2168
- ${open.steps.map((step) => {
2169
- const done = completedSet.has(step.id);
2170
- return html`
2171
- <div style="padding: 8px 0; border-bottom: 1px solid var(--border); display: flex; gap: 12px;">
2172
- <div style="width: 16px; color: ${done ? "var(--ok)" : "var(--fg-3)"}; font-family: var(--mono);">
2173
- ${done ? "✓" : "·"}
2174
- </div>
2175
- <div style="flex: 1;">
2176
- <div style="color: ${done ? "var(--fg-2)" : "var(--fg-0)"}; font-weight: 500;">
2177
- ${step.title}
2178
- </div>
2179
- ${step.action ? html`<div style="color: var(--fg-2); font-size: 12px; margin-top: 2px;">${step.action}</div>` : null}
2180
- ${step.risk ? html`<span class="pill pill-${step.risk === "high" ? "err" : step.risk === "medium" ? "warn" : "dim"}" style="margin-top: 4px;">${step.risk}</span>` : null}
2181
- </div>
2182
- </div>
2183
- `;
2184
- })}
2185
- </div>
2186
- </div>
2187
- `;
2188
- }
2189
-
2190
- return html`
2191
- <div>
2192
- <div class="panel-header">
2193
- <h2 class="panel-title">Plans</h2>
2194
- <span class="panel-subtitle">${plans.length} archived · click to view</span>
2195
- </div>
2196
- ${
2197
- plans.length === 0
2198
- ? html`<div class="empty">No archived plans yet — run a turn that calls <code>submit_plan</code> + <code>mark_step_complete</code>.</div>`
2199
- : html`
2200
- <table>
2201
- <thead>
2202
- <tr>
2203
- <th>session</th>
2204
- <th>title</th>
2205
- <th class="numeric">progress</th>
2206
- <th class="numeric">archived</th>
2207
- </tr>
2208
- </thead>
2209
- <tbody>
2210
- ${plans.map(
2211
- (p, i) => html`
2212
- <tr key=${i} onClick=${() => setOpen(p)} style="cursor: pointer;">
2213
- <td><code>${p.session}</code></td>
2214
- <td>${p.summary ?? html`<span class="muted">(no title)</span>`}</td>
2215
- <td class="numeric">${p.completedSteps}/${p.totalSteps} · ${fmtPct(p.completionRatio)}</td>
2216
- <td class="numeric muted">${fmtRelativeTime(p.completedAt)}</td>
2217
- </tr>
2218
- `,
2219
- )}
2220
- </tbody>
2221
- </table>
2222
- `
2223
- }
2224
- </div>
2225
- `;
2226
- }
2227
-
2228
- // ---------- Usage time-series chart (uPlot) ----------
2229
-
2230
- let uPlotPromise = null;
2231
- function loadUPlot() {
2232
- if (!uPlotPromise) {
2233
- uPlotPromise = import("https://esm.sh/uplot@1.6.31").then((m) => m.default ?? m);
2234
- }
2235
- return uPlotPromise;
2236
- }
2237
-
2238
- function UsageChart({ days }) {
2239
- const containerRef = useRef(null);
2240
- const plotRef = useRef(null);
2241
-
2242
- useEffect(() => {
2243
- let cancelled = false;
2244
- loadUPlot().then((uPlot) => {
2245
- if (cancelled || !containerRef.current) return;
2246
- // Destroy previous instance on data refresh.
2247
- if (plotRef.current) {
2248
- plotRef.current.destroy();
2249
- plotRef.current = null;
2250
- }
2251
- // Don't render an empty chart — let the parent show a fallback.
2252
- if (!days || days.length === 0) return;
2253
- const xs = days.map((d) => Math.floor(Date.parse(d.day) / 1000));
2254
- const cost = days.map((d) => d.costUsd);
2255
- const saved = days.map((d) => d.cacheSavingsUsd);
2256
- const turns = days.map((d) => d.turns);
2257
- const data = [xs, cost, saved, turns];
2258
- const opts = {
2259
- width: containerRef.current.clientWidth,
2260
- height: 280,
2261
- cursor: { drag: { x: true, y: false } },
2262
- scales: {
2263
- x: { time: true },
2264
- y: { auto: true },
2265
- turns: { auto: true },
2266
- },
2267
- axes: [
2268
- {
2269
- stroke: "#94a3b8",
2270
- grid: { stroke: "rgba(148, 163, 184, 0.08)" },
2271
- },
2272
- {
2273
- scale: "y",
2274
- label: "USD",
2275
- stroke: "#94a3b8",
2276
- grid: { stroke: "rgba(148, 163, 184, 0.08)" },
2277
- values: (_u, v) => v.map((n) => `$${n.toFixed(4)}`),
2278
- },
2279
- {
2280
- scale: "turns",
2281
- side: 1,
2282
- label: "turns",
2283
- stroke: "#94a3b8",
2284
- grid: { show: false },
2285
- },
2286
- ],
2287
- series: [
2288
- {},
2289
- {
2290
- label: "cost",
2291
- stroke: "#67e8f9",
2292
- width: 2,
2293
- fill: "rgba(103, 232, 249, 0.10)",
2294
- },
2295
- {
2296
- label: "cache saved",
2297
- stroke: "#5eead4",
2298
- width: 2,
2299
- dash: [4, 4],
2300
- },
2301
- {
2302
- label: "turns",
2303
- stroke: "#c4b5fd",
2304
- scale: "turns",
2305
- width: 1.5,
2306
- points: { show: true, size: 4 },
2307
- },
2308
- ],
2309
- legend: { live: true },
2310
- };
2311
- plotRef.current = new uPlot(opts, data, containerRef.current);
2312
- });
2313
-
2314
- // Resize observer keeps the chart at full panel width.
2315
- const ro = new ResizeObserver(() => {
2316
- if (plotRef.current && containerRef.current) {
2317
- plotRef.current.setSize({
2318
- width: containerRef.current.clientWidth,
2319
- height: 280,
2320
- });
2321
- }
2322
- });
2323
- if (containerRef.current) ro.observe(containerRef.current);
2324
-
2325
- return () => {
2326
- cancelled = true;
2327
- ro.disconnect();
2328
- if (plotRef.current) {
2329
- plotRef.current.destroy();
2330
- plotRef.current = null;
2331
- }
2332
- };
2333
- }, [days]);
2334
-
2335
- return html`<div ref=${containerRef} style="width: 100%; min-height: 280px;"></div>`;
2336
- }
2337
-
2338
- // ---------- existing UsagePanel rewrite — chart + table ----------
2339
-
2340
- function UsageWithChart() {
2341
- const { data: summary, error, loading } = usePoll("/usage", 5000);
2342
- const [series, setSeries] = useState(null);
2343
-
2344
- useEffect(() => {
2345
- let cancelled = false;
2346
- (async () => {
2347
- try {
2348
- const s = await api("/usage/series");
2349
- if (!cancelled) setSeries(s.days ?? []);
2350
- } catch {
2351
- /* keep null; chart hides */
2352
- }
2353
- })();
2354
- const t = setInterval(async () => {
2355
- try {
2356
- const s = await api("/usage/series");
2357
- if (!cancelled) setSeries(s.days ?? []);
2358
- } catch {
2359
- /* swallow */
2360
- }
2361
- }, 30_000);
2362
- return () => {
2363
- cancelled = true;
2364
- clearInterval(t);
2365
- };
2366
- }, []);
2367
-
2368
- if (loading && !summary) return html`<div class="boot">loading usage…</div>`;
2369
- if (error) return html`<div class="notice err">usage failed: ${error.message}</div>`;
2370
- const u = summary;
2371
-
2372
- return html`
2373
- <div>
2374
- <div class="panel-header">
2375
- <h2 class="panel-title">Usage</h2>
2376
- <span class="panel-subtitle">${u.recordCount.toLocaleString()} records · ${u.logSize}</span>
2377
- </div>
2378
-
2379
- ${
2380
- series && series.length > 0
2381
- ? html`
2382
- <div class="card" style="padding: 18px;">
2383
- <div class="card-title" style="margin-bottom: 12px;">Daily usage (cost · cache saved · turns)</div>
2384
- <${UsageChart} days=${series} />
2385
- </div>
2386
- `
2387
- : null
2388
- }
2389
-
2390
- ${
2391
- u.recordCount === 0
2392
- ? html`<div class="empty" style="margin-top: 16px;">No usage data yet — run a turn in <code>reasonix chat</code> / <code>code</code> / <code>run</code> and refresh.</div>`
2393
- : html`
2394
- <div class="section-title">Rolling windows</div>
2395
- <table>
2396
- <thead>
2397
- <tr>
2398
- <th></th>
2399
- <th class="numeric">turns</th>
2400
- <th class="numeric">cache hit</th>
2401
- <th class="numeric">cost (USD)</th>
2402
- <th class="numeric">cache saved</th>
2403
- <th class="numeric">vs Claude</th>
2404
- <th class="numeric">saved</th>
2405
- </tr>
2406
- </thead>
2407
- <tbody>
2408
- ${u.buckets.map((b) => {
2409
- const hitRatio =
2410
- b.cacheHitTokens + b.cacheMissTokens > 0
2411
- ? b.cacheHitTokens / (b.cacheHitTokens + b.cacheMissTokens)
2412
- : 0;
2413
- const claudeSavings = b.claudeEquivUsd > 0 ? 1 - b.costUsd / b.claudeEquivUsd : 0;
2414
- return html`
2415
- <tr>
2416
- <td>${b.label}</td>
2417
- <td class="numeric">${fmtNum(b.turns)}</td>
2418
- <td class="numeric">${b.turns > 0 ? fmtPct(hitRatio) : "—"}</td>
2419
- <td class="numeric">${b.turns > 0 ? fmtUsd(b.costUsd) : "—"}</td>
2420
- <td class="numeric">${b.turns > 0 && b.cacheSavingsUsd > 0 ? fmtUsd(b.cacheSavingsUsd) : "—"}</td>
2421
- <td class="numeric">${b.turns > 0 ? fmtUsd(b.claudeEquivUsd) : "—"}</td>
2422
- <td class="numeric">${b.turns > 0 && claudeSavings > 0 ? fmtPct(claudeSavings) : "—"}</td>
2423
- </tr>
2424
- `;
2425
- })}
2426
- </tbody>
2427
- </table>
2428
- `
2429
- }
2430
-
2431
- ${
2432
- u.byModel.length > 0
2433
- ? html`
2434
- <div class="section-title">Most used models</div>
2435
- <table>
2436
- <thead><tr><th>model</th><th class="numeric">turns</th></tr></thead>
2437
- <tbody>
2438
- ${u.byModel.slice(0, 5).map(
2439
- (m) => html`
2440
- <tr><td><code>${m.model}</code></td><td class="numeric">${fmtNum(m.turns)}</td></tr>
2441
- `,
2442
- )}
2443
- </tbody>
2444
- </table>
2445
- `
2446
- : null
2447
- }
2448
- </div>
2449
- `;
2450
- }
2451
-
2452
- // ---------- Settings ----------
2453
-
2454
- function SettingsPanel() {
2455
- const [data, setData] = useState(null);
2456
- const [error, setError] = useState(null);
2457
- const [saving, setSaving] = useState(false);
2458
- const [saved, setSaved] = useState(null);
2459
- const [draft, setDraft] = useState({});
2460
-
2461
- const load = useCallback(async () => {
2462
- try {
2463
- const r = await api("/settings");
2464
- setData(r);
2465
- setDraft({});
2466
- } catch (err) {
2467
- setError(err.message);
2468
- }
2469
- }, []);
2470
- useEffect(() => {
2471
- load();
2472
- }, [load]);
2473
-
2474
- const save = useCallback(
2475
- async (fields) => {
2476
- setSaving(true);
2477
- setError(null);
2478
- try {
2479
- await api("/settings", { method: "POST", body: fields });
2480
- await load();
2481
- setSaved(`saved: ${Object.keys(fields).join(", ")}`);
2482
- setTimeout(() => setSaved(null), 3000);
2483
- } catch (err) {
2484
- setError(err.message);
2485
- } finally {
2486
- setSaving(false);
2487
- }
2488
- },
2489
- [load],
2490
- );
2491
-
2492
- if (!data && !error) return html`<div class="boot">loading settings…</div>`;
2493
- if (error && !data) return html`<div class="notice err">${error}</div>`;
2494
- const v = data;
2495
-
2496
- return html`
2497
- <div>
2498
- <div class="panel-header">
2499
- <h2 class="panel-title">Settings</h2>
2500
- <span class="panel-subtitle">~/.reasonix/config.json · most fields apply next session</span>
2501
- </div>
2502
- ${saved ? html`<div class="notice">${saved}</div>` : null}
2503
- ${error ? html`<div class="notice err">${error}</div>` : null}
2504
-
2505
- <div class="section-title">DeepSeek API</div>
2506
- <div class="card">
2507
- <div class="row">
2508
- <span class="card-title" style="margin: 0;">API key</span>
2509
- <code style="margin-left: auto;">${v.apiKey ?? "(not set)"}</code>
2510
- </div>
2511
- <div class="row" style="margin-top: 8px;">
2512
- <input
2513
- type="password"
2514
- placeholder="paste a fresh sk-… token to replace"
2515
- value=${draft.apiKey ?? ""}
2516
- onInput=${(e) => setDraft({ ...draft, apiKey: e.target.value })}
2517
- />
2518
- <button
2519
- class="primary"
2520
- disabled=${saving || !(draft.apiKey ?? "").trim()}
2521
- onClick=${() => save({ apiKey: draft.apiKey })}
2522
- >Save key</button>
2523
- </div>
2524
- <div class="row" style="margin-top: 12px;">
2525
- <span class="card-title" style="margin: 0;">Base URL</span>
2526
- <input
2527
- type="text"
2528
- value=${draft.baseUrl ?? v.baseUrl ?? ""}
2529
- placeholder="https://api.deepseek.com (default)"
2530
- onInput=${(e) => setDraft({ ...draft, baseUrl: e.target.value })}
2531
- />
2532
- <button
2533
- disabled=${saving || (draft.baseUrl ?? v.baseUrl ?? "") === (v.baseUrl ?? "")}
2534
- onClick=${() => save({ baseUrl: draft.baseUrl })}
2535
- >Save</button>
2536
- </div>
2537
- </div>
2538
-
2539
- <div class="section-title">Defaults</div>
2540
- <div class="card">
2541
- <div class="row">
2542
- <span class="card-title" style="margin: 0; flex: 0 0 110px;">Preset</span>
2543
- <select
2544
- value=${
2545
- // Unknown values (legacy fast/smart/max, or anything
2546
- // hand-edited into config.json) display as `auto`.
2547
- ["auto", "flash", "pro"].includes(v.preset) ? v.preset : "auto"
2548
- }
2549
- onChange=${(e) => save({ preset: e.target.value })}
2550
- disabled=${saving}
2551
- >
2552
- <option value="auto">auto — flash → pro on hard turns (default)</option>
2553
- <option value="flash">flash — always flash, no auto-escalate</option>
2554
- <option value="pro">pro — always pro</option>
2555
- </select>
2556
- <span class="muted" style="margin-left: auto; font-size: 12px;">applies next turn</span>
2557
- </div>
2558
- <div class="row" style="margin-top: 12px;">
2559
- <span class="card-title" style="margin: 0; flex: 0 0 110px;">Effort</span>
2560
- <select
2561
- value=${v.reasoningEffort}
2562
- onChange=${(e) => save({ reasoningEffort: e.target.value })}
2563
- disabled=${saving}
2564
- >
2565
- <option value="max">max (default — best)</option>
2566
- <option value="high">high (cheaper / faster)</option>
2567
- </select>
2568
- <span class="muted" style="margin-left: auto; font-size: 12px;">applies next turn</span>
2569
- </div>
2570
- <div class="row" style="margin-top: 12px;">
2571
- <span class="card-title" style="margin: 0; flex: 0 0 110px;">Web search</span>
2572
- <button
2573
- class=${v.search ? "primary" : ""}
2574
- onClick=${() => save({ search: !v.search })}
2575
- disabled=${saving}
2576
- >${v.search ? "ON" : "off"}</button>
2577
- <span class="muted" style="margin-left: auto; font-size: 12px;">web_fetch + web_search tools</span>
2578
- </div>
2579
- </div>
2580
-
2581
- <div class="section-title">Runtime</div>
2582
- <div class="card">
2583
- <div class="row">
2584
- <span class="card-title" style="margin: 0; flex: 0 0 110px;">Active model</span>
2585
- <code>${v.model ?? "—"}</code>
2586
- </div>
2587
- <div class="row" style="margin-top: 8px;">
2588
- <span class="card-title" style="margin: 0; flex: 0 0 110px;">Edit mode</span>
2589
- <code>${v.editMode}</code>
2590
- <span class="muted" style="margin-left: auto; font-size: 12px;">switch from the Chat tab header</span>
2591
- </div>
2592
- </div>
2593
- </div>
2594
- `;
2595
- }
2596
-
2597
- // ---------- Hooks ----------
2598
-
2599
- function HooksPanel() {
2600
- const [data, setData] = useState(null);
2601
- const [error, setError] = useState(null);
2602
- const [drafts, setDrafts] = useState({}); // { project: jsonText, global: jsonText }
2603
- const [busy, setBusy] = useState(false);
2604
- const [info, setInfo] = useState(null);
2605
-
2606
- const load = useCallback(async () => {
2607
- try {
2608
- const r = await api("/hooks");
2609
- setData(r);
2610
- setDrafts({
2611
- project: JSON.stringify(r.project.hooks ?? {}, null, 2),
2612
- global: JSON.stringify(r.global.hooks ?? {}, null, 2),
2613
- });
2614
- } catch (err) {
2615
- setError(err.message);
2616
- }
2617
- }, []);
2618
- useEffect(() => {
2619
- load();
2620
- }, [load]);
2621
-
2622
- const saveScope = useCallback(
2623
- async (scope) => {
2624
- setBusy(true);
2625
- setError(null);
2626
- let parsed;
2627
- try {
2628
- parsed = JSON.parse(drafts[scope]);
2629
- } catch (err) {
2630
- setError(`${scope} JSON: ${err.message}`);
2631
- setBusy(false);
2632
- return;
2633
- }
2634
- try {
2635
- await api("/hooks/save", { method: "POST", body: { scope, hooks: parsed } });
2636
- await api("/hooks/reload", { method: "POST", body: {} });
2637
- setInfo(`saved + reloaded ${scope}`);
2638
- setTimeout(() => setInfo(null), 3000);
2639
- await load();
2640
- } catch (err) {
2641
- setError(err.message);
2642
- } finally {
2643
- setBusy(false);
2644
- }
2645
- },
2646
- [drafts, load],
2647
- );
2648
-
2649
- if (!data && !error) return html`<div class="boot">loading hooks…</div>`;
2650
- if (error && !data) return html`<div class="notice err">${error}</div>`;
2651
-
2652
- return html`
2653
- <div>
2654
- <div class="panel-header">
2655
- <h2 class="panel-title">Hooks</h2>
2656
- <span class="panel-subtitle">${data.resolved.length} resolved · events: ${data.events.join(", ")}</span>
2657
- </div>
2658
- ${info ? html`<div class="notice">${info}</div>` : null}
2659
- ${error ? html`<div class="notice err">${error}</div>` : null}
2660
- ${["project", "global"].map((scope) => {
2661
- const meta = data[scope];
2662
- return html`
2663
- <div class="section-title">${scope} — <code>${meta.path ?? "(no project)"}</code></div>
2664
- ${
2665
- scope === "project" && !meta.path
2666
- ? html`<div class="empty">No active project — open <code>/dashboard</code> from <code>reasonix code</code> to edit project hooks.</div>`
2667
- : html`
2668
- <textarea
2669
- style="width: 100%; height: 240px; font-family: var(--mono); font-size: 12.5px; background: var(--bg-2); color: var(--fg-0); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 10px;"
2670
- value=${drafts[scope] ?? ""}
2671
- onInput=${(e) => setDrafts({ ...drafts, [scope]: e.target.value })}
2672
- disabled=${busy}
2673
- ></textarea>
2674
- <div class="row" style="margin-top: 8px;">
2675
- <button class="primary" disabled=${busy} onClick=${() => saveScope(scope)}>Save + Reload</button>
2676
- <button disabled=${busy} onClick=${load}>Discard changes</button>
2677
- </div>
2678
- `
2679
- }
2680
- `;
2681
- })}
2682
- </div>
2683
- `;
2684
- }
2685
-
2686
- // ---------- Memory ----------
2687
-
2688
- function MemoryPanel() {
2689
- const [tree, setTree] = useState(null);
2690
- const [error, setError] = useState(null);
2691
- const [open, setOpen] = useState(null); // { scope, name } | null
2692
- const [body, setBody] = useState("");
2693
- const [busy, setBusy] = useState(false);
2694
- const [info, setInfo] = useState(null);
2695
-
2696
- const load = useCallback(async () => {
2697
- try {
2698
- const r = await api("/memory");
2699
- setTree(r);
2700
- } catch (err) {
2701
- setError(err.message);
2702
- }
2703
- }, []);
2704
- useEffect(() => {
2705
- load();
2706
- }, [load]);
2707
-
2708
- const openFile = useCallback(async (scope, name) => {
2709
- setOpen({ scope, name });
2710
- setBusy(true);
2711
- try {
2712
- const path =
2713
- scope === "project" ? "/memory/project" : `/memory/${scope}/${encodeURIComponent(name)}`;
2714
- const r = await api(path);
2715
- setBody(r.body);
2716
- } catch (err) {
2717
- setError(err.message);
2718
- } finally {
2719
- setBusy(false);
2720
- }
2721
- }, []);
2722
-
2723
- const save = useCallback(async () => {
2724
- if (!open) return;
2725
- setBusy(true);
2726
- setError(null);
2727
- try {
2728
- const path =
2729
- open.scope === "project"
2730
- ? "/memory/project"
2731
- : `/memory/${open.scope}/${encodeURIComponent(open.name)}`;
2732
- await api(path, { method: "POST", body: { body } });
2733
- setInfo(`saved ${open.scope}${open.name ? `/${open.name}` : ""}`);
2734
- setTimeout(() => setInfo(null), 3000);
2735
- await load();
2736
- } catch (err) {
2737
- setError(err.message);
2738
- } finally {
2739
- setBusy(false);
2740
- }
2741
- }, [open, body, load]);
2742
-
2743
- if (!tree && !error) return html`<div class="boot">loading memory…</div>`;
2744
- if (error && !tree) return html`<div class="notice err">${error}</div>`;
2745
-
2746
- if (open) {
2747
- return html`
2748
- <div>
2749
- <div class="panel-header">
2750
- <h2 class="panel-title">Memory · ${open.scope}${open.name ? `/${open.name}` : ""}</h2>
2751
- <button onClick=${() => setOpen(null)} style="margin-left: auto;">← back</button>
2752
- </div>
2753
- ${info ? html`<div class="notice">${info}</div>` : null}
2754
- ${error ? html`<div class="notice err">${error}</div>` : null}
2755
- <textarea
2756
- style="width: 100%; height: 480px; font-family: var(--mono); font-size: 13px; background: var(--bg-2); color: var(--fg-0); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 12px;"
2757
- value=${body}
2758
- onInput=${(e) => setBody(e.target.value)}
2759
- disabled=${busy}
2760
- ></textarea>
2761
- <div class="row" style="margin-top: 8px;">
2762
- <button class="primary" disabled=${busy} onClick=${save}>Save</button>
2763
- <span class="muted" style="font-size: 12px;">${body.length.toLocaleString()} chars · re-applied on next /new or session restart</span>
2764
- </div>
2765
- </div>
2766
- `;
2767
- }
2768
-
2769
- return html`
2770
- <div>
2771
- <div class="panel-header">
2772
- <h2 class="panel-title">Memory</h2>
2773
- <span class="panel-subtitle">REASONIX.md (committable) + private notes (~/.reasonix/memory)</span>
2774
- </div>
2775
- <div class="section-title">Project — REASONIX.md</div>
2776
- ${
2777
- tree.project.path
2778
- ? html`
2779
- <div class="card row" style="cursor: pointer;" onClick=${() => openFile("project")}>
2780
- <span><code>${tree.project.path}</code></span>
2781
- <span class="pill ${tree.project.exists ? "pill-ok" : "pill-dim"}" style="margin-left: auto;">
2782
- ${tree.project.exists ? "exists" : "create"}
2783
- </span>
2784
- </div>
2785
- `
2786
- : html`<div class="empty">No active project.</div>`
2787
- }
2788
-
2789
- <div class="section-title">Global — ~/.reasonix/memory/global</div>
2790
- ${
2791
- tree.global.files.length === 0
2792
- ? html`<div class="empty">No global memory files yet.</div>`
2793
- : html`
2794
- <table>
2795
- <thead><tr><th>name</th><th class="numeric">size</th><th class="numeric">touched</th></tr></thead>
2796
- <tbody>
2797
- ${tree.global.files.map(
2798
- (f) => html`
2799
- <tr key=${f.name} style="cursor: pointer;" onClick=${() => openFile("global", f.name)}>
2800
- <td><code>${f.name}</code></td>
2801
- <td class="numeric">${fmtBytes(f.size)}</td>
2802
- <td class="numeric muted">${fmtRelativeTime(f.mtime)}</td>
2803
- </tr>
2804
- `,
2805
- )}
2806
- </tbody>
2807
- </table>
2808
- `
2809
- }
2810
-
2811
- ${
2812
- tree.projectMem.path
2813
- ? html`
2814
- <div class="section-title">Project private — ~/.reasonix/memory/&lt;hash&gt;</div>
2815
- ${
2816
- tree.projectMem.files.length === 0
2817
- ? html`<div class="empty">No project-private memory yet.</div>`
2818
- : html`
2819
- <table>
2820
- <thead><tr><th>name</th><th class="numeric">size</th><th class="numeric">touched</th></tr></thead>
2821
- <tbody>
2822
- ${tree.projectMem.files.map(
2823
- (f) => html`
2824
- <tr key=${f.name} style="cursor: pointer;" onClick=${() => openFile("project-mem", f.name)}>
2825
- <td><code>${f.name}</code></td>
2826
- <td class="numeric">${fmtBytes(f.size)}</td>
2827
- <td class="numeric muted">${fmtRelativeTime(f.mtime)}</td>
2828
- </tr>
2829
- `,
2830
- )}
2831
- </tbody>
2832
- </table>
2833
- `
2834
- }
2835
- `
2836
- : null
2837
- }
2838
- </div>
2839
- `;
2840
- }
2841
-
2842
- // ---------- Skills ----------
2843
-
2844
- function SkillsPanel() {
2845
- const [data, setData] = useState(null);
2846
- const [error, setError] = useState(null);
2847
- const [open, setOpen] = useState(null);
2848
- const [body, setBody] = useState("");
2849
- const [busy, setBusy] = useState(false);
2850
- const [info, setInfo] = useState(null);
2851
- const [newName, setNewName] = useState("");
2852
- const [newScope, setNewScope] = useState("global");
2853
-
2854
- const load = useCallback(async () => {
2855
- try {
2856
- setData(await api("/skills"));
2857
- } catch (err) {
2858
- setError(err.message);
2859
- }
2860
- }, []);
2861
- useEffect(() => {
2862
- load();
2863
- }, [load]);
2864
-
2865
- const openSkill = useCallback(async (scope, name) => {
2866
- setOpen({ scope, name });
2867
- setBusy(true);
2868
- try {
2869
- const r = await api(`/skills/${scope}/${encodeURIComponent(name)}`);
2870
- setBody(r.body);
2871
- } catch (err) {
2872
- setError(err.message);
2873
- } finally {
2874
- setBusy(false);
2875
- }
2876
- }, []);
2877
-
2878
- const save = useCallback(async () => {
2879
- if (!open) return;
2880
- setBusy(true);
2881
- try {
2882
- await api(`/skills/${open.scope}/${encodeURIComponent(open.name)}`, {
2883
- method: "POST",
2884
- body: { body },
2885
- });
2886
- setInfo(`saved ${open.scope}/${open.name}`);
2887
- setTimeout(() => setInfo(null), 3000);
2888
- await load();
2889
- } catch (err) {
2890
- setError(err.message);
2891
- } finally {
2892
- setBusy(false);
2893
- }
2894
- }, [open, body, load]);
2895
-
2896
- const remove = useCallback(async () => {
2897
- if (!open) return;
2898
- if (!confirm(`Delete skill ${open.scope}/${open.name}?`)) return;
2899
- setBusy(true);
2900
- try {
2901
- await api(`/skills/${open.scope}/${encodeURIComponent(open.name)}`, { method: "DELETE" });
2902
- setOpen(null);
2903
- await load();
2904
- } catch (err) {
2905
- setError(err.message);
2906
- } finally {
2907
- setBusy(false);
2908
- }
2909
- }, [open, load]);
2910
-
2911
- const create = useCallback(async () => {
2912
- if (!newName.trim()) return;
2913
- setBusy(true);
2914
- const stub = `---\nname: ${newName.trim()}\ndescription: TODO — one-line description that helps the model match this skill\n---\n\n# ${newName.trim()}\n\n`;
2915
- try {
2916
- await api(`/skills/${newScope}/${encodeURIComponent(newName.trim())}`, {
2917
- method: "POST",
2918
- body: { body: stub },
2919
- });
2920
- setNewName("");
2921
- await load();
2922
- openSkill(newScope, newName.trim());
2923
- } catch (err) {
2924
- setError(err.message);
2925
- } finally {
2926
- setBusy(false);
2927
- }
2928
- }, [newName, newScope, load, openSkill]);
2929
-
2930
- if (!data && !error) return html`<div class="boot">loading skills…</div>`;
2931
- if (error && !data) return html`<div class="notice err">${error}</div>`;
2932
-
2933
- if (open) {
2934
- return html`
2935
- <div>
2936
- <div class="panel-header">
2937
- <h2 class="panel-title">Skill · ${open.scope}/${open.name}</h2>
2938
- <button onClick=${() => setOpen(null)} style="margin-left: auto;">← back</button>
2939
- </div>
2940
- ${info ? html`<div class="notice">${info}</div>` : null}
2941
- ${error ? html`<div class="notice err">${error}</div>` : null}
2942
- <textarea
2943
- style="width: 100%; height: 520px; font-family: var(--mono); font-size: 13px; background: var(--bg-2); color: var(--fg-0); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 12px;"
2944
- value=${body}
2945
- onInput=${(e) => setBody(e.target.value)}
2946
- disabled=${busy}
2947
- ></textarea>
2948
- <div class="row" style="margin-top: 8px;">
2949
- <button class="primary" disabled=${busy} onClick=${save}>Save</button>
2950
- <button class="danger" disabled=${busy} onClick=${remove}>Delete</button>
2951
- <span class="muted" style="font-size: 12px; margin-left: auto;">re-loaded on next /new or session restart</span>
2952
- </div>
2953
- </div>
2954
- `;
2955
- }
2956
-
2957
- const renderList = (label, items, scope) => html`
2958
- <div class="section-title">${label} (${items.length})</div>
2959
- ${
2960
- items.length === 0
2961
- ? html`<div class="empty">none</div>`
2962
- : html`
2963
- <table>
2964
- <thead><tr><th>name</th><th>description</th><th></th></tr></thead>
2965
- <tbody>
2966
- ${items.map(
2967
- (s) => html`
2968
- <tr key=${s.name} style="cursor: ${scope === "builtin" ? "default" : "pointer"};" onClick=${() => scope !== "builtin" && openSkill(scope, s.name)}>
2969
- <td><code>${s.name}</code></td>
2970
- <td>${s.description ?? html`<span class="muted">(no description)</span>`}</td>
2971
- <td>${scope === "builtin" ? html`<span class="pill pill-dim">builtin</span>` : null}</td>
2972
- </tr>
2973
- `,
2974
- )}
2975
- </tbody>
2976
- </table>
2977
- `
2978
- }
2979
- `;
2980
-
2981
- return html`
2982
- <div>
2983
- <div class="panel-header">
2984
- <h2 class="panel-title">Skills</h2>
2985
- <span class="panel-subtitle">click to edit · creates land in next /new</span>
2986
- </div>
2987
- ${error ? html`<div class="notice err">${error}</div>` : null}
2988
-
2989
- <div class="section-title">Create new</div>
2990
- <div class="card row">
2991
- <select value=${newScope} onChange=${(e) => setNewScope(e.target.value)}>
2992
- <option value="global">global</option>
2993
- ${data.paths.project ? html`<option value="project">project</option>` : null}
2994
- </select>
2995
- <input
2996
- type="text"
2997
- placeholder="skill-name"
2998
- value=${newName}
2999
- onInput=${(e) => setNewName(e.target.value)}
3000
- />
3001
- <button class="primary" disabled=${busy || !newName.trim()} onClick=${create}>Create</button>
3002
- </div>
3003
-
3004
- ${renderList("Project", data.project, "project")}
3005
- ${renderList("Global", data.global, "global")}
3006
- ${renderList("Builtin (read-only)", data.builtin, "builtin")}
3007
- </div>
3008
- `;
3009
- }
3010
-
3011
- // ---------- MCP ----------
3012
-
3013
- // ---------- Semantic index ----------
3014
-
3015
- function SemanticPanel() {
3016
- const [data, setData] = useState(null);
3017
- const [error, setError] = useState(null);
3018
- const [busy, setBusy] = useState(false);
3019
- const [info, setInfo] = useState(null);
3020
-
3021
- const load = useCallback(async () => {
3022
- try {
3023
- const r = await api("/semantic");
3024
- setData(r);
3025
- } catch (err) {
3026
- setError(err.message);
3027
- }
3028
- }, []);
3029
-
3030
- // Poll fast while a job is running OR while ollama is pulling a
3031
- // model (the latest-line readout updates every few hundred ms during
3032
- // a download). Slow when idle so the panel doesn't burn network just
3033
- // sitting open in a tab.
3034
- useEffect(() => {
3035
- load();
3036
- const phase = data?.job?.phase;
3037
- const running = phase === "scan" || phase === "embed" || phase === "write";
3038
- const pulling = data?.pull?.status === "pulling";
3039
- const ms = running || pulling ? 1200 : 5000;
3040
- const id = setInterval(load, ms);
3041
- return () => clearInterval(id);
3042
- }, [load, data?.job?.phase, data?.pull?.status]);
3043
-
3044
- const start = useCallback(
3045
- async (rebuild) => {
3046
- setBusy(true);
3047
- setError(null);
3048
- setInfo(null);
3049
- try {
3050
- await api("/semantic/start", { method: "POST", body: { rebuild: !!rebuild } });
3051
- setInfo(rebuild ? "rebuild started" : "incremental index started");
3052
- await load();
3053
- } catch (err) {
3054
- setError(err.message);
3055
- } finally {
3056
- setBusy(false);
3057
- }
3058
- },
3059
- [load],
3060
- );
3061
-
3062
- const stop = useCallback(async () => {
3063
- setBusy(true);
3064
- setError(null);
3065
- try {
3066
- await api("/semantic/stop", { method: "POST", body: {} });
3067
- setInfo("stopping requested — current chunk batch will finish first");
3068
- await load();
3069
- } catch (err) {
3070
- setError(err.message);
3071
- } finally {
3072
- setBusy(false);
3073
- }
3074
- }, [load]);
3075
-
3076
- const startDaemon = useCallback(async () => {
3077
- setBusy(true);
3078
- setError(null);
3079
- setInfo("starting ollama daemon (15s timeout)…");
3080
- try {
3081
- const r = await api("/semantic/ollama/start", { method: "POST", body: {} });
3082
- setInfo(
3083
- r.ready ? "daemon is up" : "daemon didn't come up in time — check `ollama serve` manually",
3084
- );
3085
- await load();
3086
- } catch (err) {
3087
- setError(err.message);
3088
- } finally {
3089
- setBusy(false);
3090
- }
3091
- }, [load]);
3092
-
3093
- const pullModel = useCallback(
3094
- async (model) => {
3095
- setBusy(true);
3096
- setError(null);
3097
- setInfo(`pulling ${model} — this may take a few minutes on first install`);
3098
- try {
3099
- await api("/semantic/ollama/pull", { method: "POST", body: { model } });
3100
- await load();
3101
- } catch (err) {
3102
- setError(err.message);
3103
- } finally {
3104
- setBusy(false);
3105
- }
3106
- },
3107
- [load],
3108
- );
3109
-
3110
- if (!data && !error) return html`<div class="boot">loading semantic status…</div>`;
3111
- if (error && !data) return html`<div class="notice err">${error}</div>`;
3112
-
3113
- if (data && !data.attached) {
3114
- return html`
3115
- <div>
3116
- <div class="panel-header">
3117
- <h2 class="panel-title">Semantic</h2>
3118
- <span class="panel-subtitle">code-mode required</span>
3119
- </div>
3120
- <div class="empty">${data.reason}</div>
3121
- </div>
3122
- `;
3123
- }
3124
-
3125
- const job = data.job;
3126
- const phase = job?.phase;
3127
- const running = phase === "scan" || phase === "embed" || phase === "write";
3128
- const pull = data.pull;
3129
- const pulling = pull?.status === "pulling";
3130
-
3131
- // Tri-state Ollama check. Each level gates the next:
3132
- // binary missing → user must install (we won't run a package
3133
- // manager on their behalf).
3134
- // daemon down → one-click start (`ollama serve`).
3135
- // model missing → one-click pull.
3136
- // all good → ready to index.
3137
- const o = data.ollama ?? {};
3138
- const binaryFound = o.binaryFound === true;
3139
- const daemonRunning = o.daemonRunning === true;
3140
- const modelPulled = o.modelPulled === true;
3141
- const modelName = o.modelName ?? "nomic-embed-text";
3142
- const installedModels = o.installedModels ?? [];
3143
- const ready = binaryFound && daemonRunning && modelPulled;
3144
-
3145
- return html`
3146
- <div>
3147
- <div class="panel-header">
3148
- <h2 class="panel-title">Semantic</h2>
3149
- <span class="panel-subtitle">${data.index.exists ? "index built" : "no index yet"}</span>
3150
- </div>
3151
- ${info ? html`<div class="notice">${info}</div>` : null}
3152
- ${error ? html`<div class="notice err">${error}</div>` : null}
3153
-
3154
- <div class="section-title">Status</div>
3155
- <div class="kv">
3156
- <div><span class="kv-key">project</span><code>${data.root}</code></div>
3157
- <div>
3158
- <span class="kv-key">ollama</span>
3159
- ${
3160
- binaryFound
3161
- ? daemonRunning
3162
- ? html`<span class="pill pill-ok">reachable</span><span class="muted" style="margin-left: 8px;">${installedModels.length} model(s)${
3163
- installedModels.length > 0
3164
- ? ` · ${installedModels.slice(0, 3).join(", ")}${installedModels.length > 3 ? "…" : ""}`
3165
- : ""
3166
- }</span>`
3167
- : html`<span class="pill pill-warn">daemon down</span><span class="muted" style="margin-left: 8px;">binary on PATH but not serving</span>`
3168
- : html`<span class="pill pill-err">not installed</span><span class="muted" style="margin-left: 8px;">${o.error ?? "ollama binary not on PATH"}</span>`
3169
- }
3170
- </div>
3171
- <div>
3172
- <span class="kv-key">model</span>
3173
- <code>${modelName}</code>
3174
- ${
3175
- modelPulled
3176
- ? html`<span class="pill pill-ok" style="margin-left: 8px;">pulled</span>`
3177
- : daemonRunning
3178
- ? html`<span class="pill pill-warn" style="margin-left: 8px;">not pulled</span>`
3179
- : html`<span class="pill pill-dim" style="margin-left: 8px;">unknown (daemon down)</span>`
3180
- }
3181
- </div>
3182
- <div>
3183
- <span class="kv-key">index</span>
3184
- ${
3185
- data.index.exists
3186
- ? html`<span class="muted">present at <code>.reasonix/semantic/</code></span>`
3187
- : html`<span class="muted">none — run an index to enable <code>semantic_search</code></span>`
3188
- }
3189
- </div>
3190
- </div>
3191
-
3192
- ${
3193
- !binaryFound
3194
- ? html`
3195
- <div class="section-title">Install Ollama</div>
3196
- <div class="card" style="font-size: 13px;">
3197
- Reasonix doesn't run package managers for you. Install Ollama
3198
- first, then come back to this panel:
3199
- <ul style="margin: 10px 0 4px 18px; padding: 0;">
3200
- <li><strong>macOS / Windows:</strong> download from <a href="https://ollama.com/download" target="_blank" rel="noreferrer">ollama.com/download</a></li>
3201
- <li><strong>Linux:</strong> <code>curl -fsSL https://ollama.com/install.sh | sh</code></li>
3202
- </ul>
3203
- <div class="muted" style="margin-top: 8px;">After install, this panel will offer to start the daemon and pull <code>${modelName}</code> for you. Refresh after installing.</div>
3204
- </div>
3205
- `
3206
- : null
3207
- }
3208
-
3209
- ${
3210
- binaryFound && !daemonRunning
3211
- ? html`
3212
- <div class="section-title">Daemon</div>
3213
- <div class="card" style="font-size: 13px;">
3214
- <code>ollama</code> is on your PATH but the HTTP daemon isn't reachable.
3215
- <div class="row" style="margin-top: 10px;">
3216
- <button class="primary" disabled=${busy} onClick=${startDaemon}>Start daemon</button>
3217
- <span class="muted" style="font-size: 12px; align-self: center;">runs <code>ollama serve</code> detached — survives Reasonix exit</span>
3218
- </div>
3219
- </div>
3220
- `
3221
- : null
3222
- }
3223
-
3224
- ${
3225
- daemonRunning && !modelPulled
3226
- ? html`
3227
- <div class="section-title">Model</div>
3228
- <div class="card" style="font-size: 13px;">
3229
- <code>${modelName}</code> isn't installed yet. ${pulling ? "" : "~270 MB download on first pull."}
3230
- <div class="row" style="margin-top: 10px;">
3231
- <button
3232
- class="primary"
3233
- disabled=${busy || pulling}
3234
- onClick=${() => pullModel(modelName)}
3235
- >${pulling ? "pulling…" : `Pull ${modelName}`}</button>
3236
- </div>
3237
- ${
3238
- pull
3239
- ? html`
3240
- <div class="kv" style="margin-top: 10px;">
3241
- <div>
3242
- <span class="kv-key">status</span>
3243
- <span class=${`pill ${pull.status === "done" ? "pill-ok" : pull.status === "error" ? "pill-err" : "pill-active"}`}>${pull.status}</span>
3244
- <span class="muted" style="margin-left: 8px;">${((Date.now() - pull.startedAt) / 1000).toFixed(1)}s</span>
3245
- </div>
3246
- ${
3247
- pull.lastLine
3248
- ? html`<div><span class="kv-key">last</span><code style="font-size: 11.5px;">${pull.lastLine}</code></div>`
3249
- : null
3250
- }
3251
- </div>
3252
- `
3253
- : null
3254
- }
3255
- </div>
3256
- `
3257
- : null
3258
- }
3259
-
3260
- <div class="section-title">Job</div>
3261
- ${job ? html`<${SemanticJobView} job=${job} running=${running} />` : html`<div class="muted">No job has run in this dashboard yet.</div>`}
3262
-
3263
- <div class="row" style="margin-top: 14px;">
3264
- <button class="primary" disabled=${busy || running || !ready} onClick=${() => start(false)}>Index (incremental)</button>
3265
- <button disabled=${busy || running || !ready} onClick=${() => start(true)}>Rebuild (wipe + full)</button>
3266
- <button disabled=${busy || !running} onClick=${stop}>Stop</button>
3267
- </div>
3268
-
3269
- <${SemanticExcludesCard} />
3270
- </div>
3271
- `;
3272
- }
3273
-
3274
- function SemanticExcludesCard() {
3275
- const [data, setData] = useState(null);
3276
- const [draft, setDraft] = useState(null);
3277
- const [preview, setPreview] = useState(null);
3278
- const [busy, setBusy] = useState(false);
3279
- const [error, setError] = useState(null);
3280
- const [info, setInfo] = useState(null);
3281
- const [open, setOpen] = useState(false);
3282
-
3283
- const load = useCallback(async () => {
3284
- try {
3285
- const r = await api("/index-config");
3286
- setData(r);
3287
- setDraft(toDraft(r.resolved));
3288
- } catch (err) {
3289
- setError(err.message);
3290
- }
3291
- }, []);
3292
-
3293
- useEffect(() => {
3294
- if (open && !data) load();
3295
- }, [open, data, load]);
3296
-
3297
- const reset = useCallback(() => {
3298
- if (data) setDraft(toDraft(data.defaults));
3299
- setPreview(null);
3300
- }, [data]);
3301
-
3302
- const save = useCallback(async () => {
3303
- if (!draft) return;
3304
- setBusy(true);
3305
- setError(null);
3306
- setInfo(null);
3307
- try {
3308
- const payload = fromDraft(draft);
3309
- const r = await api("/index-config", { method: "POST", body: payload });
3310
- setInfo(`saved · ${r.changed.length || 0} fields updated · re-run index to apply`);
3311
- await load();
3312
- } catch (err) {
3313
- setError(err.message);
3314
- } finally {
3315
- setBusy(false);
3316
- }
3317
- }, [draft, load]);
3318
-
3319
- const runPreview = useCallback(async () => {
3320
- if (!draft) return;
3321
- setBusy(true);
3322
- setError(null);
3323
- setInfo("running dry walk against project root…");
3324
- try {
3325
- const payload = fromDraft(draft);
3326
- const r = await api("/index-config/preview", { method: "POST", body: payload });
3327
- setPreview(r);
3328
- setInfo(null);
3329
- } catch (err) {
3330
- setError(err.message);
3331
- setInfo(null);
3332
- } finally {
3333
- setBusy(false);
3334
- }
3335
- }, [draft]);
3336
-
3337
- return html`
3338
- <div class="excludes-toggle" onClick=${() => setOpen(!open)}>
3339
- <span class="caret">${open ? "▼" : "▶"}</span>
3340
- <span class="label">Excludes</span>
3341
- <span class="hint">config-driven skip rules applied during indexing</span>
3342
- </div>
3343
- ${
3344
- !open
3345
- ? null
3346
- : !draft
3347
- ? html`<div class="empty">loading…</div>`
3348
- : html`
3349
- <div class="card excludes-card">
3350
- ${info ? html`<div class="notice">${info}</div>` : null}
3351
- ${error ? html`<div class="notice err">${error}</div>` : null}
3352
- <div class="lead">
3353
- One value per line. Dirs / files match by basename. Patterns use picomatch syntax (e.g. <code>**/*.generated.ts</code>, <code>vendor/**</code>, <code>!keep-me</code>).
3354
- </div>
3355
- <div class="excludes-grid">
3356
- <${ExcludesField} label="Exclude dirs" value=${draft.excludeDirs} onChange=${(v) => setDraft({ ...draft, excludeDirs: v })} />
3357
- <${ExcludesField} label="Exclude files" value=${draft.excludeFiles} onChange=${(v) => setDraft({ ...draft, excludeFiles: v })} />
3358
- <${ExcludesField} label="Exclude extensions" value=${draft.excludeExts} onChange=${(v) => setDraft({ ...draft, excludeExts: v })} />
3359
- <${ExcludesField} label="Exclude patterns (glob)" value=${draft.excludePatterns} onChange=${(v) => setDraft({ ...draft, excludePatterns: v })} />
3360
- </div>
3361
- <div class="excludes-options">
3362
- <label>
3363
- <input type="checkbox" checked=${draft.respectGitignore} onChange=${(e) => setDraft({ ...draft, respectGitignore: e.target.checked })} />
3364
- Respect <code>.gitignore</code>
3365
- </label>
3366
- <label>
3367
- Max file size
3368
- <input type="number" min="1024" step="1024" value=${draft.maxFileBytes} onChange=${(e) => setDraft({ ...draft, maxFileBytes: Number(e.target.value) || 0 })} />
3369
- <span class="muted">bytes</span>
3370
- </label>
3371
- </div>
3372
- <div class="excludes-actions">
3373
- <button class="primary" disabled=${busy} onClick=${save}>Save</button>
3374
- <button disabled=${busy} onClick=${runPreview}>Preview (dry-walk)</button>
3375
- <button disabled=${busy} onClick=${reset}>Reset to defaults</button>
3376
- </div>
3377
- ${preview ? html`<${ExcludesPreview} preview=${preview} />` : null}
3378
- </div>
3379
- `
3380
- }
3381
- `;
3382
- }
3383
-
3384
- function ExcludesPreview({ preview }) {
3385
- const buckets = preview.skipBuckets || {};
3386
- const samples = preview.skipSamples || {};
3387
- const totalSkipped = Object.values(buckets).reduce((a, b) => a + (b || 0), 0);
3388
- const reasons = [
3389
- "gitignore",
3390
- "pattern",
3391
- "defaultDir",
3392
- "defaultFile",
3393
- "binaryExt",
3394
- "binaryContent",
3395
- "tooLarge",
3396
- "readError",
3397
- ].filter((k) => (buckets[k] || 0) > 0);
3398
- return html`
3399
- <div class="excludes-preview">
3400
- <div class="summary">
3401
- Preview — would index <strong>${preview.filesIncluded}</strong> file(s), skip <strong>${totalSkipped}</strong>
3402
- </div>
3403
- ${
3404
- reasons.length === 0
3405
- ? html`<div class="muted">nothing skipped — all walked files would be indexed.</div>`
3406
- : reasons.map(
3407
- (r) => html`
3408
- <details>
3409
- <summary><strong>${r}: ${buckets[r]}</strong></summary>
3410
- <ul>
3411
- ${(samples[r] || []).map((p) => html`<li><code>${p}</code></li>`)}
3412
- ${
3413
- (buckets[r] || 0) > (samples[r] || []).length
3414
- ? html`<li class="muted">…${buckets[r] - samples[r].length} more</li>`
3415
- : null
3416
- }
3417
- </ul>
3418
- </details>
3419
- `,
3420
- )
3421
- }
3422
- ${
3423
- preview.sampleIncluded?.length
3424
- ? html`
3425
- <details>
3426
- <summary>first ${preview.sampleIncluded.length} included file(s)</summary>
3427
- <ul>
3428
- ${preview.sampleIncluded.map((p) => html`<li><code>${p}</code></li>`)}
3429
- </ul>
3430
- </details>
3431
- `
3432
- : null
3433
- }
3434
- </div>
3435
- `;
3436
- }
3437
-
3438
- function ExcludesField({ label, value, onChange }) {
3439
- return html`
3440
- <div class="excludes-field">
3441
- <label>${label}</label>
3442
- <textarea rows="5" value=${value} onChange=${(e) => onChange(e.target.value)}></textarea>
3443
- </div>
3444
- `;
3445
- }
3446
-
3447
- function toDraft(c) {
3448
- return {
3449
- excludeDirs: (c.excludeDirs ?? []).join("\n"),
3450
- excludeFiles: (c.excludeFiles ?? []).join("\n"),
3451
- excludeExts: (c.excludeExts ?? []).join("\n"),
3452
- excludePatterns: (c.excludePatterns ?? []).join("\n"),
3453
- respectGitignore: c.respectGitignore !== false,
3454
- maxFileBytes: c.maxFileBytes ?? 262144,
3455
- };
3456
- }
3457
-
3458
- function fromDraft(d) {
3459
- const lines = (s) =>
3460
- s
3461
- .split(/\r?\n/)
3462
- .map((x) => x.trim())
3463
- .filter((x) => x.length > 0);
3464
- return {
3465
- excludeDirs: lines(d.excludeDirs),
3466
- excludeFiles: lines(d.excludeFiles),
3467
- excludeExts: lines(d.excludeExts),
3468
- excludePatterns: lines(d.excludePatterns),
3469
- respectGitignore: !!d.respectGitignore,
3470
- maxFileBytes: d.maxFileBytes,
3471
- };
3472
- }
3473
-
3474
- function SemanticJobView({ job, running }) {
3475
- const phaseLabel =
3476
- {
3477
- scan: "scanning files",
3478
- embed: "embedding chunks",
3479
- write: "writing index",
3480
- done: "done",
3481
- error: "error",
3482
- }[job.phase] ?? job.phase;
3483
- const total = job.chunksTotal ?? 0;
3484
- const doneN = job.chunksDone ?? 0;
3485
- const ratio = total > 0 ? Math.min(1, doneN / total) : 0;
3486
- const elapsed = ((Date.now() - job.startedAt) / 1000).toFixed(1);
3487
-
3488
- return html`
3489
- <div class="kv">
3490
- <div><span class="kv-key">phase</span>
3491
- <span class=${`pill ${job.phase === "error" ? "pill-err" : running ? "pill-active" : "pill-dim"}`}>${phaseLabel}</span>
3492
- ${job.aborted ? html`<span class="pill pill-warn" style="margin-left: 6px;">stopping</span>` : null}
3493
- <span class="muted" style="margin-left: 8px;">${elapsed}s</span>
3494
- </div>
3495
- ${
3496
- job.filesScanned !== null && job.filesScanned !== undefined
3497
- ? html`<div><span class="kv-key">files</span>scanned ${job.filesScanned}${
3498
- job.filesChanged != null ? ` · changed ${job.filesChanged}` : ""
3499
- }${job.filesSkipped ? ` · skipped ${job.filesSkipped}` : ""}</div>`
3500
- : null
3501
- }
3502
- ${
3503
- total > 0
3504
- ? html`
3505
- <div>
3506
- <span class="kv-key">chunks</span>${doneN} / ${total} (${(ratio * 100).toFixed(0)}%)
3507
- </div>
3508
- <div class="bar" style="margin-top: 4px;">
3509
- <div class="fill" style=${`width: ${(ratio * 100).toFixed(1)}%; background: var(--primary);`}></div>
3510
- </div>
3511
- `
3512
- : null
3513
- }
3514
- ${
3515
- job.error
3516
- ? html`<div><span class="kv-key">error</span><span class="err">${job.error}</span></div>`
3517
- : null
3518
- }
3519
- ${
3520
- job.result
3521
- ? html`<div><span class="kv-key">result</span>added ${job.result.chunksAdded} · removed ${job.result.chunksRemoved}${
3522
- job.result.chunksSkipped ? ` · failed ${job.result.chunksSkipped}` : ""
3523
- } · ${(job.result.durationMs / 1000).toFixed(1)}s</div>`
3524
- : null
3525
- }
3526
- ${
3527
- job.result?.skipBuckets
3528
- ? html`<${SkipBucketsView} buckets=${job.result.skipBuckets} />`
3529
- : null
3530
- }
3531
- </div>
3532
- `;
3533
- }
3534
-
3535
- function SkipBucketsView({ buckets }) {
3536
- const order = [
3537
- ["gitignore", "gitignore"],
3538
- ["pattern", "pattern"],
3539
- ["defaultDir", "defaultDir"],
3540
- ["defaultFile", "defaultFile"],
3541
- ["binaryExt", "binaryExt"],
3542
- ["binaryContent", "binaryContent"],
3543
- ["tooLarge", "tooLarge"],
3544
- ["readError", "readError"],
3545
- ];
3546
- const total = order.reduce((a, [k]) => a + (buckets[k] || 0), 0);
3547
- if (total === 0) return null;
3548
- const parts = order
3549
- .filter(([k]) => (buckets[k] || 0) > 0)
3550
- .map(([k, label]) => `${label}: ${buckets[k]}`);
3551
- return html`<div><span class="kv-key">skipped</span>${total} files <span class="muted">(${parts.join(", ")})</span></div>`;
3552
- }
3553
-
3554
- function McpPanel() {
3555
- const [data, setData] = useState(null);
3556
- const [specs, setSpecs] = useState(null);
3557
- const [error, setError] = useState(null);
3558
- const [info, setInfo] = useState(null);
3559
- const [newSpec, setNewSpec] = useState("");
3560
- const [busy, setBusy] = useState(false);
3561
- const [open, setOpen] = useState(null); // server detail
3562
-
3563
- const load = useCallback(async () => {
3564
- try {
3565
- setData(await api("/mcp"));
3566
- setSpecs((await api("/mcp/specs")).specs);
3567
- } catch (err) {
3568
- setError(err.message);
3569
- }
3570
- }, []);
3571
- useEffect(() => {
3572
- load();
3573
- }, [load]);
3574
-
3575
- const addSpec = useCallback(async () => {
3576
- if (!newSpec.trim()) return;
3577
- setBusy(true);
3578
- try {
3579
- const r = await api("/mcp/specs", { method: "POST", body: { spec: newSpec.trim() } });
3580
- setInfo(
3581
- r.requiresRestart ? "saved — restart `reasonix code` to bridge this server" : "saved",
3582
- );
3583
- setTimeout(() => setInfo(null), 4000);
3584
- setNewSpec("");
3585
- await load();
3586
- } catch (err) {
3587
- setError(err.message);
3588
- } finally {
3589
- setBusy(false);
3590
- }
3591
- }, [newSpec, load]);
3592
-
3593
- const removeSpec = useCallback(
3594
- async (spec) => {
3595
- if (!confirm(`Remove MCP spec from config?\n\n${spec}`)) return;
3596
- setBusy(true);
3597
- try {
3598
- await api("/mcp/specs", { method: "DELETE", body: { spec } });
3599
- setInfo("removed — restart to drop the live bridge");
3600
- setTimeout(() => setInfo(null), 4000);
3601
- await load();
3602
- } catch (err) {
3603
- setError(err.message);
3604
- } finally {
3605
- setBusy(false);
3606
- }
3607
- },
3608
- [load],
3609
- );
3610
-
3611
- if (!data && !error) return html`<div class="boot">loading MCP…</div>`;
3612
- if (error && !data) return html`<div class="notice err">${error}</div>`;
3613
-
3614
- if (open) {
3615
- return html`
3616
- <div>
3617
- <div class="panel-header">
3618
- <h2 class="panel-title">MCP · ${open.label}</h2>
3619
- <button onClick=${() => setOpen(null)} style="margin-left: auto;">← back</button>
3620
- </div>
3621
- <div class="card">
3622
- <div class="row"><span class="card-title" style="margin: 0; flex: 0 0 110px;">spec</span><code>${open.spec}</code></div>
3623
- <div class="row"><span class="card-title" style="margin: 0; flex: 0 0 110px;">server</span><span>${open.serverInfo?.name ?? "—"} ${open.serverInfo?.version ? `v${open.serverInfo.version}` : ""}</span></div>
3624
- <div class="row"><span class="card-title" style="margin: 0; flex: 0 0 110px;">protocol</span><code>${open.protocolVersion}</code></div>
3625
- </div>
3626
- ${open.instructions ? html`<div class="notice">${open.instructions}</div>` : null}
3627
- <div class="section-title">Tools (${open.tools.length})</div>
3628
- <table>
3629
- <thead><tr><th>name</th><th>description</th></tr></thead>
3630
- <tbody>
3631
- ${open.tools.map((t) => html`<tr><td><code>${t.name}</code></td><td>${t.description ?? ""}</td></tr>`)}
3632
- </tbody>
3633
- </table>
3634
- ${
3635
- open.resources.length > 0
3636
- ? html`
3637
- <div class="section-title">Resources (${open.resources.length})</div>
3638
- <table>
3639
- <thead><tr><th>name</th><th>uri</th></tr></thead>
3640
- <tbody>
3641
- ${open.resources.map((r) => html`<tr><td>${r.name}</td><td><code>${r.uri}</code></td></tr>`)}
3642
- </tbody>
3643
- </table>
3644
- `
3645
- : null
3646
- }
3647
- ${
3648
- open.prompts.length > 0
3649
- ? html`
3650
- <div class="section-title">Prompts (${open.prompts.length})</div>
3651
- <table>
3652
- <thead><tr><th>name</th><th>description</th></tr></thead>
3653
- <tbody>
3654
- ${open.prompts.map((p) => html`<tr><td><code>${p.name}</code></td><td>${p.description ?? ""}</td></tr>`)}
3655
- </tbody>
3656
- </table>
3657
- `
3658
- : null
3659
- }
3660
- </div>
3661
- `;
3662
- }
3663
-
3664
- return html`
3665
- <div>
3666
- <div class="panel-header">
3667
- <h2 class="panel-title">MCP</h2>
3668
- <span class="panel-subtitle">${data.servers.length} bridged · ${specs?.length ?? 0} in config</span>
3669
- </div>
3670
- ${info ? html`<div class="notice">${info}</div>` : null}
3671
- ${error ? html`<div class="notice err">${error}</div>` : null}
3672
-
3673
- <div class="section-title">Add server</div>
3674
- <div class="card row">
3675
- <input
3676
- type="text"
3677
- placeholder='spec — e.g. "fs=npx -y @modelcontextprotocol/server-filesystem /tmp/safe"'
3678
- value=${newSpec}
3679
- onInput=${(e) => setNewSpec(e.target.value)}
3680
- />
3681
- <button class="primary" disabled=${busy || !newSpec.trim()} onClick=${addSpec}>Add</button>
3682
- </div>
3683
-
3684
- <div class="section-title">Bridged (${data.servers.length})</div>
3685
- ${
3686
- data.servers.length === 0
3687
- ? html`<div class="empty">No MCP servers in this session.</div>`
3688
- : html`
3689
- <table>
3690
- <thead><tr><th>label</th><th>spec</th><th class="numeric">tools</th><th></th></tr></thead>
3691
- <tbody>
3692
- ${data.servers.map(
3693
- (s) => html`
3694
- <tr key=${s.label} style="cursor: pointer;" onClick=${() => setOpen(s)}>
3695
- <td><code>${s.label}</code></td>
3696
- <td><code style="font-size: 11px;">${s.spec}</code></td>
3697
- <td class="numeric">${fmtNum(s.toolCount)}</td>
3698
- <td></td>
3699
- </tr>
3700
- `,
3701
- )}
3702
- </tbody>
3703
- </table>
3704
- `
3705
- }
3706
-
3707
- <div class="section-title">Persisted specs (config.json)</div>
3708
- ${
3709
- (specs ?? []).length === 0
3710
- ? html`<div class="empty">No MCP specs persisted in <code>~/.reasonix/config.json</code>.</div>`
3711
- : html`
3712
- <table>
3713
- <thead><tr><th>spec</th><th></th></tr></thead>
3714
- <tbody>
3715
- ${specs.map(
3716
- (spec) => html`
3717
- <tr key=${spec}>
3718
- <td><code>${spec}</code></td>
3719
- <td class="numeric">
3720
- <button class="danger" disabled=${busy} onClick=${(e) => {
3721
- e.stopPropagation();
3722
- removeSpec(spec);
3723
- }}>remove</button>
3724
- </td>
3725
- </tr>
3726
- `,
3727
- )}
3728
- </tbody>
3729
- </table>
3730
- `
3731
- }
3732
- </div>
3733
- `;
3734
- }
3735
-
3736
- // ---------- Editor (CodeMirror 6, multi-tab) ----------
3737
-
3738
- // Lazy-loaded CodeMirror modules — kept off the initial bundle so users
3739
- // who never open Editor never pay the ~200KB cost. Cached after first
3740
- // resolve so tab switches don't re-fetch.
3741
- //
3742
- // CodeMirror loads from a locally bundled file (`/assets/codemirror.js`,
3743
- // produced by `scripts/bundle-codemirror.mjs`). One bundle = one copy
3744
- // of every package = no Tag identity mismatch between oneDark and the
3745
- // language parsers, no esm.sh round-trips on every cold load. The
3746
- // previous esm.sh + ?deps= setup hit silent failure modes whenever the
3747
- // CDN resolved a transitive @lezer/* to a different version than the
3748
- // bundled cache thought it would.
3749
- let cmModulesPromise = null;
3750
- async function loadCodeMirror() {
3751
- if (cmModulesPromise) return cmModulesPromise;
3752
- cmModulesPromise = import(`/assets/codemirror.js?token=${TOKEN}`);
3753
- return cmModulesPromise;
3754
- }
3755
-
3756
- // Map file path → CodeMirror language extension factory.
3757
- function langExtensionFor(path, langs) {
3758
- const lang = langFromPath(path);
3759
- if (!lang) return null;
3760
- // CodeMirror's javascript pack handles ts/tsx/jsx via options.
3761
- if (lang === "typescript") return langs.typescript ? langs.typescript() : null;
3762
- if (lang === "javascript") return langs.javascript ? langs.javascript({ jsx: true }) : null;
3763
- const fn = langs[lang];
3764
- return fn ? fn() : null;
3765
- }
3766
-
3767
- // Build a nested folder tree from a flat list of repo paths. Nodes use
3768
- // Maps so insertion order is stable; sorting happens at render time.
3769
- function buildFileTree(paths) {
3770
- const root = { name: "", path: "", children: new Map(), isFile: false };
3771
- for (const p of paths) {
3772
- const parts = p.split("/").filter(Boolean);
3773
- let node = root;
3774
- for (let i = 0; i < parts.length; i++) {
3775
- const isLast = i === parts.length - 1;
3776
- const name = parts[i];
3777
- const childPath = parts.slice(0, i + 1).join("/");
3778
- let child = node.children.get(name);
3779
- if (!child) {
3780
- child = { name, path: childPath, children: new Map(), isFile: isLast };
3781
- node.children.set(name, child);
3782
- } else if (isLast && child.children.size === 0) {
3783
- child.isFile = true;
3784
- }
3785
- node = child;
3786
- }
3787
- }
3788
- return root;
3789
- }
3790
-
3791
- // Walk the tree honoring the expanded set; produce a flat row list the
3792
- // renderer can map straight to JSX. Folders precede files; both sorted
3793
- // case-insensitively.
3794
- function flattenTree(node, expanded, depth, out) {
3795
- const children = [...node.children.values()].sort((a, b) => {
3796
- if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
3797
- return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
3798
- });
3799
- for (const child of children) {
3800
- out.push({ name: child.name, path: child.path, depth, isFile: child.isFile });
3801
- if (!child.isFile && expanded.has(child.path)) {
3802
- flattenTree(child, expanded, depth + 1, out);
3803
- }
3804
- }
3805
- return out;
3806
- }
3807
-
3808
- function EditorPanel({ onClose } = {}) {
3809
- // tabs: { path, content, original, dirty, savedAt }
3810
- const [tabs, setTabs] = useState([]);
3811
- const [activeIdx, setActiveIdx] = useState(0);
3812
- const [files, setFiles] = useState([]);
3813
- const [filesError, setFilesError] = useState(null);
3814
- const [openInput, setOpenInput] = useState("");
3815
- const [filter, setFilter] = useState("");
3816
- const [error, setError] = useState(null);
3817
- const [busy, setBusy] = useState(false);
3818
- const [cmReady, setCmReady] = useState(false);
3819
- const [sideCollapsed, setSideCollapsed] = useState(false);
3820
- const [expanded, setExpanded] = useState(() => new Set());
3821
- // View mode for markdown tabs: "edit" (source only), "split" (source +
3822
- // preview side-by-side), "preview" (rendered only). Non-md tabs always
3823
- // render in edit mode regardless of this state.
3824
- const [viewMode, setViewMode] = useState("edit");
3825
- const editorContainerRef = useRef(null);
3826
- const viewRef = useRef(null);
3827
- const cmRef = useRef(null);
3828
- const tabsRef = useRef(tabs);
3829
- const activeIdxRef = useRef(activeIdx);
3830
- useEffect(() => {
3831
- tabsRef.current = tabs;
3832
- }, [tabs]);
3833
- useEffect(() => {
3834
- activeIdxRef.current = activeIdx;
3835
- }, [activeIdx]);
3836
-
3837
- // Load file list (gitignore-aware) for the picker.
3838
- const loadFiles = useCallback(async () => {
3839
- try {
3840
- const r = await api("/files");
3841
- setFiles(r.files ?? []);
3842
- } catch (err) {
3843
- setFilesError(err.message);
3844
- }
3845
- }, []);
3846
- useEffect(() => {
3847
- loadFiles();
3848
- }, [loadFiles]);
3849
-
3850
- // Open a file → fetch + push tab + activate. If already open, just
3851
- // switch to the existing tab so we don't lose unsaved edits.
3852
- const openPath = useCallback(async (path) => {
3853
- if (!path) return;
3854
- const existing = tabsRef.current.findIndex((t) => t.path === path);
3855
- if (existing >= 0) {
3856
- setActiveIdx(existing);
3857
- return;
3858
- }
3859
- setBusy(true);
3860
- setError(null);
3861
- try {
3862
- const r = await api(`/file/${path.split("/").map(encodeURIComponent).join("/")}`);
3863
- setTabs((prev) => [
3864
- ...prev,
3865
- { path, content: r.content, original: r.content, dirty: false, savedAt: r.mtime },
3866
- ]);
3867
- setActiveIdx(tabsRef.current.length);
3868
- } catch (err) {
3869
- setError(`open ${path}: ${err.message}`);
3870
- } finally {
3871
- setBusy(false);
3872
- }
3873
- }, []);
3874
-
3875
- // Subscribe to "open-file" events fired from elsewhere (Chat panel
3876
- // tool cards, file-mention links).
3877
- useEffect(() => {
3878
- const onOpen = (ev) => openPath(ev.detail.path);
3879
- appBus.addEventListener("open-file", onOpen);
3880
- return () => appBus.removeEventListener("open-file", onOpen);
3881
- }, [openPath]);
3882
-
3883
- // Mount CodeMirror lazily on first render.
3884
- useEffect(() => {
3885
- let cancelled = false;
3886
- loadCodeMirror().then((cm) => {
3887
- if (!cancelled) {
3888
- cmRef.current = cm;
3889
- setCmReady(true);
3890
- }
3891
- });
3892
- return () => {
3893
- cancelled = true;
3894
- };
3895
- }, []);
3896
-
3897
- // Re-mount the editor view when active tab changes. Each tab's
3898
- // content is held in React state — the view is just a presentation
3899
- // layer over the current tab's string.
3900
- useEffect(() => {
3901
- if (!cmReady || !editorContainerRef.current) return;
3902
- const cm = cmRef.current;
3903
- if (!cm) return;
3904
- const tab = tabs[activeIdx];
3905
- if (!tab) {
3906
- if (viewRef.current) {
3907
- viewRef.current.destroy();
3908
- viewRef.current = null;
3909
- }
3910
- return;
3911
- }
3912
-
3913
- if (viewRef.current) {
3914
- viewRef.current.destroy();
3915
- viewRef.current = null;
3916
- }
3917
-
3918
- const langExt = langExtensionFor(tab.path, cm.langs);
3919
- const updateListener = cm.EditorView.updateListener.of((update) => {
3920
- if (update.docChanged) {
3921
- const text = update.state.doc.toString();
3922
- // Mutate the tab's content + dirty flag without forcing a
3923
- // full re-render of the editor (which would lose the cursor).
3924
- // We DO setTabs so the tab bar's dirty dot updates.
3925
- const idx = activeIdxRef.current;
3926
- const live = tabsRef.current;
3927
- if (live[idx]) {
3928
- const next = [...live];
3929
- next[idx] = { ...next[idx], content: text, dirty: text !== next[idx].original };
3930
- tabsRef.current = next;
3931
- setTabs(next);
3932
- }
3933
- }
3934
- });
3935
-
3936
- const extensions = [
3937
- cm.lineNumbers(),
3938
- cm.highlightActiveLineGutter ? cm.highlightActiveLineGutter() : [],
3939
- cm.foldGutter ? cm.foldGutter() : [],
3940
- cm.highlightActiveLine(),
3941
- cm.drawSelection(),
3942
- cm.history(),
3943
- cm.bracketMatching(),
3944
- cm.indentOnInput(),
3945
- cm.closeBrackets ? cm.closeBrackets() : [],
3946
- cm.autocompletion
3947
- ? cm.autocompletion({
3948
- activateOnTyping: true,
3949
- closeOnBlur: true,
3950
- maxRenderedOptions: 30,
3951
- })
3952
- : [],
3953
- cm.highlightSelectionMatches ? cm.highlightSelectionMatches() : [],
3954
- cm.keymap.of([
3955
- ...cm.defaultKeymap,
3956
- ...cm.historyKeymap,
3957
- ...(cm.closeBracketsKeymap ?? []),
3958
- ...(cm.searchKeymap ?? []),
3959
- ...(cm.completionKeymap ?? []),
3960
- ...(cm.foldKeymap ?? []),
3961
- cm.indentWithTab,
3962
- ]),
3963
- // oneDark is an array of [theme, syntaxHighlighting(oneDarkHighlightStyle)] —
3964
- // including it gives both the dark UI and the highlight tags. Keep
3965
- // defaultHighlightStyle as a fallback only for languages oneDark omits.
3966
- cm.oneDark,
3967
- cm.syntaxHighlighting(cm.defaultHighlightStyle, { fallback: true }),
3968
- cm.EditorView.lineWrapping,
3969
- updateListener,
3970
- ];
3971
- if (langExt) extensions.push(langExt);
3972
-
3973
- const state = cm.EditorState.create({ doc: tab.content, extensions });
3974
- viewRef.current = new cm.EditorView({ state, parent: editorContainerRef.current });
3975
-
3976
- return () => {
3977
- if (viewRef.current) {
3978
- viewRef.current.destroy();
3979
- viewRef.current = null;
3980
- }
3981
- };
3982
- }, [cmReady, activeIdx, tabs[activeIdx]?.path]);
3983
-
3984
- // Becoming visible after display:none — CM6 measures lazily, force it.
3985
- useEffect(() => {
3986
- if (viewMode === "preview") return;
3987
- viewRef.current?.requestMeasure?.();
3988
- }, [viewMode]);
3989
-
3990
- const closeTab = useCallback((idx) => {
3991
- const tab = tabsRef.current[idx];
3992
- if (tab?.dirty && !confirm(`${tab.path} has unsaved changes. Discard?`)) return;
3993
- setTabs((prev) => prev.filter((_, i) => i !== idx));
3994
- if (activeIdxRef.current >= idx) {
3995
- setActiveIdx(Math.max(0, activeIdxRef.current - 1));
3996
- }
3997
- }, []);
3998
-
3999
- const saveTab = useCallback(async (idx) => {
4000
- const tab = tabsRef.current[idx];
4001
- if (!tab) return;
4002
- setBusy(true);
4003
- setError(null);
4004
- try {
4005
- const r = await api(`/file/${tab.path.split("/").map(encodeURIComponent).join("/")}`, {
4006
- method: "POST",
4007
- body: { content: tab.content },
4008
- });
4009
- setTabs((prev) => {
4010
- const next = [...prev];
4011
- if (next[idx]) {
4012
- next[idx] = { ...next[idx], original: tab.content, dirty: false, savedAt: r.mtime };
4013
- }
4014
- return next;
4015
- });
4016
- showToast(`saved ${tab.path}`, "info");
4017
- } catch (err) {
4018
- setError(`save ${tab.path}: ${err.message}`);
4019
- } finally {
4020
- setBusy(false);
4021
- }
4022
- }, []);
4023
-
4024
- // Cmd/Ctrl+S — save active tab.
4025
- useEffect(() => {
4026
- const onKey = (e) => {
4027
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
4028
- e.preventDefault();
4029
- if (tabsRef.current[activeIdxRef.current]) {
4030
- saveTab(activeIdxRef.current);
4031
- }
4032
- }
4033
- };
4034
- window.addEventListener("keydown", onKey);
4035
- return () => window.removeEventListener("keydown", onKey);
4036
- }, [saveTab]);
4037
-
4038
- const tab = tabs[activeIdx];
4039
-
4040
- const tree = useMemo(() => buildFileTree(files), [files]);
4041
- const treeRows = useMemo(() => flattenTree(tree, expanded, 0, []), [tree, expanded]);
4042
-
4043
- const toggleFolder = useCallback((path) => {
4044
- setExpanded((prev) => {
4045
- const next = new Set(prev);
4046
- if (next.has(path)) next.delete(path);
4047
- else next.add(path);
4048
- return next;
4049
- });
4050
- }, []);
4051
-
4052
- const filtering = filter.trim().length > 0;
4053
- const filteredFiles = filtering
4054
- ? files.filter((f) => f.toLowerCase().includes(filter.toLowerCase())).slice(0, 80)
4055
- : null;
4056
-
4057
- const openPaths = tabs.map((t) => t.path);
4058
-
4059
- return html`
4060
- <div class="editor-shell">
4061
- ${
4062
- onClose
4063
- ? html`
4064
- <div class="editor-drawer-head">
4065
- <span class="editor-drawer-title">Editor</span>
4066
- <button class="editor-drawer-close" onClick=${onClose} title="close editor (Esc)">×</button>
4067
- </div>
4068
- `
4069
- : null
4070
- }
4071
- <div class="editor-tabs">
4072
- ${
4073
- tabs.length === 0
4074
- ? html`<div class="editor-no-tabs">No files open. Pick from the list, paste a path above, or click a path in chat.</div>`
4075
- : tabs.map(
4076
- (t, i) => html`
4077
- <div
4078
- key=${t.path}
4079
- class="editor-tab ${i === activeIdx ? "active" : ""}"
4080
- onClick=${() => setActiveIdx(i)}
4081
- >
4082
- <span class="editor-tab-name" title=${t.path}>${t.path.split("/").pop()}</span>
4083
- ${t.dirty ? html`<span class="editor-tab-dirty">●</span>` : null}
4084
- <span class="editor-tab-close" onClick=${(e) => {
4085
- e.stopPropagation();
4086
- closeTab(i);
4087
- }}>×</span>
4088
- </div>
4089
- `,
4090
- )
4091
- }
4092
- </div>
4093
- <div class="editor-body">
4094
- ${
4095
- sideCollapsed
4096
- ? html`
4097
- <div class="editor-side collapsed">
4098
- <button
4099
- class="editor-side-toggle"
4100
- onClick=${() => setSideCollapsed(false)}
4101
- title="show files"
4102
- >▶</button>
4103
- </div>
4104
- `
4105
- : html`
4106
- <div class="editor-side">
4107
- <div class="editor-side-head">
4108
- <span class="editor-side-label">FILES</span>
4109
- <button
4110
- class="editor-side-toggle"
4111
- onClick=${() => setSideCollapsed(true)}
4112
- title="hide files"
4113
- >◀</button>
4114
- </div>
4115
- <div class="row" style="margin-bottom: 8px;">
4116
- <input
4117
- type="text"
4118
- placeholder="open by path…"
4119
- value=${openInput}
4120
- onInput=${(e) => setOpenInput(e.target.value)}
4121
- onKeyDown=${(e) => {
4122
- if (e.key === "Enter" && openInput.trim()) {
4123
- openPath(openInput.trim());
4124
- setOpenInput("");
4125
- }
4126
- }}
4127
- />
4128
- </div>
4129
- <input
4130
- type="search"
4131
- placeholder=${`filter ${files.length} files…`}
4132
- value=${filter}
4133
- onInput=${(e) => setFilter(e.target.value)}
4134
- style="margin-bottom: 8px;"
4135
- />
4136
- ${
4137
- filesError
4138
- ? html`<div class="notice err">${filesError}</div>`
4139
- : filtering
4140
- ? html`
4141
- <div class="editor-files">
4142
- ${filteredFiles.map(
4143
- (f) => html`
4144
- <div
4145
- key=${f}
4146
- class="editor-file ${openPaths.includes(f) ? "open" : ""}"
4147
- onClick=${() => openPath(f)}
4148
- title=${f}
4149
- >${f}</div>
4150
- `,
4151
- )}
4152
- ${files.length > 80 ? html`<div class="muted" style="padding: 8px; font-size: 11px;">narrow filter to see more</div>` : null}
4153
- </div>
4154
- `
4155
- : html`
4156
- <div class="editor-files">
4157
- ${treeRows.map((row) =>
4158
- row.isFile
4159
- ? html`
4160
- <div
4161
- key=${row.path}
4162
- class="editor-tree-file ${openPaths.includes(row.path) ? "open" : ""}"
4163
- style=${`padding-left: ${row.depth * 12 + 22}px`}
4164
- onClick=${() => openPath(row.path)}
4165
- title=${row.path}
4166
- >${row.name}</div>
4167
- `
4168
- : html`
4169
- <div
4170
- key=${row.path}
4171
- class="editor-tree-folder"
4172
- style=${`padding-left: ${row.depth * 12 + 4}px`}
4173
- onClick=${() => toggleFolder(row.path)}
4174
- >
4175
- <span class="editor-tree-caret">${expanded.has(row.path) ? "▼" : "▶"}</span>
4176
- <span class="editor-tree-name">${row.name}</span>
4177
- </div>
4178
- `,
4179
- )}
4180
- ${files.length === 0 ? html`<div class="muted" style="padding: 8px; font-size: 11px;">no files</div>` : null}
4181
- </div>
4182
- `
4183
- }
4184
- </div>
4185
- `
4186
- }
4187
-
4188
- <div class="editor-main">
4189
- ${
4190
- tab
4191
- ? html`
4192
- <div class="editor-bar">
4193
- <code style="font-size: 12px;">${tab.path}</code>
4194
- <span class="muted" style="font-size: 12px;">${langFromPath(tab.path) ?? "plaintext"}</span>
4195
- ${
4196
- langFromPath(tab.path) === "markdown"
4197
- ? html`
4198
- <div class="view-mode-group" style="margin-left: auto;">
4199
- <button
4200
- class=${`view-mode ${viewMode === "edit" ? "active" : ""}`}
4201
- onClick=${() => setViewMode("edit")}
4202
- title="source only"
4203
- >Edit</button>
4204
- <button
4205
- class=${`view-mode ${viewMode === "split" ? "active" : ""}`}
4206
- onClick=${() => setViewMode("split")}
4207
- title="source + preview side-by-side"
4208
- >Split</button>
4209
- <button
4210
- class=${`view-mode ${viewMode === "preview" ? "active" : ""}`}
4211
- onClick=${() => setViewMode("preview")}
4212
- title="rendered only"
4213
- >Preview</button>
4214
- </div>
4215
- `
4216
- : null
4217
- }
4218
- <button
4219
- class="primary"
4220
- style=${langFromPath(tab.path) === "markdown" ? "" : "margin-left: auto;"}
4221
- onClick=${() => saveTab(activeIdx)}
4222
- disabled=${busy || !tab.dirty}
4223
- >${tab.dirty ? "Save (⌘S)" : "Saved"}</button>
4224
- </div>
4225
- ${error ? html`<div class="notice err">${error}</div>` : null}
4226
- ${(() => {
4227
- const isMd = langFromPath(tab.path) === "markdown";
4228
- const mode = isMd ? viewMode : "edit";
4229
- // Stable DOM across mode toggles — CM-managed children + Preact reconciliation don't mix when the host moves.
4230
- return html`
4231
- <div class="editor-stage" data-mode=${mode}>
4232
- <div ref=${editorContainerRef} class="editor-host"></div>
4233
- ${
4234
- isMd
4235
- ? html`<div
4236
- class="editor-md-preview md"
4237
- dangerouslySetInnerHTML=${{
4238
- __html: previewMarked.parse(tab.content ?? ""),
4239
- }}
4240
- ></div>`
4241
- : null
4242
- }
4243
- </div>
4244
- `;
4245
- })()}
4246
- `
4247
- : html`
4248
- <div class="editor-empty">
4249
- ${
4250
- cmReady
4251
- ? html`<div>Open a file to start editing.</div>`
4252
- : html`<div>Loading editor (~200KB CodeMirror)…</div>`
4253
- }
4254
- </div>
4255
- `
4256
- }
4257
- </div>
4258
- </div>
4259
- </div>
4260
- `;
4261
- }
4262
-
4263
- function ComingSoonPanel({ name, milestone }) {
4264
- return html`
4265
- <div>
4266
- <div class="panel-header">
4267
- <h2 class="panel-title">${name}</h2>
4268
- <span class="panel-subtitle">coming in ${milestone}</span>
4269
- </div>
4270
- <div class="empty">This panel lands in ${milestone} (see CHANGELOG).</div>
4271
- </div>
4272
- `;
4273
- }
4274
-
4275
- // ---------- shell ----------
4276
-
4277
- const TABS = [
4278
- {
4279
- id: "chat",
4280
- name: "Chat",
4281
- glyph: "◆",
4282
- panel: () => html`<${ChatPanel} />`,
4283
- ready: true,
4284
- badge: null,
4285
- },
4286
- {
4287
- id: "editor",
4288
- name: "Editor",
4289
- glyph: "✎",
4290
- panel: () => html`<${EditorPanel} />`,
4291
- ready: true,
4292
- badge: null,
4293
- },
4294
- {
4295
- id: "overview",
4296
- name: "Overview",
4297
- glyph: "◈",
4298
- panel: () => html`<${OverviewPanel} />`,
4299
- ready: true,
4300
- badge: null,
4301
- },
4302
- {
4303
- id: "usage",
4304
- name: "Usage",
4305
- glyph: "$",
4306
- panel: () => html`<${UsageWithChart} />`,
4307
- ready: true,
4308
- badge: null,
4309
- },
4310
- {
4311
- id: "sessions",
4312
- name: "Sessions",
4313
- glyph: "›",
4314
- panel: () => html`<${SessionsPanel} />`,
4315
- ready: true,
4316
- badge: null,
4317
- },
4318
- {
4319
- id: "plans",
4320
- name: "Plans",
4321
- glyph: "P",
4322
- panel: () => html`<${PlansPanel} />`,
4323
- ready: true,
4324
- badge: null,
4325
- },
4326
- {
4327
- id: "tools",
4328
- name: "Tools",
4329
- glyph: "▣",
4330
- panel: () => html`<${ToolsPanel} />`,
4331
- ready: true,
4332
- badge: null,
4333
- },
4334
- {
4335
- id: "permissions",
4336
- name: "Permissions",
4337
- glyph: "▎",
4338
- panel: () => html`<${PermissionsPanel} />`,
4339
- ready: true,
4340
- badge: null,
4341
- },
4342
- {
4343
- id: "health",
4344
- name: "System",
4345
- glyph: "+",
4346
- panel: () => html`<${SystemPanel} />`,
4347
- ready: true,
4348
- badge: null,
4349
- },
4350
- {
4351
- id: "semantic",
4352
- name: "Semantic",
4353
- glyph: "≈",
4354
- panel: () => html`<${SemanticPanel} />`,
4355
- ready: true,
4356
- badge: null,
4357
- },
4358
- {
4359
- id: "mcp",
4360
- name: "MCP",
4361
- glyph: "M",
4362
- panel: () => html`<${McpPanel} />`,
4363
- ready: true,
4364
- badge: null,
4365
- },
4366
- {
4367
- id: "skills",
4368
- name: "Skills",
4369
- glyph: "S",
4370
- panel: () => html`<${SkillsPanel} />`,
4371
- ready: true,
4372
- badge: null,
4373
- },
4374
- {
4375
- id: "memory",
4376
- name: "Memory",
4377
- glyph: "·",
4378
- panel: () => html`<${MemoryPanel} />`,
4379
- ready: true,
4380
- badge: null,
4381
- },
4382
- {
4383
- id: "hooks",
4384
- name: "Hooks",
4385
- glyph: "H",
4386
- panel: () => html`<${HooksPanel} />`,
4387
- ready: true,
4388
- badge: null,
4389
- },
4390
- {
4391
- id: "settings",
4392
- name: "Settings",
4393
- glyph: "⌘",
4394
- panel: () => html`<${SettingsPanel} />`,
4395
- ready: true,
4396
- badge: null,
4397
- },
4398
- ];
4399
-
4400
- // ---------- Toast system ----------
4401
- //
4402
- // One Set of currently-displayed toast objects, pushed via a custom
4403
- // DOM event so any panel can fire a toast without prop-drilling. Auto-
4404
- // dismiss after `ttl` ms (default 3000). The stack lives at the App
4405
- // level so toasts persist across tab switches.
4406
-
4407
- const toastBus = new EventTarget();
4408
- function showToast(text, kind = "info", ttl = 3000) {
4409
- toastBus.dispatchEvent(new CustomEvent("toast", { detail: { text, kind, ttl } }));
4410
- }
4411
-
4412
- // ---------- App-wide event bus ----------
4413
- //
4414
- // Three events:
4415
- // - "open-file" { path } Editor panel opens the path in a tab
4416
- // - "navigate-tab" { tabId } App switches active sidebar tab
4417
- // - "error" { error, source } global ErrorOverlay shows it full-screen
4418
- //
4419
- // Used by Chat tool cards / file-mention links to deep-link into the
4420
- // Editor without prop-drilling, and by global error handlers to surface
4421
- // crashes in a full-screen modal with a "Report on GitHub" button.
4422
-
4423
- const appBus = new EventTarget();
4424
- function openFileInEditor(path) {
4425
- if (!path) return;
4426
- // Just signal "open this file" — the App-level editor drawer subscribes
4427
- // and pops itself open. We don't navigate the sidebar; the drawer
4428
- // sits over the current panel so the user can keep their place in
4429
- // chat / overview / wherever they were.
4430
- appBus.dispatchEvent(new CustomEvent("open-file", { detail: { path } }));
4431
- }
4432
-
4433
- // ---------- Global error capture ----------
4434
- //
4435
- // Three sources feed into one overlay:
4436
- // 1. window.error — sync exceptions, script load failures
4437
- // 2. window.unhandledrejection — async promise rejections
4438
- // 3. Preact ErrorBoundary — render-time component exceptions
4439
- //
4440
- // All three normalize to `{ error, source, info? }` and dispatch via
4441
- // appBus. ErrorOverlay queues the most recent and lets the user copy
4442
- // the trace or open a pre-filled GitHub issue.
4443
-
4444
- function reportAppError(error, source, info) {
4445
- // Console-log so devtools still has the message even when the
4446
- // overlay is dismissed; keeps "what just broke" debuggable.
4447
- // eslint-disable-next-line no-console
4448
- console.error(`[reasonix dashboard] ${source}:`, error, info);
4449
- appBus.dispatchEvent(
4450
- new CustomEvent("error", { detail: { error, source, info, ts: Date.now() } }),
4451
- );
4452
- }
4453
-
4454
- window.addEventListener("error", (ev) => {
4455
- // Resource-load errors (failing img/script) come through with no
4456
- // `error` object and are noisy; only surface real exceptions.
4457
- if (!ev.error) return;
4458
- reportAppError(ev.error, "window", ev.message);
4459
- });
4460
-
4461
- window.addEventListener("unhandledrejection", (ev) => {
4462
- reportAppError(ev.reason, "promise");
4463
- });
4464
-
4465
- function ToastStack() {
4466
- const [toasts, setToasts] = useState([]);
4467
- useEffect(() => {
4468
- const onToast = (ev) => {
4469
- const id = `${Date.now()}-${Math.random()}`;
4470
- const t = { id, ...ev.detail };
4471
- setToasts((prev) => [...prev, t]);
4472
- setTimeout(() => setToasts((prev) => prev.filter((x) => x.id !== id)), t.ttl);
4473
- };
4474
- toastBus.addEventListener("toast", onToast);
4475
- return () => toastBus.removeEventListener("toast", onToast);
4476
- }, []);
4477
- if (toasts.length === 0) return null;
4478
- return html`
4479
- <div class="toast-stack">
4480
- ${toasts.map((t) => html`<div key=${t.id} class="toast ${t.kind}">${t.text}</div>`)}
4481
- </div>
4482
- `;
4483
- }
4484
-
4485
- // ---------- Error overlay ----------
4486
- //
4487
- // Renders a full-screen modal whenever a window error / promise
4488
- // rejection / Preact render error fires through `appBus`. Includes a
4489
- // "Copy details" button (clipboard) and "Report on GitHub" link with a
4490
- // pre-filled body containing redacted environment info — the URL is
4491
- // safe to surface (token is never embedded; just version + UA + the
4492
- // trace itself).
4493
-
4494
- const REPO_URL = "https://github.com/esengine/reasonix";
4495
-
4496
- function buildIssueBody({ error, source, info }) {
4497
- const ua = typeof navigator === "object" ? navigator.userAgent : "(unknown)";
4498
- const errMsg = error?.message ?? String(error);
4499
- const stack = error?.stack ?? "(no stack)";
4500
- return [
4501
- "**What happened**",
4502
- "(describe what you were doing — typing, switching tabs, clicking a tool path, etc.)",
4503
- "",
4504
- "**Error**",
4505
- "```",
4506
- `${source}: ${errMsg}`,
4507
- info ? `info: ${info}` : null,
4508
- "",
4509
- stack,
4510
- "```",
4511
- "",
4512
- "**Environment**",
4513
- `- Reasonix: ${MODE}`,
4514
- `- Browser: ${ua}`,
4515
- `- URL: ${location.pathname} (token redacted)`,
4516
- "",
4517
- "_Reported from the local dashboard's error overlay._",
4518
- ]
4519
- .filter((l) => l !== null)
4520
- .join("\n");
4521
- }
4522
-
4523
- function ErrorOverlay() {
4524
- const [err, setErr] = useState(null); // { error, source, info, ts }
4525
- const [copied, setCopied] = useState(false);
4526
-
4527
- useEffect(() => {
4528
- const onError = (ev) => {
4529
- // Show only the latest — if a second fires while overlay is up,
4530
- // it replaces. Cumulative replay would be nice but for now the
4531
- // user can copy / file the issue with the most recent.
4532
- setErr(ev.detail);
4533
- setCopied(false);
4534
- };
4535
- appBus.addEventListener("error", onError);
4536
- return () => appBus.removeEventListener("error", onError);
4537
- }, []);
4538
-
4539
- // Esc dismisses (assuming non-fatal).
4540
- useEffect(() => {
4541
- if (!err) return;
4542
- const onKey = (e) => {
4543
- if (e.key === "Escape") setErr(null);
4544
- };
4545
- window.addEventListener("keydown", onKey);
4546
- return () => window.removeEventListener("keydown", onKey);
4547
- }, [err]);
4548
-
4549
- if (!err) return null;
4550
- const error = err.error;
4551
- const errMsg = error?.message ?? String(error);
4552
- const stack = error?.stack ?? "(no stack)";
4553
-
4554
- const issueUrl = `${REPO_URL}/issues/new?title=${encodeURIComponent(`[dashboard] ${errMsg.slice(0, 80)}`)}&body=${encodeURIComponent(buildIssueBody(err))}`;
4555
-
4556
- const copyDetails = async () => {
4557
- const body = buildIssueBody(err);
4558
- try {
4559
- await navigator.clipboard.writeText(body);
4560
- setCopied(true);
4561
- setTimeout(() => setCopied(false), 2000);
4562
- } catch {
4563
- /* clipboard blocked — user can still hit "report on GitHub" */
4564
- }
4565
- };
4566
-
4567
- return html`
4568
- <div class="error-overlay">
4569
- <div class="error-overlay-card">
4570
- <div class="error-overlay-head">
4571
- <span class="error-overlay-icon">✦</span>
4572
- <div>
4573
- <div class="error-overlay-title">Something broke in the dashboard</div>
4574
- <div class="error-overlay-subtitle">${err.source} error · ${errMsg}</div>
4575
- </div>
4576
- </div>
4577
-
4578
- <pre class="error-overlay-trace">${stack}</pre>
4579
-
4580
- ${
4581
- err.info
4582
- ? html`<div class="error-overlay-info"><strong>info:</strong> ${err.info}</div>`
4583
- : null
4584
- }
4585
-
4586
- <div class="error-overlay-help">
4587
- The TUI is unaffected — only this browser tab tripped. You can
4588
- dismiss and keep working, or report it so we can fix the
4589
- underlying cause.
4590
- </div>
4591
-
4592
- <div class="error-overlay-actions">
4593
- <button class="primary" onClick=${copyDetails}>
4594
- ${copied ? "Copied ✓" : "Copy details"}
4595
- </button>
4596
- <a class="button" href=${issueUrl} target="_blank" rel="noopener noreferrer">
4597
- Report on GitHub
4598
- </a>
4599
- <button onClick=${() => setErr(null)} style="margin-left: auto;">Dismiss (Esc)</button>
4600
- </div>
4601
- </div>
4602
- </div>
4603
- `;
4604
- }
4605
-
4606
- // Preact ErrorBoundary — catches render-time exceptions in the App
4607
- // subtree and dispatches them to the error overlay instead of leaving
4608
- // the user with a blank white page. Recovers automatically the first
4609
- // few times so transient hiccups don't strand the user; if a panel
4610
- // throws repeatedly we stop the loop and render a manual "Try again"
4611
- // fallback so the page never looks blank-but-ticking.
4612
- class ErrorBoundary extends Component {
4613
- constructor(props) {
4614
- super(props);
4615
- this.state = { caught: false, lastErr: null, attempts: 0 };
4616
- }
4617
- static getDerivedStateFromError(error) {
4618
- return { caught: true, lastErr: error };
4619
- }
4620
- componentDidCatch(error, info) {
4621
- reportAppError(error, "render", info?.componentStack ?? "");
4622
- const attempts = (this.state.attempts ?? 0) + 1;
4623
- if (attempts >= 3) {
4624
- // Stop the auto-recover loop — the panel is genuinely broken,
4625
- // surface a "Try again" button instead of flickering.
4626
- this.setState({ attempts });
4627
- return;
4628
- }
4629
- setTimeout(() => this.setState({ caught: false, attempts }), 100);
4630
- }
4631
- render() {
4632
- if (this.state.caught) {
4633
- if ((this.state.attempts ?? 0) >= 3) {
4634
- return html`
4635
- <div class="boot" style="flex-direction: column; gap: 12px;">
4636
- <div>this panel keeps crashing — the error overlay has the trace.</div>
4637
- <button onClick=${() => this.setState({ caught: false, attempts: 0 })}>
4638
- Try again
4639
- </button>
4640
- </div>
4641
- `;
4642
- }
4643
- return html`<div class="boot">recovering…</div>`;
4644
- }
4645
- return this.props.children;
4646
- }
4647
- }
4648
-
4649
- function App() {
4650
- const [activeId, setActiveId] = useState("chat");
4651
- const [sidebarOpen, setSidebarOpen] = useState(false); // mobile drawer
4652
- // Desktop "icon only" collapse — narrow sidebar that shows just the
4653
- // glyphs. Persisted so the choice survives reload.
4654
- const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
4655
- try {
4656
- return localStorage.getItem("rx.sidebarCollapsed") === "1";
4657
- } catch {
4658
- return false;
4659
- }
4660
- });
4661
- useEffect(() => {
4662
- try {
4663
- localStorage.setItem("rx.sidebarCollapsed", sidebarCollapsed ? "1" : "0");
4664
- } catch {
4665
- /* private mode / disabled storage — ignore */
4666
- }
4667
- }, [sidebarCollapsed]);
4668
- // Editor drawer — opens whenever any panel fires "open-file" via
4669
- // appBus. Lives at the App level so the editor's tab state persists
4670
- // across sidebar-tab switches; you can open a file from Chat, switch
4671
- // to Usage to glance at numbers, come back, and the editor's still
4672
- // there. × on the drawer or Esc closes it.
4673
- const [editorOpen, setEditorOpen] = useState(false);
4674
- const active = TABS.find((t) => t.id === activeId) ?? TABS[0];
4675
-
4676
- // Esc anywhere closes the mobile drawer (modals already handle their
4677
- // own Esc). On desktop the drawer is always-open so this is a no-op.
4678
- useEffect(() => {
4679
- const onKey = (e) => {
4680
- if (e.key === "Escape") setSidebarOpen(false);
4681
- };
4682
- window.addEventListener("keydown", onKey);
4683
- return () => window.removeEventListener("keydown", onKey);
4684
- }, []);
4685
-
4686
- // Cross-component navigation — sidebar-tab switching when something
4687
- // fires `navigate-tab` (kept in case other features want it; the
4688
- // editor drawer no longer uses it).
4689
- useEffect(() => {
4690
- const onNav = (ev) => {
4691
- const id = ev.detail?.tabId;
4692
- if (id) setActiveId(id);
4693
- };
4694
- appBus.addEventListener("navigate-tab", onNav);
4695
- return () => appBus.removeEventListener("navigate-tab", onNav);
4696
- }, []);
4697
-
4698
- // Open the editor drawer whenever any panel signals a file-open.
4699
- // The drawer's <EditorPanel> is permanently mounted (with display:
4700
- // none when closed) so its tab state survives toggling — opening
4701
- // the same file twice from chat doesn't lose unsaved changes.
4702
- useEffect(() => {
4703
- const onOpenFile = () => setEditorOpen(true);
4704
- appBus.addEventListener("open-file", onOpenFile);
4705
- return () => appBus.removeEventListener("open-file", onOpenFile);
4706
- }, []);
4707
-
4708
- // Esc also closes the editor (in addition to the mobile drawer).
4709
- useEffect(() => {
4710
- if (!editorOpen) return;
4711
- const onKey = (e) => {
4712
- if (e.key === "Escape") setEditorOpen(false);
4713
- };
4714
- window.addEventListener("keydown", onKey);
4715
- return () => window.removeEventListener("keydown", onKey);
4716
- }, [editorOpen]);
4717
-
4718
- const pickTab = useCallback((id) => {
4719
- setActiveId(id);
4720
- setSidebarOpen(false); // collapse drawer after pick on mobile
4721
- }, []);
4722
-
4723
- return html`
4724
- <div class=${`sidebar ${sidebarOpen ? "open" : ""} ${sidebarCollapsed ? "collapsed" : ""}`}>
4725
- <div class="sidebar-header">
4726
- <div class="sidebar-brand" title="Reasonix"><span class="glyph">◈</span><span class="sidebar-label"> REASONIX</span></div>
4727
- <div class="sidebar-version sidebar-label">dashboard</div>
4728
- <div class="sidebar-mode sidebar-label">${MODE}</div>
4729
- </div>
4730
- <div class="gradient-rule"></div>
4731
- <div class="sidebar-tabs">
4732
- ${TABS.map(
4733
- (tab) => html`
4734
- <div
4735
- class="tab ${tab.id === active.id ? "active" : ""} ${!tab.ready ? "tab-stub" : ""}"
4736
- onClick=${() => tab.ready && pickTab(tab.id)}
4737
- title=${tab.name}
4738
- >
4739
- <span class="glyph">${tab.glyph}</span>
4740
- <span class="sidebar-label">${tab.name}</span>
4741
- ${tab.badge ? html`<span class="badge sidebar-label">${tab.badge}</span>` : null}
4742
- </div>
4743
- `,
4744
- )}
4745
- </div>
4746
- <button
4747
- class="sidebar-collapse-toggle"
4748
- onClick=${() => setSidebarCollapsed((c) => !c)}
4749
- title=${sidebarCollapsed ? "expand sidebar" : "collapse to icons"}
4750
- >${sidebarCollapsed ? "▶" : "◀"}<span class="sidebar-label"> ${sidebarCollapsed ? "expand" : "collapse"}</span></button>
4751
- <div class="sidebar-footer sidebar-label">127.0.0.1 only · token-gated</div>
4752
- </div>
4753
- <div class="sidebar-backdrop" onClick=${() => setSidebarOpen(false)}></div>
4754
- <button class="menu-toggle" onClick=${() => setSidebarOpen((s) => !s)} aria-label="Toggle sidebar">≡</button>
4755
- <div class=${`main ${active.id === "editor" ? "main-editor" : ""}`}>
4756
- <${ErrorBoundary}>${active.panel()}<//>
4757
- </div>
4758
- <div class=${`editor-drawer-host ${editorOpen ? "open" : ""}`}>
4759
- <${ErrorBoundary}>
4760
- <${EditorPanel} onClose=${() => setEditorOpen(false)} />
4761
- <//>
4762
- </div>
4763
- <${ToastStack} />
4764
- <${ErrorOverlay} />
4765
- `;
4766
- }
4767
-
4768
- render(html`<${App} />`, document.getElementById("root"));