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.
- package/CHANGELOG.md +75 -0
- package/README.md +34 -27
- package/package.json +126 -125
- package/src/core/sandbox.js +400 -0
- package/src/core/session-store.js +245 -167
- package/src/core/session.js +89 -24
- package/src/providers/anthropic.js +10 -5
- package/src/providers/openai-compat.js +12 -4
- package/src/tools/index.js +18 -0
- package/src/tui/App.tsx +84 -4
- package/src/tui/components/Modals.tsx +160 -0
- 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
|
@@ -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
|
-
|
|
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 — 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
|
|
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 ───
|