Repository navigation
Expand file tree
/
Copy pathrenderer.js
More file actions
8752 lines (8335 loc) · 448 KB
/
Copy pathrenderer.js
File metadata and controls
8752 lines (8335 loc) · 448 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
document.getElementById('btn-close').addEventListener('click', () => window.mw?.close());
document.getElementById('btn-min').addEventListener('click', () => window.mw?.minimize());
document.getElementById('btn-max').addEventListener('click', () => window.mw?.maximize());
// The inline diff card in the transcript: an expandable tool block like everything else.
const diffcard = document.querySelector('.diffcard');
const diffcardWrap = document.querySelector('.diffcard-wrap');
if (diffcard) {
diffcard.addEventListener('click', () => {
diffcardWrap.classList.toggle('collapsed');
});
}
// ── Settings modal ────────────────────────────────────────────────────
const settingsOverlay = document.getElementById('settings-overlay');
document.getElementById('open-settings').addEventListener('click', () => {
settingsOverlay.hidden = false;
refreshProviderList();
});
document.getElementById('settings-close').addEventListener('click', () => {
settingsOverlay.hidden = true;
});
settingsOverlay.addEventListener('click', (e) => {
if (e.target === settingsOverlay) settingsOverlay.hidden = true;
});
window.addEventListener('keydown', (e) => {
if (e.key !== 'Escape' || settingsOverlay.hidden) return;
// Something open ON TOP of Settings (the notes editor, a confirm) takes the Escape.
if (!document.getElementById('att-viewer').hidden || !document.getElementById('confirm-overlay').hidden) return;
settingsOverlay.hidden = true;
});
// Smooth wheel scroll — the browser's default wheel scroll jumps by a fixed
// step per notch; this eases toward the accumulated target instead. High
// lerp factor on purpose: fast to catch up, not a slow glide, so a fast
// scroll still feels immediate. Returns a controller so a caller (the chat
// body's own auto-scroll-to-bottom) can drive the SAME target/raf state
// instead of fighting it with a second, uncoordinated scrollTop write.
// The wheel and the touchpad scroll NATIVELY: the browser animates them off the main thread, in step
// with the display and the system's own scroll settings, snapped to whole device pixels. The old
// hand-made glide ran on the page's thread with a fixed 42%-per-FRAME ease, so on a 300 Hz screen a
// notch landed in 27 ms (133 ms at 60 Hz): it read as a jump. Set false to get that glide back.
const NATIVE_WHEEL = true;
function smoothScrollify(el, onUserScroll) {
let target = el.scrollTop;
let raf = null;
// Where a running glide really is, to the fraction (what is drawn is only this rounded), when the
// last frame ran, and the position we last drew. The glide keeps its own exact position so a tiny
// step on a fast screen is not rounded away; if the page was moved by anything else (rows settling,
// the reader) it starts again from where the page is.
let pos = el.scrollTop;
let lastFrame = 0;
let drawn = el.scrollTop;
// Pinned = chase the LIVE bottom every frame. Content keeps growing while
// the animation runs (a streaming reply, a result filling in), and a target
// fixed at the moment of the call used to stop short of it.
let pinned = false;
// The last position WE wrote. A scroll event that doesn't match it came
// from the user (scrollbar drag, keyboard, middle-click), not from us.
let lastSet = el.scrollTop;
function write(v) {
// Whole pixels: a scroll that stops on a fraction blurs every row of text and every dot.
el.scrollTop = Math.round(v);
lastSet = drawn = el.scrollTop;
}
function step(now) {
if (pinned) target = el.scrollHeight - el.clientHeight;
if (Math.abs(el.scrollTop - drawn) >= 1) pos = el.scrollTop;
if (Math.abs(target - pos) < 0.5) {
write(target);
pos = el.scrollTop;
raf = null;
lastFrame = 0;
return;
}
// The ease is per TIME, not per frame (scroll-ease.js), so a 300 Hz screen takes the same moment
// to arrive as a 60 Hz one, in smaller steps.
pos = mwScrollEase.glideStep(pos, target, now - lastFrame);
lastFrame = now;
const px = Math.round(pos);
if (px !== Math.round(el.scrollTop)) write(px);
raf = requestAnimationFrame(step);
}
function scrollTo(pos, opts = {}) {
pinned = !!opts.pin;
const max = el.scrollHeight - el.clientHeight;
target = Math.min(max, Math.max(0, pos));
if (opts.instant) {
if (raf) cancelAnimationFrame(raf);
raf = null;
lastFrame = 0;
write(target);
pos = el.scrollTop;
return;
}
if (!raf) {
pos = el.scrollTop;
lastFrame = performance.now();
raf = requestAnimationFrame(step);
}
}
// Only the reader's own input takes the scroll over. The browser also moves it by itself (rows off
// screen settling their height, anchoring): those must not cancel a scroll this is running.
let lastInputAt = 0;
const input = () => { lastInputAt = performance.now(); };
el.addEventListener('pointerdown', input, { passive: true });
el.addEventListener('touchstart', input, { passive: true });
window.addEventListener('keydown', (e) => { if (/^(Page|Arrow|Home|End| )/.test(e.key) || e.key === ' ') input(); }, true);
el.addEventListener('scroll', () => {
if (Math.abs(el.scrollTop - lastSet) <= 1) return;
if (performance.now() - lastInputAt > 400) { lastSet = el.scrollTop; return; }
if (raf) cancelAnimationFrame(raf);
raf = null;
pinned = false;
target = lastSet = el.scrollTop;
onUserScroll?.(target, el.scrollHeight - el.clientHeight);
});
el.addEventListener('wheel', (e) => {
// A list with its own scroll inside (a test's steps) scrolls itself while the
// pointer is over it; the page stays where it is.
const inner = e.target.closest?.('.lt-steps');
if (inner && inner !== el && el.contains(inner) && inner.scrollHeight > inner.clientHeight) return;
const max = el.scrollHeight - el.clientHeight;
if (max <= 0) return;
if (NATIVE_WHEEL) {
// The browser scrolls. This only takes the scroll over from any glide of ours (a jump to the
// bottom, following a reply) and marks it as the reader's: the scroll listener above reports
// where they end up.
input();
if (raf) cancelAnimationFrame(raf);
raf = null;
lastFrame = 0;
pinned = false;
return;
}
e.preventDefault();
pinned = false;
scrollTo(target + e.deltaY);
// Reports where the user's OWN scroll is actually headed (the target,
// not the current mid-animation position) — the caller uses this to
// decide whether new content should keep following, without re-deriving
// "near the bottom" from geometry that a just-appended block has
// already skewed (that was the actual bug: checking proximity AFTER
// the DOM grew read as "you scrolled away" when you hadn't).
onUserScroll?.(target, max);
}, { passive: NATIVE_WHEEL });
return {
scrollTo,
isNearBottom(threshold = 96) {
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
},
};
}
// Not forEach(smoothScrollify): forEach would pass the index as its onUserScroll.
document.querySelectorAll('.settings-panel').forEach((panel) => smoothScrollify(panel));
document.querySelectorAll('.settings-nav button').forEach((navBtn) => {
navBtn.addEventListener('click', () => {
document.querySelectorAll('.settings-nav button').forEach((b) => b.classList.remove('active'));
navBtn.classList.add('active');
document.querySelectorAll('.settings-panel').forEach((p) => p.classList.remove('active'));
document.getElementById(`panel-${navBtn.dataset.panel}`).classList.add('active');
});
});
function wireSwitch(sw) {
sw.addEventListener('click', () => sw.classList.toggle('on'));
}
document.querySelectorAll('.sw').forEach(wireSwitch);
// Chat view: a tool call is collapsed by default, click its header to
// reveal the result. Open by default — closing is the opt-out for a turn
// that's gotten long.
function wireToolToggle(tool) {
tool.querySelector('.tool-head').addEventListener('click', () => {
tool.classList.toggle('collapsed');
});
}
document.querySelectorAll('.tool').forEach(wireToolToggle);
// Truncated long content ("Show N more lines") — a long written/edited
// file previews at a fixed height with a fade instead of dumping the
// whole thing inline; this button reveals the rest in place.
// One toggle for every "Show more / Show less". Opening grows to the measured height in a short
// animation (or at once when it is very tall: animating a huge height stutters on a slow machine).
// Closing is instant and keeps the button exactly where it was on screen, so nothing jumps away
// from under the pointer when you collapse a long diff you had scrolled into.
const TRUNC_ANIMATE_MAX = 900; // px of growth still worth animating
// After opening, bring the "Show less" button into view (with a little room under it), so closing
// again needs no scrolling. Never scroll the start of what was opened off the top: when the whole
// block does not fit, its beginning stays in view and the button is a short scroll away.
// How far to scroll (measured now, from the final layout), and in what, or null when nothing needs to move.
function revealAfterOpen(trunc, btn, grew) {
const scroller = trunc.closest('.chat-body, .agent-scroll, .settings-panel');
if (!scroller || grew <= 0) return null;
const view = scroller.getBoundingClientRect();
const btnBottom = btn.getBoundingClientRect().bottom + 16; // where it ends up: the growth is already laid out
const overshoot = btnBottom - view.bottom;
if (overshoot <= 0) return null;
const blockTop = trunc.getBoundingClientRect().top - view.top;
const by = Math.max(0, Math.min(overshoot, blockTop - 8));
return by ? { scroller, by } : null;
}
// Opening is ONE motion: the block grows and the view follows it in the same frames, on the same curve,
// so it reads as a single unfold rather than "open, then scroll". Scrolling with the wheel mid-way hands
// the view back to the reader; the block still finishes opening.
function unfold(trunc, body, from, to, rv) {
const grew = to - from;
const dur = Math.min(320, 200 + grew * 0.12);
const start = rv ? rv.scroller.scrollTop : 0;
let readerTook = false;
if (rv) rv.scroller.addEventListener('wheel', () => { readerTook = true; }, { once: true, passive: true });
const ease = (t) => 1 - Math.pow(1 - t, 3);
const t0 = performance.now();
body.style.maxHeight = from + 'px';
const frame = (now) => {
const t = Math.max(0, Math.min(1, (now - t0) / dur));
const e = ease(t);
body.style.maxHeight = (from + grew * e) + 'px';
if (rv && !readerTook) rv.scroller.scrollTop = start + rv.by * e;
if (t < 1) trunc._unfold = requestAnimationFrame(frame);
else { body.style.maxHeight = ''; trunc._unfold = null; }
};
trunc._unfold = requestAnimationFrame(frame);
}
function toggleTrunc(trunc, label, moreText) {
const body = trunc.querySelector('.trunc-body');
const btn = trunc.querySelector('.trunc-more');
const opening = !trunc.classList.contains('open');
if (!body) { trunc.classList.toggle('open', opening); return; }
body.getAnimations().forEach((a) => a.cancel());
if (trunc._unfold) { cancelAnimationFrame(trunc._unfold); trunc._unfold = null; body.style.maxHeight = ''; }
if (opening) {
const from = body.offsetHeight;
trunc.classList.add('open');
label.textContent = 'Show less';
const to = body.scrollHeight;
const rv = revealAfterOpen(trunc, btn, to - from); // measured now, from the final layout
if (to - from > 0 && to - from <= TRUNC_ANIMATE_MAX) unfold(trunc, body, from, to, rv);
else if (rv) rv.scroller.scrollBy({ top: rv.by, behavior: 'smooth' });
return;
}
const before = btn.getBoundingClientRect().top;
trunc.classList.remove('open');
label.textContent = moreText;
const scroller = trunc.closest('.chat-body, .agent-scroll, .settings-panel');
const moved = btn.getBoundingClientRect().top - before;
if (scroller && moved) scroller.scrollTop += moved;
}
document.querySelectorAll('.trunc').forEach((trunc) => {
const btn = trunc.querySelector('.trunc-more');
const label = btn.querySelector('.trunc-more-label');
const moreText = label.textContent;
btn.addEventListener('click', () => toggleTrunc(trunc, label, moreText));
});
// ── Providers — the real registry (drivers/registry.ts), not a fixed six ──
// Every provider mwcode ships with, with whether a key is actually on file
// and the models it currently offers (discovered providers like OpenRouter
// refreshed on open, same TTL the CLI's picker uses).
const providerList = document.getElementById('provider-list');
const providerSearch = document.getElementById('provider-search');
const providerEmpty = document.getElementById('provider-empty');
const providerIcon = '<path d="M6 2v3M10 2v3M4.5 5h7v3a3.5 3.5 0 0 1-7 0V5Z" stroke="currentColor" stroke-width="1.15" fill="none" stroke-linejoin="round"/>';
function renderProviderList(providers) {
providerList.querySelectorAll('.settings-row').forEach((row) => row.remove());
const sorted = [...providers].sort((a, b) => {
if (a.connected !== b.connected) return a.connected ? -1 : 1;
return a.label.localeCompare(b.label);
});
sorted.forEach((p) => {
const row = document.createElement('div');
row.className = 'settings-row';
// A runtime on this computer that is not running has no models to list, whatever it answered last time.
const shown = p.local && !p.connected ? [] : p.models;
const modelNames = shown.map((m) => m.label).join(', ');
row.dataset.search = `${p.label} ${p.local ? 'local ' : ''}${modelNames}`.toLowerCase();
const preview =
shown.length === 0
? (p.local ? 'Models on your own computer, no key' : 'No models listed')
: shown.length <= 4
? modelNames
: `${shown.slice(0, 3).map((m) => m.label).join(', ')} +${shown.length - 3} more`;
row.innerHTML = `
<div class="settings-row-main">
<div class="settings-row-icon"><svg viewBox="0 0 16 16">${providerIcon}</svg></div>
<div class="settings-row-label"><span class="t">${p.label}</span><span class="d">${preview}</span></div>
</div>
<div class="settings-row-actions">
<span class="settings-badge ${p.connected ? 'ok' : 'off'}">${p.connected ? 'Connected' : 'Not connected'}</span>
<button class="settings-btn ${p.connected ? 'ghost' : ''}">${p.connected ? 'Manage' : 'Connect'}</button>
</div>
`;
// A provider with a detail screen opens it: from the row, and from Connect,
// which there has a real key field.
const hasDetail = !!window.PROVIDER_INFO?.[p.id];
if (hasDetail) {
row.classList.add('has-detail');
row.querySelector('.settings-row-actions').insertAdjacentHTML('beforeend',
'<svg class="row-chev" viewBox="0 0 12 12" fill="none"><path d="M4.5 2.5L8 6l-3.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>');
row.addEventListener('click', (e) => {
if (e.target.closest('.settings-btn') && p.connected) return;
openProviderDetail(p.id);
});
}
row.querySelector('.settings-btn').addEventListener('click', () => {
if (p.local) { openProviderDetail(p.id); return; } // no key to manage: its page is the place
if (hasDetail && !p.connected) return; // the row's click opens the detail screen
if (hasDetail && p.connected) { openKeyManager(p.id, 'list'); return; }
if (p.connected) {
confirmAction(`Remove the saved key for ${p.label}?`, async () => {
const list = await window.mw.clearProviderKey(p.apiKeyEnv);
renderProviderList(list);
});
} else {
const key = window.prompt(`API key for ${p.label}\n\nGet one at ${p.keysUrl}`);
if (!key || !key.trim()) return;
window.mw.setProviderKey(p.apiKeyEnv, key.trim()).then(renderProviderList);
}
});
providerList.insertBefore(row, providerEmpty);
});
providerSearch.dispatchEvent(new Event('input'));
}
function refreshProviderList() {
window.mw.listProviders?.().then(renderProviderList);
}
// ── Provider detail: a screen inside the Providers panel ──────────────
// Opened by clicking a provider that has an entry in providerInfo.js. The
// models and prices come from the engine (providerDetail); the about text,
// links and disclosure come from providerInfo.js.
const providersListView = document.getElementById('providers-list-view');
const providerDetailEl = document.getElementById('provider-detail');
const EXT_SVG = '<svg viewBox="0 0 12 12" fill="none"><path d="M4.5 2.5H9.5V7.5M9.2 2.8L3 9" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
// Two decimals, but never rounded to nothing: $0.003 stays $0.003, not $0.00.
// null means the provider has no separate rate for it.
function fmtPrice(n) {
if (n === null || n === undefined) return '—';
if (n === 0) return 'Free';
let out = n.toFixed(4).replace(/0+$/, '');
if ((out.split('.')[1] ?? '').length < 2) out = n.toFixed(2);
return `$${out}`;
}
// Where the list was when a provider's page was opened, so Back puts you exactly there, not at the top.
let providerListScroll = 0;
// Back from a provider's page (keepPlace) lands where the list was. Anything else that shows the list
// (switching to another Settings tab, opening Settings) starts it fresh, so the old place is forgotten.
function showProviderList({ keepPlace = false } = {}) {
providerDetailEl.hidden = true;
providerDetailEl.innerHTML = '';
providersListView.hidden = false;
if (keepPlace) providerDetailEl.closest('.settings-panel').scrollTop = providerListScroll;
else providerListScroll = 0;
}
// The list's place, remembered on the way into a page (only when it is the list that is showing).
function rememberProviderListPlace() {
if (!providersListView.hidden) providerListScroll = providerDetailEl.closest('.settings-panel').scrollTop;
}
async function openProviderDetail(id, { refreshed = false } = {}) {
const info = window.PROVIDER_INFO?.[id];
let d = await window.mw.providerDetail(id);
if (!info || !d) return;
// A runtime on this computer (Ollama) is asked again every time its page opens.
if (d.local && !refreshed) {
await window.mw.listProviders?.();
d = (await window.mw.providerDetail(id)) ?? d;
}
// Where it is, when it was pointed at another computer ('' = this one).
if (d.local) d = { ...d, address: (await window.mw.ollamaAddress?.()) || '' };
rememberProviderListPlace(); // only when coming FROM the list (a page redrawing itself keeps it)
providersListView.hidden = true;
providerDetailEl.hidden = false;
renderProviderDetail(d, info);
providerDetailEl.closest('.settings-panel').scrollTop = 0;
}
function renderProviderDetail(d, info) {
// A runtime on this computer (Ollama): no key, no prices. Its page says how to set it up and has a
// button that looks again.
const local = !!d.local;
if (local && !d.connected) d = { ...d, models: [] }; // not running: no models, whatever it answered last time
const CHECK_ICON = '<svg viewBox="0 0 16 16" fill="none"><path d="M13 8a5 5 0 1 1-1.6-3.7M13 2.5v3h-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
// A capability every model has is said once above the table, not on every row.
const allThink = d.models.length > 0 && d.models.every((m) => m.thinks);
const allImages = d.models.length > 0 && d.models.every((m) => m.acceptsImages);
const shared = [allThink && 'thinking', allImages && 'image input'].filter(Boolean);
// No model bills cache writes separately: drop the column rather than fill it with dashes.
const hasWrite = d.models.some((m) => m.price.cacheWrite !== null);
const notes = info.modelNotes ?? {};
const rows = d.models.map((m, i) => `
<div class="pd-model" data-search="${escapeHtml(`${m.label} ${m.id}`.toLowerCase())}" data-order="${i}" data-price="${m.price.input + m.price.output}">
<div class="pd-model-name">
<span class="n">${escapeHtml(m.label)}</span>
<span class="id">${escapeHtml(m.id)}</span>
<span class="tags">${m.thinks && !allThink ? '<span class="pd-tag">Thinking</span>' : ''}${m.acceptsImages && !allImages ? '<span class="pd-tag">Images</span>' : ''}</span>
${notes[m.id] ? `<span class="pd-model-note">${escapeHtml(notes[m.id])}</span>` : ''}
</div>
${local ? '' : `<span class="pd-num">${fmtPrice(m.price.input)}</span>
<span class="pd-num">${fmtPrice(m.price.output)}</span>
<span class="pd-num dim">${fmtPrice(m.price.cacheRead)}</span>
${hasWrite ? `<span class="pd-num dim">${fmtPrice(m.price.cacheWrite)}</span>` : ''}`}
</div>`).join('');
// Who bills you and whom Mindweave is independent of: the company, not the model name.
const company = escapeHtml(info.company ?? d.label);
const checked = info.pricesCheckedAt
? `Prices are ${company}'s published API list prices, last checked ${escapeHtml(info.pricesCheckedAt)}.`
: `Prices come from ${escapeHtml(d.label)}'s live catalogue.`;
providerDetailEl.innerHTML = `
<button class="pd-back" id="pd-back"><svg viewBox="0 0 12 12" fill="none"><path d="M7.5 2.5L4 6l3.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>Providers</button>
<div class="pd-head">
<div class="pd-mark">${escapeHtml(info.mark)}</div>
<div class="pd-title">
<h3>${escapeHtml(d.label)}</h3>
<span>${escapeHtml(info.tagline)}</span>
</div>
<span class="settings-badge ${d.connected ? 'ok' : 'off'}">${d.connected ? 'Connected' : 'Not connected'}</span>
</div>
<p class="pd-about">${escapeHtml(info.about)}</p>
<div class="pd-connect ${d.connected || local ? 'is-on' : ''}" id="pd-connect">
${local ? (d.connected ? `
<div class="pd-connect-text"><span class="t">Connected</span><span class="d" id="pd-local-note">Ollama is running ${d.address ? `at ${escapeHtml(d.address)}` : 'on this computer'} with ${d.models.length} model${d.models.length === 1 ? '' : 's'} that use${d.models.length === 1 ? 's' : ''} tools.${d.address ? '' : ' Nothing to paste.'}</span></div>
<div class="pd-actions"><button class="settings-btn pd-check" id="pd-check" type="button">${CHECK_ICON}<span>Check again</span></button></div>
` : `
<div class="pd-connect-text"><span class="t">Connect ${escapeHtml(d.label)}</span><span class="d" id="pd-local-note">${d.address ? `Nothing answered at ${escapeHtml(d.address)} yet. Start Ollama there, or choose another address below.` : 'Nothing to paste. Install Ollama, pull a model that uses tools (for example ollama pull qwen3:8b), and keep it running. mwcode finds it by itself.'}</span></div>
<div class="pd-actions">
<button class="pd-link" data-url="${escapeHtml(d.keysUrl)}">Get Ollama ${EXT_SVG}</button>
<button class="settings-btn pd-check" id="pd-check" type="button">${CHECK_ICON}<span>Check again</span></button>
</div>
`) : d.connected ? `
<div class="pd-connect-text"><span class="t">Connected</span><span class="d">Add more keys, choose the default, switch keys off, or let Mindweave move to the next key when one runs out.</span></div>
<button class="settings-btn" id="pd-manage">Manage keys</button>
` : `
<div class="pd-connect-text"><span class="t">Connect ${escapeHtml(d.label)}</span><span class="d">Paste an API key from your ${company} account. It stays on this computer.</span></div>
<div class="pd-key">
<input type="password" id="pd-key-input" placeholder="API key" spellcheck="false" autocomplete="off">
<button class="settings-btn" id="pd-save" disabled>Connect</button>
</div>
<button class="pd-link" data-url="${escapeHtml(d.keysUrl)}">Get an API key ${EXT_SVG}</button>
`}
</div>
${local ? `
<div class="pd-section" id="pd-where">
<div class="pd-section-head">
<span class="settings-group">Where Ollama runs</span>
<span class="pd-unit">Saved for the terminal too</span>
</div>
<div class="pd-where">
<div class="settings-seg" id="pd-where-seg">
<button type="button" data-where="here" class="${d.address ? '' : 'active'}">This computer</button>
<button type="button" data-where="net" class="${d.address ? 'active' : ''}">Another address</button>
</div>
<div class="pd-key" id="pd-where-row" ${d.address ? '' : 'hidden'}>
<input type="text" id="pd-addr" value="${escapeHtml(d.address)}" placeholder="e.g. 192.168.1.20:11434" spellcheck="false" autocomplete="off" aria-label="Address of the computer Ollama runs on">
<button class="settings-btn" id="pd-addr-save" type="button" disabled>Save</button>
</div>
<p class="pd-where-note" id="pd-addr-msg">${d.address ? 'Your messages go to this address. Use a computer you trust.' : ''}</p>
</div>
</div>` : ''}
<div class="pd-section">
<div class="pd-section-head">
<span class="settings-group">${local ? (d.address ? 'Models at this address' : 'Models on this computer') : 'Models through the API'}</span>
<span class="pd-unit">${shared.length ? `All support ${shared.join(' and ')} · ` : ''}${local ? (d.address ? 'Free, nothing is billed' : 'Free, they run on your computer') : 'USD per 1M tokens'}</span>
</div>
${d.models.length > 12 ? `
<div class="pd-tools">
<div class="settings-search pd-filter">
<svg viewBox="0 0 14 14"><circle cx="6" cy="6" r="4.2" stroke="currentColor" stroke-width="1.2" fill="none"/><path d="M9.2 9.2L12.5 12.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
<input type="text" id="pd-filter" placeholder="Filter ${d.models.length} models…" spellcheck="false">
</div>
<div class="pd-sort-wrap">
<button class="pd-sort-btn" id="pd-sort-btn" aria-haspopup="menu" title="Sort models">
<svg viewBox="0 0 16 16" fill="none"><path d="M5 3v10M5 13l-2.5-2.5M5 13l2.5-2.5M11 13V3M11 3L8.5 5.5M11 3l2.5 2.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span id="pd-sort-label">Default order</span>
<svg class="chev" viewBox="0 0 10 10" fill="none"><path d="M2.5 4l2.5 2.5L7.5 4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="pd-sort-menu" id="pd-sort" role="menu" hidden>
<div class="pd-sort-head" title="Input + output price per 1M tokens">Sort by price</div>
<button data-sort="default" class="active" role="menuitemradio">Default order</button>
<button data-sort="asc" role="menuitemradio">Lowest price first</button>
<button data-sort="desc" role="menuitemradio">Highest price first</button>
</div>
</div>
</div>` : ''}
<div class="pd-table ${local ? 'local' : hasWrite ? '' : 'no-write'}">
<div class="pd-model pd-th">
<span>Model</span>${local ? '' : `<span class="pd-num">Input</span><span class="pd-num">Output</span><span class="pd-num">Cache read</span>${hasWrite ? '<span class="pd-num">Cache write</span>' : ''}`}
</div>
${rows || `<div class="pd-empty">${d.connected ? 'No models are listed right now.' : local ? 'No models yet. Pull one that uses tools (for example ollama pull qwen3:8b), then press Check again.' : `Connect a key to load ${escapeHtml(d.label)}'s current model list.`}</div>`}
</div>
${info.priceNotes?.length ? `<ul class="pd-notes">${info.priceNotes.map((n) => `<li>${escapeHtml(n)}</li>`).join('')}</ul>` : ''}
</div>
<div class="pd-links">
${info.links.map((l) => `<button class="pd-link" data-url="${escapeHtml(l.url)}">${escapeHtml(l.label)} ${EXT_SVG}</button>`).join('')}
</div>
<p class="pd-disclosure">
${local ? `
${d.address ? `${company} runs at the address you chose, not at a provider, so nothing is billed by one. Your messages go to that computer.` : `${company} runs on your computer, so nothing is sent to a provider and nothing is billed.`}
The Thinking and Images labels show what works through Mindweave.` : `
${checked}
They can change at any time, and ${company}'s pricing page is the authority.
The Thinking and Images labels show what works through Mindweave.
Usage is billed by ${company} directly to your own API key;
Mindweave never charges you and never sees your bill.`}
Mindweave is independent and is not affiliated with or endorsed by ${company}.
${escapeHtml(info.trademark)}
</p>
`;
providerDetailEl.querySelector('#pd-back').addEventListener('click', () => showProviderList({ keepPlace: true }));
// Sort: the provider's own order, or by input + output price. Ties keep that order.
const sortBtn = providerDetailEl.querySelector('#pd-sort-btn');
const sortMenu = providerDetailEl.querySelector('#pd-sort');
sortBtn?.addEventListener('click', (e) => {
e.stopPropagation();
sortMenu.hidden = !sortMenu.hidden;
sortBtn.classList.toggle('open', !sortMenu.hidden);
});
// A click anywhere else on the screen closes it. A property, not a listener, so
// reopening the screen replaces it instead of stacking another.
providerDetailEl.onclick = () => {
if (sortMenu && !sortMenu.hidden) { sortMenu.hidden = true; sortBtn.classList.remove('open'); }
};
providerDetailEl.querySelectorAll('#pd-sort button').forEach((b) => {
b.addEventListener('click', (e) => {
e.stopPropagation();
sortMenu.hidden = true;
sortBtn.classList.remove('open');
sortBtn.classList.toggle('sorted', b.dataset.sort !== 'default');
providerDetailEl.querySelector('#pd-sort-label').textContent = b.textContent;
providerDetailEl.querySelectorAll('#pd-sort button').forEach((x) => x.classList.toggle('active', x === b));
const table = providerDetailEl.querySelector('.pd-table');
const byOrder = (x, y) => x.dataset.order - y.dataset.order;
const sorted = [...table.querySelectorAll('.pd-model[data-order]')].sort((x, y) =>
b.dataset.sort === 'asc' ? x.dataset.price - y.dataset.price || byOrder(x, y)
: b.dataset.sort === 'desc' ? y.dataset.price - x.dataset.price || byOrder(x, y)
: byOrder(x, y));
table.append(...sorted);
});
});
providerDetailEl.querySelector('#pd-filter')?.addEventListener('input', (e) => {
const q = e.target.value.trim().toLowerCase();
providerDetailEl.querySelectorAll('.pd-model[data-search]').forEach((row) => {
row.hidden = !!q && !row.dataset.search.includes(q);
});
});
providerDetailEl.querySelectorAll('.pd-link').forEach((b) => {
b.addEventListener('click', () => window.mw.openExternal(b.dataset.url));
});
const input = providerDetailEl.querySelector('#pd-key-input');
const save = providerDetailEl.querySelector('#pd-save');
if (input) {
input.addEventListener('input', () => { save.disabled = !input.value.trim(); });
const connect = async () => {
const key = input.value.trim();
if (!key) return;
const list = await window.mw.setProviderKey(d.apiKeyEnv, key);
renderProviderList(list);
openProviderDetail(d.id);
};
save.addEventListener('click', connect);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') connect(); });
}
providerDetailEl.querySelector('#pd-manage')?.addEventListener('click', () => openKeyManager(d.id));
// A runtime on a computer (Ollama): where it runs, and Check again. Looking again spins the icon and says
// so for long enough to be seen, then the page is drawn again with what was found. Only this page shows it.
if (local) {
const checkBtn = providerDetailEl.querySelector('#pd-check');
const seg = providerDetailEl.querySelector('#pd-where-seg');
const whereRow = providerDetailEl.querySelector('#pd-where-row');
const addr = providerDetailEl.querySelector('#pd-addr');
const save = providerDetailEl.querySelector('#pd-addr-save');
const msg = providerDetailEl.querySelector('#pd-addr-msg');
const look = async () => {
if (!checkBtn || checkBtn.classList.contains('busy')) return;
checkBtn.classList.add('busy');
checkBtn.setAttribute('aria-busy', 'true');
checkBtn.querySelector('span').textContent = 'Checking…';
const now = (await window.mw.ollamaAddress?.()) || '';
const place = now ? `at ${now}` : 'on this computer';
const note = providerDetailEl.querySelector('#pd-local-note');
if (note) note.textContent = `Looking for ${d.label} ${place}…`;
const t0 = performance.now();
let list = null;
try { list = await window.mw.listProviders(); } catch { /* shown as "did not answer" below */ }
await new Promise((r) => setTimeout(r, Math.max(0, 800 - (performance.now() - t0))));
if (list) renderProviderList(list);
await openProviderDetail(d.id, { refreshed: true });
const found = list?.find((p) => p.id === d.id);
const fresh = providerDetailEl.querySelector('#pd-local-note');
if (fresh && !found?.connected) fresh.textContent = `${d.label} did not answer ${place} with a model that uses tools. Start it, pull a model, then check again.`;
};
checkBtn?.addEventListener('click', look);
// The switch: This computer (nothing saved) or Another address (pasted, saved, then looked at).
const setAddress = async (value) => {
const r = await window.mw.setOllamaAddress(value);
if (r?.ok) return true;
if (msg) { msg.textContent = r?.error || 'That address could not be saved.'; msg.classList.add('err'); }
return false;
};
seg?.querySelectorAll('button').forEach((b) => b.addEventListener('click', async () => {
const net = b.dataset.where === 'net';
seg.querySelectorAll('button').forEach((x) => x.classList.toggle('active', x === b));
whereRow.hidden = !net;
msg.classList.remove('err');
if (net) { msg.textContent = 'Your messages go to this address. Use a computer you trust.'; addr.focus(); return; }
msg.textContent = '';
if (d.address && await setAddress('')) look(); // back to this computer
}));
const dirty = () => { const v = addr.value.trim(); save.disabled = !v || v === d.address; };
addr?.addEventListener('input', () => { msg.classList.remove('err'); dirty(); });
const commit = async () => { if (save.disabled) return; if (await setAddress(addr.value)) look(); };
save?.addEventListener('click', commit);
addr?.addEventListener('keydown', (e) => { if (e.key === 'Enter') commit(); });
}
}
// ── Manage keys: a screen inside a provider's detail screen ──────────
// Every key the provider has, which one is in use and which is the default,
// names, switching keys off, and moving on automatically when one is refused.
// Each action goes to the engine and the screen redraws from the view it
// returns, so it can never show a state that isn't what's stored.
const KEY_SVG = '<svg viewBox="0 0 16 16" fill="none"><circle cx="5.5" cy="10.5" r="3" stroke="currentColor" stroke-width="1.2"/><path d="M7.7 8.3L13 3M11 5l1.6 1.6M9.6 6.4l1.2 1.2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>';
const DOTS_SVG = '<svg viewBox="0 0 16 16" fill="currentColor"><circle cx="3.5" cy="8" r="1.3"/><circle cx="8" cy="8" r="1.3"/><circle cx="12.5" cy="8" r="1.3"/></svg>';
const FAILURE_TEXT = { 'no-credit': 'Out of credit', 'rate-limited': 'Rate limited', rejected: 'Rejected' };
let keyEditing = null; // { slot, kind: 'rename' | 'replace' } | { kind: 'add' }
// Where Back goes: the screen Manage was opened from ('list' or 'detail').
let keyManagerFrom = 'detail';
function agoShort(ms) {
const sec = Math.max(0, Math.round((Date.now() - ms) / 1000));
if (sec < 60) return 'just now';
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
return `${Math.floor(sec / 3600)}h ago`;
}
async function openKeyManager(id, from = 'detail') {
keyEditing = null;
keyManagerFrom = from;
const view = await window.mw.providerKeys(id);
rememberProviderListPlace(); // "Manage" on a row comes straight from the list
providersListView.hidden = true;
providerDetailEl.hidden = false;
renderKeyManager(view);
providerDetailEl.closest('.settings-panel').scrollTop = 0;
}
async function keyAct(view, action, args) {
const { result, view: next, providers } = await window.mw.keyAction(view.providerId, action, args);
renderProviderList(providers);
keyEditing = result.ok ? null : keyEditing;
renderKeyManager(next, result.ok ? null : result.error);
}
function renderKeyManager(view, error = null) {
const info = window.PROVIDER_INFO?.[view.providerId] ?? {};
const company = escapeHtml(info.company ?? view.label);
const on = view.keys.filter((k) => !k.disabled).length;
const rows = view.keys.map((k) => {
const name = k.label || `Key ${k.slot}`;
const editing = keyEditing && keyEditing.slot === k.slot ? keyEditing.kind : null;
const badges = [
k.live ? '<span class="km-badge live">In use</span>' : '',
k.isDefault ? '<span class="km-badge def">Default</span>' : '',
k.disabled ? '<span class="km-badge off">Off</span>' : '',
k.failure ? `<span class="km-badge bad" title="HTTP ${k.failure.status}">${FAILURE_TEXT[k.failure.reason]} · ${agoShort(k.failure.at)}</span>` : '',
].join('');
return `
<div class="km-key ${k.disabled ? 'is-off' : ''} ${k.live ? 'is-live' : ''}" data-slot="${k.slot}">
<div class="km-key-main">
<span class="km-key-ico">${KEY_SVG}</span>
<div class="km-key-text">
<span class="n">${escapeHtml(name)}<span class="hint">${escapeHtml(k.hint)}</span></span>
<span class="km-badges">${badges}</span>
</div>
<div class="sw ${k.disabled ? '' : 'on'} km-toggle" role="switch" aria-checked="${!k.disabled}" title="${k.disabled ? 'Turn this key on' : 'Turn this key off'}"></div>
<div class="km-more-wrap">
<button class="km-more" aria-label="More actions" aria-haspopup="menu">${DOTS_SVG}</button>
<div class="km-menu" role="menu" hidden>
<button data-act="use" ${k.live || k.disabled ? 'disabled' : ''}>Use this key now</button>
<button data-act="makeDefault" ${k.isDefault ? 'disabled' : ''}>Make default</button>
<div class="km-menu-sep"></div>
<button data-act="rename">Rename</button>
<button data-act="replace">Replace key…</button>
<div class="km-menu-sep"></div>
<button data-act="remove" class="danger">Remove</button>
</div>
</div>
</div>
${editing === 'rename' ? `
<div class="km-edit">
<input type="text" class="km-input" id="km-edit-input" maxlength="40" placeholder="Name, e.g. Work or Personal" value="${escapeHtml(k.label)}" spellcheck="false">
<button class="settings-btn ghost" data-edit="cancel">Cancel</button>
<button class="settings-btn" data-edit="save">Save</button>
</div>` : ''}
${editing === 'replace' ? `
<div class="km-edit">
<input type="password" class="km-input mono" id="km-edit-input" placeholder="Paste the new key" spellcheck="false" autocomplete="off">
<button class="settings-btn ghost" data-edit="cancel">Cancel</button>
<button class="settings-btn" data-edit="save">Replace</button>
</div>` : ''}
</div>`;
}).join('');
providerDetailEl.innerHTML = `
<button class="pd-back" id="km-back"><svg viewBox="0 0 12 12" fill="none"><path d="M7.5 2.5L4 6l3.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>${keyManagerFrom === 'list' ? 'Providers' : escapeHtml(view.label)}</button>
<div class="km-head">
<div>
<h3>API keys</h3>
<span>${escapeHtml(view.label)} · ${view.keys.length} of ${view.maxKeys} saved${view.keys.length ? ` · ${on} on` : ''}</span>
</div>
<button class="settings-btn" id="km-add-btn" ${view.keys.length >= view.maxKeys ? 'disabled' : ''}>+ Add key</button>
</div>
${error ? `<div class="km-error">${escapeHtml(error)}</div>` : ''}
${keyEditing?.kind === 'add' ? `
<div class="km-add">
<div class="km-add-title">Add a key</div>
<input type="password" class="km-input mono" id="km-add-key" placeholder="Paste an API key from your ${company} account" spellcheck="false" autocomplete="off">
<div class="km-add-row">
<input type="text" class="km-input" id="km-add-label" maxlength="40" placeholder="Name (optional), e.g. Work" spellcheck="false">
<button class="settings-btn ghost" id="km-add-cancel">Cancel</button>
<button class="settings-btn" id="km-add-save">Add key</button>
</div>
<button class="pd-link" data-url="${escapeHtml(view.keysUrl)}">Get an API key ${EXT_SVG}</button>
</div>` : ''}
<div class="km-list">
${rows || `<div class="km-empty">No keys saved for ${escapeHtml(view.label)} yet.</div>`}
</div>
<div class="km-auto">
<div class="km-auto-text">
<span class="t">Switch keys automatically</span>
<span class="d">If the key in use runs out of credit, hits its rate limit or is rejected, carry on with the next key that's on, without stopping the task.${view.keys.length < 2 ? ' Needs two or more keys.' : ''}</span>
</div>
<div class="sw ${view.autoSwitch ? 'on' : ''}" id="km-auto" role="switch" aria-checked="${view.autoSwitch}"></div>
</div>
<p class="pd-disclosure">
Keys are stored only on this computer, in ~/.mindweave/.env, and only their last four characters are ever shown.
The default key is the one used each time Mindweave starts; "Use this key now" switches without changing the default.
A key that is off stays saved but is never sent.
</p>
`;
const $ = (sel) => providerDetailEl.querySelector(sel);
$('#km-back').addEventListener('click', () => (keyManagerFrom === 'list' ? showProviderList({ keepPlace: true }) : openProviderDetail(view.providerId)));
providerDetailEl.querySelectorAll('.pd-link').forEach((b) => b.addEventListener('click', () => window.mw.openExternal(b.dataset.url)));
$('#km-add-btn').addEventListener('click', () => { keyEditing = { kind: 'add' }; renderKeyManager(view); $('#km-add-key')?.focus(); });
$('#km-auto').addEventListener('click', () => keyAct(view, 'autoSwitch', { on: !view.autoSwitch }));
if (keyEditing?.kind === 'add') {
const add = () => keyAct(view, 'add', { value: $('#km-add-key').value, label: $('#km-add-label').value });
$('#km-add-save').addEventListener('click', add);
$('#km-add-cancel').addEventListener('click', () => { keyEditing = null; renderKeyManager(view); });
[$('#km-add-key'), $('#km-add-label')].forEach((i) => i.addEventListener('keydown', (e) => {
if (e.key === 'Enter') add();
if (e.key === 'Escape') { e.stopPropagation(); keyEditing = null; renderKeyManager(view); }
}));
}
// Close any open ⋯ menu on a click elsewhere. A property, so re-renders replace it.
providerDetailEl.onclick = () => providerDetailEl.querySelectorAll('.km-menu').forEach((m) => { m.hidden = true; });
providerDetailEl.querySelectorAll('.km-key').forEach((row) => {
const slot = Number(row.dataset.slot);
const k = view.keys.find((x) => x.slot === slot);
row.querySelector('.km-toggle').addEventListener('click', () => keyAct(view, k.disabled ? 'enable' : 'disable', { slot }));
const menu = row.querySelector('.km-menu');
row.querySelector('.km-more').addEventListener('click', (e) => {
e.stopPropagation();
const wasHidden = menu.hidden;
providerDetailEl.querySelectorAll('.km-menu').forEach((m) => { m.hidden = true; });
menu.hidden = !wasHidden;
});
menu.querySelectorAll('button[data-act]').forEach((b) => b.addEventListener('click', (e) => {
e.stopPropagation();
menu.hidden = true;
const act = b.dataset.act;
if (act === 'rename' || act === 'replace') {
keyEditing = { slot, kind: act };
renderKeyManager(view);
$('#km-edit-input')?.focus();
} else if (act === 'remove') {
confirmAction(`Remove ${k.label || `Key ${k.slot}`} (${k.hint}) from ${view.label}? It will be deleted from this computer.`, () => keyAct(view, 'remove', { slot }));
} else {
keyAct(view, act, { slot });
}
}));
const input = row.querySelector('#km-edit-input');
if (input) {
const save = () => keyAct(view, keyEditing.kind === 'rename' ? 'rename' : 'edit', keyEditing.kind === 'rename' ? { slot, label: input.value } : { slot, value: input.value });
row.querySelector('[data-edit="save"]').addEventListener('click', save);
row.querySelector('[data-edit="cancel"]').addEventListener('click', () => { keyEditing = null; renderKeyManager(view); });
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') save();
if (e.key === 'Escape') { e.stopPropagation(); keyEditing = null; renderKeyManager(view); }
});
}
});
}
// ── Settings > MCP Servers ────────────────────────────────────────────
// Three screens in one panel: the list, one server's page, and the add/edit
// form. Everything reads from and writes to the engine (core/mcpServers.ts),
// which writes the same mcp.json files the CLI's /mcp box does and applies each
// change to the running servers at once.
const mcpRoot = document.getElementById('mcp-root');
const mcpPanel = document.getElementById('panel-mcp');
let mcpView = { kind: 'list' }; // { kind: 'list' } | { kind: 'detail', name } | { kind: 'form', edit?: detail }
let mcpSignInUrl = null; // { name, url } while a sign-in is waiting on the browser
let mcpBusy = null; // name of the server an action is running on
const MCP_STATE = {
connected: { dot: 'ok', text: 'Connected' },
pending: { dot: 'wait', text: 'Connecting…' },
failed: { dot: 'bad', text: 'Failed' },
'needs-auth': { dot: 'warn', text: 'Needs sign-in' },
disabled: { dot: 'off', text: 'Off' },
};
const CHEV_R = '<svg viewBox="0 0 12 12" fill="none"><path d="M4.5 2.5L8 6l-3.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const BACK_SVG = '<svg viewBox="0 0 12 12" fill="none"><path d="M7.5 2.5L4 6l3.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const PLUG_SVG = '<svg viewBox="0 0 16 16" fill="none"><path d="M6 2v3M10 2v3M4.5 5h7v3a3.5 3.5 0 0 1-7 0V5ZM8 11.5V14" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
function mcpStatusLine(s) {
if (s.state === 'connected') {
const parts = [plural(s.toolCount, 'tool')];
if (s.promptCount) parts.push(plural(s.promptCount, 'prompt'));
if (s.offersResources) parts.push('resources');
return `Connected · ${parts.join(' · ')}`;
}
if (s.state === 'failed') return s.error ? `Failed: ${s.error}` : 'Failed to connect';
return MCP_STATE[s.state]?.text ?? s.state;
}
const mcpChips = (s) =>
`<span class="mcp-chip">${s.type === 'http' ? 'Remote' : 'Local'}</span><span class="mcp-chip">${s.scope === 'global' ? 'All projects' : 'This project'}</span>`;
async function mcpRender() {
if (mcpView.kind === 'list') return mcpRenderList(await window.mw.mcpList());
if (mcpView.kind === 'detail') {
const d = await window.mw.mcpDetail(mcpView.name);
if (!d) { mcpView = { kind: 'list' }; return mcpRender(); }
return mcpRenderDetail(d);
}
return mcpRenderForm(mcpView.edit ?? null, mcpView.error ?? null, mcpView.draft ?? null);
}
// The list's place is kept while one of its pages is open, so coming back lands exactly there.
let mcpListScroll = 0;
async function mcpGo(view) {
const from = mcpView.kind;
if (from === 'list' && view.kind !== 'list') mcpListScroll = mcpPanel.scrollTop;
mcpView = view;
mcpPanel.scrollTop = 0;
await mcpRender();
if (view.kind === 'list' && from !== 'list') mcpPanel.scrollTop = mcpListScroll;
}
async function mcpAct(action, args, after) {
mcpBusy = args.name ?? null;
if (mcpView.kind !== 'form') await mcpRender();
const { result } = await window.mw.mcpAction(action, args);
mcpBusy = null;
if (after) return after(result);
if (!result.ok) mcpToast(result.error);
return mcpRender();
}
function mcpToast(text) {
const el = mcpRoot.querySelector('.mcp-toast');
if (el) { el.textContent = text; el.hidden = false; }
}
// ── List ──
function mcpRenderList(servers) {
const connected = servers.filter((s) => s.state === 'connected');
const tools = connected.reduce((n, s) => n + s.toolCount, 0);
const blocked = servers.reduce((n, s) => n + s.blockedCount, 0);
const cards = servers.map((s) => {
const st = MCP_STATE[s.state] ?? MCP_STATE.pending;
return `
<div class="mcp-card state-${s.state} ${mcpBusy === s.name ? 'busy' : ''}" data-name="${escapeHtml(s.name)}" tabindex="0">
<span class="mcp-tile">${escapeHtml(s.name.slice(0, 1).toUpperCase())}</span>
<div class="mcp-card-text">
<div class="mcp-card-top"><span class="n">${escapeHtml(s.name)}</span>${mcpChips(s)}</div>
<div class="mcp-status"><span class="mcp-dot ${st.dot}"></span><span class="mcp-status-text">${escapeHtml(mcpStatusLine(s))}</span></div>
</div>
${s.blockedCount ? `<span class="km-badge bad" title="Tools that changed since you allowed them">${s.blockedCount} blocked</span>` : ''}
<div class="sw ${s.state === 'disabled' ? '' : 'on'} mcp-toggle" role="switch" aria-checked="${s.state !== 'disabled'}" title="${s.state === 'disabled' ? 'Turn on' : 'Turn off'}"></div>
<span class="mcp-chev">${CHEV_R}</span>
</div>`;
}).join('');
mcpRoot.innerHTML = `
<div class="mcp-head">
<div>
<div class="settings-section-title">MCP Servers</div>
<div class="settings-section-desc">Tools from outside Mindweave that the agent can use, from servers on this computer or on the web.</div>
</div>
<button class="settings-btn" id="mcp-add">+ Add server</button>
</div>
<div class="mcp-toast" hidden></div>
${servers.length ? `
<div class="mcp-summary">
<span>${plural(servers.length, 'server')}</span><span class="sep"></span>
<span><span class="mcp-dot ok"></span>${connected.length} connected</span><span class="sep"></span>
<span>${plural(tools, 'tool')} available</span>
</div>
${blocked ? `
<div class="mcp-warn">
<div><span class="t">${plural(blocked, 'tool')} changed since you allowed ${blocked === 1 ? 'it' : 'them'}</span>
<span class="d">A server changed what ${blocked === 1 ? 'a tool says it does' : 'some tools say they do'}, so ${blocked === 1 ? 'it is' : 'they are'} blocked until you allow ${blocked === 1 ? 'it' : 'them'} again.</span></div>
<button class="settings-btn ghost" id="mcp-allow">Allow</button>
</div>` : ''}
<div class="mcp-list">${cards}</div>
` : `
<div class="mcp-empty">
<span class="mcp-empty-ico">${PLUG_SVG}</span>
<div class="t">No MCP servers yet</div>
<div class="d">Add one to give the agent more tools, such as GitHub, a database or a browser.</div>
<button class="settings-btn" id="mcp-add-empty">+ Add server</button>
</div>`}
`;
mcpRoot.querySelector('#mcp-add').addEventListener('click', () => mcpGo({ kind: 'form' }));
mcpRoot.querySelector('#mcp-add-empty')?.addEventListener('click', () => mcpGo({ kind: 'form' }));
mcpRoot.querySelector('#mcp-allow')?.addEventListener('click', () =>
confirmAction('Allow the changed tools? Their new descriptions will be trusted from now on.', () => mcpAct('allowChanged', {})));
mcpRoot.querySelectorAll('.mcp-card').forEach((card) => {
const name = card.dataset.name;
const s = servers.find((x) => x.name === name);
card.querySelector('.mcp-toggle').addEventListener('click', (e) => {
e.stopPropagation();
mcpAct(s.state === 'disabled' ? 'enable' : 'disable', { name });
});
card.addEventListener('click', () => mcpGo({ kind: 'detail', name }));
card.addEventListener('keydown', (e) => { if (e.key === 'Enter') mcpGo({ kind: 'detail', name }); });
});
}
// ── One server ──
function mcpRenderDetail(d) {
const st = MCP_STATE[d.state] ?? MCP_STATE.pending;
const off = d.state === 'disabled';
const secrets = d.type === 'http' ? d.config.headers : d.config.env;
const secretRows = Object.entries(secrets).map(([k, v]) => `
<div class="mcp-kv"><span class="k mono">${escapeHtml(k)}</span><span class="v mono mcp-secret" data-value="${escapeHtml(v)}">${'•'.repeat(Math.min(12, Math.max(6, v.length)))}</span></div>`).join('');
const tools = d.tools.map((t) => `
<div class="mcp-tool" data-search="${escapeHtml(`${t.name} ${t.description}`.toLowerCase())}">
<div class="mcp-tool-top">
<span class="n mono">${escapeHtml(t.name)}</span>
${t.readOnly ? '<span class="pd-tag">Read-only</span>' : ''}
${t.blocked === 'changed' ? '<span class="km-badge bad">Changed · blocked</span>' : t.blocked === 'forbidden' ? '<span class="km-badge off">Forbidden</span>' : ''}
</div>
${t.description ? `<div class="mcp-tool-desc">${escapeHtml(t.description)}</div>` : ''}
</div>`).join('');
const waiting = mcpSignInUrl && mcpSignInUrl.name === d.name;
mcpRoot.innerHTML = `
<button class="pd-back" id="mcp-back">${BACK_SVG}MCP Servers</button>
<div class="mcp-dhead">
<span class="mcp-tile big state-${d.state}">${escapeHtml(d.name.slice(0, 1).toUpperCase())}</span>
<div class="mcp-dtitle">
<h3>${escapeHtml(d.name)}</h3>
<div class="mcp-card-top">${mcpChips(d)}<span class="mcp-status"><span class="mcp-dot ${st.dot}"></span>${escapeHtml(st.text)}</span></div>
</div>
<div class="sw ${off ? '' : 'on'}" id="mcp-d-toggle" role="switch" aria-checked="${!off}" title="${off ? 'Turn on' : 'Turn off'}"></div>
</div>
<div class="mcp-actions">
<button class="settings-btn ghost" id="mcp-reconnect" ${off || mcpBusy ? 'disabled' : ''}>${mcpBusy === d.name ? 'Working…' : 'Reconnect'}</button>
${d.type === 'http' ? (d.signedIn
? '<button class="settings-btn ghost" id="mcp-signout">Sign out</button>'
: `<button class="settings-btn ${d.state === 'needs-auth' ? '' : 'ghost'}" id="mcp-signin" ${off ? 'disabled' : ''}>Sign in</button>`) : ''}
<button class="settings-btn ghost" id="mcp-edit">Edit</button>
<span class="grow"></span>
<button class="settings-btn ghost danger-text" id="mcp-remove">Remove</button>
</div>
<div class="mcp-toast" hidden></div>
${waiting ? `
<div class="mcp-note">
<span class="t">Finish signing in in your browser</span>
<span class="d">If no browser window opened, open the sign-in page yourself.</span>
<button class="pd-link" id="mcp-open-signin">Open sign-in page ${EXT_SVG}</button>