loom-agent 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +402 -0
  3. package/LICENSE +21 -0
  4. package/LOOM.md +235 -0
  5. package/README.md +433 -0
  6. package/bin/loom-tui.js +43 -0
  7. package/bin/loom.js +44 -0
  8. package/docs/acp.md +151 -0
  9. package/docs/web.md +205 -0
  10. package/package.json +97 -0
  11. package/scripts/acp-smoke.js +146 -0
  12. package/src/acp/acp-server.js +287 -0
  13. package/src/config/provider-cmd.js +37 -0
  14. package/src/config/settings.js +164 -0
  15. package/src/core/agents.js +361 -0
  16. package/src/core/background-tasks.js +103 -0
  17. package/src/core/cli.js +579 -0
  18. package/src/core/custom-commands.js +70 -0
  19. package/src/core/errors.js +29 -0
  20. package/src/core/events.js +24 -0
  21. package/src/core/file-diffs.js +282 -0
  22. package/src/core/format.js +206 -0
  23. package/src/core/graph.js +257 -0
  24. package/src/core/hooks.js +82 -0
  25. package/src/core/lsp.js +385 -0
  26. package/src/core/memory.js +87 -0
  27. package/src/core/model-router.js +87 -0
  28. package/src/core/permissions.js +327 -0
  29. package/src/core/platform.js +33 -0
  30. package/src/core/plugin-cmd.js +380 -0
  31. package/src/core/restore.js +207 -0
  32. package/src/core/session-store.js +167 -0
  33. package/src/core/session.js +910 -0
  34. package/src/core/subagent-log.js +134 -0
  35. package/src/core/tokens.js +31 -0
  36. package/src/core/update.js +6 -0
  37. package/src/core/usage.js +166 -0
  38. package/src/index.js +41 -0
  39. package/src/mcp/mcp-client.js +201 -0
  40. package/src/mcp/mcp-manager.js +193 -0
  41. package/src/providers/anthropic.js +243 -0
  42. package/src/providers/google.js +29 -0
  43. package/src/providers/index.js +175 -0
  44. package/src/providers/local.js +27 -0
  45. package/src/providers/nvidia.js +85 -0
  46. package/src/providers/openai-compat.js +269 -0
  47. package/src/providers/openai.js +35 -0
  48. package/src/providers/openrouter.js +43 -0
  49. package/src/providers/registry.js +196 -0
  50. package/src/providers/tokenrouter.js +19 -0
  51. package/src/skills/skill-matcher.js +133 -0
  52. package/src/skills/skills-manager.js +213 -0
  53. package/src/tools/index.js +543 -0
  54. package/src/tui/App.tsx +1578 -0
  55. package/src/tui/components/BreadcrumbBar.tsx +34 -0
  56. package/src/tui/components/ChatArea.tsx +518 -0
  57. package/src/tui/components/InputBar.tsx +354 -0
  58. package/src/tui/components/MdText.tsx +105 -0
  59. package/src/tui/components/Modals.tsx +851 -0
  60. package/src/tui/components/PermissionPopup.tsx +264 -0
  61. package/src/tui/components/Sidebar.tsx +182 -0
  62. package/src/tui/components/SplashScreen.tsx +51 -0
  63. package/src/tui/components/SubagentPanel.tsx +217 -0
  64. package/src/tui/components/ToastOverlay.tsx +34 -0
  65. package/src/tui/keybinds.ts +318 -0
  66. package/src/tui/mcp-presets.ts +189 -0
  67. package/src/tui/md-render.ts +228 -0
  68. package/src/tui/store.ts +714 -0
  69. package/src/tui/suite-home.ts +20 -0
  70. package/src/tui/theme.ts +313 -0
  71. package/src/tui/themes.generated.ts +968 -0
  72. package/src/tui/tool-display.ts +176 -0
  73. package/src/tui/toolname.ts +60 -0
  74. package/src/tui/tui-config.ts +28 -0
  75. package/src/tui-open.tsx +51 -0
  76. package/src/web/attach.js +242 -0
  77. package/src/web/graph-view.html +262 -0
  78. package/src/web/index.html +824 -0
  79. package/src/web/web-server.js +470 -0
