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/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
- var st0 = sandbox.sandboxStatus();
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
- 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
- }
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
- setTimeout(function() { try { createRestorePoint(raw); } catch {} }, 0);
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.mcpAddCmd(args.slice(1)) }); return; }
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
- 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." });
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
- 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." }); }
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
- 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 });
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". Skipped in the
1601
- // test harness (same flag as the session prompt).
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.sandboxStatus().pending) {
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, A opens the add flow, Esc closes.
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 = plugin.mcpAddLineCmd(line);
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 = plugin.mcpAddLineCmd(line2);
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
- if (kbs.dialogIs("dialog_select_next", ks)) { setSel(i => Math.min(servers().length - 1, i + 1)); return; }
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 - 1, i + wheelStep(e, 1)))); };
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 a server" + rangeSub()} footer={kbNav().submit + " toggle · A add · " + kbNav().cancel + " close"}>
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
- const [docker, setDocker] = createSignal(sb.detectDocker());
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 already installed → advance straight to OS selection (with a
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
- if (docker().state === "ready") {
706
- setTimeout(() => { if (step() === "docker") setStep("os"); }, 600);
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
- const d = sb.detectDocker();
712
- setDocker(d);
713
- if (d.state === "ready") setStep("os");
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
- if (docker().state === "not_installed") {
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 (docker().state === "no_daemon") {
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().state;
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}