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.
- package/CHANGELOG.md +92 -0
- package/README.md +34 -27
- package/package.json +126 -125
- package/src/core/sandbox.js +576 -0
- package/src/core/session-store.js +245 -167
- package/src/core/session.js +90 -24
- package/src/providers/anthropic.js +10 -5
- package/src/providers/openai-compat.js +12 -4
- package/src/tools/index.js +27 -1
- package/src/tui/App.tsx +84 -4
- package/src/tui/components/Modals.tsx +220 -1
- package/src/tui/store.ts +34 -33
|
@@ -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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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));
|
package/src/tools/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
501
|
-
{ cmd: "agents", desc: "List agents
|
|
502
|
-
{ cmd: "build", desc: "Build mode — full
|
|
503
|
-
{ cmd: "plan", desc: "Plan mode — read
|
|
504
|
-
{ cmd: "chat", desc: "Chat mode —
|
|
505
|
-
{ cmd: "connect", desc: "
|
|
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"
|
|
508
|
-
{ cmd: "
|
|
509
|
-
{ cmd: "
|
|
510
|
-
{ cmd: "
|
|
511
|
-
{ cmd: "
|
|
512
|
-
{ cmd: "
|
|
513
|
-
{ cmd: "
|
|
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
|
|
517
|
-
{ cmd: "settings", desc: "
|
|
518
|
-
{ cmd: "sessions", desc: "Browse saved sessions
|
|
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: "
|
|
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
|
|
526
|
-
{ cmd: "permissions", desc: "
|
|
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: "
|
|
531
|
-
{ cmd: "context", desc: "
|
|
532
|
-
{ cmd: "think", desc: "Thinking budget", args: "off|low|medium|high" },
|
|
533
|
-
{ cmd: "approve", desc: "Approve
|
|
534
|
-
{ cmd: "tasks", desc: "
|
|
535
|
-
{ cmd: "
|
|
536
|
-
{ cmd: "
|
|
537
|
-
{ cmd: "
|
|
538
|
-
{ cmd: "style", desc: "Output style preset
|
|
539
|
-
{ cmd: "vim", desc: "Toggle vim
|
|
540
|
-
{ cmd: "remember", desc: "Save a fact to
|
|
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 ───
|