loom-agent 1.2.36 → 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.
Files changed (39) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +38 -56
  3. package/bin/loom-bun.js +2 -10
  4. package/bin/loom.js +0 -10
  5. package/package.json +126 -126
  6. package/src/connectors/convex.js +356 -0
  7. package/src/connectors/deploy.js +390 -0
  8. package/src/connectors/index.js +23 -0
  9. package/src/connectors/supabase.js +279 -0
  10. package/src/core/agents.js +32 -2
  11. package/src/core/cli.js +30 -5
  12. package/src/core/permissions.js +12 -1
  13. package/src/core/plugin-cmd.js +1 -0
  14. package/src/core/sandbox.js +400 -0
  15. package/src/core/session-store.js +245 -167
  16. package/src/core/session.js +157 -11
  17. package/src/providers/anthropic.js +10 -5
  18. package/src/providers/openai-compat.js +12 -4
  19. package/src/tools/browser.js +204 -0
  20. package/src/tools/index.js +35 -1
  21. package/src/tui/App.tsx +103 -8
  22. package/src/tui/components/BreadcrumbBar.tsx +10 -9
  23. package/src/tui/components/ChatArea.tsx +3 -3
  24. package/src/tui/components/InputBar.tsx +22 -11
  25. package/src/tui/components/MdText.tsx +2 -1
  26. package/src/tui/components/Modals.tsx +191 -17
  27. package/src/tui/components/PermissionPopup.tsx +15 -12
  28. package/src/tui/components/Sidebar.tsx +6 -2
  29. package/src/tui/components/SplashScreen.tsx +17 -24
  30. package/src/tui/components/SubagentPanel.tsx +1 -1
  31. package/src/tui/components/ToastOverlay.tsx +23 -12
  32. package/src/tui/store.ts +38 -35
  33. package/src/tui/theme.ts +45 -30
  34. package/src/tui/tool-display.ts +13 -2
  35. package/docs/web.md +0 -205
  36. package/src/web/attach.js +0 -242
  37. package/src/web/graph-view.html +0 -262
  38. package/src/web/index.html +0 -824
  39. package/src/web/web-server.js +0 -535
package/src/tui/App.tsx CHANGED
@@ -52,7 +52,7 @@ import { prettyToolName, stripAnsi } from "./toolname.ts";
52
52
  import { toolDisplay } from "./tool-display.ts";