@@ -0,0 +1,851 @@
1
+ // Modals -- provider picker, model picker, key input, base URL, settings, palette.
2
+ import { createSignal, onMount } from "solid-js";
3
+ import { useKeyboard, usePaste } from "@opentui/solid";
4
+ import { palette } from "../theme.ts";
5
+ import * as kbs from "../keybinds.ts";
6
+ import {
7
+ openModal, closeModal, modal, PROVIDERS, PROVIDER_ORDER, PROVIDER_LABELS,
8
+ refreshProviderState, appendMessage, showToast, getSession, allModelOptions,
9
+ SLASH_LIST, windowFor,
10
+ sidebarVisible, setSidebarVisible, showToolDetails, setShowToolDetails,
11
+ showThinking, setShowThinking, persistUi,
12
+ } from "../store.ts";
13
+ import { loadConfig, saveConfig, getBaseUrl, setBaseUrl } from "../../config/settings.js";
14
+ import { MCP_PRESETS, CONNECTOR_PRESETS } from "../mcp-presets.ts";
15
+ const plugin = require("../../core/plugin-cmd.js");
16
+
17
+ const ui = palette("loom");
18
+
19
+ // Dialog nav labels from the resolved keybinds (shown in modal footers).
20
+ function kbNav() {
21
+ return {
22
+ prev: kbs.label("dialog_select_prev").toUpperCase(),
23
+ next: kbs.label("dialog_select_next").toUpperCase(),
24
+ submit: kbs.label("dialog_select_submit").toUpperCase(),
25
+ cancel: kbs.label("modal_cancel").toUpperCase(),
26
+ };
27
+ }
28
+
29
+ function wheelStep(e: any, delta: number) {
30
+ const dir = e?.scroll?.direction;
31
+ return dir === "up" ? -delta : delta;
32
+ }
33
+
34
+ export function ModalFrame(props: { title: string; subtitle?: string; children: any; footer?: string }) {
35
+ // Floating panel: centered over the terminal but NOT a full-screen takeover —
36
+ // no backdrop fill, so the chat and any running agent stay visible behind it
37
+ // ("same window, hovering"). Keys still belong to the modal while open.
38
+ return (
39
+ <box position="absolute" top={0} left={0} right={0} bottom={0}
40
+ alignItems="center" justifyContent="center" flexDirection="column">
41
+ <box border borderStyle="rounded" borderColor={ui.primary} backgroundColor={ui.bgPanel}
42
+ paddingX={3} paddingY={2} flexDirection="column" minWidth={52} maxWidth={72}>
43
+ <text fg={ui.primary}>{props.title}</text>
44
+ {props.subtitle ? <text fg={ui.fgMuted} marginTop={0}>{props.subtitle}</text> : null}
45
+ <box flexDirection="column" marginTop={1}>{props.children}</box>
46
+ {props.footer ? <text fg={ui.fgMuted} marginTop={1}>{props.footer}</text> : null}
47
+ </box>
48
+ </box>
49
+ );
50
+ }
51
+
52
+ export function ProviderPicker() {
53
+ // opencode-scale provider list: make sure the models.dev registry is cached
54
+ // (fetched once, ~/.loom/models-dev.json). When a fetch lands mid-session
55
+ // the picker re-opens with the full provider list. When the cache is already
56
+ // fresh the picker must NOT re-open itself — that would loop forever and
57
+ // clobber every other modal.
58
+ onMount(() => {
59
+ const { ensureRegistry } = require("../../providers/index.js");
60
+ const hadCache = require("../../providers/registry.js").isRegistryFresh();
61
+ ensureRegistry().then((count) => {
62
+ if (modal()?.type !== "provider") return;
63
+ if (hadCache || !count) return;
64
+ closeModal();
65
+ setTimeout(() => openModal({ type: "provider" }), 50);
66
+ });
67
+ });
68
+
69
+ const opts = PROVIDER_ORDER.map(p => ({
70
+ label: PROVIDER_LABELS[p] || p,
71
+ value: p,
72
+ sub: PROVIDERS[p]?.models?.length ? String(PROVIDERS[p].models.length) + " models" : undefined,
73
+ }));
74
+ const pick = (val: any) => {
75
+ const p = String(val);
76
+ const sess = getSession();
77
+ const cfg = loadConfig();
78
+ const modelId = cfg.model?.[p] || (PROVIDERS[p]?.models?.[0]?.id);
79
+ if (modelId) sess.setModel(p, modelId);
80
+ else { cfg.provider = p; saveConfig(cfg); }
81
+ closeModal(); refreshProviderState();
82
+ showToast("Provider: " + (PROVIDER_LABELS[p] || p), "ok");
83
+ setTimeout(() => openKeyModal(p), 100);
84
+ };
85
+ return (
86
+ <SelectModal
87
+ title="Connect Provider"
88
+ options={opts}
89
+ searchable={true}
90
+ onPick={pick}
91
+ />
92
+ );
93
+ }
94
+
95
+ // ── Model picker ──
96
+ export function openModelPicker() {
97
+ const opts = allModelOptions().filter(function(o) { return o.value !== "__custom__"; });
98
+ openModal({
99
+ type: "select", title: "Select Model", options: opts, searchable: true,
100
+ onPick(val, opt) {
101
+ if (val === "__custom__") return;
102
+ const provider = opt?.provider || loadConfig().provider;
103
+ const sess = getSession();
104
+ sess.setModel(provider, val);
105
+ refreshProviderState(); closeModal();
106
+ showToast("Model: " + provider + " -> " + val, "ok");
107
+ },
108
+ });
109
+ }
110
+
111
+ export function openKeyModal(provider: string) {
112
+ const cur = (loadConfig().apiKeys || {})[provider] || "";
113
+ openModal({
114
+ type: "input", title: "API key for " + (PROVIDER_LABELS[provider] || provider),
115
+ placeholder: cur ? "(already set -- leave blank to keep)" : "Paste your key",
116
+ isKey: true,
117
+ onPick(val) {
118
+ const c = loadConfig(); c.apiKeys = c.apiKeys || {};
119
+ if (val.trim()) { c.apiKeys[provider] = val.trim(); saveConfig(c); showToast("Key saved for " + provider, "ok"); }
120
+ else showToast("Using env var " + provider.toUpperCase() + "_API_KEY");
121
+ refreshProviderState(); closeModal();
122
+ },
123
+ });
124
+ }
125
+
126
+ export function openBaseUrlEditor(provider: string) {
127
+ const cur = getBaseUrl(provider) || "(default)";
128
+ openModal({
129
+ type: "input", title: "Base URL for " + (PROVIDER_LABELS[provider] || provider),
130
+ placeholder: "Current: " + cur,
131
+ onPick(val) {
132
+ if (!val.trim()) { closeModal(); return; }
133
+ setBaseUrl(provider, val.trim()); closeModal();
134
+ showToast("Base URL: " + val.trim());
135
+ },
136
+ });
137
+ }
138
+
139
+ export function SelectModal(props: {
140
+ title: string;
141
+ options: { label: string; value: any; sub?: string; provider?: string; header?: string; isHeader?: boolean; tags?: string[]; recent?: boolean }[];
142
+ onPick: (value: any, option: any) => void;
143
+ searchable?: boolean;
144
+ onCancel?: () => void;
145
+ // Live-preview: fire on selection change so theme pickers can repaint the
146
+ // app while the user scrolls without closing the modal.
147
+ onPreview?: (value: any) => void;
148
+ }) {
149
+ // Long labels/subs must never wrap: the modal is maxWidth 72 (62 usable cells
150
+ // after padding), so truncate each piece to its budget before rendering.
151
+ const LABEL_MAX = 26, SUB_MAX = 18;
152
+ const fit = (s: string, n: number) => (s.length <= n ? s : s.slice(0, Math.max(1, n - 1)) + "\u2026");
153
+ const [index, setIndex] = createSignal(0);
154
+ const [q, setQ] = createSignal("");
155
+
156
+ const filtered = () => {
157
+ const query = q().trim().toLowerCase();
158
+ if (!query) return props.options;
159
+ return props.options.filter(o => {
160
+ if (o.isHeader) return false;
161
+ const hay = (o.label + " " + (o.sub || "") + " " + (o.provider || "") + " " + String(o.tags || []).toLowerCase() + " " + String(o.value)).toLowerCase();
162
+ return query.split(/\s+/).every(part => hay.includes(part));
163
+ });
164
+ };
165
+
166
+ // Live-hook: fire onPreview AFTER the index has settled (Solid may batch
167
+ // the signal write — schedule.fire settles next microtask).
168
+ function firePreview() {
169
+ if (!props.onPreview) return;
170
+ setTimeout(() => {
171
+ try { props.onPreview!(filtered()[index()]?.value); } catch {}
172
+ }, 0);
173
+ }
174
+
175
+ // The selection must never land on a section header: it would render with
176
+ // no highlight and Enter would do nothing. Always skip to the next row.
177
+ const firstSelectable = () => {
178
+ const list = filtered();
179
+ const i = list.findIndex(o => o && !o.isHeader);
180
+ return i < 0 ? 0 : i;
181
+ };
182
+ const stepSelectable = (i: number, dir: number) => {
183
+ const list = filtered();
184
+ let j = i + dir;
185
+ while (j >= 0 && j < list.length && list[j] && list[j].isHeader) j += dir;
186
+ if (j < 0 || j >= list.length) return i;
187
+ return j;
188
+ };
189
+ // Page jumps step over a full window (12 rows), skipping section headers.
190
+ const pageJump = (i: number, dir: number) => {
191
+ const list = filtered();
192
+ if (!list.length) return i;
193
+ let j = i;
194
+ for (let n = 0; n < 12; n++) {
195
+ const next = stepSelectable(j, dir);
196
+ if (next === j) return j;
197
+ j = next;
198
+ }
199
+ return j;
200
+ };
201
+ const lastSelectable = () => {
202
+ const list = filtered();
203
+ for (let j = list.length - 1; j >= 0; j--) if (list[j] && !list[j].isHeader) return j;
204
+ return 0;
205
+ };
206
+ // Start on the first real row (not a header).
207
+ setIndex(firstSelectable());
208
+
209
+ const nav = kbNav();
210
+
211
+ useKeyboard(key => {
212
+ const ks = kbs.keyString(key);
213
+ if (kbs.is("modal_cancel", ks)) { closeModal(); if (props.onCancel) props.onCancel(); return; }
214
+ if (kbs.dialogIs("dialog_select_prev", ks)) { setIndex(i => stepSelectable(i, -1)); firePreview(); return; }
215
+ if (kbs.dialogIs("dialog_select_next", ks)) { setIndex(i => stepSelectable(i, 1)); firePreview(); return; }
216
+ if (kbs.dialogIs("dialog_select_page_up", ks)) { setIndex(i => pageJump(i, -1)); firePreview(); return; }
217
+ if (kbs.dialogIs("dialog_select_page_down", ks)) { setIndex(i => pageJump(i, 1)); firePreview(); return; }
218
+ if (kbs.dialogIs("dialog_select_home", ks)) { setIndex(firstSelectable()); firePreview(); return; }
219
+ if (kbs.dialogIs("dialog_select_end", ks)) { setIndex(lastSelectable()); firePreview(); return; }
220
+ if (kbs.dialogIs("dialog_select_submit", ks)) {
221
+ const opt = filtered()[index()];
222
+ if (!opt || opt.isHeader) return;
223
+ props.onPick(opt.value, opt);
224
+ return;
225
+ }
226
+ if (props.searchable) {
227
+ // Reset to 0, not firstSelectable(): setQ is batched, so firstSelectable()
228
+ // would read the STALE list and land past its end (dead arrows/blank row).
229
+ if (key.name === "backspace") { setQ(v => v.slice(0, -1)); setIndex(0); firePreview(); return; }
230
+ const s = key.sequence;
231
+ if (!key.ctrl && !key.meta && s && s.length <= 10 && s !== "\r" && s !== "\n") {
232
+ setQ(v => v + s);
233
+ setIndex(0);
234
+ firePreview();
235
+ return;
236
+ }
237
+ }
238
+ });
239
+
240
+ const scrollBy = (e: any) => {
241
+ const step = wheelStep(e, 1);
242
+ const dir = step < 0 ? -1 : 1;
243
+ let i = index();
244
+ for (let n = 0; n < Math.abs(step); n++) {
245
+ const j = stepSelectable(i, dir);
246
+ if (j === i) break;
247
+ i = j;
248
+ }
249
+ setIndex(i);
250
+ };
251
+ const clickRow = (i: number) => {
252
+ if (i !== index()) {
253
+ setIndex(i);
254
+ firePreview();
255
+ }
256
+ const o = filtered()[i];
257
+ if (o?.isHeader) return;
258
+ props.onPick(o?.value, o);
259
+ };
260
+ let winStart = 0;
261
+ const win = () => {
262
+ const total = filtered().length;
263
+ winStart = windowFor(index(), total, 12, winStart);
264
+ return { total, start: winStart, items: filtered().slice(winStart, winStart + 12) };
265
+ };
266
+ const rangeSub = () => {
267
+ const w = win();
268
+ if (w.total <= 12) return "";
269
+ return " showing " + (w.start + 1) + "-" + Math.min(w.start + 12, w.total) + " of " + w.total;
270
+ };
271
+
272
+ return (
273
+ <ModalFrame title={props.title} subtitle={(props.searchable ? "search: " + (q() || "_") + rangeSub() : rangeSub())} footer={nav.prev + "/" + nav.next + " navigate | " + nav.submit + " select | wheel scroll | " + nav.cancel + " cancel" + (props.searchable ? " | type to search" : "")}>
274
+ <box onMouseScroll={scrollBy}>
275
+ {win().items.map((opt, i) => {
276
+ const abs = win().start + i;
277
+ if (opt.isHeader) return (
278
+ <text fg={ui.secondary} marginTop={i === 0 ? 0 : 1}>
279
+ {opt.header + ":"}
280
+ </text>
281
+ );
282
+ const active = abs === index();
283
+ return (
284
+ <box
285
+ flexDirection="row" paddingLeft={2}
286
+ // Hover moves the selection (live theme preview via onPreview);
287
+ // a click on the hovered row still selects+submits.
288
+ onMouseOver={() => { if (abs !== index()) { setIndex(abs); firePreview(); } }}
289
+ onMouseDown={() => setIndex(abs)}
290
+ onMouseUp={() => clickRow(abs)}
291
+ >
292
+ <text fg={active ? ui.primary : ui.fgDim}>
293
+ {(active ? " > " : " ") + fit(opt.label, LABEL_MAX)}
294
+ </text>
295
+ {opt.recent ? <text fg={ui.fgMuted}> {" \u2713 recent"}</text> : null}
296
+ {opt.sub ? <text fg={ui.fgDim}> {"(" + fit(opt.sub, SUB_MAX) + ")"}</text> : null}
297
+ </box>
298
+ );
299
+ })}
300
+ </box>
301
+ </ModalFrame>
302
+ );
303
+ }
304
+
305
+ export function InputModal(props: {
306
+ title: string;
307
+ placeholder: string;
308
+ onPick: (value: string) => void;
309
+ isKey?: boolean;
310
+ value?: string;
311
+ caretStart?: number;
312
+ onCancel?: () => void;
313
+ }) {
314
+ const [val, setVal] = createSignal(props.value || "");
315
+ const [caret, setCaret] = createSignal(
316
+ typeof props.caretStart === "number" ? props.caretStart : (props.value || "").length
317
+ );
318
+ const masked = () => props.isKey ? "x".repeat(Math.max(0, val().length)) : val();
319
+ // Caret insertion: shown = left part + block cursor + right part.
320
+ const shown = () => {
321
+ const d = masked();
322
+ const c = Math.min(caret(), d.length);
323
+ return d.slice(0, c) + "\u258c" + d.slice(c);
324
+ };
325
+
326
+ useKeyboard(key => {
327
+ const ks = kbs.keyString(key);
328
+ if (kbs.is("modal_cancel", ks)) { closeModal(); if (props.onCancel) props.onCancel(); return; }
329
+ if (kbs.dialogIs("dialog_select_submit", ks)) { props.onPick(val()); return; }
330
+ if (key.name === "backspace") {
331
+ setVal(v => { const c = Math.min(caret(), v.length); return v.slice(0, Math.max(0, c - 1)) + v.slice(c); });
332
+ setCaret(c => Math.max(0, c - 1));
333
+ return;
334
+ }
335
+ if (key.name === "left") { setCaret(c => Math.max(0, c - 1)); return; }
336
+ if (key.name === "right") { setCaret(c => Math.min(val().length, c + 1)); return; }
337
+ if (key.name === "home") { setCaret(0); return; }
338
+ if (key.name === "end") { setCaret(val().length); return; }
339
+ const s = key.sequence;
340
+ if (!key.ctrl && !key.meta && s && s.length <= 10 && s !== "\r" && s !== "\n") {
341
+ setVal(v => { const c = Math.min(caret(), v.length); return v.slice(0, c) + s + v.slice(c); });
342
+ setCaret(c => Math.min(val().length, c) + s.length);
343
+ }
344
+ });
345
+
346
+ usePaste(event => {
347
+ const txt = new TextDecoder().decode((event as any).bytes || "").replace(/[\r\n]+/g, "");
348
+ if (txt) {
349
+ setVal(v => { const c = Math.min(caret(), v.length); return v.slice(0, c) + txt + v.slice(c); });
350
+ setCaret(c => Math.min(val().length, c) + txt.length);
351
+ }
352
+ });
353
+
354
+ return (
355
+ <ModalFrame title={props.title} subtitle={props.placeholder} footer={kbNav().submit + " confirm | \u2190\u2192 move | Ctrl+V paste | " + kbNav().cancel + " cancel"}>
356
+ <box border borderStyle="rounded" borderColor={ui.border} paddingX={1} marginTop={1}>
357
+ <text fg={ui.fg}>{shown()}</text>
358
+ </box>
359
+ </ModalFrame>
360
+ );
361
+ }
362
+
363
+ export function SettingsModal() {
364
+ useKeyboard(key => {
365
+ const ks = kbs.keyString(key);
366
+ if (kbs.is("modal_cancel", ks)) { closeModal(); }
367
+ if (key.name === "d" || key.name === "D") { setShowToolDetails(v => !v); persistUi(); }
368
+ if (key.name === "t" || key.name === "T") { setShowThinking(v => !v); persistUi(); }
369
+ if (key.name === "b" || key.name === "B") { setSidebarVisible(v => !v); persistUi(); }
370
+ });
371
+
372
+ return (
373
+ <ModalFrame title="Settings" footer={"d/t/b toggle | " + kbNav().cancel + " close"}>
374
+ <text fg={ui.fg}>{"[d] Tool details: " + (showToolDetails() ? "on" : "off") + " show tool output"}</text>
375
+ <text fg={ui.fg}>{"[t] Thinking: " + (showThinking() ? "on" : "off") + " show think time"}</text>
376
+ <text fg={ui.fg}>{"[b] Sidebar: " + (sidebarVisible() ? "on" : "off") + " todos + files"}</text>
377
+ </ModalFrame>
378
+ );
379
+ }
380
+
381
+ export function showHelpText() {
382
+ const lines = ["Loom Code -- Slash Commands", ""];
383
+ for (const c of SLASH_LIST) lines.push(" /" + c.cmd.padEnd(14) + " " + c.desc + (c.args ? " (" + c.args + ")" : ""));
384
+ lines.push("", " " + kbs.label("session_interrupt") + "=interrupt " + kbs.label("app_exit") + "=exit " + kbs.label("sidebar_toggle") + "=sidebar " + kbs.label("command_list") + "=palette" +
385
+ (kbs.leaderKey() ? " " + kbs.leaderKey() + "=leader" : ""));
386
+ lines.push(" Customize in ~/.loom/tui.json \u2014 edit that file directly, then relaunch.");
387
+ appendMessage({ role: "system", content: lines.join("\n") });
388
+ }
389
+
390
+ export function showProvidersText() {
391
+ const lines = ["Supported providers:", ""];
392
+ for (const p of PROVIDER_ORDER) {
393
+ const mods = PROVIDERS[p]?.models?.length || 0;
394
+ lines.push(" " + p.padEnd(12) + " " + String(mods).padEnd(3) + " models " + (PROVIDER_LABELS[p] || p));
395
+ }
396
+ lines.push("", "/connect to pick interactively.");
397
+ appendMessage({ role: "system", content: lines.join("\n") });
398
+ }
399
+
400
+ // ── Agents (OpenCode-style primaries + subagents) ──
401
+ export function showAgentsText() {
402
+ const { loadAgents } = require("../../core/agents.js");
403
+ const agents = loadAgents() as Record<string, any>;
404
+ const lines = ["AGENTS", ""];
405
+ for (const a of Object.values(agents)) {
406
+ const mode = a.mode === "primary" ? "primary" : "subagent";
407
+ const tools = a.tools && a.tools.length ? "[" + a.tools.join(" ") + "]" : "[*]";
408
+ const model = a.model ? " model=" + a.model : "";
409
+ lines.push(" " + a.id.padEnd(10) + " " + mode.padEnd(9) + " " + tools.padEnd(22) + model);
410
+ lines.push(" " + a.description);
411
+ }
412
+ lines.push(
413
+ "",
414
+ " Automatic: the main agent calls the task tool whenever a subtask needs it.",
415
+ " Manual: type @<agent> in the input (e.g. @explore find the bug).",
416
+ " Config: ~/.loom/config.json \u2192 agents: { name: { mode, description, tools, model, prompt } }"
417
+ );
418
+ appendMessage({ role: "system", content: lines.join("\n") });
419
+ }
420
+
421
+ // ── MCP server / connector browser popup ──
422
+ // Lists every configured server (seeded defaults + user-added) with on/off
423
+ // state; Enter toggles, A opens the add flow, Esc closes.
424
+ // `kind` decides which preset list the "A" flow offers: "mcp" (dev tools) or
425
+ // "connector" (hosting/cloud services). Both share the same underlying
426
+ // mcp-manager — a connector IS an MCP server, just surfaced separately.
427
+ const MCP_FIT = 52;
428
+ function mcpFit(s: string, n = MCP_FIT) {
429
+ const flat = String(s || "").replace(/\s+/g, " ").trim();
430
+ return flat.length <= n ? flat : flat.slice(0, Math.max(1, n - 1)) + "\u2026";
431
+ }
432
+
433
+ // Build a claude/opencode-style one-liner for a preset, with -e KEY=VALUE
434
+ // entries (empty values = placeholders the user fills inline in the one-line
435
+ // editor). $KEY args resolve at add time from the -e env.
436
+ function presetAddLine(p: any, envValues?: Record<string, string>): string {
437
+ const parts: string[] = [];
438
+ for (const k of Object.keys(p.env || {})) parts.push("-e", k + "=" + (envValues ? (envValues[k] || "") : ""));
439
+ parts.push(p.id, "--");
440
+ if (p.command) parts.push(p.command, ...(p.args || []));
441
+ else parts.push("npx", "-y", p.package, ...(p.args || []));
442
+ return parts.join(" ");
443
+ }
444
+
445
+ // The add flow: pick a preset (or Custom). Presets that need secrets walk a
446
+ // guided key-entry dialog (one masked field per prompt, pasted/typed, then the
447
+ // one-liner is built automatically); everything else opens the one-line
448
+ // editor in claude/opencode syntax — `[-e KEY=V] <name> [--] <command> [args...]`.
449
+ function openPresetPicker(presets: any[], kind: "mcp" | "connector") {
450
+ const addLabel = kind === "connector" ? "Add connector" : "Add MCP server";
451
+ const backType = kind === "connector" ? "connectors" : "mcp";
452
+
453
+ // Build the server from collected env + optional args and land it; reopen
454
+ // the browser on success, toast the error otherwise.
455
+ const submitLine = (line: string, reopenOnError: boolean) => {
456
+ const msg = plugin.mcpAddLineCmd(line);
457
+ if (msg.startsWith("Added")) {
458
+ showToast(msg, "ok");
459
+ closeModal();
460
+ setTimeout(function() { openModal({ type: backType as any }); }, 10);
461
+ } else {
462
+ showToast(String(msg).slice(0, 80), "error");
463
+ if (reopenOnError) {
464
+ closeModal();
465
+ setTimeout(function() { openModal({ type: backType as any }); }, 10);
466
+ }
467
+ }
468
+ };
469
+
470
+ const guidedAdd = (preset: any) => {
471
+ const prompts: any[] = preset.prompts || [];
472
+ const env: Record<string, string> = {};
473
+ const askPrompt = (i: number) => {
474
+ if (i >= prompts.length) {
475
+ const oap = preset.optionalArgsPrompt;
476
+ if (!oap) { submitLine(presetAddLine(preset, env), true); return; }
477
+ // Optional extra arg (e.g. --project-ref): Enter alone skips it.
478
+ openModal({
479
+ type: "input",
480
+ title: addLabel + " \u00B7 " + preset.label,
481
+ placeholder: oap.label,
482
+ onCancel: function() { submitLine(presetAddLine(preset, env), true); },
483
+ onPick: function(v: string) {
484
+ const t = String(v || "").trim();
485
+ const line = presetAddLine(preset, env) + (t ? " " + (oap.flag ? oap.flag + " " : "") + t : "");
486
+ submitLine(line, true);
487
+ },
488
+ });
489
+ return;
490
+ }
491
+ const pr = prompts[i];
492
+ openModal({
493
+ type: "input",
494
+ title: addLabel + " \u00B7 " + preset.label + " (" + (i + 1) + "/" + prompts.length + ")",
495
+ placeholder: pr.label,
496
+ isKey: pr.mask !== false,
497
+ onCancel: function() { closeModal(); setTimeout(function() { openModal({ type: backType as any }); }, 10); },
498
+ onPick: function(val: string) {
499
+ const t = String(val || "").trim();
500
+ if (!t) { showToast(pr.key + " is required", "error"); askPrompt(i); return; }
501
+ // A value with whitespace would break the generated `-e KEY=value`
502
+ // token in the one-liner — reject it and re-prompt (covers every
503
+ // credential prompt in the loop, including multi-credential presets).
504
+ if (/\s/.test(t)) { showToast(pr.key + " must not contain spaces", "error"); askPrompt(i); return; }
505
+ env[pr.key] = t;
506
+ askPrompt(i + 1);
507
+ },
508
+ });
509
+ };
510
+
511
+ closeModal();
512
+ if (!prompts.length) {
513
+ openLine(presetAddLine(preset));
514
+ return;
515
+ }
516
+ askPrompt(0);
517
+ };
518
+ const openLine = function(line: string) {
519
+ openModal({
520
+ type: "input",
521
+ title: addLabel + " \u00B7 one line",
522
+ placeholder: "e.g. -e KEY=V " + (kind === "connector" ? "railway" : "stm32") + " -- <command> <args>",
523
+ value: line,
524
+ // Caret starts right after the last "=" so a preset's first -e KEY=
525
+ // placeholder is one keystroke away; otherwise at the end.
526
+ caretStart: line ? line.lastIndexOf("=") + 1 : 0,
527
+ onCancel: function() { closeModal(); setTimeout(function() { openModal({ type: backType as any }); }, 10); },
528
+ onPick: function(line2: string) {
529
+ const msg = plugin.mcpAddLineCmd(line2);
530
+ if (msg.startsWith("Added")) {
531
+ showToast(msg, "ok");
532
+ closeModal();
533
+ setTimeout(function() { openModal({ type: backType as any }); }, 10);
534
+ } else {
535
+ showToast(String(msg).slice(0, 80), "error");
536
+ openLine(line2); // keep the text so the user can fix it
537
+ }
538
+ },
539
+ });
540
+ };
541
+ const picker = presets.map(p => ({
542
+ label: p.label,
543
+ sub: p.prompts.length ? "needs a token" : "no key needed",
544
+ value: p.id,
545
+ })).concat([{ label: "Custom…", sub: "name + command + args + env", value: "__custom__" }]);
546
+ openModal({
547
+ type: "select", title: addLabel,
548
+ searchable: false,
549
+ options: picker,
550
+ onPick: function(val: any) {
551
+ const preset = val === "__custom__" ? undefined : presets.find((p: any) => p.id === val);
552
+ if (!preset) { closeModal(); openLine(""); return; }
553
+ if ((preset.prompts || []).length) { guidedAdd(preset); return; }
554
+ closeModal();
555
+ openLine(presetAddLine(preset));
556
+ },
557
+ });
558
+ }
559
+
560
+ function ServerBrowser(props: { kind: "mcp" | "connector" }) {
561
+ const kind = props.kind;
562
+ const title = kind === "connector" ? "Connectors" : "MCP Servers";
563
+ const presets = kind === "connector" ? CONNECTOR_PRESETS : MCP_PRESETS;
564
+ const { listServers, toggleServer } = require("../../mcp/mcp-manager.js");
565
+ const [servers, setServers] = createSignal(listServers());
566
+ const [sel, setSel] = createSignal(0);
567
+
568
+ const refresh = () => setServers(listServers());
569
+
570
+ useKeyboard(key => {
571
+ const ks = kbs.keyString(key);
572
+ if (kbs.is("modal_cancel", ks)) { closeModal(); return; }
573
+ if (kbs.dialogIs("dialog_select_prev", ks)) { setSel(i => Math.max(0, i - 1)); return; }
574
+ if (kbs.dialogIs("dialog_select_next", ks)) { setSel(i => Math.min(servers().length - 1, i + 1)); return; }
575
+ if (key.name === "a" || key.name === "A") { openPresetPicker(presets, kind); return; }
576
+ if (kbs.dialogIs("dialog_select_submit", ks)) {
577
+ const s = servers()[sel()];
578
+ if (!s) return;
579
+ const res = toggleServer(s.name);
580
+ if (res && res.error) { showToast(String(res.error), "error"); return; }
581
+ showToast("MCP " + s.name + ": " + (s.enabled ? "off" : "on"), "ok");
582
+ refresh();
583
+ return;
584
+ }
585
+ });
586
+
587
+ const scrollBy = (e: any) => { setSel(i => Math.max(0, Math.min(servers().length - 1, i + wheelStep(e, 1)))); };
588
+ let winStart = 0;
589
+ const win = () => {
590
+ const total = servers().length;
591
+ winStart = windowFor(sel(), total, 12, winStart);
592
+ return { total, start: winStart, items: servers().slice(winStart, winStart + 12) };
593
+ };
594
+ const rangeSub = () => {
595
+ const w = win();
596
+ return w.total > 12 ? " showing " + (w.start + 1) + "-" + Math.min(w.start + 12, w.total) + " of " + w.total : "";
597
+ };
598
+ // Name column width = longest name + " [on] "/" [off] " prefix (7-8 chars),
599
+ // capped so the command column keeps ~28 chars. Without an explicit width the
600
+ // name text yoga-shrinks to zero width when the command is long.
601
+ const nameW = () => Math.min(30, Math.max(18, ...servers().map(s => String(s.name).length + 9)));
602
+ // Command column budget: modal inner ~64 chars minus name column minus the
603
+ // "→ " arrow — any longer and the text pixel-punches past the right border.
604
+ const cmdW = () => Math.max(24, 62 - nameW());
605
+
606
+ return (
607
+ <ModalFrame title={title} subtitle={"Enter toggles a server on/off" + rangeSub()} footer={kbNav().submit + " toggle | A add " + (kind === "connector" ? "connector" : "server") + " | wheel scroll | " + kbNav().cancel + " close"}>
608
+ <box onMouseScroll={scrollBy} flexDirection="column" flexShrink={0}>
609
+ {win().items.map((s, i) => {
610
+ const abs = win().start + i;
611
+ return (
612
+ <box
613
+ flexDirection="row" paddingY={0} height={1} flexShrink={0}
614
+ onMouseDown={() => setSel(abs)}
615
+ onMouseUp={() => { if (abs === sel()) { const r = toggleServer(s.name); if (!r || !r.error) refresh(); } }}
616
+ >
617
+ <text fg={abs === sel() ? ui.primary : ui.fgDim} width={nameW()} height={1} flexShrink={0}>
618
+ {" " + (s.enabled ? "[on] " : "[off] ") + s.name}
619
+ </text>
620
+ <text fg={ui.fgMuted} height={1} flexGrow={1}>{"\u2192 " + mcpFit(s.command + " " + (s.args || []).join(" "), cmdW())}</text>
621
+ </box>
622
+ );
623
+ })}
624
+ </box>
625
+ </ModalFrame>
626
+ );
627
+ }
628
+
629
+ export function McpModal() {
630
+ return <ServerBrowser kind="mcp" />;
631
+ }
632
+
633
+ export function ConnectorsModal() {
634
+ return <ServerBrowser kind="connector" />;
635
+ }
636
+
637
+ // Palette modal (ctrl+p) - proper popup window
638
+ export function PaletteModal(props: { onPick: (cmd: string) => void }) {
639
+ const items = SLASH_LIST.map(c => ({ label: "/" + c.cmd, value: c.cmd, sub: c.desc }));
640
+ const [sel, setSel] = createSignal(0);
641
+ const [q, setQ] = createSignal("");
642
+
643
+ const filtered = () => {
644
+ const query = q().toLowerCase();
645
+ return query ? items.filter(x => x.value.startsWith(query)) : items;
646
+ };
647
+
648
+ useKeyboard(key => {
649
+ const k = key.name;
650
+ const ks = kbs.keyString(key);
651
+ if (kbs.is("modal_cancel", ks)) { closeModal(); return; }
652
+ if (key.name === "backspace") { setQ(v => v.slice(0, -1)); setSel(0); return; }
653
+ if (kbs.dialogIs("dialog_select_prev", ks)) { setSel(i => Math.max(0, i - 1)); return; }
654
+ if (kbs.dialogIs("dialog_select_next", ks)) { setSel(i => Math.min(filtered().length - 1, i + 1)); return; }
655
+ if (kbs.dialogIs("dialog_select_submit", ks)) {
656
+ const f = filtered();
657
+ const i = sel();
658
+ if (f.length > i) {
659
+ const cmd = "/" + f[i].value;
660
+ props.onPick(cmd);
661
+ closeModal();
662
+ }
663
+ return;
664
+ }
665
+ if (!key.ctrl && !key.meta && key.sequence && key.sequence.length <= 3 && ["\r","\n"].indexOf(key.sequence) === -1) {
666
+ setQ(v => v + key.sequence);
667
+ setSel(0);
668
+ }
669
+ });
670
+
671
+ const scrollBy = (e: any) => { setSel(i => Math.max(0, Math.min(filtered().length - 1, i + wheelStep(e, 1)))); };
672
+ const clickRow = (i: number) => {
673
+ if (i !== sel()) return;
674
+ const f = filtered();
675
+ if (f.length > i) {
676
+ props.onPick("/" + f[i].value);
677
+ closeModal();
678
+ }
679
+ };
680
+ let winStart = 0;
681
+ const win = () => {
682
+ const f = filtered();
683
+ winStart = windowFor(sel(), f.length, 12, winStart);
684
+ return { start: winStart, items: f.slice(winStart, winStart + 12) };
685
+ };
686
+
687
+ return (
688
+ <ModalFrame title="Command Palette" subtitle={"Type to filter (" + filtered().length + " commands)"}>
689
+ <box onMouseScroll={scrollBy}>
690
+ {win().items.map((it, i) => {
691
+ const abs = win().start + i;
692
+ return (
693
+ <box
694
+ flexDirection="row" paddingY={0}
695
+ onMouseDown={() => setSel(abs)}
696
+ onMouseUp={() => clickRow(abs)}
697
+ >
698
+ <text fg={abs === sel() ? ui.primary : ui.fgDim}>
699
+ {abs === sel() ? "> " : " "}{it.label.split("/")[1] || it.label}
700
+ </text>
701
+ </box>
702
+ );
703
+ })}
704
+ </box>
705
+ </ModalFrame>
706
+ );
707
+ }
708
+
709
+ export function openCustomModelId() {
710
+ const pv = loadConfig().provider || "nvidia";
711
+ openModal({
712
+ type: "input", title: "Custom model ID for " + (PROVIDER_LABELS[pv] || pv),
713
+ placeholder: "e.g. deepseek-ai/deepseek-v4-flash",
714
+ onPick(val) {
715
+ if (!val.trim()) { closeModal(); return; }
716
+ const c = loadConfig(); c.provider = pv; c.model = c.model || {}; c.model[pv] = val.trim();
717
+ saveConfig(c); refreshProviderState(); closeModal();
718
+ showToast("Model: " + pv + " -> " + val.trim(), "ok");
719
+ },
720
+ });
721
+ }
722
+
723
+ /**
724
+ * Graph Modal — full-screen view of the memory graph.
725
+ * The graph data is built synchronously by openGraphModal() (straight from
726
+ * LOOM.md + .loom/graph/nodes/*.md) and passed in as props — no async
727
+ * loading inside the modal. Renders the ## / ### hierarchy as an ASCII
728
+ * tree; select a node (↑/↓) and peek at its body (Enter). ESC closes.
729
+ * NOTE: glyphs are ASCII-only (|, +-, >) — Windows legacy consoles
730
+ * (CP437) can't render box-drawing or Unicode arrows.
731
+ */
732
+ export function GraphModal(props: { graph: any; err: string | null }) {
733
+ const [sel, setSel] = createSignal(0);
734
+ const [open, setOpen] = createSignal<number | null>(null);
735
+
736
+ // Flatten the node graph into tree-walk order: top-level nodes (no
737
+ // 'child' parent) followed by their ### children, recursively.
738
+ const buildFlat = (g: any): any[] => {
739
+ const nodes = g.nodes || [];
740
+ const edges = g.edges || [];
741
+ const byId = new Map<string, any>();
742
+ for (const n of nodes) byId.set(n.id, n);
743
+ const childMap = new Map<string, string[]>();
744
+ const hasParent = new Set<string>();
745
+ for (const e of edges) {
746
+ if (e.type !== 'child') continue;
747
+ if (!childMap.has(e.source)) childMap.set(e.source, []);
748
+ childMap.get(e.source)!.push(e.target);
749
+ hasParent.add(e.target);
750
+ }
751
+ const flat: any[] = [];
752
+ const seen = new Set<string>();
753
+ const walk = (id: string, depth: number) => {
754
+ if (seen.has(id)) return;
755
+ seen.add(id);
756
+ const n = byId.get(id);
757
+ if (!n) return;
758
+ flat.push({ node: n, depth });
759
+ for (const k of childMap.get(id) || []) walk(k, depth + 1);
760
+ };
761
+ for (const n of nodes) if (!hasParent.has(n.id)) walk(n.id, 0);
762
+ for (const n of nodes) if (!seen.has(n.id)) walk(n.id, 0); // orphans
763
+ return flat;
764
+ };
765
+
766
+ // Build the view; tracks the line of the selected node for viewport follow.
767
+ let selLine = 0;
768
+ const content = () => {
769
+ if (props.err) return 'Error: ' + props.err;
770
+ const g = props.graph;
771
+ if (!g) return 'No memory graph found - is there a LOOM.md?';
772
+ const nodes = g.nodes || [];
773
+ const edges = g.edges || [];
774
+ const refs = edges.filter((e: any) => e.type !== 'child');
775
+ const flat = buildFlat(g);
776
+ const lines: string[] = [];
777
+ lines.push('LOOM Memory Graph - ' + nodes.length + ' nodes | ' + edges.length + ' links (from LOOM.md)');
778
+ lines.push('');
779
+ let idx = 0;
780
+ for (const { node, depth } of flat) {
781
+ const cur = idx === sel();
782
+ if (cur) selLine = lines.length;
783
+ const marker = cur ? '>' : ' ';
784
+ const indent = depth === 0 ? ' ' : ' ' + ' '.repeat(depth - 1) + '+- ';
785
+ const tag = node.tags && node.tags.length ? ' #' + node.tags.join(' #') : '';
786
+ lines.push(marker + indent + node.title + tag);
787
+ if (cur && open() === idx && node.body) {
788
+ const bl = String(node.body).split('\n').slice(0, 4);
789
+ for (const l of bl) lines.push(' ' + l);
790
+ }
791
+ idx++;
792
+ }
793
+ if (refs.length) {
794
+ lines.push('');
795
+ lines.push(' Links:');
796
+ for (const e of refs) lines.push(' ' + e.source + ' -> ' + e.target);
797
+ }
798
+ lines.push('');
799
+ lines.push(' up/down select | Enter toggle body | PgUp/PgDn | ESC close');
800
+ return lines.join('\n');
801
+ };
802
+
803
+ const count = () => {
804
+ const g = props.graph;
805
+ return g && g.nodes ? g.nodes.length : 1;
806
+ };
807
+ useKeyboard(key => {
808
+ const ks = kbs.keyString(key);
809
+ if (kbs.is("modal_cancel", ks)) { closeModal(); return; }
810
+ if (kbs.dialogIs("dialog_select_prev", ks)) { setSel(s => Math.max(0, s - 1)); return; }
811
+ if (kbs.dialogIs("dialog_select_next", ks)) { setSel(s => Math.min(count() - 1, s + 1)); return; }
812
+ if (kbs.dialogIs("dialog_select_submit", ks)) { setOpen(o => (o === sel() ? null : sel())); return; }
813
+ if (kbs.dialogIs("dialog_select_page_up", ks)) { setSel(s => Math.max(0, s - 10)); return; }
814
+ if (kbs.dialogIs("dialog_select_page_down", ks)) { setSel(s => Math.min(count() - 1, s + 10)); return; }
815
+ if (kbs.dialogIs("dialog_select_home", ks)) { setSel(0); return; }
816
+ if (kbs.dialogIs("dialog_select_end", ks)) { setSel(count() - 1); return; }
817
+ });
818
+
819
+ // Reactive chain: content() reads sel()/open() and stamps selLine while
820
+ // computing, so navigation must recompute the view inside accessors (a
821
+ // one-time const would freeze the tree at creation). lines() is evaluated
822
+ // before selLine is read so the stamped value is fresh.
823
+ const lines = () => content().split('\n');
824
+ const visible = 34;
825
+ const start = () => Math.max(0, Math.min(lines().length - visible, selLine - 2));
826
+ const view = () => lines().slice(start(), start() + visible).join('\n');
827
+
828
+ return (
829
+ <ModalFrame title="Memory Graph" subtitle={"up/down select | Enter details | ESC close"} footer={""}>
830
+ <box flexDirection="column" paddingX={2} paddingY={1}>
831
+ <text fg={ui.fg}>{view()}</text>
832
+ </box>
833
+ </ModalFrame>
834
+ );
835
+ }
836
+
837
+ /**
838
+ * Opens the graph modal. Builds the graph synchronously from LOOM.md so the
839
+ * modal renders immediately (no async load, no file picking).
840
+ */
841
+ export function openGraphModal() {
842
+ let graph: any = null;
843
+ let err: string | null = null;
844
+ try {
845
+ const { buildGraph } = require("../../core/graph.js");
846
+ graph = buildGraph(process.cwd());
847
+ } catch (e) {
848
+ err = String((e as any)?.message || e);
849
+ }
850
+ openModal({ type: "graph", graph, graphError: err });
851
+ }