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.
- package/.env.example +25 -0
- package/CHANGELOG.md +402 -0
- package/LICENSE +21 -0
- package/LOOM.md +235 -0
- package/README.md +433 -0
- package/bin/loom-tui.js +43 -0
- package/bin/loom.js +44 -0
- package/docs/acp.md +151 -0
- package/docs/web.md +205 -0
- package/package.json +97 -0
- package/scripts/acp-smoke.js +146 -0
- package/src/acp/acp-server.js +287 -0
- package/src/config/provider-cmd.js +37 -0
- package/src/config/settings.js +164 -0
- package/src/core/agents.js +361 -0
- package/src/core/background-tasks.js +103 -0
- package/src/core/cli.js +579 -0
- package/src/core/custom-commands.js +70 -0
- package/src/core/errors.js +29 -0
- package/src/core/events.js +24 -0
- package/src/core/file-diffs.js +282 -0
- package/src/core/format.js +206 -0
- package/src/core/graph.js +257 -0
- package/src/core/hooks.js +82 -0
- package/src/core/lsp.js +385 -0
- package/src/core/memory.js +87 -0
- package/src/core/model-router.js +87 -0
- package/src/core/permissions.js +327 -0
- package/src/core/platform.js +33 -0
- package/src/core/plugin-cmd.js +380 -0
- package/src/core/restore.js +207 -0
- package/src/core/session-store.js +167 -0
- package/src/core/session.js +910 -0
- package/src/core/subagent-log.js +134 -0
- package/src/core/tokens.js +31 -0
- package/src/core/update.js +6 -0
- package/src/core/usage.js +166 -0
- package/src/index.js +41 -0
- package/src/mcp/mcp-client.js +201 -0
- package/src/mcp/mcp-manager.js +193 -0
- package/src/providers/anthropic.js +243 -0
- package/src/providers/google.js +29 -0
- package/src/providers/index.js +175 -0
- package/src/providers/local.js +27 -0
- package/src/providers/nvidia.js +85 -0
- package/src/providers/openai-compat.js +269 -0
- package/src/providers/openai.js +35 -0
- package/src/providers/openrouter.js +43 -0
- package/src/providers/registry.js +196 -0
- package/src/providers/tokenrouter.js +19 -0
- package/src/skills/skill-matcher.js +133 -0
- package/src/skills/skills-manager.js +213 -0
- package/src/tools/index.js +543 -0
- package/src/tui/App.tsx +1578 -0
- package/src/tui/components/BreadcrumbBar.tsx +34 -0
- package/src/tui/components/ChatArea.tsx +518 -0
- package/src/tui/components/InputBar.tsx +354 -0
- package/src/tui/components/MdText.tsx +105 -0
- package/src/tui/components/Modals.tsx +851 -0
- package/src/tui/components/PermissionPopup.tsx +264 -0
- package/src/tui/components/Sidebar.tsx +182 -0
- package/src/tui/components/SplashScreen.tsx +51 -0
- package/src/tui/components/SubagentPanel.tsx +217 -0
- package/src/tui/components/ToastOverlay.tsx +34 -0
- package/src/tui/keybinds.ts +318 -0
- package/src/tui/mcp-presets.ts +189 -0
- package/src/tui/md-render.ts +228 -0
- package/src/tui/store.ts +714 -0
- package/src/tui/suite-home.ts +20 -0
- package/src/tui/theme.ts +313 -0
- package/src/tui/themes.generated.ts +968 -0
- package/src/tui/tool-display.ts +176 -0
- package/src/tui/toolname.ts +60 -0
- package/src/tui/tui-config.ts +28 -0
- package/src/tui-open.tsx +51 -0
- package/src/web/attach.js +242 -0
- package/src/web/graph-view.html +262 -0
- package/src/web/index.html +824 -0
- 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
|
+
}
|