// Modals -- provider picker, model picker, key input, base URL, settings, palette. import { createSignal, createMemo, onMount } from "solid-js"; import { useKeyboard, usePaste } from "@opentui/solid"; import { palette } from "../theme.ts"; import * as kbs from "../keybinds.ts"; import { openModal, closeModal, modal, PROVIDERS, PROVIDER_ORDER, PROVIDER_LABELS, refreshProviderState, appendMessage, showToast, getSession, allModelOptions, SLASH_LIST, windowFor, sidebarVisible, setSidebarVisible, showToolDetails, setShowToolDetails, showThinking, setShowThinking, persistUi, } from "../store.ts"; import { loadConfig, saveConfig, getBaseUrl, setBaseUrl } from "../../config/settings.js"; import { MCP_PRESETS, CONNECTOR_PRESETS } from "../mcp-presets.ts"; const plugin = require("../../core/plugin-cmd.js"); const ui = palette("loom"); // Dialog nav labels from the resolved keybinds (shown in modal footers). function kbNav() { return { prev: kbs.label("dialog_select_prev").toUpperCase(), next: kbs.label("dialog_select_next").toUpperCase(), submit: kbs.label("dialog_select_submit").toUpperCase(), cancel: kbs.label("modal_cancel").toUpperCase(), }; } function wheelStep(e: any, delta: number) { const dir = e?.scroll?.direction; return dir === "up" ? -delta : delta; } export function ModalFrame(props: { title: string; subtitle?: string; children: any; footer?: string }) { // Floating panel: centered over the terminal but NOT a full-screen takeover — // no backdrop fill, so the chat and any running agent stay visible behind it // ("same window, hovering"). Keys still belong to the modal while open. return ( {props.title} {props.subtitle ? {props.subtitle} : null} {props.children} {props.footer ? {props.footer} : null} ); } export function ProviderPicker() { // opencode-scale provider list: make sure the models.dev registry is cached // (fetched once, ~/.loom/models-dev.json). When a fetch lands mid-session // the picker re-opens with the full provider list. When the cache is already // fresh the picker must NOT re-open itself — that would loop forever and // clobber every other modal. onMount(() => { const { ensureRegistry } = require("../../providers/index.js"); const hadCache = require("../../providers/registry.js").isRegistryFresh(); ensureRegistry().then((count) => { if (modal()?.type !== "provider") return; if (hadCache || !count) return; closeModal(); setTimeout(() => openModal({ type: "provider" }), 50); }); }); const opts = PROVIDER_ORDER.map(p => ({ label: PROVIDER_LABELS[p] || p, value: p, sub: PROVIDERS[p]?.models?.length ? String(PROVIDERS[p].models.length) + " models" : undefined, })); const pick = (val: any) => { const p = String(val); const sess = getSession(); const cfg = loadConfig(); const modelId = cfg.model?.[p] || (PROVIDERS[p]?.models?.[0]?.id); if (modelId) sess.setModel(p, modelId); else { cfg.provider = p; saveConfig(cfg); } closeModal(); refreshProviderState(); showToast("Provider: " + (PROVIDER_LABELS[p] || p), "ok"); setTimeout(() => openKeyModal(p), 100); }; return ( ); } // ── Model picker ── export function openModelPicker() { const opts = allModelOptions().filter(function(o) { return o.value !== "__custom__"; }); openModal({ type: "select", title: "Select Model", options: opts, searchable: true, onPick(val, opt) { if (val === "__custom__") return; const provider = opt?.provider || loadConfig().provider; const sess = getSession(); sess.setModel(provider, val); refreshProviderState(); closeModal(); showToast("Model: " + provider + " -> " + val, "ok"); }, }); } export function openKeyModal(provider: string) { const cur = (loadConfig().apiKeys || {})[provider] || ""; openModal({ type: "input", title: "API key for " + (PROVIDER_LABELS[provider] || provider), placeholder: cur ? "(already set -- leave blank to keep)" : "Paste your key", isKey: true, onPick(val) { const c = loadConfig(); c.apiKeys = c.apiKeys || {}; if (val.trim()) { c.apiKeys[provider] = val.trim(); saveConfig(c); showToast("Key saved for " + provider, "ok"); } else showToast("Using env var " + provider.toUpperCase() + "_API_KEY"); refreshProviderState(); closeModal(); }, }); } export function openBaseUrlEditor(provider: string) { const cur = getBaseUrl(provider) || "(default)"; openModal({ type: "input", title: "Base URL for " + (PROVIDER_LABELS[provider] || provider), placeholder: "Current: " + cur, onPick(val) { if (!val.trim()) { closeModal(); return; } setBaseUrl(provider, val.trim()); closeModal(); showToast("Base URL: " + val.trim()); }, }); } export function SelectModal(props: { title: string; options: { label: string; value: any; sub?: string; provider?: string; header?: string; isHeader?: boolean; tags?: string[]; recent?: boolean }[]; onPick: (value: any, option: any) => void; searchable?: boolean; onCancel?: () => void; // Live-preview: fire on selection change so theme pickers can repaint the // app while the user scrolls without closing the modal. onPreview?: (value: any) => void; }) { // Long labels/subs must never wrap: the modal is maxWidth 72 (62 usable cells // after padding), so truncate each piece to its budget before rendering. const LABEL_MAX = 26, SUB_MAX = 18; const fit = (s: string, n: number) => (s.length <= n ? s : s.slice(0, Math.max(1, n - 1)) + "\u2026"); const [index, setIndex] = createSignal(0); const [q, setQ] = createSignal(""); const filtered = () => { const query = q().trim().toLowerCase(); if (!query) return props.options; return props.options.filter(o => { if (o.isHeader) return false; const hay = (o.label + " " + (o.sub || "") + " " + (o.provider || "") + " " + String(o.tags || []).toLowerCase() + " " + String(o.value)).toLowerCase(); return query.split(/\s+/).every(part => hay.includes(part)); }); }; // Live-hook: fire onPreview AFTER the index has settled (Solid may batch // the signal write — schedule.fire settles next microtask). function firePreview() { if (!props.onPreview) return; setTimeout(() => { try { props.onPreview!(filtered()[index()]?.value); } catch {} }, 0); } // The selection must never land on a section header: it would render with // no highlight and Enter would do nothing. Always skip to the next row. const firstSelectable = () => { const list = filtered(); const i = list.findIndex(o => o && !o.isHeader); return i < 0 ? 0 : i; }; const stepSelectable = (i: number, dir: number) => { const list = filtered(); let j = i + dir; while (j >= 0 && j < list.length && list[j] && list[j].isHeader) j += dir; if (j < 0 || j >= list.length) return i; return j; }; // Page jumps step over a full window (12 rows), skipping section headers. const pageJump = (i: number, dir: number) => { const list = filtered(); if (!list.length) return i; let j = i; for (let n = 0; n < 12; n++) { const next = stepSelectable(j, dir); if (next === j) return j; j = next; } return j; }; const lastSelectable = () => { const list = filtered(); for (let j = list.length - 1; j >= 0; j--) if (list[j] && !list[j].isHeader) return j; return 0; }; // Start on the first real row (not a header). setIndex(firstSelectable()); // Hover must not fight scrolling: any keyboard/wheel/search index change // locks hover-selection briefly, so the list sliding under a stationary // cursor cannot yank the selection back (this feedback loop read as // "jitter" while scrolling the model picker). let hoverLockUntil = 0; const lockHover = () => { hoverLockUntil = Date.now() + 250; }; const setIndexByKey = (fn: (i: number) => number) => { setIndex(fn); lockHover(); }; const nav = kbNav(); useKeyboard(key => { const ks = kbs.keyString(key); if (kbs.is("modal_cancel", ks)) { closeModal(); if (props.onCancel) props.onCancel(); return; } if (kbs.dialogIs("dialog_select_prev", ks)) { setIndexByKey(i => stepSelectable(i, -1)); firePreview(); return; } if (kbs.dialogIs("dialog_select_next", ks)) { setIndexByKey(i => stepSelectable(i, 1)); firePreview(); return; } if (kbs.dialogIs("dialog_select_page_up", ks)) { setIndexByKey(i => pageJump(i, -1)); firePreview(); return; } if (kbs.dialogIs("dialog_select_page_down", ks)) { setIndexByKey(i => pageJump(i, 1)); firePreview(); return; } if (kbs.dialogIs("dialog_select_home", ks)) { setIndexByKey(firstSelectable); firePreview(); return; } if (kbs.dialogIs("dialog_select_end", ks)) { setIndexByKey(lastSelectable); firePreview(); return; } if (kbs.dialogIs("dialog_select_submit", ks)) { const opt = filtered()[index()]; if (!opt || opt.isHeader) return; props.onPick(opt.value, opt); return; } if (props.searchable) { // Reset to 0, not firstSelectable(): setQ is batched, so firstSelectable() // would read the STALE list and land past its end (dead arrows/blank row). if (key.name === "backspace") { setQ(v => v.slice(0, -1)); setIndexByKey(() => 0); firePreview(); return; } const s = key.sequence; if (!key.ctrl && !key.meta && s && s.length <= 10 && s !== "\r" && s !== "\n") { setQ(v => v + s); setIndexByKey(() => 0); firePreview(); return; } } }); const scrollBy = (e: any) => { const step = wheelStep(e, 1); const dir = step < 0 ? -1 : 1; let i = index(); for (let n = 0; n < Math.abs(step); n++) { const j = stepSelectable(i, dir); if (j === i) break; i = j; } setIndexByKey(() => i); }; const clickRow = (i: number) => { if (i !== index()) { setIndex(i); firePreview(); } const o = filtered()[i]; if (o?.isHeader) return; props.onPick(o?.value, o); }; // Window of 12 rows, computed ONCE per reactive change (the old version // mutated `winStart` from inside the JSX — called three times per render). let lastStart = 0; const win = createMemo(() => { const total = filtered().length; lastStart = windowFor(index(), total, 12, lastStart); return { total, start: lastStart, items: filtered().slice(lastStart, lastStart + 12) }; }); const rangeSub = () => { const w = win(); if (w.total <= 12) return ""; return " showing " + (w.start + 1) + "-" + Math.min(w.start + 12, w.total) + " of " + w.total; }; return ( {/* Fixed height: the modal frame must not resize while scrolling. Headers used to add an extra margin row, so the centered modal bounced between 12/13/14 rows on every page of a header-heavy list (the model picker) — the "jitter". Every item is now exactly one row and the window is always 12 rows tall. */} {win().items.map((opt, i) => { const abs = win().start + i; if (opt.isHeader) return ( {opt.header + ":"} ); const active = abs === index(); return ( { if (Date.now() >= hoverLockUntil && abs !== index()) { setIndex(abs); firePreview(); } }} onMouseDown={() => setIndex(abs)} onMouseUp={() => clickRow(abs)} > {(active ? " > " : " ") + fit(opt.label, LABEL_MAX)} {opt.recent ? {" \u2713 recent"} : null} {opt.sub ? {"(" + fit(opt.sub, SUB_MAX) + ")"} : null} ); })} ); } export function InputModal(props: { title: string; placeholder: string; onPick: (value: string) => void; isKey?: boolean; value?: string; caretStart?: number; onCancel?: () => void; }) { const [val, setVal] = createSignal(props.value || ""); const [caret, setCaret] = createSignal( typeof props.caretStart === "number" ? props.caretStart : (props.value || "").length ); const masked = () => props.isKey ? "x".repeat(Math.max(0, val().length)) : val(); // Caret insertion: shown = left part + block cursor + right part. const shown = () => { const d = masked(); const c = Math.min(caret(), d.length); return d.slice(0, c) + "\u258c" + d.slice(c); }; useKeyboard(key => { const ks = kbs.keyString(key); if (kbs.is("modal_cancel", ks)) { closeModal(); if (props.onCancel) props.onCancel(); return; } if (kbs.dialogIs("dialog_select_submit", ks)) { props.onPick(val()); return; } if (key.name === "backspace") { setVal(v => { const c = Math.min(caret(), v.length); return v.slice(0, Math.max(0, c - 1)) + v.slice(c); }); setCaret(c => Math.max(0, c - 1)); return; } if (key.name === "left") { setCaret(c => Math.max(0, c - 1)); return; } if (key.name === "right") { setCaret(c => Math.min(val().length, c + 1)); return; } if (key.name === "home") { setCaret(0); return; } if (key.name === "end") { setCaret(val().length); return; } const s = key.sequence; if (!key.ctrl && !key.meta && s && s.length <= 10 && s !== "\r" && s !== "\n") { setVal(v => { const c = Math.min(caret(), v.length); return v.slice(0, c) + s + v.slice(c); }); setCaret(c => Math.min(val().length, c) + s.length); } }); usePaste(event => { const txt = new TextDecoder().decode((event as any).bytes || "").replace(/[\r\n]+/g, ""); if (txt) { setVal(v => { const c = Math.min(caret(), v.length); return v.slice(0, c) + txt + v.slice(c); }); setCaret(c => Math.min(val().length, c) + txt.length); } }); return ( {shown()} ); } export function SettingsModal() { useKeyboard(key => { const ks = kbs.keyString(key); if (kbs.is("modal_cancel", ks)) { closeModal(); } if (key.name === "d" || key.name === "D") { setShowToolDetails(v => !v); persistUi(); } if (key.name === "t" || key.name === "T") { setShowThinking(v => !v); persistUi(); } if (key.name === "b" || key.name === "B") { setSidebarVisible(v => !v); persistUi(); } }); return ( {"[d] Tool details: " + (showToolDetails() ? "on" : "off") + " show tool output"} {"[t] Thinking: " + (showThinking() ? "on" : "off") + " show think time"} {"[b] Sidebar: " + (sidebarVisible() ? "on" : "off") + " todos + files"} ); } export function showHelpText() { const lines = ["Loom Code -- Slash Commands", ""]; for (const c of SLASH_LIST) lines.push(" /" + c.cmd.padEnd(14) + " " + c.desc + (c.args ? " (" + c.args + ")" : "")); lines.push("", " " + kbs.label("session_interrupt") + "=interrupt " + kbs.label("app_exit") + "=exit " + kbs.label("sidebar_toggle") + "=sidebar " + kbs.label("command_list") + "=palette" + (kbs.leaderKey() ? " " + kbs.leaderKey() + "=leader" : "")); lines.push(" Customize in ~/.loom/tui.json \u2014 edit that file directly, then relaunch."); appendMessage({ role: "system", content: lines.join("\n") }); } export function showProvidersText() { const lines = ["Supported providers:", ""]; for (const p of PROVIDER_ORDER) { const mods = PROVIDERS[p]?.models?.length || 0; lines.push(" " + p.padEnd(12) + " " + String(mods).padEnd(3) + " models " + (PROVIDER_LABELS[p] || p)); } lines.push("", "/connect to pick interactively."); appendMessage({ role: "system", content: lines.join("\n") }); } // ── Agents (OpenCode-style primaries + subagents) ── export function showAgentsText() { const { loadAgents } = require("../../core/agents.js"); const agents = loadAgents() as Record; const lines = ["AGENTS", ""]; for (const a of Object.values(agents)) { const mode = a.mode === "primary" ? "primary" : "subagent"; const tools = a.tools && a.tools.length ? "[" + a.tools.join(" ") + "]" : "[*]"; const model = a.model ? " model=" + a.model : ""; lines.push(" " + a.id.padEnd(10) + " " + mode.padEnd(9) + " " + tools.padEnd(22) + model); lines.push(" " + a.description); } lines.push( "", " Automatic: the main agent calls the task tool whenever a subtask needs it.", " Manual: type @ in the input (e.g. @explore find the bug).", " Config: ~/.loom/config.json \u2192 agents: { name: { mode, description, tools, model, prompt } }" ); appendMessage({ role: "system", content: lines.join("\n") }); } // ── MCP server / connector browser popup ── // Lists every configured server (seeded defaults + user-added) with on/off // state; Enter toggles, A opens the add flow, Esc closes. // `kind` decides which preset list the "A" flow offers: "mcp" (dev tools) or // "connector" (hosting/cloud services). Both share the same underlying // mcp-manager — a connector IS an MCP server, just surfaced separately. const MCP_FIT = 52; function mcpFit(s: string, n = MCP_FIT) { const flat = String(s || "").replace(/\s+/g, " ").trim(); return flat.length <= n ? flat : flat.slice(0, Math.max(1, n - 1)) + "\u2026"; } // Build a claude/opencode-style one-liner for a preset, with -e KEY=VALUE // entries (empty values = placeholders the user fills inline in the one-line // editor). $KEY args resolve at add time from the -e env. function presetAddLine(p: any, envValues?: Record): string { const parts: string[] = []; for (const k of Object.keys(p.env || {})) parts.push("-e", k + "=" + (envValues ? (envValues[k] || "") : "")); parts.push(p.id, "--"); if (p.command) parts.push(p.command, ...(p.args || [])); else parts.push("npx", "-y", p.package, ...(p.args || [])); return parts.join(" "); } // The add flow: pick a preset (or Custom). Presets that need secrets walk a // guided key-entry dialog (one masked field per prompt, pasted/typed, then the // one-liner is built automatically); everything else opens the one-line // editor in claude/opencode syntax — `[-e KEY=V] [--] [args...]`. function openPresetPicker(presets: any[], kind: "mcp" | "connector") { const addLabel = kind === "connector" ? "Add connector" : "Add MCP server"; const backType = kind === "connector" ? "connectors" : "mcp"; // Build the server from collected env + optional args and land it; reopen // the browser on success, toast the error otherwise. const submitLine = (line: string, reopenOnError: boolean) => { const msg = plugin.mcpAddLineCmd(line); if (msg.startsWith("Added")) { showToast(msg, "ok"); closeModal(); setTimeout(function() { openModal({ type: backType as any }); }, 10); } else { showToast(String(msg).slice(0, 80), "error"); if (reopenOnError) { closeModal(); setTimeout(function() { openModal({ type: backType as any }); }, 10); } } }; const guidedAdd = (preset: any) => { const prompts: any[] = preset.prompts || []; const env: Record = {}; const askPrompt = (i: number) => { if (i >= prompts.length) { const oap = preset.optionalArgsPrompt; if (!oap) { submitLine(presetAddLine(preset, env), true); return; } // Optional extra arg (e.g. --project-ref): Enter alone skips it. openModal({ type: "input", title: addLabel + " \u00B7 " + preset.label, placeholder: oap.label, onCancel: function() { submitLine(presetAddLine(preset, env), true); }, onPick: function(v: string) { const t = String(v || "").trim(); const line = presetAddLine(preset, env) + (t ? " " + (oap.flag ? oap.flag + " " : "") + t : ""); submitLine(line, true); }, }); return; } const pr = prompts[i]; openModal({ type: "input", title: addLabel + " \u00B7 " + preset.label + " (" + (i + 1) + "/" + prompts.length + ")", placeholder: pr.label, isKey: pr.mask !== false, onCancel: function() { closeModal(); setTimeout(function() { openModal({ type: backType as any }); }, 10); }, onPick: function(val: string) { const t = String(val || "").trim(); if (!t) { showToast(pr.key + " is required", "error"); askPrompt(i); return; } // A value with whitespace would break the generated `-e KEY=value` // token in the one-liner — reject it and re-prompt (covers every // credential prompt in the loop, including multi-credential presets). if (/\s/.test(t)) { showToast(pr.key + " must not contain spaces", "error"); askPrompt(i); return; } env[pr.key] = t; askPrompt(i + 1); }, }); }; closeModal(); if (!prompts.length) { openLine(presetAddLine(preset)); return; } askPrompt(0); }; const openLine = function(line: string) { openModal({ type: "input", title: addLabel + " \u00B7 one line", placeholder: "e.g. -e KEY=V " + (kind === "connector" ? "railway" : "stm32") + " -- ", value: line, // Caret starts right after the last "=" so a preset's first -e KEY= // placeholder is one keystroke away; otherwise at the end. caretStart: line ? line.lastIndexOf("=") + 1 : 0, onCancel: function() { closeModal(); setTimeout(function() { openModal({ type: backType as any }); }, 10); }, onPick: function(line2: string) { const msg = plugin.mcpAddLineCmd(line2); if (msg.startsWith("Added")) { showToast(msg, "ok"); closeModal(); setTimeout(function() { openModal({ type: backType as any }); }, 10); } else { showToast(String(msg).slice(0, 80), "error"); openLine(line2); // keep the text so the user can fix it } }, }); }; const picker = presets.map(p => ({ label: p.label, sub: p.prompts.length ? "needs a token" : "no key needed", value: p.id, })).concat([{ label: "Custom…", sub: "name + command + args + env", value: "__custom__" }]); openModal({ type: "select", title: addLabel, searchable: false, options: picker, onPick: function(val: any) { const preset = val === "__custom__" ? undefined : presets.find((p: any) => p.id === val); if (!preset) { closeModal(); openLine(""); return; } if ((preset.prompts || []).length) { guidedAdd(preset); return; } closeModal(); openLine(presetAddLine(preset)); }, }); } function ServerBrowser(props: { kind: "mcp" | "connector" }) { const kind = props.kind; const title = kind === "connector" ? "Connectors" : "MCP Servers"; const presets = kind === "connector" ? CONNECTOR_PRESETS : MCP_PRESETS; const { listServers, toggleServer } = require("../../mcp/mcp-manager.js"); const [servers, setServers] = createSignal(listServers()); const [sel, setSel] = createSignal(0); const refresh = () => setServers(listServers()); useKeyboard(key => { const ks = kbs.keyString(key); if (kbs.is("modal_cancel", ks)) { closeModal(); return; } if (kbs.dialogIs("dialog_select_prev", ks)) { setSel(i => Math.max(0, i - 1)); return; } if (kbs.dialogIs("dialog_select_next", ks)) { setSel(i => Math.min(servers().length - 1, i + 1)); return; } if (key.name === "a" || key.name === "A") { openPresetPicker(presets, kind); return; } if (kbs.dialogIs("dialog_select_submit", ks)) { const s = servers()[sel()]; if (!s) return; const res = toggleServer(s.name); if (res && res.error) { showToast(String(res.error), "error"); return; } showToast("MCP " + s.name + ": " + (s.enabled ? "off" : "on"), "ok"); refresh(); return; } }); const scrollBy = (e: any) => { setSel(i => Math.max(0, Math.min(servers().length - 1, i + wheelStep(e, 1)))); }; let winStart = 0; const win = () => { const total = servers().length; winStart = windowFor(sel(), total, 12, winStart); return { total, start: winStart, items: servers().slice(winStart, winStart + 12) }; }; const rangeSub = () => { const w = win(); return w.total > 12 ? " showing " + (w.start + 1) + "-" + Math.min(w.start + 12, w.total) + " of " + w.total : ""; }; // Name column width = longest name + " [on] "/" [off] " prefix (7-8 chars), // capped so the command column keeps ~28 chars. Without an explicit width the // name text yoga-shrinks to zero width when the command is long. const nameW = () => Math.min(30, Math.max(18, ...servers().map(s => String(s.name).length + 9))); // Command column budget: modal inner ~64 chars minus name column minus the // "→ " arrow — any longer and the text pixel-punches past the right border. const cmdW = () => Math.max(24, 62 - nameW()); return ( {win().items.map((s, i) => { const abs = win().start + i; return ( setSel(abs)} onMouseUp={() => { if (abs === sel()) { const r = toggleServer(s.name); if (!r || !r.error) refresh(); } }} > {" " + (s.enabled ? "[on] " : "[off] ") + s.name} {"\u2192 " + mcpFit(s.command + " " + (s.args || []).join(" "), cmdW())} ); })} ); } export function McpModal() { return ; } export function ConnectorsModal() { return ; } // Palette modal (ctrl+p) - proper popup window export function PaletteModal(props: { onPick: (cmd: string) => void }) { const items = SLASH_LIST.map(c => ({ label: "/" + c.cmd, value: c.cmd, sub: c.desc })); const [sel, setSel] = createSignal(0); const [q, setQ] = createSignal(""); const filtered = () => { const query = q().toLowerCase(); return query ? items.filter(x => x.value.startsWith(query)) : items; }; useKeyboard(key => { const k = key.name; const ks = kbs.keyString(key); if (kbs.is("modal_cancel", ks)) { closeModal(); return; } if (key.name === "backspace") { setQ(v => v.slice(0, -1)); setSel(0); return; } if (kbs.dialogIs("dialog_select_prev", ks)) { setSel(i => Math.max(0, i - 1)); return; } if (kbs.dialogIs("dialog_select_next", ks)) { setSel(i => Math.min(filtered().length - 1, i + 1)); return; } if (kbs.dialogIs("dialog_select_submit", ks)) { const f = filtered(); const i = sel(); if (f.length > i) { const cmd = "/" + f[i].value; props.onPick(cmd); closeModal(); } return; } if (!key.ctrl && !key.meta && key.sequence && key.sequence.length <= 3 && ["\r","\n"].indexOf(key.sequence) === -1) { setQ(v => v + key.sequence); setSel(0); } }); const scrollBy = (e: any) => { setSel(i => Math.max(0, Math.min(filtered().length - 1, i + wheelStep(e, 1)))); }; const clickRow = (i: number) => { if (i !== sel()) return; const f = filtered(); if (f.length > i) { props.onPick("/" + f[i].value); closeModal(); } }; let winStart = 0; const win = () => { const f = filtered(); winStart = windowFor(sel(), f.length, 12, winStart); return { start: winStart, items: f.slice(winStart, winStart + 12) }; }; return ( {win().items.map((it, i) => { const abs = win().start + i; return ( setSel(abs)} onMouseUp={() => clickRow(abs)} > {abs === sel() ? "> " : " "}{it.label.split("/")[1] || it.label} ); })} ); } export function openCustomModelId() { const pv = loadConfig().provider || "nvidia"; openModal({ type: "input", title: "Custom model ID for " + (PROVIDER_LABELS[pv] || pv), placeholder: "e.g. deepseek-ai/deepseek-v4-flash", onPick(val) { if (!val.trim()) { closeModal(); return; } const c = loadConfig(); c.provider = pv; c.model = c.model || {}; c.model[pv] = val.trim(); saveConfig(c); refreshProviderState(); closeModal(); showToast("Model: " + pv + " -> " + val.trim(), "ok"); }, }); } /** * Graph Modal — full-screen view of the memory graph. * The graph data is built synchronously by openGraphModal() (straight from * LOOM.md + .loom/graph/nodes/*.md) and passed in as props — no async * loading inside the modal. Renders the ## / ### hierarchy as an ASCII * tree; select a node (↑/↓) and peek at its body (Enter). ESC closes. * NOTE: glyphs are ASCII-only (|, +-, >) — Windows legacy consoles * (CP437) can't render box-drawing or Unicode arrows. */ export function GraphModal(props: { graph: any; err: string | null }) { const [sel, setSel] = createSignal(0); const [open, setOpen] = createSignal(null); // Flatten the node graph into tree-walk order: top-level nodes (no // 'child' parent) followed by their ### children, recursively. const buildFlat = (g: any): any[] => { const nodes = g.nodes || []; const edges = g.edges || []; const byId = new Map(); for (const n of nodes) byId.set(n.id, n); const childMap = new Map(); const hasParent = new Set(); for (const e of edges) { if (e.type !== 'child') continue; if (!childMap.has(e.source)) childMap.set(e.source, []); childMap.get(e.source)!.push(e.target); hasParent.add(e.target); } const flat: any[] = []; const seen = new Set(); const walk = (id: string, depth: number) => { if (seen.has(id)) return; seen.add(id); const n = byId.get(id); if (!n) return; flat.push({ node: n, depth }); for (const k of childMap.get(id) || []) walk(k, depth + 1); }; for (const n of nodes) if (!hasParent.has(n.id)) walk(n.id, 0); for (const n of nodes) if (!seen.has(n.id)) walk(n.id, 0); // orphans return flat; }; // Build the view; tracks the line of the selected node for viewport follow. let selLine = 0; const content = () => { if (props.err) return 'Error: ' + props.err; const g = props.graph; if (!g) return 'No memory graph found - is there a LOOM.md?'; const nodes = g.nodes || []; const edges = g.edges || []; const refs = edges.filter((e: any) => e.type !== 'child'); const flat = buildFlat(g); const lines: string[] = []; lines.push('LOOM Memory Graph - ' + nodes.length + ' nodes | ' + edges.length + ' links (from LOOM.md)'); lines.push(''); let idx = 0; for (const { node, depth } of flat) { const cur = idx === sel(); if (cur) selLine = lines.length; const marker = cur ? '>' : ' '; const indent = depth === 0 ? ' ' : ' ' + ' '.repeat(depth - 1) + '+- '; const tag = node.tags && node.tags.length ? ' #' + node.tags.join(' #') : ''; lines.push(marker + indent + node.title + tag); if (cur && open() === idx && node.body) { const bl = String(node.body).split('\n').slice(0, 4); for (const l of bl) lines.push(' ' + l); } idx++; } if (refs.length) { lines.push(''); lines.push(' Links:'); for (const e of refs) lines.push(' ' + e.source + ' -> ' + e.target); } lines.push(''); lines.push(' up/down select | Enter toggle body | PgUp/PgDn | ESC close'); return lines.join('\n'); }; const count = () => { const g = props.graph; return g && g.nodes ? g.nodes.length : 1; }; useKeyboard(key => { const ks = kbs.keyString(key); if (kbs.is("modal_cancel", ks)) { closeModal(); return; } if (kbs.dialogIs("dialog_select_prev", ks)) { setSel(s => Math.max(0, s - 1)); return; } if (kbs.dialogIs("dialog_select_next", ks)) { setSel(s => Math.min(count() - 1, s + 1)); return; } if (kbs.dialogIs("dialog_select_submit", ks)) { setOpen(o => (o === sel() ? null : sel())); return; } if (kbs.dialogIs("dialog_select_page_up", ks)) { setSel(s => Math.max(0, s - 10)); return; } if (kbs.dialogIs("dialog_select_page_down", ks)) { setSel(s => Math.min(count() - 1, s + 10)); return; } if (kbs.dialogIs("dialog_select_home", ks)) { setSel(0); return; } if (kbs.dialogIs("dialog_select_end", ks)) { setSel(count() - 1); return; } }); // Reactive chain: content() reads sel()/open() and stamps selLine while // computing, so navigation must recompute the view inside accessors (a // one-time const would freeze the tree at creation). lines() is evaluated // before selLine is read so the stamped value is fresh. const lines = () => content().split('\n'); const visible = 34; const start = () => Math.max(0, Math.min(lines().length - visible, selLine - 2)); const view = () => lines().slice(start(), start() + visible).join('\n'); return ( {view()} ); } /** * Opens the graph modal. Builds the graph synchronously from LOOM.md so the * modal renders immediately (no async load, no file picking). */ export function openGraphModal() { let graph: any = null; let err: string | null = null; try { const { buildGraph } = require("../../core/graph.js"); graph = buildGraph(process.cwd()); } catch (e) { err = String((e as any)?.message || e); } openModal({ type: "graph", graph, graphError: err }); }