loom-agent 1.2.37 → 1.3.2

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));
@@ -193,6 +193,24 @@ const TOOLS = {
193
193
  background: { type: 'boolean', required: false, description: 'Run without waiting (dev servers, long builds). Returns a task id immediately; check with the /tasks panel.' },
194
194
  },
195
195
  async execute(params, ctx) {
196
+ // Docker sandbox: when enabled (/sandbox setup), every command runs
197
+ // inside the loom-sandbox container instead of the host shell.
198
+ // Background tasks stay on the host (they outlive tool timeouts).
199
+ const sandbox = require('../core/sandbox');
200
+ if (!params.background && sandbox.shouldUseSandbox()) {
201
+ if (!pathAllowed(params.workdir || cwd)) return { error: sandboxDenied('bash', params.workdir || cwd) };
202
+ const risk = params._approved ? null : commandRiskLabel(params.command);
203
+ if (risk) return { error: `Blocked by safety filter: ${risk}. Approve it via the permission prompt to run it anyway.` };
204
+ const stream = ctx && typeof ctx.stream === 'function' ? ctx.stream : null;
205
+ const res = await sandbox.runInSandbox(params.command, {
206
+ workdir: params.workdir || cwd,
207
+ onChunk: stream ? (text, which) => stream(text, which) : undefined,
208
+ });
209
+ if ('error' in res) return { error: res.error };
210
+ let out = res.output;
211
+ if (res.code !== 0) out += `\n[exit code: ${res.code}]`;
212
+ return out || 'command completed';
213
+ }
196
214
  // Background mode: fire-and-forget via the task registry.
197
215
  if (params.background) {
198
216
  const bt = require('../core/background-tasks');
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 — agent commands run on the host again. 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 — agent commands now run inside " + sandbox.CONTAINER + "." });
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 routing ON" : " \u00b7 routing 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}
@@ -661,6 +661,166 @@ 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. The container is global — once built, every session and every
667
+ // agent routes commands through it.
668
+ export function SandboxModal() {
669
+ const sb = require("../../core/sandbox.js");
670
+ const [step, setStep] = createSignal<"docker" | "os" | "setup">("docker");
671
+ const [docker, setDocker] = createSignal(sb.detectDocker());
672
+ const [osSel, setOsSel] = createSignal(0);
673
+ const [logs, setLogs] = createSignal<string[]>([]);
674
+ const [busy, setBusy] = createSignal(false);
675
+ const [err, setErr] = createSignal<string | null>(null);
676
+
677
+ // Docker already installed → advance straight to OS selection (with a
678
+ // brief "installed" flash so the user sees the check happened).
679
+ onMount(() => {
680
+ if (docker().state === "ready") {
681
+ setTimeout(() => { if (step() === "docker") setStep("os"); }, 600);
682
+ }
683
+ });
684
+
685
+ function recheck() {
686
+ const d = sb.detectDocker();
687
+ setDocker(d);
688
+ if (d.state === "ready") setStep("os");
689
+ }
690
+
691
+ function startSetup() {
692
+ const os = sb.OS_CATALOG[osSel()];
693
+ setStep("setup");
694
+ setBusy(true);
695
+ setErr(null);
696
+ setLogs(["Preparing " + os.label + " (" + os.image + ")…"]);
697
+ sb.setupSandbox({
698
+ osId: os.id,
699
+ onLog: (/** @type {string} */ l) => setLogs((xs) => [...xs, l]),
700
+ }).then((/** @type {{ ok: boolean, error?: string }} */ res) => {
701
+ setBusy(false);
702
+ 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." });
704
+ showToast("Sandbox ready — " + os.label, "ok");
705
+ closeModal();
706
+ } else {
707
+ setErr(res.error || "setup failed");
708
+ }
709
+ });
710
+ }
711
+
712
+ useKeyboard((key) => {
713
+ const ks = kbs.keyString(key);
714
+ if (kbs.is("modal_cancel", ks)) { closeModal(); return; }
715
+ const s = step();
716
+ if (s === "setup") {
717
+ // Retry after a failed setup; while busy esc is the only exit.
718
+ if (!busy() && err() && kbs.dialogIs("dialog_select_submit", ks)) { setErr(null); startSetup(); }
719
+ return;
720
+ }
721
+ if (kbs.dialogIs("dialog_select_prev", ks)) { if (s === "os") setOsSel((i) => Math.max(0, i - 1)); return; }
722
+ if (kbs.dialogIs("dialog_select_next", ks)) { if (s === "os") setOsSel((i) => Math.min(sb.OS_CATALOG.length - 1, i + 1)); return; }
723
+ if (kbs.dialogIs("dialog_select_submit", ks)) {
724
+ if (s === "docker") {
725
+ if (docker().state === "not_installed") {
726
+ sb.markInstallPending();
727
+ sb.openInstallPage();
728
+ 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
+ closeModal();
730
+ } else if (docker().state === "no_daemon") {
731
+ recheck();
732
+ } else {
733
+ setStep("os");
734
+ }
735
+ return;
736
+ }
737
+ if (s === "os" && !busy()) startSetup();
738
+ return;
739
+ }
740
+ });
741
+
742
+ const link = sb.installLink();
743
+ const st = () => docker().state;
744
+
745
+ return (
746
+ <ModalFrame
747
+ title="Sandbox Setup"
748
+ subtitle={
749
+ step() === "docker" ? "Step 1/3 — Docker check"
750
+ : step() === "os" ? "Step 2/3 — pick a lightweight OS"
751
+ : "Step 3/3 — building the sandbox"
752
+ }
753
+ footer={
754
+ step() === "docker"
755
+ ? (st() === "not_installed" ? "enter open download page · esc close"
756
+ : st() === "no_daemon" ? "enter re-check · esc close"
757
+ : "checking…")
758
+ : step() === "os" ? "up/down pick · enter start setup · esc close"
759
+ : busy() ? "working… esc to keep running in background"
760
+ : "enter retry · esc close"
761
+ }
762
+ >
763
+ {step() === "docker" && st() === "not_installed" ? (
764
+ <box flexDirection="column">
765
+ <text fg={ui.warning}>Docker is not installed</text>
766
+ <text fg={ui.fgMuted} marginTop={1}>The sandbox needs Docker once. After that it is permanent.</text>
767
+ <box flexDirection="column" marginTop={1}>
768
+ <text fg={ui.fg}>1. Install Docker Desktop (restart if asked)</text>
769
+ <text fg={ui.primary}> {link}</text>
770
+ <text fg={ui.fg}>2. Start it and wait for "running"</text>
771
+ <text fg={ui.fg}>3. Come back here and press enter</text>
772
+ </box>
773
+ </box>
774
+ ) : null}
775
+ {step() === "docker" && st() === "no_daemon" ? (
776
+ <box flexDirection="column">
777
+ <text fg={ui.warning}>Docker is installed but not running</text>
778
+ <text fg={ui.fgMuted} marginTop={1}>Start Docker Desktop (or: sudo systemctl start docker), wait for it, then press enter to re-check.</text>
779
+ </box>
780
+ ) : null}
781
+ {step() === "docker" && st() === "ready" ? (
782
+ <box flexDirection="column">
783
+ <text fg={ui.success}>Docker installed (v{docker().version})</text>
784
+ <text fg={ui.fgMuted} marginTop={1}>Opening OS selection…</text>
785
+ </box>
786
+ ) : null}
787
+ {step() === "os" ? (
788
+ <box flexDirection="column">
789
+ {sb.OS_CATALOG.map((/** @type {{ id: string, label: string, sizeNote: string, browser: boolean }} */ os, /** @type {number} */ i) => {
790
+ const active = i === osSel();
791
+ return (
792
+ <box
793
+ key={os.id} flexDirection="row" paddingY={0}
794
+ backgroundColor={active ? ui.bgPanelAlt : undefined}
795
+ onMouseDown={() => setOsSel(i)}
796
+ onMouseUp={() => { if (osSel() === i) startSetup(); }}
797
+ >
798
+ <text fg={active ? ui.primary : ui.fgDim}>{active ? "▸ " : " "}{os.label}</text>
799
+ <text fg={active ? ui.fg : ui.fgMuted}>{" " + os.sizeNote}</text>
800
+ {os.browser ? <text fg={ui.success}> +browser</text> : null}
801
+ </box>
802
+ );
803
+ })}
804
+ <text fg={ui.fgMuted} marginTop={1}>All options include git, curl, ripgrep and a shell.</text>
805
+ </box>
806
+ ) : null}
807
+ {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}
811
+ {err() ? (
812
+ <box flexDirection="column" marginTop={1}>
813
+ <text fg={ui.error}>{err()}</text>
814
+ <text fg={ui.fgMuted}>press enter to retry, esc to close</text>
815
+ </box>
816
+ ) : null}
817
+ {!busy() && !err() ? <text fg={ui.success}>done</text> : null}
818
+ </box>
819
+ ) : null}
820
+ </ModalFrame>
821
+ );
822
+ }
823
+
664
824
  // Palette modal (ctrl+p) - proper popup window
665
825
  export function PaletteModal(props: { onPick: (cmd: string) => void }) {
666
826
  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 ───