loom-agent 1.3.3 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +189 -0
- package/README.md +9 -7
- package/package.json +2 -2
- package/src/core/agents.js +14 -1
- package/src/core/cli.js +30 -2
- package/src/core/hooks.js +28 -14
- package/src/core/memory.js +54 -6
- package/src/core/permissions.js +17 -4
- package/src/core/plugin-cmd.js +88 -1
- package/src/core/sandbox.js +61 -1
- package/src/core/session.js +262 -36
- package/src/core/transcript-undo.js +74 -0
- package/src/core/update.js +116 -6
- package/src/mcp/connector-presets.js +105 -0
- package/src/providers/anthropic.js +122 -25
- package/src/providers/google.js +19 -11
- package/src/providers/nvidia.js +45 -33
- package/src/providers/openai-compat.js +59 -27
- package/src/providers/openai.js +42 -18
- package/src/providers/openrouter.js +56 -26
- package/src/tools/index.js +193 -30
- package/src/tui/App.tsx +249 -35
- package/src/tui/components/Modals.tsx +64 -19
- package/src/tui/mcp-presets.ts +4 -65
- package/src/tui/store.ts +6 -1
package/src/tui/App.tsx
CHANGED
|
@@ -63,6 +63,7 @@ import { loadConfig, saveConfig, getBaseUrl } from "../config/settings.js";
|
|
|
63
63
|
import { loadAgents, resolveAgent } from "../core/agents.js";
|
|
64
64
|
import { snapshotBefore, snapshotAfter, snapshotBashBefore, diffBashAfter, clearFileDiffs } from "../core/file-diffs.js";
|
|
65
65
|
import { createRestorePoint, listRestorePoints, restoreTo } from "../core/restore.js";
|
|
66
|
+
import { TranscriptUndo } from "../core/transcript-undo.js";
|
|
66
67
|
import { PROVIDERS, PROVIDER_ORDER, PROVIDER_LABELS } from "../providers/index.js";
|
|
67
68
|
import * as plugin from "../core/plugin-cmd.js";
|
|
68
69
|
import * as sandbox from "../core/sandbox.js";
|
|
@@ -71,6 +72,13 @@ import { listSkills } from "../skills/skills-manager.js";
|
|
|
71
72
|
|
|
72
73
|
let _vimReg = ""; // vim NORMAL-mode paste register
|
|
73
74
|
|
|
75
|
+
// Transcript undo/redo — one undoable step per user turn. The engine is pure
|
|
76
|
+
// (src/core/transcript-undo.js); App owns the stacks and clones snapshots.
|
|
77
|
+
const _undo = new TranscriptUndo(50);
|
|
78
|
+
// Restore-point id created for the most recent turn, attached to the snapshot
|
|
79
|
+
// pushed just before that turn so an undo can also roll the working tree back.
|
|
80
|
+
let _lastRestoreId: string | null = null;
|
|
81
|
+
|
|
74
82
|
const ui = palette("loom");
|
|
75
83
|
|
|
76
84
|
export function App(props: { initialPrompt?: string; resumeSession?: string; autoMode?: boolean }) {
|
|
@@ -168,18 +176,37 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
|
|
|
168
176
|
// Docker-install resume: while an install is pending, "docker is ready"
|
|
169
177
|
// continues the sandbox setup in THIS session instead of going to the
|
|
170
178
|
// model. Outside the pending flow the phrase is ordinary conversation.
|
|
179
|
+
// All docker state checks are ASYNC (spawn-based) — a sync status call
|
|
180
|
+
// here froze the whole TUI whenever the docker daemon was slow.
|
|
171
181
|
if (sandbox.isReadySignal(raw)) {
|
|
172
|
-
|
|
173
|
-
if (st0.pending || st0.docker === "ready" && st0.container === "absent" && /set ?up|build|create|make/i.test(raw)) {
|
|
182
|
+
if (sandbox.sandboxPending()) {
|
|
174
183
|
recordPrompt(raw); setDraft("");
|
|
175
184
|
appendMessage({ role: "user", content: raw });
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
185
|
+
sandbox.sandboxStatusAsync().then(function(st0) {
|
|
186
|
+
if (st0.docker === "ready" || st0.docker === "no_daemon") {
|
|
187
|
+
appendMessage({ role: "system", content: "Docker detected \u2014 pick an OS to finish the sandbox setup." });
|
|
188
|
+
openModal({ type: "sandbox" });
|
|
189
|
+
} else {
|
|
190
|
+
sandbox.markInstallPending();
|
|
191
|
+
appendMessage({ role: "system", content: sandbox.installGuide() });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (!thinking()) {
|
|
197
|
+
// Might be a "set up the sandbox" request (docker ready, no container
|
|
198
|
+
// yet). Check async, then either open the wizard or treat as chat.
|
|
199
|
+
sandbox.sandboxStatusAsync().then(function(st0) {
|
|
200
|
+
if (st0.docker === "ready" && st0.container === "absent" && /set ?up|build|create|make/i.test(raw)) {
|
|
201
|
+
recordPrompt(raw); setDraft("");
|
|
202
|
+
appendMessage({ role: "user", content: raw });
|
|
203
|
+
appendMessage({ role: "system", content: "Docker detected \u2014 pick an OS to finish the sandbox setup." });
|
|
204
|
+
openModal({ type: "sandbox" });
|
|
205
|
+
} else {
|
|
206
|
+
recordPrompt(raw); setDraft("");
|
|
207
|
+
runPrompt(raw, false);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
183
210
|
return;
|
|
184
211
|
}
|
|
185
212
|
}
|
|
@@ -210,10 +237,28 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
|
|
|
210
237
|
}
|
|
211
238
|
|
|
212
239
|
function runPrompt(raw: string, shown: boolean, agentId?: string | null, userText?: string) {
|
|
240
|
+
// Undo checkpoint: capture the transcript as it is BEFORE this turn so
|
|
241
|
+
// /undo can step back to it. Done synchronously before any message is
|
|
242
|
+
// appended; the restore point is created in the background below and its
|
|
243
|
+
// id is stitched onto the snapshot so undo can also roll files back.
|
|
244
|
+
var preUi = messages().slice();
|
|
245
|
+
var preSess = getSession().messages.slice();
|
|
246
|
+
var preTodos = (getSession().todos || []).slice();
|
|
247
|
+
var preStep = _undo.push({ ui: preUi, session: preSess, todos: preTodos }, raw);
|
|
248
|
+
|
|
213
249
|
if (!shown) appendMessage({ role: "user", content: raw });
|
|
214
250
|
// Snapshot the project in the background so /restore always works, without
|
|
215
|
-
// stalling the first API call.
|
|
216
|
-
|
|
251
|
+
// stalling the first API call. The created point's id is bound to the undo
|
|
252
|
+
// step just pushed, so an undo can rewind both transcript and files.
|
|
253
|
+
setTimeout(function() {
|
|
254
|
+
try {
|
|
255
|
+
var rp = createRestorePoint(raw);
|
|
256
|
+
if (rp && rp.id) {
|
|
257
|
+
_lastRestoreId = rp.id;
|
|
258
|
+
if (preStep) preStep.restoreId = rp.id;
|
|
259
|
+
}
|
|
260
|
+
} catch {}
|
|
261
|
+
}, 0);
|
|
217
262
|
setThinking(true); setThinkStart(Date.now());
|
|
218
263
|
var idx = messages().length;
|
|
219
264
|
appendMessage({ role: "assistant", content: "", thinking: true, agentLabel: agentId || undefined });
|
|
@@ -517,6 +562,9 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
|
|
|
517
562
|
clearInterval(speedTimer);
|
|
518
563
|
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
|
519
564
|
if (offTodos) { offTodos(); offTodos = null; }
|
|
565
|
+
// Per-turn autosave: persist after every turn so a crash/exit never
|
|
566
|
+
// loses more than the last exchange (previously only /exit saved).
|
|
567
|
+
try { saveSession(syncSessionForSave()); } catch {}
|
|
520
568
|
// Freeze the subagent panel at its final state ("finished").
|
|
521
569
|
if (subAcc.agent) patchMessageAt(idx, { subagent: { agent: subAcc.agent, text: subAcc.text, log: subAcc.log, status: subAcc.status, done: true } });
|
|
522
570
|
// Let the settled assistant message render before the global turn state
|
|
@@ -682,24 +730,48 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
|
|
|
682
730
|
case "context": {
|
|
683
731
|
const msgs = getSession().messages || [];
|
|
684
732
|
const est = (s: any) => Math.ceil(String(s == null ? "" : (typeof s === "object" ? JSON.stringify(s) : s)).length / 4);
|
|
685
|
-
let sys = 0, user = 0, asst = 0, tool = 0;
|
|
733
|
+
let sys = 0, user = 0, asst = 0, tool = 0, schemas = 0;
|
|
686
734
|
try { sys += est(getSession().systemPrompt); } catch {}
|
|
735
|
+
try { schemas += est(getSession()._agentBlock) + est(getSession()._resolvedPrimary) + est(getSession()._resolvedSandbox) + Math.ceil((getSession()._toolSchemaChars || 0) / 4); } catch {}
|
|
687
736
|
for (const m of msgs) {
|
|
688
737
|
const t = est(m.content) + est(m.toolCalls);
|
|
689
738
|
if (m.role === "user") user += t; else if (m.role === "assistant") asst += t; else if (m.role === "tool") tool += t; else sys += t;
|
|
690
739
|
}
|
|
691
|
-
const total = sys + user + asst + tool;
|
|
740
|
+
const total = sys + user + asst + tool + schemas;
|
|
741
|
+
// Live window estimate from the session (messages + system + tool
|
|
742
|
+
// schemas) — the percent is recomputed now, not a startup snapshot.
|
|
743
|
+
let winLine = "";
|
|
744
|
+
try {
|
|
745
|
+
const ce = getSession().contextEstimate();
|
|
746
|
+
winLine = "Window: ~" + ce.tokens.toLocaleString() + " / " + ce.window.toLocaleString() + " tokens (" + ce.pct + "% used)";
|
|
747
|
+
} catch {}
|
|
692
748
|
appendMessage({ role: "system", content: [
|
|
693
749
|
"=== Context (~tokens, chars/4 estimate) ===",
|
|
694
750
|
"System+memory: " + sys,
|
|
695
751
|
"User messages: " + user,
|
|
696
752
|
"Assistant: " + asst,
|
|
697
753
|
"Tool results: " + tool,
|
|
754
|
+
"Schemas+turn: " + schemas,
|
|
698
755
|
"TOTAL: " + total + " (" + msgs.length + " messages)",
|
|
756
|
+
winLine,
|
|
699
757
|
"", "Tip: /compact frees context when this grows large.",
|
|
700
758
|
].join("\n") });
|
|
701
759
|
return;
|
|
702
760
|
}
|
|
761
|
+
case "compact": {
|
|
762
|
+
appendMessage({ role: "system", content: "Compacting…" });
|
|
763
|
+
getSession().compact({
|
|
764
|
+
onCompact: (r: any) => {
|
|
765
|
+
appendMessage({ role: "system", content: r.compacted
|
|
766
|
+
? "Compacted (" + r.method + "): summarized/truncated " + r.removed + " earlier messages; ~" + (r.tokensAfter ?? "?") + " tokens remain."
|
|
767
|
+
: "Nothing to compact — the conversation is still short." });
|
|
768
|
+
},
|
|
769
|
+
}).then((r: any) => {
|
|
770
|
+
if (!r.compacted && !r.reason) appendMessage({ role: "system", content: "Compaction failed — try again." });
|
|
771
|
+
try { saveSession(syncSessionForSave()); } catch {}
|
|
772
|
+
}).catch((e: any) => appendMessage({ role: "system", content: "Compaction failed: " + String(e?.message || e) }));
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
703
775
|
case "think": {
|
|
704
776
|
const lvl = String(args[0] || "").toLowerCase();
|
|
705
777
|
if (!["off", "low", "medium", "high"].includes(lvl)) { showToast("Usage: /think off|low|medium|high", "error"); return; }
|
|
@@ -761,6 +833,50 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
|
|
|
761
833
|
} catch (e: any) { showToast("Share failed: " + String(e?.message || e), "error"); }
|
|
762
834
|
return;
|
|
763
835
|
}
|
|
836
|
+
case "export": {
|
|
837
|
+
// Markdown transcript export — the plain-text counterpart to /share
|
|
838
|
+
// (which writes HTML). One file per session under .loom/exports/.
|
|
839
|
+
try {
|
|
840
|
+
const fsx = require("fs"); const pathx = require("path");
|
|
841
|
+
const dir = pathx.join(process.cwd(), ".loom", "exports");
|
|
842
|
+
fsx.mkdirSync(dir, { recursive: true });
|
|
843
|
+
const lines: string[] = [];
|
|
844
|
+
lines.push("# Loom Code session");
|
|
845
|
+
lines.push("");
|
|
846
|
+
lines.push("- Date: " + new Date().toISOString().slice(0, 19).replace("T", " "));
|
|
847
|
+
lines.push("- Model: " + providerName() + " / " + modelName());
|
|
848
|
+
lines.push("- Messages: " + messages().length);
|
|
849
|
+
lines.push("");
|
|
850
|
+
for (const m of messages()) {
|
|
851
|
+
const who = m.role === "user" ? "User" : m.role === "assistant" ? "Assistant" : "System";
|
|
852
|
+
lines.push("## " + who);
|
|
853
|
+
lines.push("");
|
|
854
|
+
const text = String(m.content ?? "").trim();
|
|
855
|
+
if (text) { lines.push(text); lines.push(""); }
|
|
856
|
+
if (Array.isArray(m.parts)) {
|
|
857
|
+
for (const p of m.parts) {
|
|
858
|
+
if (p.type === "tool" && p.tool) {
|
|
859
|
+
const t = p.tool;
|
|
860
|
+
let row = "- tool `" + t.name + "` " + (t.status || "");
|
|
861
|
+
const out = String(t.output || "").trim();
|
|
862
|
+
if (out) row += ": " + out.replace(/\s+/g, " ").slice(0, 300);
|
|
863
|
+
lines.push(row);
|
|
864
|
+
} else if (p.type === "text" && String(p.text || "").trim()) {
|
|
865
|
+
lines.push(String(p.text).trim());
|
|
866
|
+
lines.push("");
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (m.isError) lines.push("**(turn ended with an error)**", "");
|
|
871
|
+
lines.push("---");
|
|
872
|
+
lines.push("");
|
|
873
|
+
}
|
|
874
|
+
const f = pathx.join(dir, (sessionId() || "session") + ".md");
|
|
875
|
+
fsx.writeFileSync(f, lines.join("\n"));
|
|
876
|
+
showToast("Exported: " + f, "ok", 6000);
|
|
877
|
+
} catch (e: any) { showToast("Export failed: " + String(e?.message || e), "error"); }
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
764
880
|
case "worktree": {
|
|
765
881
|
const name = String(args[0] || "").replace(/[^\w-]/g, "");
|
|
766
882
|
if (!name) { showToast("Usage: /worktree <name>", "error"); return; }
|
|
@@ -979,6 +1095,63 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
|
|
|
979
1095
|
});
|
|
980
1096
|
return;
|
|
981
1097
|
}
|
|
1098
|
+
case "undo": {
|
|
1099
|
+
// Step back one turn: restore the transcript captured before the last
|
|
1100
|
+
// user turn, and (when that step has a restore point) offer to rewind
|
|
1101
|
+
// the working tree too so files match the conversation.
|
|
1102
|
+
if (!_undo.canUndo()) { showToast("Nothing to undo.", "info"); return; }
|
|
1103
|
+
// Current state goes on the redo stack so the undo can be re-done.
|
|
1104
|
+
var curSnap = {
|
|
1105
|
+
ui: messages().slice(),
|
|
1106
|
+
session: getSession().messages.slice(),
|
|
1107
|
+
todos: (getSession().todos || []).slice(),
|
|
1108
|
+
restoreId: _lastRestoreId || undefined,
|
|
1109
|
+
};
|
|
1110
|
+
var target = _undo.undo(curSnap);
|
|
1111
|
+
if (!target) { showToast("Nothing to undo.", "info"); return; }
|
|
1112
|
+
setMessages(target.ui || []);
|
|
1113
|
+
getSession().messages = (target.session || []).slice();
|
|
1114
|
+
getSession().todos = (target.todos || []).slice();
|
|
1115
|
+
var undoLine = "Undone \u2014 transcript restored" + (target.label ? " to before: " + target.label.slice(0, 60) : "") + ".";
|
|
1116
|
+
if (target.restoreId) {
|
|
1117
|
+
_lastRestoreId = target.restoreId;
|
|
1118
|
+
undoLine += "\nAlso rewind the working tree to match? Run /undo-files to restore files from the same point.";
|
|
1119
|
+
}
|
|
1120
|
+
appendMessage({ role: "system", content: undoLine });
|
|
1121
|
+
try { saveSession(syncSessionForSave()); } catch {}
|
|
1122
|
+
refreshUsage();
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
case "redo": {
|
|
1126
|
+
if (!_undo.canRedo()) { showToast("Nothing to redo.", "info"); return; }
|
|
1127
|
+
var curSnap2 = {
|
|
1128
|
+
ui: messages().slice(),
|
|
1129
|
+
session: getSession().messages.slice(),
|
|
1130
|
+
todos: (getSession().todos || []).slice(),
|
|
1131
|
+
restoreId: _lastRestoreId || undefined,
|
|
1132
|
+
};
|
|
1133
|
+
var target2 = _undo.redo(curSnap2);
|
|
1134
|
+
if (!target2) { showToast("Nothing to redo.", "info"); return; }
|
|
1135
|
+
setMessages(target2.ui || []);
|
|
1136
|
+
getSession().messages = (target2.session || []).slice();
|
|
1137
|
+
getSession().todos = (target2.todos || []).slice();
|
|
1138
|
+
if (target2.restoreId) _lastRestoreId = target2.restoreId;
|
|
1139
|
+
appendMessage({ role: "system", content: "Redone \u2014 transcript restored" + (target2.label ? " to: " + target2.label.slice(0, 60) : "") + "." });
|
|
1140
|
+
try { saveSession(syncSessionForSave()); } catch {}
|
|
1141
|
+
refreshUsage();
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
case "undo-files": {
|
|
1145
|
+
// Roll the working tree back to the restore point bound to the most
|
|
1146
|
+
// recent undo, so files match the rewound conversation.
|
|
1147
|
+
if (!_lastRestoreId) { showToast("No file restore point bound to the last undo.", "error"); return; }
|
|
1148
|
+
var rr = restoreTo(_lastRestoreId);
|
|
1149
|
+
if (!rr.ok) { showToast("File restore failed: " + rr.error, "error"); return; }
|
|
1150
|
+
appendMessage({ role: "system", content: "Working tree rewound \u2014 " + rr.restored.length + " files restored, " + rr.deleted.length + " removed." });
|
|
1151
|
+
invalidateFilesCache();
|
|
1152
|
+
clearFileDiffs();
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
982
1155
|
case "usage": {
|
|
983
1156
|
refreshUsage();
|
|
984
1157
|
const { formatTokens, formatUsd, getUsage } = require("../core/usage.js");
|
|
@@ -1077,7 +1250,7 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
|
|
|
1077
1250
|
}
|
|
1078
1251
|
case "connectors": {
|
|
1079
1252
|
var sub3 = args[0];
|
|
1080
|
-
if (sub3 === "add") { appendMessage({ role: "system", content: plugin.
|
|
1253
|
+
if (sub3 === "add") { appendMessage({ role: "system", content: plugin.connectorAddCmd(args.slice(1)) }); return; }
|
|
1081
1254
|
if (sub3 === "remove") { appendMessage({ role: "system", content: plugin.mcpRemoveCmd(args.slice(1)) }); return; }
|
|
1082
1255
|
if (sub3 === "toggle") { appendMessage({ role: "system", content: plugin.mcpToggleCmd(args.slice(1)) }); return; }
|
|
1083
1256
|
if (sub3 === "help") { appendMessage({ role: "system", content: plugin.mcpHelp() }); return; }
|
|
@@ -1089,9 +1262,9 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
|
|
|
1089
1262
|
}
|
|
1090
1263
|
case "sandbox": {
|
|
1091
1264
|
var ssub = args[0];
|
|
1092
|
-
var st = sandbox.sandboxStatus();
|
|
1093
1265
|
// The setup wizard is a modal: docker check (with download link) →
|
|
1094
1266
|
// OS pick → live build. Bare /sandbox and /sandbox setup open it.
|
|
1267
|
+
// Status/on/rm use ASYNC docker calls — sync ones froze the TUI.
|
|
1095
1268
|
if (!ssub || ssub === "setup" || ssub === "os") { openModal({ type: "sandbox" }); return; }
|
|
1096
1269
|
if (ssub === "guide") { appendMessage({ role: "system", content: sandbox.installGuide() }); return; }
|
|
1097
1270
|
if (ssub === "off") {
|
|
@@ -1100,28 +1273,49 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
|
|
|
1100
1273
|
return;
|
|
1101
1274
|
}
|
|
1102
1275
|
if (ssub === "on") {
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1276
|
+
sandbox.sandboxStatusAsync().then(function(stOn) {
|
|
1277
|
+
if (stOn.container !== "running") { appendMessage({ role: "system", content: "Sandbox container is not running. Run /sandbox setup first." }); return; }
|
|
1278
|
+
sandbox.setSandboxEnabled(true);
|
|
1279
|
+
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." });
|
|
1280
|
+
});
|
|
1106
1281
|
return;
|
|
1107
1282
|
}
|
|
1108
1283
|
if (ssub === "rm") {
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
} catch { appendMessage({ role: "system", content: "
|
|
1284
|
+
appendMessage({ role: "system", content: "Removing the sandbox container…" });
|
|
1285
|
+
var rmCp = require("child_process");
|
|
1286
|
+
var rmSpawn = rmCp.spawn("docker", ["rm", "-f", sandbox.CONTAINER], { windowsHide: true });
|
|
1287
|
+
var rmDone = false;
|
|
1288
|
+
var rmTimer = setTimeout(function() { if (!rmDone) { rmDone = true; try { rmSpawn.kill(); } catch {} appendMessage({ role: "system", content: "Sandbox removal timed out. Is Docker running?" }); } }, 30000);
|
|
1289
|
+
rmSpawn.on("close", function(rmCode) {
|
|
1290
|
+
if (rmDone) return;
|
|
1291
|
+
rmDone = true;
|
|
1292
|
+
clearTimeout(rmTimer);
|
|
1293
|
+
if (rmCode === 0) {
|
|
1294
|
+
sandbox.setSandboxEnabled(false);
|
|
1295
|
+
appendMessage({ role: "system", content: "Sandbox container removed. Run /sandbox setup to rebuild it." });
|
|
1296
|
+
} else {
|
|
1297
|
+
appendMessage({ role: "system", content: "No sandbox container to remove." });
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
rmSpawn.on("error", function() {
|
|
1301
|
+
if (rmDone) return;
|
|
1302
|
+
rmDone = true;
|
|
1303
|
+
clearTimeout(rmTimer);
|
|
1304
|
+
appendMessage({ role: "system", content: "No sandbox container to remove (docker not reachable)." });
|
|
1305
|
+
});
|
|
1114
1306
|
return;
|
|
1115
1307
|
}
|
|
1116
1308
|
if (ssub === "status") {
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1309
|
+
sandbox.sandboxStatusAsync().then(function(st) {
|
|
1310
|
+
var line = "Sandbox: ";
|
|
1311
|
+
if (st.docker === "not_installed") line += "Docker not installed";
|
|
1312
|
+
else if (st.docker === "no_daemon") line += "Docker installed, daemon not running";
|
|
1313
|
+
else line += "Docker " + st.dockerVersion + " \u00b7 container " + st.container + (st.enabled ? " \u00b7 sandbox tool on" : " \u00b7 sandbox tool off");
|
|
1314
|
+
if (st.pending) line += "\nWaiting for you to install Docker \u2014 when it's done say \"docker is ready\" (or run /sandbox).";
|
|
1315
|
+
else if (st.docker === "not_installed") line += "\nRun /sandbox for the setup wizard.";
|
|
1316
|
+
else if (st.container === "absent") line += "\nRun /sandbox to build it.";
|
|
1317
|
+
appendMessage({ role: "system", content: line });
|
|
1318
|
+
});
|
|
1125
1319
|
return;
|
|
1126
1320
|
}
|
|
1127
1321
|
appendMessage({ role: "system", content: "Usage: /sandbox (wizard) | status | on | off | rm | guide" });
|
|
@@ -1566,6 +1760,7 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
|
|
|
1566
1760
|
// â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â• Lifecycle â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
|
|
1567
1761
|
let _skillToastOff: (() => void) | null = null;
|
|
1568
1762
|
let _skillDoneOff: (() => void) | null = null;
|
|
1763
|
+
let _taskToastOff: (() => void) | null = null;
|
|
1569
1764
|
onMount(function() {
|
|
1570
1765
|
// Keep the renderer's frame pump alive. The engine only runs its
|
|
1571
1766
|
// frame callback while there's a non-complete timeline registered;
|
|
@@ -1597,10 +1792,11 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
|
|
|
1597
1792
|
}, 500);
|
|
1598
1793
|
}
|
|
1599
1794
|
// Sandbox install pending from a previous session: remind on start so
|
|
1600
|
-
// the user knows to come back with "docker is ready".
|
|
1601
|
-
//
|
|
1795
|
+
// the user knows to come back with "docker is ready". Config-only check
|
|
1796
|
+
// (no docker invocation — the old sandboxStatus() froze startup).
|
|
1797
|
+
// Skipped in the test harness (same flag as the session prompt).
|
|
1602
1798
|
try {
|
|
1603
|
-
if (!process.env.LOOM_NO_SESSION_PROMPT && sandbox.
|
|
1799
|
+
if (!process.env.LOOM_NO_SESSION_PROMPT && sandbox.sandboxPending()) {
|
|
1604
1800
|
setTimeout(function() {
|
|
1605
1801
|
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
1802
|
}, 800);
|
|
@@ -1614,9 +1810,27 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
|
|
|
1614
1810
|
_skillDoneOff = on("turn:end", function(d: any) {
|
|
1615
1811
|
if (d?.skills?.length) showToast("skill handled: " + d.skills.join(", "), "ok");
|
|
1616
1812
|
});
|
|
1813
|
+
// Background-task completion toasts: bash background:true tasks finishing
|
|
1814
|
+
// (done/error/killed) surface immediately so the model's "started a
|
|
1815
|
+
// server" isn't silent forever.
|
|
1816
|
+
var taskStatuses: Record<string, string> = {};
|
|
1817
|
+
_taskToastOff = on("tasks:changed", function() {
|
|
1818
|
+
try {
|
|
1819
|
+
var bt = require("../core/background-tasks.js");
|
|
1820
|
+
var rows = bt.listBackgroundTasks();
|
|
1821
|
+
for (var i = 0; i < rows.length; i++) {
|
|
1822
|
+
var t = rows[i];
|
|
1823
|
+
var prev = taskStatuses[t.id];
|
|
1824
|
+
taskStatuses[t.id] = t.status;
|
|
1825
|
+
if (prev === "running" && t.status !== "running") {
|
|
1826
|
+
showToast("Task " + t.id + " " + t.status + (t.exitCode != null ? " (exit " + t.exitCode + ")" : "") + " \u2014 /tasks for output", t.status === "done" ? "ok" : "error", 6000);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
} catch {}
|
|
1830
|
+
});
|
|
1617
1831
|
(globalThis as any).__loomTrace?.("onMount-done", new Error("mount complete"));
|
|
1618
1832
|
});
|
|
1619
|
-
onCleanup(function() { persistUi(); if (_skillToastOff) _skillToastOff(); if (_skillDoneOff) _skillDoneOff(); });
|
|
1833
|
+
onCleanup(function() { persistUi(); if (_skillToastOff) _skillToastOff(); if (_skillDoneOff) _skillDoneOff(); if (_taskToastOff) _taskToastOff(); });
|
|
1620
1834
|
|
|
1621
1835
|
var showSplash = createMemo(function() { return messages().length === 0; });
|
|
1622
1836
|
|
|
@@ -441,7 +441,8 @@ export function showAgentsText() {
|
|
|
441
441
|
|
|
442
442
|
// ── MCP server / connector browser popup ──
|
|
443
443
|
// Lists every configured server (seeded defaults + user-added) with on/off
|
|
444
|
-
// state; Enter toggles
|
|
444
|
+
// state; Enter toggles a server (or opens the add flow on the pinned "+ Add"
|
|
445
|
+
// row at the end of the list), A opens the add flow, Esc closes.
|
|
445
446
|
// `kind` decides which preset list the "A" flow offers: "mcp" (dev tools) or
|
|
446
447
|
// "connector" (hosting/cloud services). Both share the same underlying
|
|
447
448
|
// mcp-manager — a connector IS an MCP server, just surfaced separately.
|
|
@@ -470,11 +471,15 @@ function presetAddLine(p: any, envValues?: Record<string, string>): string {
|
|
|
470
471
|
function openPresetPicker(presets: any[], kind: "mcp" | "connector") {
|
|
471
472
|
const addLabel = kind === "connector" ? "Add connector" : "Add MCP server";
|
|
472
473
|
const backType = kind === "connector" ? "connectors" : "mcp";
|
|
474
|
+
// Connector adds run through the preset-aware engine: a bare preset name
|
|
475
|
+
// plus -e KEY=V flags expands to the full one-liner; full one-liners (with
|
|
476
|
+
// "--") pass through unchanged.
|
|
477
|
+
const addLine = kind === "connector" ? plugin.connectorAddLineCmd : plugin.mcpAddLineCmd;
|
|
473
478
|
|
|
474
479
|
// Build the server from collected env + optional args and land it; reopen
|
|
475
480
|
// the browser on success, toast the error otherwise.
|
|
476
481
|
const submitLine = (line: string, reopenOnError: boolean) => {
|
|
477
|
-
const msg =
|
|
482
|
+
const msg = addLine(line);
|
|
478
483
|
if (msg.startsWith("Added")) {
|
|
479
484
|
showToast(msg, "ok");
|
|
480
485
|
closeModal();
|
|
@@ -547,7 +552,7 @@ const openLine = function(line: string) {
|
|
|
547
552
|
caretStart: line ? line.lastIndexOf("=") + 1 : 0,
|
|
548
553
|
onCancel: function() { closeModal(); setTimeout(function() { openModal({ type: backType as any }); }, 10); },
|
|
549
554
|
onPick: function(line2: string) {
|
|
550
|
-
const msg =
|
|
555
|
+
const msg = addLine(line2);
|
|
551
556
|
if (msg.startsWith("Added")) {
|
|
552
557
|
showToast(msg, "ok");
|
|
553
558
|
closeModal();
|
|
@@ -592,9 +597,11 @@ function ServerBrowser(props: { kind: "mcp" | "connector" }) {
|
|
|
592
597
|
const ks = kbs.keyString(key);
|
|
593
598
|
if (kbs.is("modal_cancel", ks)) { closeModal(); return; }
|
|
594
599
|
if (kbs.dialogIs("dialog_select_prev", ks)) { setSel(i => Math.max(0, i - 1)); return; }
|
|
595
|
-
|
|
600
|
+
// One past the last server is the pinned "+ Add" row.
|
|
601
|
+
if (kbs.dialogIs("dialog_select_next", ks)) { setSel(i => Math.min(servers().length, i + 1)); return; }
|
|
596
602
|
if (key.name === "a" || key.name === "A") { openPresetPicker(presets, kind); return; }
|
|
597
603
|
if (kbs.dialogIs("dialog_select_submit", ks)) {
|
|
604
|
+
if (sel() >= servers().length) { openPresetPicker(presets, kind); return; }
|
|
598
605
|
const s = servers()[sel()];
|
|
599
606
|
if (!s) return;
|
|
600
607
|
const res = toggleServer(s.name);
|
|
@@ -605,7 +612,7 @@ function ServerBrowser(props: { kind: "mcp" | "connector" }) {
|
|
|
605
612
|
}
|
|
606
613
|
});
|
|
607
614
|
|
|
608
|
-
const scrollBy = (e: any) => { setSel(i => Math.max(0, Math.min(servers().length
|
|
615
|
+
const scrollBy = (e: any) => { setSel(i => Math.max(0, Math.min(servers().length, i + wheelStep(e, 1)))); };
|
|
609
616
|
let winStart = 0;
|
|
610
617
|
const win = () => {
|
|
611
618
|
const total = servers().length;
|
|
@@ -626,7 +633,7 @@ function ServerBrowser(props: { kind: "mcp" | "connector" }) {
|
|
|
626
633
|
const cmdW = () => Math.max(22, 60 - nameW());
|
|
627
634
|
|
|
628
635
|
return (
|
|
629
|
-
<ModalFrame title={title} subtitle={"Enter toggles
|
|
636
|
+
<ModalFrame title={title} subtitle={"Enter toggles, + Add row adds" + rangeSub()} footer={kbNav().submit + " toggle/add · A add · " + kbNav().cancel + " close"}>
|
|
630
637
|
<box onMouseScroll={scrollBy} flexDirection="column" flexShrink={0}>
|
|
631
638
|
{win().items.map((s, i) => {
|
|
632
639
|
const abs = win().start + i;
|
|
@@ -648,6 +655,25 @@ function ServerBrowser(props: { kind: "mcp" | "connector" }) {
|
|
|
648
655
|
</box>
|
|
649
656
|
);
|
|
650
657
|
})}
|
|
658
|
+
{/* Pinned add row — always visible under the list; Enter (or a click)
|
|
659
|
+
opens the preset picker, same as the A shortcut. */}
|
|
660
|
+
{(() => {
|
|
661
|
+
const addActive = sel() >= servers().length;
|
|
662
|
+
const addLabel2 = "+ Add " + (kind === "connector" ? "connector" : "MCP server") + "…";
|
|
663
|
+
return (
|
|
664
|
+
<box
|
|
665
|
+
flexDirection="row" paddingY={0} height={1} flexShrink={0}
|
|
666
|
+
paddingLeft={1}
|
|
667
|
+
backgroundColor={addActive ? ui.bgPanelAlt : undefined}
|
|
668
|
+
onMouseDown={() => setSel(servers().length)}
|
|
669
|
+
onMouseUp={() => { if (sel() >= servers().length) openPresetPicker(presets, kind); }}
|
|
670
|
+
>
|
|
671
|
+
<text fg={addActive ? ui.primary : ui.fgMuted} height={1}>
|
|
672
|
+
{(addActive ? "▸ " : " ") + addLabel2}
|
|
673
|
+
</text>
|
|
674
|
+
</box>
|
|
675
|
+
);
|
|
676
|
+
})()}
|
|
651
677
|
</box>
|
|
652
678
|
</ModalFrame>
|
|
653
679
|
);
|
|
@@ -669,7 +695,9 @@ export function ConnectorsModal() {
|
|
|
669
695
|
export function SandboxModal() {
|
|
670
696
|
const sb = require("../../core/sandbox.js");
|
|
671
697
|
const [step, setStep] = createSignal<"docker" | "os" | "setup">("docker");
|
|
672
|
-
|
|
698
|
+
// null = detection running (spawn-based, non-blocking — the old sync
|
|
699
|
+
// detectDocker() froze the whole TUI for up to 10s while docker answered).
|
|
700
|
+
const [docker, setDocker] = createSignal<{ state: string; version: string } | null>(null);
|
|
673
701
|
const [osSel, setOsSel] = createSignal(0);
|
|
674
702
|
const [logs, setLogs] = createSignal<string[]>([]);
|
|
675
703
|
const [busy, setBusy] = createSignal(false);
|
|
@@ -699,18 +727,28 @@ export function SandboxModal() {
|
|
|
699
727
|
done: "Ready",
|
|
700
728
|
};
|
|
701
729
|
|
|
702
|
-
// Docker
|
|
703
|
-
// brief "installed" flash so the user sees the check happened).
|
|
730
|
+
// Async Docker check on open — never blocks the render loop.
|
|
704
731
|
onMount(() => {
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
732
|
+
startSpinner();
|
|
733
|
+
sb.detectDockerAsync().then((/** @type {{ state: string; version: string }} */ d) => {
|
|
734
|
+
setDocker(d);
|
|
735
|
+
if (d.state === "ready") {
|
|
736
|
+
// brief "installed" flash, then straight to OS selection.
|
|
737
|
+
setTimeout(() => { if (step() === "docker") setStep("os"); }, 600);
|
|
738
|
+
} else {
|
|
739
|
+
stopSpinner();
|
|
740
|
+
}
|
|
741
|
+
});
|
|
708
742
|
});
|
|
709
743
|
|
|
710
744
|
function recheck() {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
745
|
+
setDocker(null);
|
|
746
|
+
startSpinner();
|
|
747
|
+
sb.detectDockerAsync().then((/** @type {{ state: string; version: string }} */ d) => {
|
|
748
|
+
setDocker(d);
|
|
749
|
+
stopSpinner();
|
|
750
|
+
if (d.state === "ready") setStep("os");
|
|
751
|
+
});
|
|
714
752
|
}
|
|
715
753
|
|
|
716
754
|
function startSetup() {
|
|
@@ -751,12 +789,14 @@ export function SandboxModal() {
|
|
|
751
789
|
if (kbs.dialogIs("dialog_select_next", ks)) { if (s === "os") setOsSel((i) => Math.min(sb.OS_CATALOG.length - 1, i + 1)); return; }
|
|
752
790
|
if (kbs.dialogIs("dialog_select_submit", ks)) {
|
|
753
791
|
if (s === "docker") {
|
|
754
|
-
|
|
792
|
+
const d = docker();
|
|
793
|
+
if (!d) return; // still detecting — enter does nothing yet
|
|
794
|
+
if (d.state === "not_installed") {
|
|
755
795
|
sb.markInstallPending();
|
|
756
796
|
sb.openInstallPage();
|
|
757
797
|
appendMessage({ role: "system", content: "Opened the Docker download page. Install Docker (restart if asked), then run /sandbox again — or just say \"docker is ready\" in chat." });
|
|
758
798
|
closeModal();
|
|
759
|
-
} else if (
|
|
799
|
+
} else if (d.state === "no_daemon") {
|
|
760
800
|
recheck();
|
|
761
801
|
} else {
|
|
762
802
|
setStep("os");
|
|
@@ -769,7 +809,7 @@ export function SandboxModal() {
|
|
|
769
809
|
});
|
|
770
810
|
|
|
771
811
|
const link = sb.installLink();
|
|
772
|
-
const st = () => docker()
|
|
812
|
+
const st = () => (docker() ? docker()!.state : "checking");
|
|
773
813
|
|
|
774
814
|
return (
|
|
775
815
|
<ModalFrame
|
|
@@ -789,6 +829,11 @@ export function SandboxModal() {
|
|
|
789
829
|
: "enter retry · esc close"
|
|
790
830
|
}
|
|
791
831
|
>
|
|
832
|
+
{step() === "docker" && st() === "checking" ? (
|
|
833
|
+
<box flexDirection="column">
|
|
834
|
+
<text fg={ui.fgMuted}>{SPIN_FRAMES[spin()] + " Checking Docker…"}</text>
|
|
835
|
+
</box>
|
|
836
|
+
) : null}
|
|
792
837
|
{step() === "docker" && st() === "not_installed" ? (
|
|
793
838
|
<box flexDirection="column">
|
|
794
839
|
<text fg={ui.warning}>Docker is not installed</text>
|
|
@@ -809,7 +854,7 @@ export function SandboxModal() {
|
|
|
809
854
|
) : null}
|
|
810
855
|
{step() === "docker" && st() === "ready" ? (
|
|
811
856
|
<box flexDirection="column">
|
|
812
|
-
<text fg={ui.success}>Docker installed (v{docker().version})</text>
|
|
857
|
+
<text fg={ui.success}>Docker installed (v{(docker() || { version: "?" }).version})</text>
|
|
813
858
|
<text fg={ui.fgMuted} marginTop={1}>Opening OS selection…</text>
|
|
814
859
|
</box>
|
|
815
860
|
) : null}
|