loom-agent 1.3.2 → 1.5.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.
@@ -1,5 +1,5 @@
1
1
  // Modals -- provider picker, model picker, key input, base URL, settings, palette.
2
- import { createSignal, createMemo, onMount } from "solid-js";
2
+ import { createSignal, createMemo, onMount, onCleanup } from "solid-js";
3
3
  import { useKeyboard, usePaste } from "@opentui/solid";
4
4
  import { palette } from "../theme.ts";
5
5
  import * as kbs from "../keybinds.ts";
@@ -441,7 +441,8 @@ export function showAgentsText() {
441
441
 
442
442
  // ── MCP server / connector browser popup ──
443
443
  // Lists every configured server (seeded defaults + user-added) with on/off
444
- // state; Enter toggles, A opens the add flow, Esc closes.
444
+ // state; Enter toggles a server (or opens the add flow on the pinned "+ Add"
445
+ // row at the end of the list), A opens the add flow, Esc closes.
445
446
  // `kind` decides which preset list the "A" flow offers: "mcp" (dev tools) or
446
447
  // "connector" (hosting/cloud services). Both share the same underlying
447
448
  // mcp-manager — a connector IS an MCP server, just surfaced separately.
@@ -470,11 +471,15 @@ function presetAddLine(p: any, envValues?: Record<string, string>): string {
470
471
  function openPresetPicker(presets: any[], kind: "mcp" | "connector") {
471
472
  const addLabel = kind === "connector" ? "Add connector" : "Add MCP server";
472
473
  const backType = kind === "connector" ? "connectors" : "mcp";
474
+ // Connector adds run through the preset-aware engine: a bare preset name
475
+ // plus -e KEY=V flags expands to the full one-liner; full one-liners (with
476
+ // "--") pass through unchanged.
477
+ const addLine = kind === "connector" ? plugin.connectorAddLineCmd : plugin.mcpAddLineCmd;
473
478
 
474
479
  // Build the server from collected env + optional args and land it; reopen
475
480
  // the browser on success, toast the error otherwise.
476
481
  const submitLine = (line: string, reopenOnError: boolean) => {
477
- const msg = plugin.mcpAddLineCmd(line);
482
+ const msg = addLine(line);
478
483
  if (msg.startsWith("Added")) {
479
484
  showToast(msg, "ok");
480
485
  closeModal();
@@ -547,7 +552,7 @@ const openLine = function(line: string) {
547
552
  caretStart: line ? line.lastIndexOf("=") + 1 : 0,
548
553
  onCancel: function() { closeModal(); setTimeout(function() { openModal({ type: backType as any }); }, 10); },
549
554
  onPick: function(line2: string) {
550
- const msg = plugin.mcpAddLineCmd(line2);
555
+ const msg = addLine(line2);
551
556
  if (msg.startsWith("Added")) {
552
557
  showToast(msg, "ok");
553
558
  closeModal();
@@ -592,9 +597,11 @@ function ServerBrowser(props: { kind: "mcp" | "connector" }) {
592
597
  const ks = kbs.keyString(key);
593
598
  if (kbs.is("modal_cancel", ks)) { closeModal(); return; }
594
599
  if (kbs.dialogIs("dialog_select_prev", ks)) { setSel(i => Math.max(0, i - 1)); return; }
595
- if (kbs.dialogIs("dialog_select_next", ks)) { setSel(i => Math.min(servers().length - 1, i + 1)); return; }
600
+ // One past the last server is the pinned "+ Add" row.
601
+ if (kbs.dialogIs("dialog_select_next", ks)) { setSel(i => Math.min(servers().length, i + 1)); return; }
596
602
  if (key.name === "a" || key.name === "A") { openPresetPicker(presets, kind); return; }
597
603
  if (kbs.dialogIs("dialog_select_submit", ks)) {
604
+ if (sel() >= servers().length) { openPresetPicker(presets, kind); return; }
598
605
  const s = servers()[sel()];
599
606
  if (!s) return;
600
607
  const res = toggleServer(s.name);
@@ -605,7 +612,7 @@ function ServerBrowser(props: { kind: "mcp" | "connector" }) {
605
612
  }
606
613
  });
607
614
 
608
- const scrollBy = (e: any) => { setSel(i => Math.max(0, Math.min(servers().length - 1, i + wheelStep(e, 1)))); };
615
+ const scrollBy = (e: any) => { setSel(i => Math.max(0, Math.min(servers().length, i + wheelStep(e, 1)))); };
609
616
  let winStart = 0;
610
617
  const win = () => {
611
618
  const total = servers().length;
@@ -626,7 +633,7 @@ function ServerBrowser(props: { kind: "mcp" | "connector" }) {
626
633
  const cmdW = () => Math.max(22, 60 - nameW());
627
634
 
628
635
  return (
629
- <ModalFrame title={title} subtitle={"Enter toggles a server" + rangeSub()} footer={kbNav().submit + " toggle · A add · " + kbNav().cancel + " close"}>
636
+ <ModalFrame title={title} subtitle={"Enter toggles, + Add row adds" + rangeSub()} footer={kbNav().submit + " toggle/add · A add · " + kbNav().cancel + " close"}>
630
637
  <box onMouseScroll={scrollBy} flexDirection="column" flexShrink={0}>
631
638
  {win().items.map((s, i) => {
632
639
  const abs = win().start + i;
@@ -648,6 +655,25 @@ function ServerBrowser(props: { kind: "mcp" | "connector" }) {
648
655
  </box>
649
656
  );
650
657
  })}
658
+ {/* Pinned add row — always visible under the list; Enter (or a click)
659
+ opens the preset picker, same as the A shortcut. */}
660
+ {(() => {
661
+ const addActive = sel() >= servers().length;
662
+ const addLabel2 = "+ Add " + (kind === "connector" ? "connector" : "MCP server") + "…";
663
+ return (
664
+ <box
665
+ flexDirection="row" paddingY={0} height={1} flexShrink={0}
666
+ paddingLeft={1}
667
+ backgroundColor={addActive ? ui.bgPanelAlt : undefined}
668
+ onMouseDown={() => setSel(servers().length)}
669
+ onMouseUp={() => { if (sel() >= servers().length) openPresetPicker(presets, kind); }}
670
+ >
671
+ <text fg={addActive ? ui.primary : ui.fgMuted} height={1}>
672
+ {(addActive ? "▸ " : " ") + addLabel2}
673
+ </text>
674
+ </box>
675
+ );
676
+ })()}
651
677
  </box>
652
678
  </ModalFrame>
653
679
  );
@@ -663,29 +689,66 @@ export function ConnectorsModal() {
663
689
 
664
690
  // ── Sandbox setup wizard ──
665
691
  // Three steps: docker check (with install link when missing) → OS pick →
666
- // live setup. The container is global — once built, every session and every
667
- // agent routes commands through it.
692
+ // live setup with a real progress bar (setup streams from the core layer —
693
+ // the TUI never freezes). The container is global and idles in the
694
+ // background; the agent's sandbox tool uses it on demand.
668
695
  export function SandboxModal() {
669
696
  const sb = require("../../core/sandbox.js");
670
697
  const [step, setStep] = createSignal<"docker" | "os" | "setup">("docker");
671
- const [docker, setDocker] = createSignal(sb.detectDocker());
698
+ // null = detection running (spawn-based, non-blocking — the old sync
699
+ // detectDocker() froze the whole TUI for up to 10s while docker answered).
700
+ const [docker, setDocker] = createSignal<{ state: string; version: string } | null>(null);
672
701
  const [osSel, setOsSel] = createSignal(0);
673
702
  const [logs, setLogs] = createSignal<string[]>([]);
674
703
  const [busy, setBusy] = createSignal(false);
675
704
  const [err, setErr] = createSignal<string | null>(null);
705
+ const [prog, setProg] = createSignal<{ stage: string; pct: number | null; step: number; total: number } | null>(null);
706
+ const [spin, setSpin] = createSignal(0);
707
+ let spinTimer: ReturnType<typeof setInterval> | null = null;
708
+ onCleanup(() => { if (spinTimer) clearInterval(spinTimer); });
709
+
710
+ // Spinner frames while setup is busy (drives the "working…" indicator).
711
+ function startSpinner() {
712
+ if (spinTimer) clearInterval(spinTimer);
713
+ spinTimer = setInterval(() => setSpin((s) => (s + 1) % 10), 120);
714
+ }
715
+ function stopSpinner() {
716
+ if (spinTimer) clearInterval(spinTimer);
717
+ spinTimer = null;
718
+ }
719
+ const SPIN_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
720
+
721
+ const STAGE_LABELS: Record<string, string> = {
722
+ pull: "Pulling image",
723
+ create: "Creating container",
724
+ start: "Starting container",
725
+ essentials: "Installing essentials",
726
+ browser: "Installing browser",
727
+ done: "Ready",
728
+ };
676
729
 
677
- // Docker already installed → advance straight to OS selection (with a
678
- // brief "installed" flash so the user sees the check happened).
730
+ // Async Docker check on open — never blocks the render loop.
679
731
  onMount(() => {
680
- if (docker().state === "ready") {
681
- setTimeout(() => { if (step() === "docker") setStep("os"); }, 600);
682
- }
732
+ startSpinner();
733
+ sb.detectDockerAsync().then((/** @type {{ state: string; version: string }} */ d) => {
734
+ setDocker(d);
735
+ if (d.state === "ready") {
736
+ // brief "installed" flash, then straight to OS selection.
737
+ setTimeout(() => { if (step() === "docker") setStep("os"); }, 600);
738
+ } else {
739
+ stopSpinner();
740
+ }
741
+ });
683
742
  });
684
743
 
685
744
  function recheck() {
686
- const d = sb.detectDocker();
687
- setDocker(d);
688
- if (d.state === "ready") setStep("os");
745
+ setDocker(null);
746
+ startSpinner();
747
+ sb.detectDockerAsync().then((/** @type {{ state: string; version: string }} */ d) => {
748
+ setDocker(d);
749
+ stopSpinner();
750
+ if (d.state === "ready") setStep("os");
751
+ });
689
752
  }
690
753
 
691
754
  function startSetup() {
@@ -693,14 +756,18 @@ export function SandboxModal() {
693
756
  setStep("setup");
694
757
  setBusy(true);
695
758
  setErr(null);
759
+ setProg(null);
760
+ startSpinner();
696
761
  setLogs(["Preparing " + os.label + " (" + os.image + ")…"]);
697
762
  sb.setupSandbox({
698
763
  osId: os.id,
699
764
  onLog: (/** @type {string} */ l) => setLogs((xs) => [...xs, l]),
765
+ onProgress: (/** @type {{ stage: string; pct: number | null; step: number; total: number }} */ p) => setProg(p),
700
766
  }).then((/** @type {{ ok: boolean, error?: string }} */ res) => {
701
767
  setBusy(false);
768
+ stopSpinner();
702
769
  if (res.ok) {
703
- appendMessage({ role: "system", content: "Sandbox is live (" + os.label + "). Agent commands now run inside Docker; your project is mounted at /workspace. The sandbox is shared: every session and agent uses it." });
770
+ appendMessage({ role: "system", content: "Sandbox is live (" + os.label + "). The container idles in the background; the agent will use it via the sandbox tool whenever isolation is needed. Your project is mounted at /workspace." });
704
771
  showToast("Sandbox ready — " + os.label, "ok");
705
772
  closeModal();
706
773
  } else {
@@ -722,12 +789,14 @@ export function SandboxModal() {
722
789
  if (kbs.dialogIs("dialog_select_next", ks)) { if (s === "os") setOsSel((i) => Math.min(sb.OS_CATALOG.length - 1, i + 1)); return; }
723
790
  if (kbs.dialogIs("dialog_select_submit", ks)) {
724
791
  if (s === "docker") {
725
- if (docker().state === "not_installed") {
792
+ const d = docker();
793
+ if (!d) return; // still detecting — enter does nothing yet
794
+ if (d.state === "not_installed") {
726
795
  sb.markInstallPending();
727
796
  sb.openInstallPage();
728
797
  appendMessage({ role: "system", content: "Opened the Docker download page. Install Docker (restart if asked), then run /sandbox again — or just say \"docker is ready\" in chat." });
729
798
  closeModal();
730
- } else if (docker().state === "no_daemon") {
799
+ } else if (d.state === "no_daemon") {
731
800
  recheck();
732
801
  } else {
733
802
  setStep("os");
@@ -740,7 +809,7 @@ export function SandboxModal() {
740
809
  });
741
810
 
742
811
  const link = sb.installLink();
743
- const st = () => docker().state;
812
+ const st = () => (docker() ? docker()!.state : "checking");
744
813
 
745
814
  return (
746
815
  <ModalFrame
@@ -760,6 +829,11 @@ export function SandboxModal() {
760
829
  : "enter retry · esc close"
761
830
  }
762
831
  >
832
+ {step() === "docker" && st() === "checking" ? (
833
+ <box flexDirection="column">
834
+ <text fg={ui.fgMuted}>{SPIN_FRAMES[spin()] + " Checking Docker…"}</text>
835
+ </box>
836
+ ) : null}
763
837
  {step() === "docker" && st() === "not_installed" ? (
764
838
  <box flexDirection="column">
765
839
  <text fg={ui.warning}>Docker is not installed</text>
@@ -780,7 +854,7 @@ export function SandboxModal() {
780
854
  ) : null}
781
855
  {step() === "docker" && st() === "ready" ? (
782
856
  <box flexDirection="column">
783
- <text fg={ui.success}>Docker installed (v{docker().version})</text>
857
+ <text fg={ui.success}>Docker installed (v{(docker() || { version: "?" }).version})</text>
784
858
  <text fg={ui.fgMuted} marginTop={1}>Opening OS selection…</text>
785
859
  </box>
786
860
  ) : null}
@@ -805,9 +879,39 @@ export function SandboxModal() {
805
879
  </box>
806
880
  ) : null}
807
881
  {step() === "setup" ? (
808
- <box flexDirection="column" maxWidth={64}>
809
- {logs().slice(-8).map((l) => <text fg={ui.fgMuted}>{"▸ " + l}</text>)}
810
- {busy() ? <text fg={ui.primary}>…</text> : null}
882
+ <box flexDirection="column" maxWidth={72}>
883
+ {/* Stage checklist with a live pull-progress bar (pct from core). */}
884
+ {prog() ? (() => {
885
+ const p = prog()!;
886
+ const os = sb.OS_CATALOG[osSel()];
887
+ // Same stage order the core reports (browser only when offered).
888
+ const keys = ["pull", "create", "start", "essentials"]
889
+ .concat(os.browser ? ["browser"] : [], ["done"])
890
+ .slice(0, p.total);
891
+ return (
892
+ <box flexDirection="column" marginBottom={1}>
893
+ {keys.map((key, i) => {
894
+ const done = i < p.step || p.stage === "done";
895
+ const active = i === p.step && p.stage !== "done";
896
+ const mark = done ? "✓" : active ? SPIN_FRAMES[spin()] : "○";
897
+ const color = done ? ui.success : active ? ui.primary : ui.fgDim;
898
+ let bar = "";
899
+ if (active && p.pct != null) {
900
+ const width = 20;
901
+ const filled = Math.round((p.pct / 100) * width);
902
+ bar = " [" + "█".repeat(filled) + "░".repeat(width - filled) + "] " + p.pct + "%";
903
+ } else if (active) {
904
+ bar = " …";
905
+ }
906
+ return (
907
+ <text fg={color}>{mark + " " + STAGE_LABELS[key] + bar}</text>
908
+ );
909
+ })}
910
+ </box>
911
+ );
912
+ })() : <text fg={ui.fgMuted}>Starting…</text>}
913
+ {logs().slice(-6).map((l) => <text fg={ui.fgMuted}>{"▸ " + l.slice(0, 68)}</text>)}
914
+ {busy() ? <text fg={ui.primary}>{SPIN_FRAMES[spin()] + " working — you can esc; setup continues in the background"}</text> : null}
811
915
  {err() ? (
812
916
  <box flexDirection="column" marginTop={1}>
813
917
  <text fg={ui.error}>{err()}</text>
@@ -114,71 +114,10 @@ export const MCP_PRESETS = [
114
114
  ];
115
115
 
116
116
  // Hosting / cloud-service connectors — platforms you "connect" your project to
117
- // rather than dev-tool servers. Surfaced under /connectors, not /mcp.
118
- export const CONNECTOR_PRESETS = [
119
- {
120
- id: "supabase",
121
- label: "Supabase",
122
- package: "@supabase/mcp-server-supabase",
123
- description: "Query schema, run SQL, manage Supabase projects",
124
- args: ["--access-token", "$SUPABASE_ACCESS_TOKEN"],
125
- env: { SUPABASE_ACCESS_TOKEN: "" },
126
- prompts: [
127
- { key: "SUPABASE_ACCESS_TOKEN", label: "Personal access token (supabase.com/dashboard/account/tokens)", mask: true },
128
- ],
129
- optionalArgsPrompt: { flag: "--project-ref", label: "Project ref (optional, e.g. abcdefghijklmnop) — Enter to skip" },
130
- },
131
- {
132
- id: "nextjs",
133
- label: "Next.js",
134
- package: "nextjs-mcp-server",
135
- description: "Next.js project introspection (routes, pages, config)",
136
- args: [],
137
- env: {},
138
- prompts: [],
139
- },
140
- {
141
- id: "railway",
142
- label: "Railway",
143
- package: "@railway/mcp-server",
144
- description: "Railway deploys, services, variables",
145
- args: [],
146
- env: { RAILWAY_API_TOKEN: "" },
147
- prompts: [
148
- { key: "RAILWAY_API_TOKEN", label: "API token (railway.app/account/tokens)", mask: true },
149
- ],
150
- },
151
- {
152
- id: "vercel",
153
- label: "Vercel",
154
- command: "npx",
155
- args: ["-y", "mcp-remote", "https://mcp.vercel.com"],
156
- description: "Vercel deployments and projects (remote MCP endpoint, OAuth)",
157
- env: {},
158
- prompts: [],
159
- },
160
- {
161
- id: "netlify",
162
- label: "Netlify",
163
- command: "npx",
164
- args: ["-y", "mcp-remote", "https://api.netlify.com/mcp"],
165
- description: "Netlify sites, deploys, and environment vars (remote MCP endpoint, OAuth)",
166
- env: {},
167
- prompts: [],
168
- },
169
- {
170
- id: "cloudflare",
171
- label: "Cloudflare",
172
- package: "@cloudflare/mcp-server-cloudflare",
173
- description: "Workers, DNS, and account management",
174
- args: [],
175
- env: { CLOUDFLARE_API_TOKEN: "", CLOUDFLARE_ACCOUNT_ID: "" },
176
- prompts: [
177
- { key: "CLOUDFLARE_API_TOKEN", label: "API token (dash.cloudflare.com/profile/api-tokens)", mask: true },
178
- { key: "CLOUDFLARE_ACCOUNT_ID", label: "Account ID (dash.cloudflare.com — right sidebar)", mask: false },
179
- ],
180
- },
181
- ];
117
+ // rather than dev-tool servers. Surfaced under /connectors, not /mcp. The
118
+ // catalog lives in the core layer (src/mcp/connector-presets.js) so the CLI
119
+ // (`loom add connector <preset> -e KEY=V`) and this picker share one source.
120
+ export const CONNECTOR_PRESETS: any[] = require("../mcp/connector-presets.js").CONNECTOR_PRESETS;
182
121
 
183
122
  export function presetSpawn(preset, resolvedEnv) {
184
123
  if (preset.command) {
package/src/tui/store.ts CHANGED
@@ -523,7 +523,7 @@ export const SLASH_LIST: SlashCmd[] = [
523
523
  { cmd: "graph", desc: "Memory graph" },
524
524
  { cmd: "skills", desc: "Manage skills", args: "install <dir|git> | remove <name>" },
525
525
  { cmd: "mcp", desc: "Manage MCP servers", args: "add <name> <cmd> | remove | toggle" },
526
- { cmd: "connectors", desc: "Manage connectors", args: "add <name> <cmd> | remove | toggle" },
526
+ { cmd: "connectors", desc: "Manage connectors", args: "add <preset|spec> | remove | toggle" },
527
527
  { cmd: "permissions", desc: "Permission rules", args: "reset | auto" },
528
528
  { cmd: "exit", desc: "Quit Loom Code" },
529
529
  // Newer commands sit at the END so early rows (help/connect/…) keep their
@@ -539,6 +539,11 @@ export const SLASH_LIST: SlashCmd[] = [
539
539
  { cmd: "style", desc: "Output style preset" },
540
540
  { cmd: "vim", desc: "Toggle vim editing" },
541
541
  { cmd: "remember", desc: "Save a fact to LOOM.md", args: "<fact>" },
542
+ { cmd: "compact", desc: "Summarize older messages to free context" },
543
+ { cmd: "undo", desc: "Undo the last exchange" },
544
+ { cmd: "redo", desc: "Redo the last undone exchange" },
545
+ { cmd: "undo-files", desc: "Rewind working tree to last undo point" },
546
+ { cmd: "export", desc: "Export chat as markdown" },
542
547
  ];
543
548
 
544
549
  // ─── TUI prefs ───