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.
- package/CHANGELOG.md +128 -0
- package/README.md +38 -56
- package/bin/loom-bun.js +2 -10
- package/bin/loom.js +0 -10
- package/package.json +126 -126
- package/src/connectors/convex.js +356 -0
- package/src/connectors/deploy.js +390 -0
- package/src/connectors/index.js +23 -0
- package/src/connectors/supabase.js +279 -0
- package/src/core/agents.js +32 -2
- package/src/core/cli.js +30 -5
- package/src/core/permissions.js +12 -1
- package/src/core/plugin-cmd.js +1 -0
- package/src/core/sandbox.js +400 -0
- package/src/core/session-store.js +245 -167
- package/src/core/session.js +157 -11
- package/src/providers/anthropic.js +10 -5
- package/src/providers/openai-compat.js +12 -4
- package/src/tools/browser.js +204 -0
- package/src/tools/index.js +35 -1
- package/src/tui/App.tsx +103 -8
- package/src/tui/components/BreadcrumbBar.tsx +10 -9
- package/src/tui/components/ChatArea.tsx +3 -3
- package/src/tui/components/InputBar.tsx +22 -11
- package/src/tui/components/MdText.tsx +2 -1
- package/src/tui/components/Modals.tsx +191 -17
- package/src/tui/components/PermissionPopup.tsx +15 -12
- package/src/tui/components/Sidebar.tsx +6 -2
- package/src/tui/components/SplashScreen.tsx +17 -24
- package/src/tui/components/SubagentPanel.tsx +1 -1
- package/src/tui/components/ToastOverlay.tsx +23 -12
- package/src/tui/store.ts +38 -35
- package/src/tui/theme.ts +45 -30
- package/src/tui/tool-display.ts +13 -2
- package/docs/web.md +0 -205
- package/src/web/attach.js +0 -242
- package/src/web/graph-view.html +0 -262
- package/src/web/index.html +0 -824
- 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
|
-
|
|
121
|
+
// Popup stays terse: short desc only — syntax lives in /help (args).
|
|
122
|
+
var s: Suggestion = { label: "/" + c.cmd, desc: c.desc };
|
|
121
123
|
return s;
|
|
122
124
|
});
|
|
123
125
|
// Custom commands (.loom/commands/*.md) join the picker after built-ins.
|
|
124
126
|
try {
|
|
125
127
|
const { listCustomCommands } = require("../core/custom-commands.js");
|
|
126
128
|
for (const cc of listCustomCommands()) {
|
|
127
|
-
if (cc.name.toLowerCase().startsWith(q)) hits.push({ label: "/" + cc.name, desc: "custom command
|
|
129
|
+
if (cc.name.toLowerCase().startsWith(q)) hits.push({ label: "/" + cc.name, desc: "custom command" });
|
|
128
130
|
}
|
|
129
131
|
} catch {}
|
|
130
132
|
setSuggestions(hits); setAutoKind("slash"); setAutoIndex(0);
|
|
@@ -163,6 +165,25 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
|
|
|
163
165
|
if (raw.startsWith("/")) { recordPrompt(raw); setDraft(""); processSlash(raw); return; }
|
|
164
166
|
if (raw.startsWith("!")) { recordPrompt(raw); setDraft(""); appendMessage({ role: "user", content: raw }); appendMessage({ role: "system", content: runShell(raw.slice(1)) }); return; }
|
|
165
167
|
|
|
168
|
+
// Docker-install resume: while an install is pending, "docker is ready"
|
|
169
|
+
// continues the sandbox setup in THIS session instead of going to the
|
|
170
|
+
// model. Outside the pending flow the phrase is ordinary conversation.
|
|
171
|
+
if (sandbox.isReadySignal(raw)) {
|
|
172
|
+
var st0 = sandbox.sandboxStatus();
|
|
173
|
+
if (st0.pending || st0.docker === "ready" && st0.container === "absent" && /set ?up|build|create|make/i.test(raw)) {
|
|
174
|
+
recordPrompt(raw); setDraft("");
|
|
175
|
+
appendMessage({ role: "user", content: raw });
|
|
176
|
+
if (st0.docker === "ready" || st0.docker === "no_daemon") {
|
|
177
|
+
appendMessage({ role: "system", content: "Docker detected \u2014 pick an OS to finish the sandbox setup." });
|
|
178
|
+
openModal({ type: "sandbox" });
|
|
179
|
+
} else {
|
|
180
|
+
sandbox.markInstallPending();
|
|
181
|
+
appendMessage({ role: "system", content: sandbox.installGuide() });
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
166
187
|
// "@agent …" delegates the whole turn to that subagent (same as the model
|
|
167
188
|
// calling the task tool — but explicit). "@file …" still inlines the file.
|
|
168
189
|
var agentId: string | null = null;
|
|
@@ -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
|
-
|
|
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 || "(
|
|
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
|
-
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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:#
|
|
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,
|
|
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}>{"
|
|
21
|
+
<text fg={ui.fgMuted}>{" · "}</text>
|
|
21
22
|
<text fg={ui.fg}>{cwdShort() || "~"}</text>
|
|
22
|
-
<text fg={ui.fgMuted}>{"
|
|
23
|
-
<text fg={ui.
|
|
24
|
-
<text fg={ui.fgMuted}>{"
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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={
|
|
211
|
-
{(
|
|
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={
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
{
|
|
241
|
-
|
|
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.
|
|
275
|
-
<span style={{ fg: ui.fgMuted }}>{" Ask anything...
|
|
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
|
-
|
|
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.
|
|
42
|
-
paddingX={3} paddingY={
|
|
43
|
-
<
|
|
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 ? "
|
|
314
|
+
{(active ? " ▸ " : " ") + fit(opt.label, LABEL_MAX)}
|
|
310
315
|
</text>
|
|
311
|
-
{opt.recent ? <text fg={ui.fgMuted}> {"
|
|
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 ~
|
|
619
|
-
//
|
|
620
|
-
|
|
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
|
|
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
|
-
|
|
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={
|
|
634
|
-
{" " + (s.enabled ? "
|
|
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
|
-
|
|
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={
|
|
715
|
-
{
|
|
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
|
})}
|