53
53
  import {
54
54
  ProviderPicker, SelectModal, InputModal, SettingsModal,
55
- PaletteModal, McpModal, ConnectorsModal, GraphModal,
55
+ PaletteModal, McpModal, ConnectorsModal, GraphModal, SandboxModal,
56
56
  openModelPicker, openKeyModal, openBaseUrlEditor,
57
57
  showProvidersText, showHelpText, showAgentsText,
58
58
  openGraphModal,
@@ -65,6 +65,7 @@ import { snapshotBefore, snapshotAfter, snapshotBashBefore, diffBashAfter, clear
65
65
  import { createRestorePoint, listRestorePoints, restoreTo } from "../core/restore.js";
66
66
  import { PROVIDERS, PROVIDER_ORDER, PROVIDER_LABELS } from "../providers/index.js";
67
67
  import * as plugin from "../core/plugin-cmd.js";
68
+ import * as sandbox from "../core/sandbox.js";
68
69
  import { on } from "../core/events.js";
69
70
  import { listSkills } from "../skills/skills-manager.js";
70
71
 
@@ -117,14 +118,15 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
117
118
  if (text.startsWith("/")) {
118
119
  var q = text.slice(1).toLowerCase();
119
120
  var hits = SLASH_LIST.filter(function(c) { return c.cmd.startsWith(q); }).map(function(c) {
120
- var s: Suggestion = { label: "/" + c.cmd, desc: c.desc + (c.args ? " \u2014 " + c.args : "") };
121
+ // Popup stays terse: short desc only — syntax lives in /help (args).
122
+ var s: Suggestion = { label: "/" + c.cmd, desc: c.desc };
121
123
  return s;
122
124
  });
123
125
  // Custom commands (.loom/commands/*.md) join the picker after built-ins.
124
126
  try {
125
127
  const { listCustomCommands } = require("../core/custom-commands.js");
126
128
  for (const cc of listCustomCommands()) {
127
- if (cc.name.toLowerCase().startsWith(q)) hits.push({ label: "/" + cc.name, desc: "custom command \u2014 " + cc.file });
129
+ if (cc.name.toLowerCase().startsWith(q)) hits.push({ label: "/" + cc.name, desc: "custom command" });
128
130
  }
129
131
  } catch {}
130
132
  setSuggestions(hits); setAutoKind("slash"); setAutoIndex(0);
@@ -163,6 +165,25 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
163
165
  if (raw.startsWith("/")) { recordPrompt(raw); setDraft(""); processSlash(raw); return; }
164
166
  if (raw.startsWith("!")) { recordPrompt(raw); setDraft(""); appendMessage({ role: "user", content: raw }); appendMessage({ role: "system", content: runShell(raw.slice(1)) }); return; }
165
167
 
168
+ // Docker-install resume: while an install is pending, "docker is ready"
169
+ // continues the sandbox setup in THIS session instead of going to the
170
+ // model. Outside the pending flow the phrase is ordinary conversation.
171
+ if (sandbox.isReadySignal(raw)) {
172
+ var st0 = sandbox.sandboxStatus();
173
+ if (st0.pending || st0.docker === "ready" && st0.container === "absent" && /set ?up|build|create|make/i.test(raw)) {
174
+ recordPrompt(raw); setDraft("");
175
+ appendMessage({ role: "user", content: raw });
176
+ if (st0.docker === "ready" || st0.docker === "no_daemon") {
177
+ appendMessage({ role: "system", content: "Docker detected \u2014 pick an OS to finish the sandbox setup." });
178
+ openModal({ type: "sandbox" });
179
+ } else {
180
+ sandbox.markInstallPending();
181
+ appendMessage({ role: "system", content: sandbox.installGuide() });
182
+ }
183
+ return;
184
+ }
185
+ }
186
+
166
187
  // "@agent …" delegates the whole turn to that subagent (same as the model
167
188
  // calling the task tool — but explicit). "@file …" still inlines the file.
168
189
  var agentId: string | null = null;
@@ -464,12 +485,27 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
464
485
  // Keep the partial text in the bubble; the session already stored it,
465
486
  // so the user can type "continue" to resume the task.
466
487
  patchMessageAt(idx, { thinking: false, interrupted: true, thinkTime: Date.now() - t0, isError: false, parts: snapshotParts() });
467
- appendMessage({ role: "system", content: "Interrupted \u2014 partial response kept. Type \"continue\" to resume the task." });
488
+ var keptChars = String(resp.content || "").length;
489
+ var inProgress = null;
490
+ var tdNow = sess.todos || [];
491
+ for (var ti = 0; ti < tdNow.length; ti++) { if (tdNow[ti].status === "in_progress") { inProgress = tdNow[ti]; break; } }
492
+ appendMessage({ role: "system", content: "Interrupted \u2014 kept " + keptChars + " chars of the partial response" + (inProgress ? " (in progress: " + inProgress.content + ")" : "") + ". Type \"continue\" to resume the task." });
468
493
  } else {
469
- patchMessageAt(idx, { content: resp.content || "(no response)", thinking: false, thinkTime: Date.now() - t0, isError: isErr, parts: snapshotParts() });
494
+ patchMessageAt(idx, { content: resp.content || "(the model ended the turn without any text)", thinking: false, thinkTime: Date.now() - t0, isError: isErr, parts: snapshotParts() });
495
+ // Real leftovers, not a generic stop line: when the turn wrote todos
496
+ // and still has open ones, the session reports exactly which.
497
+ var openTodos = resp.openTodos;
498
+ if (!isErr && openTodos && openTodos.length) {
499
+ var openNames = [];
500
+ for (var oi = 0; oi < Math.min(openTodos.length, 8); oi++) openNames.push("- [" + openTodos[oi].status + "] " + openTodos[oi].content);
501
+ appendMessage({ role: "system", content: "Stopped with " + openTodos.length + " todo(s) still open:\n" + openNames.join("\n") + (openTodos.length > 8 ? "\n- \u2026" : "") + "\nSay \"continue\" to keep going." });
502
+ }
470
503
  }
471
504
  if (!isErr && sess.mode === "plan") {
472
- appendMessage({ role: "system", content: "Plan complete \u2014 press Tab to switch to Build, then send \"go\" to execute." });
505
+ var plannedSteps = 0;
506
+ var planTodos = sess.todos || [];
507
+ for (var pi = 0; pi < planTodos.length; pi++) { if (planTodos[pi].status !== "completed" && planTodos[pi].status !== "cancelled") plannedSteps++; }
508
+ appendMessage({ role: "system", content: "Plan complete \u2014 " + (plannedSteps ? plannedSteps + " step(s) planned. " : "") + "press Tab to switch to Build, then send \"go\" to execute." });
473
509
  }
474
510
  setSpeedStats({ live: null, last: sess.getSpeed().last });
475
511
  }).catch(function(e) {
@@ -573,7 +609,15 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
573
609
  getSession().conversationId = id;
574
610
  var pendingParts: Record<string, any> = {};
575
611
  getSession().messages = data.messages.map(function(m) {
576
- var clean: any = { role: m.role, content: m.content, toolCalls: m.toolCalls };
612
+ // toolCallId MUST survive the resume: the provider wire format sends it
613
+ // as tool_call_id / tool_use_id — dropping it makes the next turn fail
614
+ // with "400: missing field tool_call_id" (NVIDIA) after a resume.
615
+ var clean: any = {
616
+ role: m.role,
617
+ content: m.content,
618
+ toolCalls: m.toolCalls || (m as any).tool_calls,
619
+ toolCallId: m.toolCallId || (m as any).tool_call_id,
620
+ };
577
621
  if (m.reasoning) clean.reasoning = m.reasoning;
578
622
  return clean;
579
623
  });
@@ -710,7 +754,7 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
710
754
  fsx.mkdirSync(dir, { recursive: true });
711
755
  const esc = (s: string) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
712
756
  const body = messages().map((m: any) => `<div class="m ${esc(m.role)}"><b>${esc(m.role)}</b><pre>${esc(String(m.content ?? ""))}</pre></div>`).join("\n");
713
- const html = `<!doctype html><meta charset="utf-8"><title>Loom session</title><style>body{background:#191817;color:#e8e2d9;font-family:ui-monospace,monospace;max-width:900px;margin:2rem auto;padding:0 1rem}.m{border-left:3px solid #555;padding:.25rem .75rem;margin:.6rem 0}.m.user{border-color:#d7a35f}.m.assistant{border-color:#7aa2f7}.m.system{color:#9a938a}pre{white-space:pre-wrap;font-family:inherit;margin:.2rem 0}</style><h1>Loom Code session</h1>${body}`;
757
+ const html = `<!doctype html><meta charset="utf-8"><title>Loom session</title><style>body{background:#171512;color:#f2ede4;font-family:ui-monospace,monospace;max-width:900px;margin:2rem auto;padding:0 1rem}.m{border-left:3px solid #463f36;padding:.25rem .75rem;margin:.6rem 0}.m.user{border-color:#a8c496}.m.assistant{border-color:#e8845c}.m.system{color:#a89d8d}pre{white-space:pre-wrap;font-family:inherit;margin:.2rem 0}</style><h1>Loom Code session</h1>${body}`;
714
758
  const f = pathx.join(dir, (sessionId() || "session") + ".html");
715
759
  fsx.writeFileSync(f, html);
716
760
  showToast("Shared: " + f, "ok", 6000);
@@ -1043,6 +1087,46 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
1043
1087
  openModal({ type: "connectors" });
1044
1088
  return;
1045
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
+ }
1046
1130
  case "sessions": {
1047
1131
  const saved = listSessions();
1048
1132
  if (!saved.length) {
@@ -1512,6 +1596,16 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
1512
1596
  if (!autoPerm() && !permission() && !modal()) askSessionPermissions();
1513
1597
  }, 500);
1514
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 {}
1515
1609
  _skillToastOff = on("trigger:skill", function(d: any) {
1516
1610
  const names = (d?.skills || []).join(", ");
1517
1611
  showToast("skill: " + names, "ok", 5000);
@@ -1581,6 +1675,7 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
1581
1675
  {m.type === "palette" ? <PaletteModal onPick={m.onPick} /> : null}
1582
1676
  {m.type === "mcp" ? <McpModal /> : null}
1583
1677
  {m.type === "connectors" ? <ConnectorsModal /> : null}
1678
+ {m.type === "sandbox" ? <SandboxModal /> : null}
1584
1679
  {m.type === "graph" ? <GraphModal graph={m.graph} err={m.graphError} /> : null}
1585
1680
  {m.type === "subagents" ? <SubagentPanel /> : null}
1586
1681
  {m.type === "subagent_detail" ? <SubagentDetailPanel /> : null}
@@ -1,6 +1,7 @@
1
- // Top breadcrumb bar — project path, session info, model.
1
+ // Top breadcrumb bar — project path, session info, model. bgPanel surface
2
+ // plus a dim right-hand cluster; no frame (the TUI stays grid-line-free).
2
3
  import { palette } from "../theme.ts";
3
- import { providerName, modelName, sessionId, cwdShort, username, inputMode } from "../store.ts";
4
+ import { providerName, modelName, sessionId, cwdShort, inputMode } from "../store.ts";
4
5
 
5
6
  const MODE_LABELS: Record<string, string> = { build: "B", plan: "P", chat: "C" };
6
7
 
@@ -15,19 +16,19 @@ export function BreadcrumbBar() {
15
16
  paddingX={1}
16
17
  backgroundColor={ui.bgPanel}
17
18
  >
18
- <box flexDirection="row">
19
+ <box flexDirection="row" alignItems="center">
19
20
  <text fg={ui.primary}>{"loom"}</text>
20
- <text fg={ui.fgMuted}>{" / "}</text>
21
+ <text fg={ui.fgMuted}>{" · "}</text>
21
22
  <text fg={ui.fg}>{cwdShort() || "~"}</text>
22
- <text fg={ui.fgMuted}>{" · "}</text>
23
- <text fg={ui.fgDim}>{sessionId().slice(0, 8)}</text>
24
- <text fg={ui.fgMuted}>{" · "}</text>
23
+ <text fg={ui.fgMuted}>{" · "}</text>
24
+ <text fg={ui.fgMuted}>{sessionId().slice(0, 8)}</text>
25
+ <text fg={ui.fgMuted}>{" "}</text>
25
26
  <text fg={modeColors[inputMode()] || ui.primary}>{"[" + (MODE_LABELS[inputMode()] || "B") + "]"}</text>
26
27
  </box>
27
- <box flexDirection="row">
28
+ <box flexDirection="row" alignItems="center">
28
29
  <text fg={ui.secondary}>{providerName()}</text>
29
30
  <text fg={ui.fgMuted}>{" / "}</text>
30
- <text fg={ui.fg}>{modelName()}</text>
31
+ <text fg={ui.fgDim}>{modelName()}</text>
31
32
  </box>
32
33
  </box>
33
34
  );
@@ -441,8 +441,8 @@ function MsgBubble(props: { m: any; idx: number }) {
441
441
 
442
442
  if (m.role === "system") {
443
443
  return (
444
- <box marginBottom={1} paddingX={2}>
445
- <text fg={ui.fgMuted}>{m.content}</text>
444
+ <box marginBottom={1} paddingX={2} backgroundColor={ui.bgMsg}>
445
+ <text fg={ui.fgMuted}>{"· " + m.content}</text>
446
446
  </box>
447
447
  );
448
448
  }
@@ -486,7 +486,7 @@ function MsgBubble(props: { m: any; idx: number }) {
486
486
  {!todoShown && m.todos?.length ? <TodosBlock todos={m.todos} /> : null}
487
487
  {!isThink ? (
488
488
  <box flexDirection="row" paddingLeft={3} marginTop={1}>
489
- <text fg={isErr ? ui.error : ui.secondary}>{"\u25A3 " + (agentTag || "Loom")}</text>
489
+ <text fg={isErr ? ui.error : ui.primary}>{"\u25A3 " + (agentTag || "Loom")}</text>
490
490
  <text fg={ui.fgMuted}>
491
491
  {(m.thinkTime ? " \u00B7 " + fmtMs(m.thinkTime) : "") + (isStop ? " \u00B7 interrupted" : "")}
492
492
  </text>
@@ -201,17 +201,19 @@ export function InputBar() {
201
201
  </text>
202
202
  {slashWin().items.map((item, i) => {
203
203
  const abs = slashWin().start + i;
204
+ const act = abs === autoIndex();
204
205
  return (
205
206
  <box
206
- flexDirection="row" paddingY={0}
207
+ flexDirection="row" paddingY={0}
208
+ backgroundColor={act ? ui.bgPanelAlt : undefined}
207
209
  onMouseDown={() => selectSuggestionAt(abs)}
208
210
  onMouseUp={() => pickSuggestionAt(abs)}
209
211
  >
210
- <text fg={abs === autoIndex() ? ui.primary : ui.fgDim}>
211
- {(abs === autoIndex() ? "\u25B6 " : " ") + item.label.split(" ")[0]}
212
+ <text fg={act ? ui.primary : ui.fgDim}>
213
+ {(act ? "▸ " : " ") + item.label.split(" ")[0]}
212
214
  </text>
213
215
  <Show when={!!item.desc}>
214
- <text fg={abs === autoIndex() ? ui.fg : ui.fgMuted}>
216
+ <text fg={act ? ui.fg : ui.fgMuted}>
215
217
  {" " + item.desc}
216
218
  </text>
217
219
  </Show>
@@ -230,15 +232,19 @@ export function InputBar() {
230
232
  >
231
233
  {fileWin().items.map((item, i) => {
232
234
  const abs = fileWin().start + i;
235
+ const act = abs === autoIndex();
233
236
  return (
234
- <text
235
-
236
- fg={abs === autoIndex() ? ui.primary : ui.fgDim}
237
+ // Box wrapper: text nodes ignore a direct backgroundColor prop.
238
+ <box
239
+ flexDirection="row"
240
+ backgroundColor={act ? ui.bgPanelAlt : undefined}
237
241
  onMouseDown={() => selectSuggestionAt(abs)}
238
242
  onMouseUp={() => pickSuggestionAt(abs)}
239
243
  >
240
- {(abs === autoIndex() ? "> " : " ") + item.label}
241
- </text>
244
+ <text fg={act ? ui.primary : ui.fgDim}>
245
+ {(act ? "▸ " : " ") + item.label}
246
+ </text>
247
+ </box>
242
248
  );
243
249
  })}
244
250
  <text fg={ui.fgMuted}>{" UP/DN TAB/ENTER pick wheel/click ESC close" + rangeHint(fileWin().start, fileWin().total)}</text>
@@ -271,8 +277,13 @@ export function InputBar() {
271
277
  <box flexGrow={1} flexDirection="column" justifyContent="center">
272
278
  <text>
273
279
  {/* @ts-ignore OpenTUI text nodes take fg/bg via the style prop (TextNodeOptions), not as direct props */}
274
- <span style={{ fg: showCursor() ? ui.fgMuted : ui.bgInput }}>{"\u2588"}</span>
275
- <span style={{ fg: ui.fgMuted }}>{" Ask anything... /commands @file !shell"}</span>
280
+ <span style={{ fg: showCursor() ? ui.fg : ui.bgInput }}>{"\u2588"}</span>
281
+ <span style={{ fg: ui.fgMuted }}>{" Ask anything... "}</span>
282
+ <span style={{ fg: ui.primary }}>{"/commands"}</span>
283
+ <span style={{ fg: ui.fgMuted }}>{" "}</span>
284
+ <span style={{ fg: ui.accent }}>{"@file"}</span>
285
+ <span style={{ fg: ui.fgMuted }}>{" "}</span>
286
+ <span style={{ fg: ui.warning }}>{"!shell"}</span>
276
287
  </text>
277
288
  </box>
278
289
  }
@@ -57,7 +57,8 @@ function spansToNodes(spans: any[], keyPrefix: string) {
57
57
 
58
58
  function HeadingText(props: { level: 1 | 2 | 3; spans: any[]; keyPrefix: string }) {
59
59
  const marker = props.level === 1 ? "# " : props.level === 2 ? "## " : "### ";
60
- return <text fg={ui.primary}>{marker}{spansToNodes(props.spans, props.keyPrefix)}</text>;
60
+ const color = props.level === 1 ? ui.primary : props.level === 2 ? ui.primarySoft : ui.secondary;
61
+ return <text fg={color}>{marker}{spansToNodes(props.spans, props.keyPrefix)}</text>;
61
62
  }
62
63
 
63
64
  export function MdText(props: { md: string; wrap?: boolean }) {
@@ -35,12 +35,16 @@ export function ModalFrame(props: { title: string; subtitle?: string; children:
35
35
  // Floating panel: centered over the terminal but NOT a full-screen takeover —
36
36
  // no backdrop fill, so the chat and any running agent stay visible behind it
37
37
  // ("same window, hovering"). Keys still belong to the modal while open.
38
+ // Border is subtle (ui.border) — the title carries the accent, no divider
39
+ // rules inside (they read as a grid).
38
40
  return (
39
41
  <box position="absolute" top={0} left={0} right={0} bottom={0}
40
42
  alignItems="center" justifyContent="center" flexDirection="column">
41
- <box border borderStyle="rounded" borderColor={ui.primary} backgroundColor={ui.bgPanel}
42
- paddingX={3} paddingY={2} flexDirection="column" minWidth={52} maxWidth={72}>
43
- <text fg={ui.primary}>{props.title}</text>
43
+ <box border borderStyle="rounded" borderColor={ui.border} backgroundColor={ui.bgPanel}
44
+ paddingX={3} paddingY={1} flexDirection="column" minWidth={52} maxWidth={72}>
45
+ <box flexDirection="row" alignItems="center">
46
+ <text fg={ui.primary}>{"◆ " + props.title}</text>
47
+ </box>
44
48
  {props.subtitle ? <text fg={ui.fgMuted} marginTop={0}>{props.subtitle}</text> : null}
45
49
  <box flexDirection="column" marginTop={1}>{props.children}</box>
46
50
  {props.footer ? <text fg={ui.fgMuted} marginTop={1}>{props.footer}</text> : null}
@@ -298,6 +302,7 @@ export function SelectModal(props: {
298
302
  return (
299
303
  <box
300
304
  flexDirection="row" paddingLeft={2}
305
+ backgroundColor={active ? ui.bgPanelAlt : undefined}
301
306
  // Hover moves the selection (live theme preview via onPreview) —
302
307
  // but only for genuine pointer movement, never while a
303
308
  // keyboard/wheel scroll is settling (see hoverLockUntil).
@@ -306,10 +311,10 @@ export function SelectModal(props: {
306
311
  onMouseUp={() => clickRow(abs)}
307
312
  >
308
313
  <text fg={active ? ui.primary : ui.fgDim}>
309
- {(active ? " > " : " ") + fit(opt.label, LABEL_MAX)}
314
+ {(active ? " ▸ " : " ") + fit(opt.label, LABEL_MAX)}
310
315
  </text>
311
- {opt.recent ? <text fg={ui.fgMuted}> {" \u2713 recent"}</text> : null}
312
- {opt.sub ? <text fg={ui.fgDim}> {"(" + fit(opt.sub, SUB_MAX) + ")"}</text> : null}
316
+ {opt.recent ? <text fg={ui.fgMuted}> {" ✓ recent"}</text> : null}
317
+ {opt.sub ? <text fg={active ? ui.fg : ui.fgDim}> {"(" + fit(opt.sub, SUB_MAX) + ")"}</text> : null}
313
318
  </box>
314
319
  );
315
320
  })}
@@ -615,25 +620,31 @@ function ServerBrowser(props: { kind: "mcp" | "connector" }) {
615
620
  // capped so the command column keeps ~28 chars. Without an explicit width the
616
621
  // name text yoga-shrinks to zero width when the command is long.
617
622
  const nameW = () => Math.min(30, Math.max(18, ...servers().map(s => String(s.name).length + 9)));
618
- // Command column budget: modal inner ~64 chars minus name column minus the
619
- // "→ " arrow — any longer and the text pixel-punches past the right border.
620
- const cmdW = () => Math.max(24, 62 - nameW());
623
+ // Command column budget: modal inner ~62 chars (64 minus the row's left
624
+ // padding) minus name column — any longer and the text pixel-punches past
625
+ // the right border.
626
+ const cmdW = () => Math.max(22, 60 - nameW());
621
627
 
622
628
  return (
623
- <ModalFrame title={title} subtitle={"Enter toggles a server on/off" + rangeSub()} footer={kbNav().submit + " toggle | A add " + (kind === "connector" ? "connector" : "server") + " | wheel scroll | " + kbNav().cancel + " close"}>
629
+ <ModalFrame title={title} subtitle={"Enter toggles a server" + rangeSub()} footer={kbNav().submit + " toggle · A add · " + kbNav().cancel + " close"}>
624
630
  <box onMouseScroll={scrollBy} flexDirection="column" flexShrink={0}>
625
631
  {win().items.map((s, i) => {
626
632
  const abs = win().start + i;
633
+ const active = abs === sel();
627
634
  return (
628
635
  <box
629
- flexDirection="row" paddingY={0} height={1} flexShrink={0}
636
+ flexDirection="row" paddingY={0} height={1} flexShrink={0}
637
+ paddingLeft={1}
638
+ backgroundColor={active ? ui.bgPanelAlt : undefined}
630
639
  onMouseDown={() => setSel(abs)}
631
640
  onMouseUp={() => { if (abs === sel()) { const r = toggleServer(s.name); if (!r || !r.error) refresh(); } }}
632
641
  >
633
- <text fg={abs === sel() ? ui.primary : ui.fgDim} width={nameW()} height={1} flexShrink={0}>
634
- {" " + (s.enabled ? "[on] " : "[off] ") + s.name}
642
+ <text fg={active ? ui.primary : ui.fgDim} width={nameW()} height={1} flexShrink={0}>
643
+ {(active ? "▸ " : " ") + "[" + (s.enabled ? "on" : "off") + "] " + s.name}
644
+ </text>
645
+ <text fg={s.enabled ? ui.success : ui.fgMuted} height={1} flexGrow={1}>
646
+ {mcpFit(s.command + " " + (s.args || []).join(" "), cmdW())}
635
647
  </text>
636
- <text fg={ui.fgMuted} height={1} flexGrow={1}>{"\u2192 " + mcpFit(s.command + " " + (s.args || []).join(" "), cmdW())}</text>
637
648
  </box>
638
649
  );
639
650
  })}
@@ -650,6 +661,166 @@ export function ConnectorsModal() {
650
661
  return <ServerBrowser kind="connector" />;
651
662
  }
652
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
+
653
824
  // Palette modal (ctrl+p) - proper popup window
654
825
  export function PaletteModal(props: { onPick: (cmd: string) => void }) {
655
826
  const items = SLASH_LIST.map(c => ({ label: "/" + c.cmd, value: c.cmd, sub: c.desc }));
@@ -705,15 +876,18 @@ export function PaletteModal(props: { onPick: (cmd: string) => void }) {
705
876
  <box onMouseScroll={scrollBy}>
706
877
  {win().items.map((it, i) => {
707
878
  const abs = win().start + i;
879
+ const active = abs === sel();
708
880
  return (
709
881
  <box
710
- flexDirection="row" paddingY={0}
882
+ flexDirection="row" paddingY={0}
883
+ backgroundColor={active ? ui.bgPanelAlt : undefined}
711
884
  onMouseDown={() => setSel(abs)}
712
885
  onMouseUp={() => clickRow(abs)}
713
886
  >
714
- <text fg={abs === sel() ? ui.primary : ui.fgDim}>
715
- {abs === sel() ? "> " : " "}{it.label.split("/")[1] || it.label}
887
+ <text fg={active ? ui.primary : ui.fgDim}>
888
+ {active ? "▸ " : " "}{it.label.split("/")[1] || it.label}
716
889
  </text>
890
+ {it.sub ? <text fg={active ? ui.fg : ui.fgMuted}>{" " + String(it.sub).slice(0, 36)}</text> : null}
717
891
  </box>
718
892
  );
719
893
  })}