loom-agent 1.2.37 → 1.3.3

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.
@@ -9,12 +9,17 @@ function formatMessages(messages, options) {
9
9
  for (const m of messages) {
10
10
  if (m.role === 'system') continue;
11
11
  if (m.role === 'tool') {
12
- out.push({ role: 'tool', tool_call_id: m.toolCallId, content: String(m.content) });
12
+ // A tool row without its id would be rejected by the API ("missing
13
+ // field tool_call_id" — NVIDIA is strict about this). Skip orphans
14
+ // from hand-edited/resumed sessions instead of failing the turn.
15
+ const tcid = m.toolCallId || m.tool_call_id;
16
+ if (tcid) out.push({ role: 'tool', tool_call_id: tcid, content: String(m.content) });
13
17
  continue;
14
18
  }
15
19
  const msg = { role: m.role, content: m.content || '' };
16
- if (m.toolCalls && m.toolCalls.length) {
17
- msg.tool_calls = m.toolCalls.map(tc => ({
20
+ const calls = m.toolCalls || m.tool_calls;
21
+ if (calls && calls.length) {
22
+ msg.tool_calls = calls.map(tc => ({
18
23
  id: tc.id,
19
24
  type: 'function',
20
25
  function: { name: tc.name, arguments: typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input || {}) },
@@ -80,6 +85,7 @@ function normalize(resp) {
80
85
  content: message.content || '',
81
86
  toolCalls,
82
87
  usage: resp.usage,
88
+ finishReason: choice?.finish_reason || undefined,
83
89
  };
84
90
  }
85
91
 
@@ -208,10 +214,12 @@ function createOpenAICompatProvider({ getKey, providerId, envKeyHint, clientFact
208
214
  let content = '';
209
215
  let reasoning = '';
210
216
  let usage = null;
217
+ let finishReason = null;
211
218
  const toolAcc = new Map();
212
219
 
213
220
  for await (const chunk of s) {
214
221
  if (chunk.usage) usage = chunk.usage;
222
+ if (chunk.choices?.[0]?.finish_reason) finishReason = chunk.choices[0].finish_reason;
215
223
  const delta = chunk.choices?.[0]?.delta || {};
216
224
  // DeepSeek-style reasoning_content / OpenRouter reasoning deltas.
217
225
  const rt = delta.reasoning_content || delta.reasoning;
@@ -243,7 +251,7 @@ function createOpenAICompatProvider({ getKey, providerId, envKeyHint, clientFact
243
251
  toolCalls.push({ id: acc.id, name: acc.name, input });
244
252
  }
245
253
 
246
- return { content, reasoning, toolCalls, usage };
254
+ return { content, reasoning, toolCalls, usage, finishReason: finishReason || undefined };
247
255
  } catch (err) {
248
256
  if (isAbortError(err)) throw err;
249
257
  throw wrapErr(err, options.model || '?', envKeyHint, getBaseUrl(providerId));
@@ -186,7 +186,7 @@ const TOOLS = {
186
186
  },
187
187
  bash: {
188
188
  name: 'bash',
189
- description: 'Execute a shell command.',
189
+ description: 'Execute a shell command on the user\'s machine.',
190
190
  parameters: {
191
191
  command: { type: 'string', required: true, description: 'The command to execute' },
192
192
  workdir: { type: 'string', required: false, description: 'Working directory' },
@@ -265,6 +265,32 @@ const TOOLS = {
265
265
  });
266
266
  }
267
267
  },
268
+ sandbox: {
269
+ name: 'sandbox',
270
+ description: 'Run a shell command INSIDE the Docker sandbox (isolated Linux container, project mounted at /workspace). Use ONLY when isolation is needed: the user asked for the sandbox, the command is risky (installs, deletions, untrusted scripts), or a Linux-only environment is required. For ordinary commands use bash (host). One command per call; the container idles in the background between calls.',
271
+ parameters: {
272
+ command: { type: 'string', required: true, description: 'The Linux shell command to run inside the container' },
273
+ workdir: { type: 'string', required: false, description: 'Working directory inside the container (host paths under the project map to /workspace/...)' },
274
+ timeoutMs: { type: 'number', required: false, description: 'Kill the command after this many ms (default 120000)' },
275
+ },
276
+ async execute(params, ctx) {
277
+ // Opt-in isolation path: nothing is routed here automatically. The
278
+ // container is revived/re-mounted on demand, then torn back to idle.
279
+ const sandbox = require('../core/sandbox');
280
+ const risk = params._approved ? null : commandRiskLabel(params.command);
281
+ if (risk) return { error: `Blocked by safety filter: ${risk}. Approve it via the permission prompt to run it anyway.` };
282
+ const stream = ctx && typeof ctx.stream === 'function' ? ctx.stream : null;
283
+ const res = await sandbox.runInSandbox(params.command, {
284
+ workdir: params.workdir || cwd,
285
+ timeoutMs: typeof params.timeoutMs === 'number' && params.timeoutMs > 0 ? params.timeoutMs : undefined,
286
+ onChunk: stream ? (text, which) => stream(text, which) : undefined,
287
+ });
288
+ if ('error' in res) return { error: res.error };
289
+ let out = res.output;
290
+ if (res.code !== 0) out += `\n[exit code: ${res.code}]`;
291
+ return out || 'command completed';
292
+ }
293
+ },
268
294
  grep: {
269
295
  name: 'grep',
270
296
  description: 'Search file contents using regular expressions.',
package/src/tui/App.tsx CHANGED
@@ -52,7 +52,7 @@ import { prettyToolName, stripAnsi } from "./toolname.ts";
52
52
  import { toolDisplay } from "./tool-display.ts";
53
53
  import {
54
54
  ProviderPicker, SelectModal, InputModal, SettingsModal,
55
- PaletteModal, McpModal, ConnectorsModal, GraphModal,
55
+ PaletteModal, McpModal, ConnectorsModal, GraphModal, SandboxModal,
56
56
  openModelPicker, openKeyModal, openBaseUrlEditor,
57
57
  showProvidersText, showHelpText, showAgentsText,
58
58
  openGraphModal,
@@ -65,6 +65,7 @@ import { snapshotBefore, snapshotAfter, snapshotBashBefore, diffBashAfter, clear
65
65
  import { createRestorePoint, listRestorePoints, restoreTo } from "../core/restore.js";
66
66
  import { PROVIDERS, PROVIDER_ORDER, PROVIDER_LABELS } from "../providers/index.js";
67
67
  import * as plugin from "../core/plugin-cmd.js";
68
+ import * as sandbox from "../core/sandbox.js";
68
69
  import { on } from "../core/events.js";
69
70
  import { listSkills } from "../skills/skills-manager.js";
70
71
 
@@ -117,14 +118,15 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
117
118
  if (text.startsWith("/")) {
118
119
  var q = text.slice(1).toLowerCase();
119
120
  var hits = SLASH_LIST.filter(function(c) { return c.cmd.startsWith(q); }).map(function(c) {
120
- var s: Suggestion = { label: "/" + c.cmd, desc: c.desc + (c.args ? " \u2014 " + c.args : "") };
121
+ // Popup stays terse: short desc only — syntax lives in /help (args).
122
+ var s: Suggestion = { label: "/" + c.cmd, desc: c.desc };
121
123
  return s;
122
124
  });
123
125
  // Custom commands (.loom/commands/*.md) join the picker after built-ins.
124
126
  try {
125
127
  const { listCustomCommands } = require("../core/custom-commands.js");
126
128
  for (const cc of listCustomCommands()) {
127
- if (cc.name.toLowerCase().startsWith(q)) hits.push({ label: "/" + cc.name, desc: "custom command \u2014 " + cc.file });
129
+ if (cc.name.toLowerCase().startsWith(q)) hits.push({ label: "/" + cc.name, desc: "custom command" });
128
130
  }
129
131
  } catch {}
130
132
  setSuggestions(hits); setAutoKind("slash"); setAutoIndex(0);
@@ -163,6 +165,25 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
163
165
  if (raw.startsWith("/")) { recordPrompt(raw); setDraft(""); processSlash(raw); return; }
164
166
  if (raw.startsWith("!")) { recordPrompt(raw); setDraft(""); appendMessage({ role: "user", content: raw }); appendMessage({ role: "system", content: runShell(raw.slice(1)) }); return; }
165
167
 
168
+ // Docker-install resume: while an install is pending, "docker is ready"
169
+ // continues the sandbox setup in THIS session instead of going to the
170
+ // model. Outside the pending flow the phrase is ordinary conversation.
171
+ if (sandbox.isReadySignal(raw)) {
172
+ var st0 = sandbox.sandboxStatus();
173
+ if (st0.pending || st0.docker === "ready" && st0.container === "absent" && /set ?up|build|create|make/i.test(raw)) {
174
+ recordPrompt(raw); setDraft("");
175
+ appendMessage({ role: "user", content: raw });
176
+ if (st0.docker === "ready" || st0.docker === "no_daemon") {
177
+ appendMessage({ role: "system", content: "Docker detected \u2014 pick an OS to finish the sandbox setup." });
178
+ openModal({ type: "sandbox" });
179
+ } else {
180
+ sandbox.markInstallPending();
181
+ appendMessage({ role: "system", content: sandbox.installGuide() });
182
+ }
183
+ return;
184
+ }
185
+ }
186
+
166
187
  // "@agent …" delegates the whole turn to that subagent (same as the model
167
188
  // calling the task tool — but explicit). "@file …" still inlines the file.
168
189
  var agentId: string | null = null;
@@ -588,7 +609,15 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
588
609
  getSession().conversationId = id;
589
610
  var pendingParts: Record<string, any> = {};
590
611
  getSession().messages = data.messages.map(function(m) {
591
- var clean: any = { role: m.role, content: m.content, toolCalls: m.toolCalls };
612
+ // toolCallId MUST survive the resume: the provider wire format sends it
613
+ // as tool_call_id / tool_use_id — dropping it makes the next turn fail
614
+ // with "400: missing field tool_call_id" (NVIDIA) after a resume.
615
+ var clean: any = {
616
+ role: m.role,
617
+ content: m.content,
618
+ toolCalls: m.toolCalls || (m as any).tool_calls,
619
+ toolCallId: m.toolCallId || (m as any).tool_call_id,
620
+ };
592
621
  if (m.reasoning) clean.reasoning = m.reasoning;
593
622
  return clean;
594
623
  });
@@ -1058,6 +1087,46 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
1058
1087
  openModal({ type: "connectors" });
1059
1088
  return;
1060
1089
  }
1090
+ case "sandbox": {
1091
+ var ssub = args[0];
1092
+ var st = sandbox.sandboxStatus();
1093
+ // The setup wizard is a modal: docker check (with download link) →
1094
+ // OS pick → live build. Bare /sandbox and /sandbox setup open it.
1095
+ if (!ssub || ssub === "setup" || ssub === "os") { openModal({ type: "sandbox" }); return; }
1096
+ if (ssub === "guide") { appendMessage({ role: "system", content: sandbox.installGuide() }); return; }
1097
+ if (ssub === "off") {
1098
+ sandbox.setSandboxEnabled(false);
1099
+ appendMessage({ role: "system", content: "Sandbox off — the sandbox tool is disabled; the agent works on your machine as usual. The container is kept (run /sandbox on to re-enable, /sandbox rm to delete it)." });
1100
+ return;
1101
+ }
1102
+ if (ssub === "on") {
1103
+ if (st.container !== "running") { appendMessage({ role: "system", content: "Sandbox container is not running. Run /sandbox setup first." }); return; }
1104
+ sandbox.setSandboxEnabled(true);
1105
+ appendMessage({ role: "system", content: "Sandbox on — the agent can use the sandbox tool for isolated commands inside " + sandbox.CONTAINER + " when needed. Everything else stays on your machine." });
1106
+ return;
1107
+ }
1108
+ if (ssub === "rm") {
1109
+ try {
1110
+ require("child_process").execFileSync("docker", ["rm", "-f", sandbox.CONTAINER], { windowsHide: true, timeout: 30000, stdio: "ignore" });
1111
+ sandbox.setSandboxEnabled(false);
1112
+ appendMessage({ role: "system", content: "Sandbox container removed. Run /sandbox setup to rebuild it." });
1113
+ } catch { appendMessage({ role: "system", content: "No sandbox container to remove." }); }
1114
+ return;
1115
+ }
1116
+ if (ssub === "status") {
1117
+ var line = "Sandbox: ";
1118
+ if (st.docker === "not_installed") line += "Docker not installed";
1119
+ else if (st.docker === "no_daemon") line += "Docker installed, daemon not running";
1120
+ else line += "Docker " + st.dockerVersion + " \u00b7 container " + st.container + (st.enabled ? " \u00b7 sandbox tool on" : " \u00b7 sandbox tool off");
1121
+ if (st.pending) line += "\nWaiting for you to install Docker \u2014 when it's done say \"docker is ready\" (or run /sandbox).";
1122
+ else if (st.docker === "not_installed") line += "\nRun /sandbox for the setup wizard.";
1123
+ else if (st.container === "absent") line += "\nRun /sandbox to build it.";
1124
+ appendMessage({ role: "system", content: line });
1125
+ return;
1126
+ }
1127
+ appendMessage({ role: "system", content: "Usage: /sandbox (wizard) | status | on | off | rm | guide" });
1128
+ return;
1129
+ }
1061
1130
  case "sessions": {
1062
1131
  const saved = listSessions();
1063
1132
  if (!saved.length) {
@@ -1527,6 +1596,16 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
1527
1596
  if (!autoPerm() && !permission() && !modal()) askSessionPermissions();
1528
1597
  }, 500);
1529
1598
  }
1599
+ // Sandbox install pending from a previous session: remind on start so
1600
+ // the user knows to come back with "docker is ready". Skipped in the
1601
+ // test harness (same flag as the session prompt).
1602
+ try {
1603
+ if (!process.env.LOOM_NO_SESSION_PROMPT && sandbox.sandboxStatus().pending) {
1604
+ setTimeout(function() {
1605
+ appendMessage({ role: "system", content: "Docker sandbox setup is still pending. Install Docker (see /sandbox guide), then say \"docker is ready\" and I'll build it automatically." });
1606
+ }, 800);
1607
+ }
1608
+ } catch {}
1530
1609
  _skillToastOff = on("trigger:skill", function(d: any) {
1531
1610
  const names = (d?.skills || []).join(", ");
1532
1611
  showToast("skill: " + names, "ok", 5000);
@@ -1596,6 +1675,7 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
1596
1675
  {m.type === "palette" ? <PaletteModal onPick={m.onPick} /> : null}
1597
1676
  {m.type === "mcp" ? <McpModal /> : null}
1598
1677
  {m.type === "connectors" ? <ConnectorsModal /> : null}
1678
+ {m.type === "sandbox" ? <SandboxModal /> : null}
1599
1679
  {m.type === "graph" ? <GraphModal graph={m.graph} err={m.graphError} /> : null}
1600
1680
  {m.type === "subagents" ? <SubagentPanel /> : null}
1601
1681
  {m.type === "subagent_detail" ? <SubagentDetailPanel /> : null}
@@ -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";
@@ -661,6 +661,225 @@ export function ConnectorsModal() {
661
661
  return <ServerBrowser kind="connector" />;
662
662
  }
663
663
 
664
+ // ── Sandbox setup wizard ──
665
+ // Three steps: docker check (with install link when missing) → OS pick →
666
+ // live setup with a real progress bar (setup streams from the core layer —
667
+ // the TUI never freezes). The container is global and idles in the
668
+ // background; the agent's sandbox tool uses it on demand.
669
+ export function SandboxModal() {
670
+ const sb = require("../../core/sandbox.js");
671
+ const [step, setStep] = createSignal<"docker" | "os" | "setup">("docker");
672
+ const [docker, setDocker] = createSignal(sb.detectDocker());
673
+ const [osSel, setOsSel] = createSignal(0);
674
+ const [logs, setLogs] = createSignal<string[]>([]);
675
+ const [busy, setBusy] = createSignal(false);
676
+ const [err, setErr] = createSignal<string | null>(null);
677
+ const [prog, setProg] = createSignal<{ stage: string; pct: number | null; step: number; total: number } | null>(null);
678
+ const [spin, setSpin] = createSignal(0);
679
+ let spinTimer: ReturnType<typeof setInterval> | null = null;
680
+ onCleanup(() => { if (spinTimer) clearInterval(spinTimer); });
681
+
682
+ // Spinner frames while setup is busy (drives the "working…" indicator).
683
+ function startSpinner() {
684
+ if (spinTimer) clearInterval(spinTimer);
685
+ spinTimer = setInterval(() => setSpin((s) => (s + 1) % 10), 120);
686
+ }
687
+ function stopSpinner() {
688
+ if (spinTimer) clearInterval(spinTimer);
689
+ spinTimer = null;
690
+ }
691
+ const SPIN_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
692
+
693
+ const STAGE_LABELS: Record<string, string> = {
694
+ pull: "Pulling image",
695
+ create: "Creating container",
696
+ start: "Starting container",
697
+ essentials: "Installing essentials",
698
+ browser: "Installing browser",
699
+ done: "Ready",
700
+ };
701
+
702
+ // Docker already installed → advance straight to OS selection (with a
703
+ // brief "installed" flash so the user sees the check happened).
704
+ onMount(() => {
705
+ if (docker().state === "ready") {
706
+ setTimeout(() => { if (step() === "docker") setStep("os"); }, 600);
707
+ }
708
+ });
709
+
710
+ function recheck() {
711
+ const d = sb.detectDocker();
712
+ setDocker(d);
713
+ if (d.state === "ready") setStep("os");
714
+ }
715
+
716
+ function startSetup() {
717
+ const os = sb.OS_CATALOG[osSel()];
718
+ setStep("setup");
719
+ setBusy(true);
720
+ setErr(null);
721
+ setProg(null);
722
+ startSpinner();
723
+ setLogs(["Preparing " + os.label + " (" + os.image + ")…"]);
724
+ sb.setupSandbox({
725
+ osId: os.id,
726
+ onLog: (/** @type {string} */ l) => setLogs((xs) => [...xs, l]),
727
+ onProgress: (/** @type {{ stage: string; pct: number | null; step: number; total: number }} */ p) => setProg(p),
728
+ }).then((/** @type {{ ok: boolean, error?: string }} */ res) => {
729
+ setBusy(false);
730
+ stopSpinner();
731
+ if (res.ok) {
732
+ 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." });
733
+ showToast("Sandbox ready — " + os.label, "ok");
734
+ closeModal();
735
+ } else {
736
+ setErr(res.error || "setup failed");
737
+ }
738
+ });
739
+ }
740
+
741
+ useKeyboard((key) => {
742
+ const ks = kbs.keyString(key);
743
+ if (kbs.is("modal_cancel", ks)) { closeModal(); return; }
744
+ const s = step();
745
+ if (s === "setup") {
746
+ // Retry after a failed setup; while busy esc is the only exit.
747
+ if (!busy() && err() && kbs.dialogIs("dialog_select_submit", ks)) { setErr(null); startSetup(); }
748
+ return;
749
+ }
750
+ if (kbs.dialogIs("dialog_select_prev", ks)) { if (s === "os") setOsSel((i) => Math.max(0, i - 1)); return; }
751
+ if (kbs.dialogIs("dialog_select_next", ks)) { if (s === "os") setOsSel((i) => Math.min(sb.OS_CATALOG.length - 1, i + 1)); return; }
752
+ if (kbs.dialogIs("dialog_select_submit", ks)) {
753
+ if (s === "docker") {
754
+ if (docker().state === "not_installed") {
755
+ sb.markInstallPending();
756
+ sb.openInstallPage();
757
+ 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." });
758
+ closeModal();
759
+ } else if (docker().state === "no_daemon") {
760
+ recheck();
761
+ } else {
762
+ setStep("os");
763
+ }
764
+ return;
765
+ }
766
+ if (s === "os" && !busy()) startSetup();
767
+ return;
768
+ }
769
+ });
770
+
771
+ const link = sb.installLink();
772
+ const st = () => docker().state;
773
+
774
+ return (
775
+ <ModalFrame
776
+ title="Sandbox Setup"
777
+ subtitle={
778
+ step() === "docker" ? "Step 1/3 — Docker check"
779
+ : step() === "os" ? "Step 2/3 — pick a lightweight OS"
780
+ : "Step 3/3 — building the sandbox"
781
+ }
782
+ footer={
783
+ step() === "docker"
784
+ ? (st() === "not_installed" ? "enter open download page · esc close"
785
+ : st() === "no_daemon" ? "enter re-check · esc close"
786
+ : "checking…")
787
+ : step() === "os" ? "up/down pick · enter start setup · esc close"
788
+ : busy() ? "working… esc to keep running in background"
789
+ : "enter retry · esc close"
790
+ }
791
+ >
792
+ {step() === "docker" && st() === "not_installed" ? (
793
+ <box flexDirection="column">
794
+ <text fg={ui.warning}>Docker is not installed</text>
795
+ <text fg={ui.fgMuted} marginTop={1}>The sandbox needs Docker once. After that it is permanent.</text>
796
+ <box flexDirection="column" marginTop={1}>
797
+ <text fg={ui.fg}>1. Install Docker Desktop (restart if asked)</text>
798
+ <text fg={ui.primary}> {link}</text>
799
+ <text fg={ui.fg}>2. Start it and wait for "running"</text>
800
+ <text fg={ui.fg}>3. Come back here and press enter</text>
801
+ </box>
802
+ </box>
803
+ ) : null}
804
+ {step() === "docker" && st() === "no_daemon" ? (
805
+ <box flexDirection="column">
806
+ <text fg={ui.warning}>Docker is installed but not running</text>
807
+ <text fg={ui.fgMuted} marginTop={1}>Start Docker Desktop (or: sudo systemctl start docker), wait for it, then press enter to re-check.</text>
808
+ </box>
809
+ ) : null}
810
+ {step() === "docker" && st() === "ready" ? (
811
+ <box flexDirection="column">
812
+ <text fg={ui.success}>Docker installed (v{docker().version})</text>
813
+ <text fg={ui.fgMuted} marginTop={1}>Opening OS selection…</text>
814
+ </box>
815
+ ) : null}
816
+ {step() === "os" ? (
817
+ <box flexDirection="column">
818
+ {sb.OS_CATALOG.map((/** @type {{ id: string, label: string, sizeNote: string, browser: boolean }} */ os, /** @type {number} */ i) => {
819
+ const active = i === osSel();
820
+ return (
821
+ <box
822
+ key={os.id} flexDirection="row" paddingY={0}
823
+ backgroundColor={active ? ui.bgPanelAlt : undefined}
824
+ onMouseDown={() => setOsSel(i)}
825
+ onMouseUp={() => { if (osSel() === i) startSetup(); }}
826
+ >
827
+ <text fg={active ? ui.primary : ui.fgDim}>{active ? "▸ " : " "}{os.label}</text>
828
+ <text fg={active ? ui.fg : ui.fgMuted}>{" " + os.sizeNote}</text>
829
+ {os.browser ? <text fg={ui.success}> +browser</text> : null}
830
+ </box>
831
+ );
832
+ })}
833
+ <text fg={ui.fgMuted} marginTop={1}>All options include git, curl, ripgrep and a shell.</text>
834
+ </box>
835
+ ) : null}
836
+ {step() === "setup" ? (
837
+ <box flexDirection="column" maxWidth={72}>
838
+ {/* Stage checklist with a live pull-progress bar (pct from core). */}
839
+ {prog() ? (() => {
840
+ const p = prog()!;
841
+ const os = sb.OS_CATALOG[osSel()];
842
+ // Same stage order the core reports (browser only when offered).
843
+ const keys = ["pull", "create", "start", "essentials"]
844
+ .concat(os.browser ? ["browser"] : [], ["done"])
845
+ .slice(0, p.total);
846
+ return (
847
+ <box flexDirection="column" marginBottom={1}>
848
+ {keys.map((key, i) => {
849
+ const done = i < p.step || p.stage === "done";
850
+ const active = i === p.step && p.stage !== "done";
851
+ const mark = done ? "✓" : active ? SPIN_FRAMES[spin()] : "○";
852
+ const color = done ? ui.success : active ? ui.primary : ui.fgDim;
853
+ let bar = "";
854
+ if (active && p.pct != null) {
855
+ const width = 20;
856
+ const filled = Math.round((p.pct / 100) * width);
857
+ bar = " [" + "█".repeat(filled) + "░".repeat(width - filled) + "] " + p.pct + "%";
858
+ } else if (active) {
859
+ bar = " …";
860
+ }
861
+ return (
862
+ <text fg={color}>{mark + " " + STAGE_LABELS[key] + bar}</text>
863
+ );
864
+ })}
865
+ </box>
866
+ );
867
+ })() : <text fg={ui.fgMuted}>Starting…</text>}
868
+ {logs().slice(-6).map((l) => <text fg={ui.fgMuted}>{"▸ " + l.slice(0, 68)}</text>)}
869
+ {busy() ? <text fg={ui.primary}>{SPIN_FRAMES[spin()] + " working — you can esc; setup continues in the background"}</text> : null}
870
+ {err() ? (
871
+ <box flexDirection="column" marginTop={1}>
872
+ <text fg={ui.error}>{err()}</text>
873
+ <text fg={ui.fgMuted}>press enter to retry, esc to close</text>
874
+ </box>
875
+ ) : null}
876
+ {!busy() && !err() ? <text fg={ui.success}>done</text> : null}
877
+ </box>
878
+ ) : null}
879
+ </ModalFrame>
880
+ );
881
+ }
882
+
664
883
  // Palette modal (ctrl+p) - proper popup window
665
884
  export function PaletteModal(props: { onPick: (cmd: string) => void }) {
666
885
  const items = SLASH_LIST.map(c => ({ label: "/" + c.cmd, value: c.cmd, sub: c.desc }));
package/src/tui/store.ts CHANGED
@@ -494,50 +494,51 @@ export function wireTodoEvents() {
494
494
  }
495
495
 
496
496
  // ─── Slash commands (for autocomplete) ───
497
+ // Terse descs only: this list feeds the popup + command palette, so every
498
+ // extra word adds clutter. Syntax lives in `args` and shows up in /help,
499
+ // NOT in the popup. Aliases keep their handlers but stay out of the list:
500
+ // /model → /models · /thinking + /details → /settings · /rewind → /restore
497
501
  export interface SlashCmd { cmd: string; desc: string; args?: string; }
498
502
 
499
503
  export const SLASH_LIST: SlashCmd[] = [
500
- { cmd: "help", desc: "Show help dialog" },
501
- { cmd: "agents", desc: "List agents — primaries + subagents (@mention or task tool)" },
502
- { cmd: "build", desc: "Build mode — full agent tools (Tab cycles)" },
503
- { cmd: "plan", desc: "Plan mode — read-only analysis, no file changes" },
504
- { cmd: "chat", desc: "Chat mode — conversation only, no tools" },
505
- { cmd: "connect", desc: "Add/connect a provider", args: "[provider]" },
504
+ { cmd: "help", desc: "Show help" },
505
+ { cmd: "agents", desc: "List agents" },
506
+ { cmd: "build", desc: "Build mode — full tools" },
507
+ { cmd: "plan", desc: "Plan mode — read only" },
508
+ { cmd: "chat", desc: "Chat mode — no tools" },
509
+ { cmd: "connect", desc: "Connect a provider" },
506
510
  { cmd: "key", desc: "Edit API key" },
507
- { cmd: "baseurl", desc: "Set provider base URL", args: "[provider] [url]" },
508
- { cmd: "model", desc: "Pick the active model", args: "[model-id]" },
509
- { cmd: "models", desc: "Open the model picker (grouped by provider)" },
510
- { cmd: "providers", desc: "List supported providers" },
511
- { cmd: "status", desc: "Show connection status" },
512
- { cmd: "usage", desc: "Show token usage and billing" },
513
- { cmd: "budget", desc: "Budget level: free | cheap | best | auto", args: "[level]" },
514
- { cmd: "new", desc: "Start a new session" },
511
+ { cmd: "baseurl", desc: "Set provider base URL" },
512
+ { cmd: "models", desc: "Model picker" },
513
+ { cmd: "providers", desc: "List providers" },
514
+ { cmd: "status", desc: "Connection status" },
515
+ { cmd: "usage", desc: "Token usage & billing" },
516
+ { cmd: "budget", desc: "Budget level", args: "free | cheap | best | auto" },
517
+ { cmd: "new", desc: "New session" },
515
518
  { cmd: "clear", desc: "Clear the chat" },
516
- { cmd: "restore", desc: "Restore project to an earlier state" },
517
- { cmd: "settings", desc: "Toggle details/thinking display" },
518
- { cmd: "sessions", desc: "Browse saved sessions (jump to one)" },
519
- { cmd: "thinking", desc: "Toggle thinking visibility" },
520
- { cmd: "details", desc: "Toggle tool detail visibility" },
519
+ { cmd: "restore", desc: "Restore earlier state" },
520
+ { cmd: "settings", desc: "Settings — details, thinking, sidebar" },
521
+ { cmd: "sessions", desc: "Browse saved sessions" },
521
522
  { cmd: "theme", desc: "Switch UI theme" },
522
- { cmd: "graph", desc: "View the memory graph (nodes + links)" },
523
+ { cmd: "graph", desc: "Memory graph" },
523
524
  { cmd: "skills", desc: "Manage skills", args: "install <dir|git> | remove <name>" },
524
525
  { cmd: "mcp", desc: "Manage MCP servers", args: "add <name> <cmd> | remove | toggle" },
525
- { cmd: "connectors", desc: "Manage connectors (hosting & cloud services)", args: "add <name> <cmd> | remove | toggle" },
526
- { cmd: "permissions", desc: "Show saved permission rules", args: "| reset | auto" },
526
+ { cmd: "connectors", desc: "Manage connectors", args: "add <name> <cmd> | remove | toggle" },
527
+ { cmd: "permissions", desc: "Permission rules", args: "reset | auto" },
527
528
  { cmd: "exit", desc: "Quit Loom Code" },
528
529
  // Newer commands sit at the END so early rows (help/connect/…) keep their
529
530
  // popup positions — the interactive suite and muscle memory rely on it.
530
- { cmd: "subagents", desc: "View subagent runs (live + history, cancel, details)" },
531
- { cmd: "context", desc: "Show ~token breakdown of the current context window" },
532
- { cmd: "think", desc: "Thinking budget", args: "off|low|medium|high" },
533
- { cmd: "approve", desc: "Approve the plan & switch to Build (Plan mode)" },
534
- { cmd: "tasks", desc: "List background tasks (bash background:true)" },
535
- { cmd: "rewind", desc: "Pick a restore point and rewind files" },
536
- { cmd: "share", desc: "Export this chat as a self-contained HTML file" },
537
- { cmd: "worktree", desc: "New git worktree + branch for parallel work", args: "<name>" },
538
- { cmd: "style", desc: "Output style preset or free text; empty clears" },
539
- { cmd: "vim", desc: "Toggle vim modal editing (Esc = NORMAL)" },
540
- { cmd: "remember", desc: "Save a fact to project LOOM.md (or type # fact)", args: "<fact>" },
531
+ { cmd: "subagents", desc: "Subagent runs" },
532
+ { cmd: "context", desc: "Context window usage" },
533
+ { cmd: "think", desc: "Thinking budget", args: "off | low | medium | high" },
534
+ { cmd: "approve", desc: "Approve plan → Build" },
535
+ { cmd: "tasks", desc: "Background tasks" },
536
+ { cmd: "share", desc: "Export chat as HTML" },
537
+ { cmd: "worktree", desc: "New git worktree", args: "<name>" },
538
+ { cmd: "sandbox", desc: "Docker sandbox", args: "setup | status | on | off | rm" },
539
+ { cmd: "style", desc: "Output style preset" },
540
+ { cmd: "vim", desc: "Toggle vim editing" },
541
+ { cmd: "remember", desc: "Save a fact to LOOM.md", args: "<fact>" },
541
542
  ];
542
543
 
543
544
  // ─── TUI prefs ───