loom-agent 1.2.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.
Files changed (79) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +402 -0
  3. package/LICENSE +21 -0
  4. package/LOOM.md +235 -0
  5. package/README.md +433 -0
  6. package/bin/loom-tui.js +43 -0
  7. package/bin/loom.js +44 -0
  8. package/docs/acp.md +151 -0
  9. package/docs/web.md +205 -0
  10. package/package.json +97 -0
  11. package/scripts/acp-smoke.js +146 -0
  12. package/src/acp/acp-server.js +287 -0
  13. package/src/config/provider-cmd.js +37 -0
  14. package/src/config/settings.js +164 -0
  15. package/src/core/agents.js +361 -0
  16. package/src/core/background-tasks.js +103 -0
  17. package/src/core/cli.js +579 -0
  18. package/src/core/custom-commands.js +70 -0
  19. package/src/core/errors.js +29 -0
  20. package/src/core/events.js +24 -0
  21. package/src/core/file-diffs.js +282 -0
  22. package/src/core/format.js +206 -0
  23. package/src/core/graph.js +257 -0
  24. package/src/core/hooks.js +82 -0
  25. package/src/core/lsp.js +385 -0
  26. package/src/core/memory.js +87 -0
  27. package/src/core/model-router.js +87 -0
  28. package/src/core/permissions.js +327 -0
  29. package/src/core/platform.js +33 -0
  30. package/src/core/plugin-cmd.js +380 -0
  31. package/src/core/restore.js +207 -0
  32. package/src/core/session-store.js +167 -0
  33. package/src/core/session.js +910 -0
  34. package/src/core/subagent-log.js +134 -0
  35. package/src/core/tokens.js +31 -0
  36. package/src/core/update.js +6 -0
  37. package/src/core/usage.js +166 -0
  38. package/src/index.js +41 -0
  39. package/src/mcp/mcp-client.js +201 -0
  40. package/src/mcp/mcp-manager.js +193 -0
  41. package/src/providers/anthropic.js +243 -0
  42. package/src/providers/google.js +29 -0
  43. package/src/providers/index.js +175 -0
  44. package/src/providers/local.js +27 -0
  45. package/src/providers/nvidia.js +85 -0
  46. package/src/providers/openai-compat.js +269 -0
  47. package/src/providers/openai.js +35 -0
  48. package/src/providers/openrouter.js +43 -0
  49. package/src/providers/registry.js +196 -0
  50. package/src/providers/tokenrouter.js +19 -0
  51. package/src/skills/skill-matcher.js +133 -0
  52. package/src/skills/skills-manager.js +213 -0
  53. package/src/tools/index.js +543 -0
  54. package/src/tui/App.tsx +1578 -0
  55. package/src/tui/components/BreadcrumbBar.tsx +34 -0
  56. package/src/tui/components/ChatArea.tsx +518 -0
  57. package/src/tui/components/InputBar.tsx +354 -0
  58. package/src/tui/components/MdText.tsx +105 -0
  59. package/src/tui/components/Modals.tsx +851 -0
  60. package/src/tui/components/PermissionPopup.tsx +264 -0
  61. package/src/tui/components/Sidebar.tsx +182 -0
  62. package/src/tui/components/SplashScreen.tsx +51 -0
  63. package/src/tui/components/SubagentPanel.tsx +217 -0
  64. package/src/tui/components/ToastOverlay.tsx +34 -0
  65. package/src/tui/keybinds.ts +318 -0
  66. package/src/tui/mcp-presets.ts +189 -0
  67. package/src/tui/md-render.ts +228 -0
  68. package/src/tui/store.ts +714 -0
  69. package/src/tui/suite-home.ts +20 -0
  70. package/src/tui/theme.ts +313 -0
  71. package/src/tui/themes.generated.ts +968 -0
  72. package/src/tui/tool-display.ts +176 -0
  73. package/src/tui/toolname.ts +60 -0
  74. package/src/tui/tui-config.ts +28 -0
  75. package/src/tui-open.tsx +51 -0
  76. package/src/web/attach.js +242 -0
  77. package/src/web/graph-view.html +262 -0
  78. package/src/web/index.html +824 -0
  79. package/src/web/web-server.js +470 -0
@@ -0,0 +1,1578 @@
1
+ // Loom Code - OpenTUI app root component.
2
+ // useKeyboard handles ALL text input char-by-char.
3
+ import { onMount, onCleanup, createMemo, Show } from "solid-js";
4
+ import { useKeyboard, usePaste, useRenderer, useSelectionHandler } from "@opentui/solid";
5
+ import path from "path";
6
+ import fs from "fs";
7
+ import os from "os";
8
+ import { execSync } from "child_process";
9
+ import { palette, setTheme, themeOptions, themeName, LOOM_LOGO } from "./theme.ts";
10
+ import {
11
+ messages, setMessages, input, setInput, cursor, setCursor, setDraft,
12
+ selStart, selEnd, setSelStart, setSelEnd, clearSelection,
13
+ pastedAt, setPastedAt,
14
+ thinking, setThinking,
15
+ thinkStart, setThinkStart,
16
+ modal, openModal, closeModal,
17
+ suggestions, setSuggestions, autoKind, setAutoKind, setAutoIndex,
18
+ sidebarVisible, setSidebarVisible, sidebarTab, setSidebarTab,
19
+ refreshProviderState, refreshUsage, appendMessage, patchLastMessage, patchMessageAt, recomputeTodos,
20
+ invalidateFilesCache,
21
+ getSession, persistUi,
22
+ providerName, modelName, sessionId, modelMeta,
23
+ showToolDetails, setShowToolDetails, showThinking, setShowThinking,
24
+ setThoughtClosed,
25
+ inputMode, setInputMode,
26
+ setSkillActive,
27
+ SLASH_LIST, fuzzyFiles,
28
+ Suggestion,
29
+ registerSuggestionPicker, pickSuggestion,
30
+ recordPrompt, historyPrev, historyNext, historyReset,
31
+ speedStats, setSpeedStats,
32
+ permission, requestPermission, askSessionPermissions,
33
+ setSessionAuto, autoPerm,
34
+ userExpandedIdx, setUserExpandedIdx,
35
+ showToast,
36
+ wireTodoEvents,
37
+ startSubagent, updateSubagent, endSubagent, persistSubagent, loadSubagentHistory,
38
+ activeSubagents, subagentHistory,
39
+ queueDraft, dequeueDraft, queuedDrafts,
40
+ vimMode, vimNormal, setVimNormal, toggleVim,
41
+ } from "./store.ts";
42
+ import { BreadcrumbBar } from "./components/BreadcrumbBar.tsx";
43
+ import * as kbs from "./keybinds.ts";
44
+ import { SplashScreen } from "./components/SplashScreen.tsx";
45
+ import { ChatArea, estVisualLines, USER_PREVIEW_LINES } from "./components/ChatArea.tsx";
46
+ import { Sidebar } from "./components/Sidebar.tsx";
47
+ import { InputBar } from "./components/InputBar.tsx";
48
+ import { ToastOverlay } from "./components/ToastOverlay.tsx";
49
+ import { PermissionPopup } from "./components/PermissionPopup.tsx";
50
+ import { prettyToolName, stripAnsi } from "./toolname.ts";
51
+ import { toolDisplay } from "./tool-display.ts";
52
+ import {
53
+ ProviderPicker, SelectModal, InputModal, SettingsModal,
54
+ PaletteModal, McpModal, ConnectorsModal, GraphModal,
55
+ openModelPicker, openKeyModal, openBaseUrlEditor,
56
+ showProvidersText, showHelpText, showAgentsText,
57
+ openGraphModal,
58
+ } from "./components/Modals.tsx";
59
+ import { SubagentPanel, SubagentDetailPanel } from "./components/SubagentPanel.tsx";
60
+ import { saveSession, loadSession, listSessions } from "../core/session-store.js";
61
+ import { loadConfig, saveConfig, getBaseUrl } from "../config/settings.js";
62
+ import { loadAgents, resolveAgent } from "../core/agents.js";
63
+ import { snapshotBefore, snapshotAfter, snapshotBashBefore, diffBashAfter, clearFileDiffs } from "../core/file-diffs.js";
64
+ import { createRestorePoint, listRestorePoints, restoreTo } from "../core/restore.js";
65
+ import { PROVIDERS, PROVIDER_ORDER, PROVIDER_LABELS } from "../providers/index.js";
66
+ import * as plugin from "../core/plugin-cmd.js";
67
+ import { on } from "../core/events.js";
68
+ import { listSkills } from "../skills/skills-manager.js";
69
+
70
+ let _vimReg = ""; // vim NORMAL-mode paste register
71
+
72
+ const ui = palette("loom");
73
+
74
+ export function App(props: { initialPrompt?: string; resumeSession?: string; autoMode?: boolean }) {
75
+ const renderer = useRenderer();
76
+ if (props.autoMode) setSessionAuto(true);
77
+
78
+ // First ESC while a turn runs only arms the interrupt; a second ESC within
79
+ // the window actually stops it (no more accidental Interrupts).
80
+ let escArmAt: number | null = null;
81
+
82
+ function quit(code: number = 0) {
83
+ persistUi();
84
+ let sessionId = "";
85
+ try { sessionId = saveSession(syncSessionForSave()).id; } catch {}
86
+ try { renderer.destroy(); } catch {}
87
+ setTimeout(function() {
88
+ console.log("");
89
+ console.log(LOOM_LOGO.join("\n"));
90
+ console.log(" resume: loomcode -s " + sessionId);
91
+ console.log("");
92
+ process.exit(code);
93
+ }, 150);
94
+ }
95
+
96
+ // Shell helper
97
+ function runShell(cmd: string): string {
98
+ try {
99
+ return (execSync(cmd, { cwd: process.cwd(), encoding: "utf8", timeout: 15000, stdio: "pipe", windowsHide: true }) || "(no output)").slice(0, 4000);
100
+ } catch (e: any) { return "Error: " + String(e?.message || e).slice(0, 500); }
101
+ }
102
+
103
+ function expandAt(text: string): string {
104
+ return text.replace(/@([\w\.\-\/\\]+)/g, function(_, r) {
105
+ var p = path.join(process.cwd(), r);
106
+ if (fs.existsSync(p)) {
107
+ try { return "\n\n[File: " + r + "]\n" + fs.readFileSync(p, "utf8").slice(0, 5000); } catch {}
108
+ }
109
+ return _;
110
+ });
111
+ }
112
+
113
+ function updateAutocomplete(text: string) {
114
+ if (!text) { setSuggestions([]); setAutoKind("none"); setAutoIndex(0); return; }
115
+ if (text.startsWith("/")) {
116
+ var q = text.slice(1).toLowerCase();
117
+ var hits = SLASH_LIST.filter(function(c) { return c.cmd.startsWith(q); }).map(function(c) {
118
+ var s: Suggestion = { label: "/" + c.cmd, desc: c.desc + (c.args ? " \u2014 " + c.args : "") };
119
+ return s;
120
+ });
121
+ // Custom commands (.loom/commands/*.md) join the picker after built-ins.
122
+ try {
123
+ const { listCustomCommands } = require("../core/custom-commands.js");
124
+ for (const cc of listCustomCommands()) {
125
+ if (cc.name.toLowerCase().startsWith(q)) hits.push({ label: "/" + cc.name, desc: "custom command \u2014 " + cc.file });
126
+ }
127
+ } catch {}
128
+ setSuggestions(hits); setAutoKind("slash"); setAutoIndex(0);
129
+ } else if (text.startsWith("@") && !/\s/.test(text.slice(text.lastIndexOf("@") + 1))) {
130
+ // Only when the trailing token after the last @ is unspaced ("@ex…"):
131
+ // a completed "@agent query" must not re-open the picker.
132
+ var m = text.match(/@([\w\.\-\/\\]*)$/);
133
+ var q = (m ? m[1] : "").toLowerCase();
134
+ // Subagents first (the main agent delegates to them automatically), then
135
+ // files, so "@ex…" suggests @explore before paths.
136
+ var agentHits = Object.values(loadAgents()).filter(function(a: any) {
137
+ return a.mode === "subagent" && (a.id.startsWith(q) || a.name.toLowerCase().startsWith(q));
138
+ }).map(function(a: any) { return { label: "@" + a.id, desc: a.description } as Suggestion; });
139
+ var fileHits = fuzzyFiles(m ? m[1] : "").slice(0, Math.max(1, 10 - agentHits.length)).map(function(f) { return { label: "@" + f } as Suggestion; });
140
+ setSuggestions(agentHits.concat(fileHits)); setAutoKind("at"); setAutoIndex(0);
141
+ } else if (text.startsWith("@")) {
142
+ setSuggestions([]); setAutoKind("none"); setAutoIndex(0);
143
+ } else if (text.startsWith("!")) {
144
+ setSuggestions([{ label: "!ls -la" }, { label: "!git status" }, { label: "!git diff" }, { label: "!pwd" }] as Suggestion[]); setAutoKind("shell"); setAutoIndex(0);
145
+ } else {
146
+ // Plain text draft: no prefix, no suggestions — stale autocomplete
147
+ // selection must not take precedence over submission.
148
+ setSuggestions([]); setAutoKind("none"); setAutoIndex(0);
149
+ }
150
+ }
151
+
152
+ // While a turn is running, Enter does NOT consume the input: the typed text
153
+ // stays in the bar (editable) and is sent normally once the task finishes.
154
+ // recordPrompt() runs only when the prompt is actually submitted, so a held
155
+ // draft that was never sent doesn't pollute the prompt history.
156
+ function submit(text: string) {
157
+ var raw = text.trim();
158
+ if (!raw) return;
159
+ setSuggestions([]); setAutoKind("none"); setAutoIndex(0);
160
+
161
+ if (raw.startsWith("/")) { recordPrompt(raw); setDraft(""); processSlash(raw); return; }
162
+ if (raw.startsWith("!")) { recordPrompt(raw); setDraft(""); appendMessage({ role: "user", content: raw }); appendMessage({ role: "system", content: runShell(raw.slice(1)) }); return; }
163
+
164
+ // "@agent …" delegates the whole turn to that subagent (same as the model
165
+ // calling the task tool — but explicit). "@file …" still inlines the file.
166
+ var agentId: string | null = null;
167
+ var userText: string | undefined;
168
+ if (raw.startsWith("@")) {
169
+ var atMatch = raw.match(/^@([\w\-]+)\s*([\s\S]*)$/);
170
+ if (atMatch) {
171
+ var atAgent = resolveAgent(atMatch[1]);
172
+ if (atAgent && atAgent.mode === "subagent") {
173
+ agentId = atAgent.id;
174
+ userText = atMatch[2].trim() || "Continue with your task.";
175
+ }
176
+ }
177
+ if (!agentId) raw = expandAt(raw);
178
+ }
179
+
180
+ if (thinking()) {
181
+ // Keep the text in the input bar — hint shown in the footer while held.
182
+ return;
183
+ }
184
+ recordPrompt(raw);
185
+ setDraft("");
186
+ runPrompt(userText != null ? userText : raw, false, agentId, userText);
187
+ }
188
+
189
+ function runPrompt(raw: string, shown: boolean, agentId?: string | null, userText?: string) {
190
+ if (!shown) appendMessage({ role: "user", content: raw });
191
+ // Snapshot the project in the background so /restore always works, without
192
+ // stalling the first API call.
193
+ setTimeout(function() { try { createRestorePoint(raw); } catch {} }, 0);
194
+ setThinking(true); setThinkStart(Date.now());
195
+ var idx = messages().length;
196
+ appendMessage({ role: "assistant", content: "", thinking: true, agentLabel: agentId || undefined });
197
+ // Live-collapse state from a previous turn must not leak onto this one.
198
+ setThoughtClosed(new Set<number>());
199
+ var t0 = Date.now();
200
+
201
+ var sess = getSession();
202
+ sess.setMode(inputMode());
203
+ setSpeedStats({ live: { elapsedMs: 0, firstTokenMs: null, tokensPerSec: 0 }, last: sess.getSpeed().last });
204
+ var turnDiffs: any[] = [];
205
+ var lastSpeedPush = 0;
206
+ // Streaming is batched: rapid deltas would re-render the whole chat (and
207
+ // the diff patches inside it) dozens of times a second, which flickers the
208
+ // whole screen. Text is accumulated and flushed ~10/sec instead.
209
+ var contentAcc = "";
210
+ var reasonAcc = "";
211
+ var flushTimer: any = null;
212
+ // Opencode-style tool activity, interleaved with thinking and text: every
213
+ // event lands as its OWN part in arrival order, so the chat streams as a
214
+ // flow — thinking, then the read row, thinking again, the edit row, then
215
+ // the reply — never a fixed "thinking on top, tools below" layout. Each
216
+ // tool call keeps its own row ("~ Preparing edit..." while running, then
217
+ // a muted "→ Read a.ts" that STAYS in the transcript like opencode).
218
+ // A finished write/edit/bash that actually changed a file attaches its
219
+ // diff to that row's part, so the patch renders inline right where the
220
+ // edit happened, then the message continues normally. Parts are
221
+ // THROTTLED: bursts must not re-render at full rate (that is what made
222
+ // fast output flicker), so the first tool of a burst patches immediately
223
+ // and the rest ride the 100ms stream flush.
224
+ var parts: any[] = [];
225
+ var lastToolPatch = 0;
226
+ // Fresh array of part objects with reasoning durations stamped in (the
227
+ // active reasoning part's duration grows while it streams; once the model
228
+ // moves on its first/last timestamps freeze, so the settled "+ Thought · Ns"
229
+ // shows the time it actually spent thinking).
230
+ function snapshotParts() {
231
+ return parts.map(function(p: any) {
232
+ if (p.type === "reasoning") {
233
+ return { ...p, thinkMs: Math.max(0, (p.lastAt || p.firstAt || 0) - (p.firstAt || 0)) };
234
+ }
235
+ return p;
236
+ });
237
+ }
238
+ function ensureTextPart(txt: string) {
239
+ if (!txt) return;
240
+ var hasText = parts.some(function(p: any) { return p.type === "text"; });
241
+ if (!hasText) parts.push({ type: "text", text: String(txt) });
242
+ }
243
+ // Live subagent delegation panel (task tool): streamed deltas, tool calls
244
+ // and status land in the message's `subagent` field while the child runs.
245
+ var subAcc: any = { agent: "", text: "", log: "", status: "" };
246
+ function flushStream() {
247
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
248
+ // Patch the FULL accumulated state (never reset): the old per-delta
249
+ // replace made the bubble show only the newest chunk and flicker the
250
+ // whole screen while writing.
251
+ if (contentAcc) patchLastMessage({ content: contentAcc });
252
+ if (reasonAcc) patchLastMessage({ thinkingContent: reasonAcc });
253
+ if (parts.length) patchLastMessage({ parts: snapshotParts() });
254
+ if (subAcc.agent) patchMessageAt(idx, { subagent: { agent: subAcc.agent, text: subAcc.text, log: subAcc.log, status: subAcc.status } });
255
+ }
256
+ var speedTimer = setInterval(function() {
257
+ if (thinking()) setSpeedStats({ live: sess.getSpeed().live, last: sess.getSpeed().last });
258
+ }, 1000);
259
+ // Per-turn todo capture: registered BEFORE the turn starts so a fast
260
+ // first todo update is never missed; todo updates land in the message's
261
+ // patch region (alongside file diffs) instead of only the sidebar.
262
+ var offTodos: (() => void) | null = on("todos:changed", function(list: any[]) {
263
+ patchMessageAt(idx, { todos: (Array.isArray(list) ? list : []).map(function(t: any) {
264
+ return { done: t.status === "completed", inProgress: t.status === "in_progress", cancelled: t.status === "cancelled", text: String(t.content || "") };
265
+ }) });
266
+ });
267
+ var sendOpts: any = agentId ? { agentId: agentId } : undefined;
268
+ try {
269
+ sess.sendUserMessage(raw, {
270
+ onDelta: function(txt) {
271
+ contentAcc += txt;
272
+ // Text streams where it arrived: merge into the running text part, or
273
+ // start a new one below the tools/reasoning that came before it.
274
+ var last = parts[parts.length - 1];
275
+ if (last && last.type === "text") last.text += txt;
276
+ else parts.push({ type: "text", text: String(txt) });
277
+ if (!flushTimer) flushTimer = setTimeout(flushStream, 100);
278
+ // Throttle sidebar speed updates to ~2/sec so fast streams don't spam renders.
279
+ var now = Date.now();
280
+ if (now - lastSpeedPush > 500) {
281
+ lastSpeedPush = now;
282
+ setSpeedStats({ live: sess.getSpeed().live, last: sess.getSpeed().last });
283
+ }
284
+ },
285
+ onReasoning: function(txt) {
286
+ reasonAcc += txt;
287
+ // Merge into the live reasoning part (the model streams one thinking
288
+ // block at a time); a tool/text part in between starts a NEW reasoning
289
+ // part below it — opencode interleaves thinking with the tool rows.
290
+ var last = parts[parts.length - 1];
291
+ if (last && last.type === "reasoning") {
292
+ last.text += txt;
293
+ last.lastAt = Date.now();
294
+ } else {
295
+ var now = Date.now();
296
+ parts.push({ type: "reasoning", text: String(txt), firstAt: now, lastAt: now });
297
+ }
298
+ if (!flushTimer) flushTimer = setTimeout(flushStream, 100);
299
+ },
300
+ onSubagent: function(ev: any) {
301
+ if (!ev) return;
302
+ // Mirror the streamed event into the global subagent tracker so the
303
+ // /subagents panel can show live status alongside the per-message block.
304
+ // Tracker writes are guarded by try/catch so a tracker bug never
305
+ // breaks streaming.
306
+ try {
307
+ const runId = ev.runId || subAcc.runId;
308
+ if (ev.type === "start" && runId) {
309
+ subAcc.runId = runId;
310
+ startSubagent({ runId, agent: ev.agent || "", agentId: ev.id || "", prompt: String(ev.text || ""), sessionId: getSession()?.conversationId });
311
+ } else if (runId) {
312
+ if (ev.type === "delta" || ev.type === "reasoning") updateSubagent(runId, { contentAppend: String(ev.text || "") });
313
+ else if (ev.type === "tool") updateSubagent(runId, { toolLogAppend: String(ev.text || "") });
314
+ else if (ev.type === "done") {
315
+ const wasInterrupted = !!ev.interrupted;
316
+ const finished = endSubagent(runId, {
317
+ status: wasInterrupted ? "cancelled" : "done",
318
+ content: String(ev.text || ""),
319
+ interrupted: wasInterrupted,
320
+ durationMs: typeof ev.durationMs === "number" ? ev.durationMs : undefined,
321
+ tokensIn: typeof ev.tokensIn === "number" ? ev.tokensIn : undefined,
322
+ tokensOut: typeof ev.tokensOut === "number" ? ev.tokensOut : undefined,
323
+ costUsd: typeof ev.costUsd === "number" ? ev.costUsd : undefined,
324
+ });
325
+ if (finished) persistSubagent(finished);
326
+ } else if (ev.type === "error") {
327
+ const finished = endSubagent(runId, { status: "error", content: String(ev.text || ""), interrupted: false });
328
+ if (finished) persistSubagent(finished);
329
+ }
330
+ }
331
+ } catch {}
332
+ if (ev.agent) subAcc.agent = ev.agent;
333
+ if (ev.type === "delta" || ev.type === "reasoning") subAcc.text += String(ev.text || "");
334
+ else if (ev.type === "tool") subAcc.log += (subAcc.log ? " \u00B7 " : "") + String(ev.text || "");
335
+ else if (ev.type === "status") subAcc.status = String(ev.text || "");
336
+ if (!flushTimer) flushTimer = setTimeout(flushStream, 100);
337
+ },
338
+ onAutoCompact: function(res) {
339
+ if (res?.compacted) {
340
+ appendMessage({ role: "system", content: "Auto-compacted: " + res.method + " \u2014 summarized/truncated " + res.removed + " earlier messages. Context was near the model limit." });
341
+ }
342
+ },
343
+ onTool: function(name, inp, callId) {
344
+ var pn = prettyToolName(name);
345
+ var dsp = toolDisplay(pn);
346
+ parts.push({ type: "tool", tool: { name: pn, icon: dsp.icon, pending: dsp.pending, label: dsp.label(inp || {}), status: "running", callId: callId || undefined } });
347
+ // First tool of a burst shows immediately; follow-ups ride the
348
+ // 100ms stream flush so a rapid tool sequence does not re-render
349
+ // the bubble (and the whole chat) dozens of times a second.
350
+ if (Date.now() - lastToolPatch > 150) {
351
+ lastToolPatch = Date.now();
352
+ flushStream();
353
+ } else if (!flushTimer) {
354
+ flushTimer = setTimeout(flushStream, 100);
355
+ }
356
+ // Diff capture is per-tool display data too ("file" snapshots the
357
+ // path, "bash" diffs the tree) — no names in the loop.
358
+ if (dsp.diffs === "file") {
359
+ var fp = inp?.filePath;
360
+ if (fp) { snapshotBefore(fp); }
361
+ }
362
+ if (dsp.diffs === "bash") snapshotBashBefore();
363
+ },
364
+ onPermissionRequest: function(name, command, label, options) {
365
+ // The permission popup rises from the input bar; the turn stays paused
366
+ // until the user picks Allow / Always allow / Deny — or, for the ask
367
+ // tool, one of the question options / a typed answer.
368
+ return requestPermission(
369
+ String(name),
370
+ String(command || ""),
371
+ String(label || ""),
372
+ name === "ask",
373
+ Array.isArray(options) ? options.map(String) : undefined
374
+ );
375
+ },
376
+ onToolOutput: function(tc, chunk, kind) {
377
+ // Live terminal output (bash): append to the matching RUNNING tool
378
+ // part so the chat streams a growing output block. The callId keeps
379
+ // parallel commands on their own rows.
380
+ var cid = tc && tc.id;
381
+ for (var pi = parts.length - 1; pi >= 0; pi--) {
382
+ var pt = parts[pi];
383
+ if (pt.type !== "tool" || pt.tool.status !== "running") continue;
384
+ if (cid) {
385
+ if (pt.tool.callId && pt.tool.callId === cid) break;
386
+ continue;
387
+ }
388
+ if (!pt.tool.callId) break;
389
+ }
390
+ if (pi >= 0 && parts[pi].type === "tool") {
391
+ var buf = (parts[pi].tool.liveOutput || "") + String(chunk || "");
392
+ if (buf.length > 6000) buf = "\u2026 (stream truncated)\n" + buf.slice(-6000);
393
+ parts[pi].tool.liveOutput = buf;
394
+ }
395
+ if (!flushTimer) flushTimer = setTimeout(flushStream, 100);
396
+ },
397
+ onToolResult: function(name, out, inp, callId) {
398
+ // Mark the LAST running tool part for this tool as done (LIFO — a
399
+ // burst of the same tool resolves in order; callId pins the exact row).
400
+ var pn = prettyToolName(name);
401
+ var doneIdx = -1;
402
+ for (var pi = parts.length - 1; pi >= 0; pi--) {
403
+ var pt = parts[pi];
404
+ if (pt.type === "tool" && pt.tool.name === pn && pt.tool.status === "running") {
405
+ if (callId && pt.tool.callId && pt.tool.callId !== callId) continue;
406
+ pt.tool.status = out?.error ? "error" : "done";
407
+ delete pt.tool.liveOutput;
408
+ doneIdx = pi;
409
+ break;
410
+ }
411
+ }
412
+ // Keep the tool's RESULT on its part (stripped, capped) so the chat can
413
+ // render an output block — opencode shows bash/generic tool output in
414
+ // the transcript; the agent's work product belongs in the chat, not
415
+ // only the log.
416
+ if (doneIdx >= 0 && !out?.error && typeof out?.result === "string") {
417
+ var resTxt = stripAnsi(out.result).trim();
418
+ if (resTxt.length > 4000) resTxt = resTxt.slice(0, 4000) + "\n\u2026 (truncated)";
419
+ if (resTxt) parts[doneIdx].tool.output = resTxt;
420
+ }
421
+ flushStream();
422
+ var diffs2: any[] = [];
423
+ var fp = inp?.filePath;
424
+ var dsp = toolDisplay(pn);
425
+ if (dsp.diffs === "file" && fp && !out?.error) {
426
+ try {
427
+ var d = snapshotAfter(fp);
428
+ // New files get no diff — there is nothing to change yet; only
429
+ // edits of existing files show a patch.
430
+ if (d.added || d.removed) diffs2.push(d);
431
+ } catch {}
432
+ }
433
+ if (dsp.diffs === "bash" && !out?.error) {
434
+ try { diffs2 = diffs2.concat(diffBashAfter().filter(function(d2: any) { return !d2.isNew; })); } catch {}
435
+ }
436
+ if (diffs2.length) {
437
+ // Attach the diff to the part that produced it (opencode renders the
438
+ // patch INLINE in the Edit part, right where the edit happened) and
439
+ // keep the message-level copy for restored sessions.
440
+ if (doneIdx >= 0) parts[doneIdx].tool.fileDiffs = diffs2.slice();
441
+ var merged = turnDiffs.filter(function(x) { return !diffs2.some(function(y) { return y.abs === x.abs; }); }).concat(diffs2);
442
+ turnDiffs = merged;
443
+ // Patch only when the list actually changed, or every tool result
444
+ // forces a full re-render of the message mid-stream.
445
+ var cur = messages()[idx]?.fileDiffs || [];
446
+ if (cur.length !== merged.length || cur.some(function(x: any, i: number) { return x.abs !== merged[i].abs; })) {
447
+ patchMessageAt(idx, { fileDiffs: merged });
448
+ }
449
+ }
450
+ },
451
+ onModelSwitch: function(info) {
452
+ appendMessage({ role: "system", content: "Model \u2014 tokens finished on " + info.from + ", auto-switched to " + info.to + " and retrying." });
453
+ refreshProviderState();
454
+ },
455
+ }, sendOpts).then(function(resp) {
456
+ // Providers that answer without streaming text (no onDelta ever fired)
457
+ // still land their reply as the final text part.
458
+ ensureTextPart(resp.content);
459
+ flushStream();
460
+ var isErr = resp.type === "error";
461
+ if (resp.interrupted) {
462
+ // Keep the partial text in the bubble; the session already stored it,
463
+ // so the user can type "continue" to resume the task.
464
+ patchMessageAt(idx, { thinking: false, interrupted: true, thinkTime: Date.now() - t0, isError: false });
465
+ appendMessage({ role: "system", content: "Interrupted \u2014 partial response kept. Type \"continue\" to resume the task." });
466
+ } else {
467
+ patchMessageAt(idx, { content: resp.content || "(no response)", thinking: false, thinkTime: Date.now() - t0, isError: isErr });
468
+ }
469
+ if (!isErr && sess.mode === "plan") {
470
+ appendMessage({ role: "system", content: "Plan complete \u2014 press Tab to switch to Build, then send \"go\" to execute." });
471
+ }
472
+ setSpeedStats({ live: null, last: sess.getSpeed().last });
473
+ }).catch(function(e) {
474
+ ensureTextPart("Error: " + String(e?.message || e).slice(0, 500));
475
+ flushStream();
476
+ var err = e || {};
477
+ patchMessageAt(idx, { content: "Error: " + String(err.message || err).slice(0, 500), thinking: false, isError: true, thinkTime: Date.now() - t0 });
478
+ }).finally(function() {
479
+ clearInterval(speedTimer);
480
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
481
+ if (offTodos) { offTodos(); offTodos = null; }
482
+ // Freeze the subagent panel at its final state ("finished").
483
+ if (subAcc.agent) patchMessageAt(idx, { subagent: { agent: subAcc.agent, text: subAcc.text, log: subAcc.log, status: subAcc.status, done: true } });
484
+ setThinking(false); setThinkStart(null); recomputeTodos(); refreshUsage();
485
+ flushQueueSoon();
486
+ });
487
+ } catch (e: any) {
488
+ // A provider that throws synchronously (bad key, malformed config) must
489
+ // still finish the turn — never leave the chat stuck on "Thinking".
490
+ ensureTextPart("Error: " + String(e?.message || e).slice(0, 500));
491
+ flushStream();
492
+ patchMessageAt(idx, { content: "Error: " + String(e?.message || e).slice(0, 500), thinking: false, isError: true, thinkTime: Date.now() - t0 });
493
+ clearInterval(speedTimer);
494
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
495
+ if (offTodos) { offTodos(); offTodos = null; }
496
+ setThinking(false); setThinkStart(null); recomputeTodos(); refreshUsage();
497
+ flushQueueSoon();
498
+ }
499
+ }
500
+
501
+ // Send the next queued draft (deferred so it never runs inside the turn's
502
+ // finally stack); no-op when a new turn already started or nothing queued.
503
+ function flushQueueSoon() {
504
+ setTimeout(function() {
505
+ if (thinking() || permission() || modal()) return;
506
+ const next = dequeueDraft();
507
+ if (next) submit(next);
508
+ }, 60);
509
+ }
510
+
511
+ // Save the FULL chat: the core session only tracks {role, content,
512
+ // toolCalls, reasoning} (that list feeds the provider API), while the TUI's
513
+ // display parts — thinking text, tool rows, diffs, todos, error flags — live
514
+ // in the store's message list. Merge them into the session before persisting
515
+ // so /sessions restores the whole conversation, not just a text dump.
516
+ function syncSessionForSave() {
517
+ var sess = getSession();
518
+ var ui = messages();
519
+ // Pair by TURN INDEX (tail-aligned), never by content: repeated, empty or
520
+ // identical assistant replies would otherwise attach the wrong message's
521
+ // parts/tools/diffs/todos. Walking from the end keeps the pairing aligned
522
+ // when auto-compaction trims the session's head but not the UI's.
523
+ var uiAsst: any[] = [];
524
+ for (var j = 0; j < ui.length; j++) if (ui[j].role === "assistant") uiAsst.push(ui[j]);
525
+ var uiIdx = uiAsst.length - 1;
526
+ var sessMsgs = sess.messages.slice();
527
+ for (var i = sessMsgs.length - 1; i >= 0; i--) {
528
+ var m = sessMsgs[i];
529
+ if (m.role !== "assistant") continue;
530
+ var u = uiAsst[uiIdx];
531
+ uiIdx--;
532
+ if (!u || !(u.parts || u.thinkingContent || u.todos || u.fileDiffs || u.isError || u.interrupted)) continue;
533
+ // Strip ephemeral stream state (live terminal output) — the saved chat
534
+ // keeps the final tool output only.
535
+ var cleanParts = Array.isArray(u.parts) ? u.parts.map(function(p: any) {
536
+ if (p.type === "tool" && p.tool && p.tool.liveOutput) {
537
+ var t = Object.assign({}, p.tool);
538
+ delete t.liveOutput;
539
+ return Object.assign({}, p, { tool: t });
540
+ }
541
+ return p;
542
+ }) : u.parts;
543
+ sessMsgs[i] = {
544
+ ...m,
545
+ parts: cleanParts, tools: u.tools, thinkingContent: u.thinkingContent,
546
+ todos: u.todos, fileDiffs: u.fileDiffs, isError: u.isError,
547
+ interrupted: u.interrupted, thinkTime: u.thinkTime,
548
+ };
549
+ }
550
+ sess.messages = sessMsgs;
551
+ return sess;
552
+ }
553
+
554
+ // Jump to a saved session: swap its messages into the live conversation
555
+ // (used by /sessions picker and `loom -s <id>` resume on start). The saved
556
+ // file carries the full display chat (parts, thinking, diffs, todos); for
557
+ // older saves that only have toolCalls, rebuild the tool rows from them and
558
+ // attach each saved tool result to its row — same look as a live turn.
559
+ function resumeSessionById(id: string) {
560
+ var data = loadSession(id);
561
+ if (!data?.messages?.length) { showToast("Session not found: " + id, "error"); return; }
562
+ // Reuse the resumed id as the live session's id so a later
563
+ // saveSession(syncSessionForSave()) persists under the SAME id.
564
+ getSession().conversationId = id;
565
+ var pendingParts: Record<string, any> = {};
566
+ getSession().messages = data.messages.map(function(m) {
567
+ var clean: any = { role: m.role, content: m.content, toolCalls: m.toolCalls };
568
+ if (m.reasoning) clean.reasoning = m.reasoning;
569
+ return clean;
570
+ });
571
+ setMessages(data.messages.map(function(m) {
572
+ // Tool result rows: attach the saved output to the matching call's row
573
+ // so bash/generic tools render their collapsed block like live turns.
574
+ if (m.role === "tool" && m.toolCallId && pendingParts[m.toolCallId]) {
575
+ pendingParts[m.toolCallId].output = m.content;
576
+ return null;
577
+ }
578
+ var out: any = { role: m.role, content: m.content, toolCalls: m.toolCalls, thinkTime: m.thinkTime };
579
+ if (m.role === "assistant") {
580
+ if (m.reasoning && !m.thinkingContent) out.thinkingContent = m.reasoning;
581
+ if (Array.isArray(m.parts) && m.parts.length) {
582
+ out.parts = m.parts;
583
+ } else {
584
+ var calls = Array.isArray(m.toolCalls) ? m.toolCalls : [];
585
+ if (calls.length) {
586
+ out.parts = calls.map(function(c: any) {
587
+ var pn = prettyToolName(c.name);
588
+ var dsp = toolDisplay(pn);
589
+ var tool: any = { name: pn, icon: dsp.icon, pending: dsp.pending, label: dsp.label(c.input || {}), status: "done" };
590
+ if (c.id) pendingParts[c.id] = tool;
591
+ return { type: "tool", tool };
592
+ });
593
+ }
594
+ }
595
+ out.todos = m.todos; out.fileDiffs = m.fileDiffs; out.isError = m.isError; out.interrupted = m.interrupted;
596
+ }
597
+ return out;
598
+ }).filter(function(x) { return x !== null; }));
599
+ appendMessage({ role: "system", content: "Resumed " + id });
600
+ refreshProviderState();
601
+ }
602
+
603
+ function processSlash(raw: string) {
604
+ // Quote-aware tokenizer so /mcp add keeps "C:\path with spaces\python.exe"
605
+ // intact (claude-compatible one-liner).
606
+ var parts = plugin.tokenizeCli(raw.slice(1));
607
+ if (!parts.length) return;
608
+ var cmd = parts[0].toLowerCase();
609
+ var args = parts.slice(1).filter(function(a) { return !/^\[.*\]$/.test(a); });
610
+ // Custom commands from .loom/commands/*.md run FIRST: /name args
611
+ // expands the md body ($ARGUMENTS → args) and submits it as a prompt.
612
+ try {
613
+ const { expandCustomCommand } = require("../core/custom-commands.js");
614
+ const expanded = expandCustomCommand(cmd, args.join(" "));
615
+ if (expanded !== null) {
616
+ setInput("");
617
+ setCursor(0);
618
+ submit(expanded);
619
+ return;
620
+ }
621
+ } catch {}
622
+ var sess = getSession();
623
+ var cfg = loadConfig();
624
+
625
+ switch (cmd) {
626
+ case "help": showHelpText(); return;
627
+ case "agents": showAgentsText(); return;
628
+ case "subagents": runAction("subagent_list"); return;
629
+ case "context": {
630
+ const msgs = getSession().messages || [];
631
+ const est = (s: any) => Math.ceil(String(s == null ? "" : (typeof s === "object" ? JSON.stringify(s) : s)).length / 4);
632
+ let sys = 0, user = 0, asst = 0, tool = 0;
633
+ try { sys += est(getSession().systemPrompt); } catch {}
634
+ for (const m of msgs) {
635
+ const t = est(m.content) + est(m.toolCalls);
636
+ if (m.role === "user") user += t; else if (m.role === "assistant") asst += t; else if (m.role === "tool") tool += t; else sys += t;
637
+ }
638
+ const total = sys + user + asst + tool;
639
+ appendMessage({ role: "system", content: [
640
+ "=== Context (~tokens, chars/4 estimate) ===",
641
+ "System+memory: " + sys,
642
+ "User messages: " + user,
643
+ "Assistant: " + asst,
644
+ "Tool results: " + tool,
645
+ "TOTAL: " + total + " (" + msgs.length + " messages)",
646
+ "", "Tip: /compact frees context when this grows large.",
647
+ ].join("\n") });
648
+ return;
649
+ }
650
+ case "think": {
651
+ const lvl = String(args[0] || "").toLowerCase();
652
+ if (!["off", "low", "medium", "high"].includes(lvl)) { showToast("Usage: /think off|low|medium|high", "error"); return; }
653
+ saveConfig(Object.assign({}, loadConfig(), { thinkLevel: lvl }));
654
+ getSession().refresh();
655
+ showToast("Thinking: " + lvl, "ok");
656
+ return;
657
+ }
658
+ case "approve": {
659
+ if (inputMode() !== "plan") { showToast("/approve only applies in Plan mode", "error"); return; }
660
+ let plan = "";
661
+ const ms2 = messages();
662
+ for (let i = ms2.length - 1; i >= 0; i--) { const m: any = ms2[i]; if (m.role === "assistant" && String(m.content || "").includes("## Plan")) { plan = String(m.content); break; } }
663
+ openModal({
664
+ type: "select", title: "Approve plan & switch to Build?", searchable: false,
665
+ options: [{ label: "Approve \u2014 execute in Build", value: "go" }, { label: "Stay in Plan", value: "stay" }],
666
+ onPick(v: string) {
667
+ closeModal();
668
+ if (v !== "go") return;
669
+ setInputMode("build");
670
+ getSession().setMode("build");
671
+ appendMessage({ role: "system", content: "Plan approved \u2014 switched to Build." });
672
+ submit(plan ? "Execute this plan:\n\n" + plan : "Proceed with the approved plan.");
673
+ },
674
+ });
675
+ return;
676
+ }
677
+ case "tasks": {
678
+ const bt = require("../core/background-tasks.js");
679
+ const rows = bt.listBackgroundTasks();
680
+ appendMessage({ role: "system", content: rows.length
681
+ ? "=== Background tasks ===\n" + rows.map((t: any) => `${t.status === "running" ? "[>]" : t.status === "done" ? "[x]" : "[-]"} ${t.id} ${t.command.slice(0, 50)}\n ${t.status}${t.exitCode != null ? " exit=" + t.exitCode : ""} \u00B7 ${(t.output.match(/\n/g) || []).length} lines`).join("\n")
682
+ : "No background tasks. Start one: ask the agent to run bash with background:true." });
683
+ return;
684
+ }
685
+ case "rewind": {
686
+ const rp = require("../core/restore.js");
687
+ let pts: any[] = [];
688
+ try { pts = rp.listRestorePoints() || []; } catch {}
689
+ if (!pts.length) { showToast("No restore points yet", "error"); return; }
690
+ openModal({
691
+ type: "select", title: "Rewind files to restore point", searchable: false,
692
+ options: pts.slice(0, 12).map((p: any) => ({ label: p.label || p.id, sub: new Date(p.createdAt || Date.now()).toLocaleString(), value: p.id })),
693
+ onPick(id: string) { closeModal(); const r = rp.restoreTo(id); showToast(r && r.ok ? "Files rewound to " + id : "Restore failed", r && r.ok ? "ok" : "error"); },
694
+ });
695
+ return;
696
+ }
697
+ case "share": {
698
+ try {
699
+ const fsx = require("fs"); const pathx = require("path");
700
+ const dir = pathx.join(process.cwd(), ".loom", "shares");
701
+ fsx.mkdirSync(dir, { recursive: true });
702
+ const esc = (s: string) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
703
+ 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");
704
+ 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}`;
705
+ const f = pathx.join(dir, (sessionId() || "session") + ".html");
706
+ fsx.writeFileSync(f, html);
707
+ showToast("Shared: " + f, "ok", 6000);
708
+ } catch (e: any) { showToast("Share failed: " + String(e?.message || e), "error"); }
709
+ return;
710
+ }
711
+ case "worktree": {
712
+ const name = String(args[0] || "").replace(/[^\w-]/g, "");
713
+ if (!name) { showToast("Usage: /worktree <name>", "error"); return; }
714
+ const out2 = runShell(`git worktree add ../loom-${name} -b wt/${name}`);
715
+ showToast(out2.toLowerCase().includes("fatal") ? "Worktree failed \u2014 see chat" : "Worktree ../loom-" + name + " ready (branch wt/" + name + ")", out2.toLowerCase().includes("fatal") ? "error" : "ok", 6000);
716
+ appendMessage({ role: "system", content: out2.slice(0, 1500) });
717
+ return;
718
+ }
719
+ case "style": {
720
+ const text = args.join(" ").trim();
721
+ const presets: Record<string, string> = { concise: "Reply in at most 2 sentences. No summaries of obvious steps.", explain: "Explain key decisions briefly after acting; teach the why." };
722
+ const val = text ? (presets[text.toLowerCase()] || text) : "";
723
+ saveConfig(Object.assign({}, loadConfig(), { outputStyle: val }));
724
+ getSession().refresh();
725
+ showToast(val ? "Output style set" : "Output style cleared", "ok");
726
+ return;
727
+ }
728
+ case "remember": {
729
+ const fact = args.join(" ").trim();
730
+ if (!fact) { showToast("Usage: /remember <fact>", "error"); return; }
731
+ try {
732
+ const mem = require("../core/memory.js");
733
+ const ok2 = mem.appendMemory(fact, "project");
734
+ showToast(ok2 ? "Remembered \u2192 LOOM.md" : "Could not write LOOM.md", ok2 ? "ok" : "error");
735
+ } catch { showToast("Could not write LOOM.md", "error"); }
736
+ return;
737
+ }
738
+ case "vim": {
739
+ const on = toggleVim();
740
+ showToast("Vim mode: " + (on ? "on \u2014 Esc toggles NORMAL" : "off"), "ok");
741
+ return;
742
+ }
743
+ case "build": case "plan": case "chat": {
744
+ var modeInfo: Record<string, string> = {
745
+ build: "Build \u2014 all tools enabled",
746
+ plan: "Plan \u2014 read-only, no file changes",
747
+ chat: "Chat \u2014 conversation only, no tools",
748
+ };
749
+ setInputMode(cmd as any);
750
+ sess.setMode(cmd);
751
+ showToast("Mode: " + modeInfo[cmd] + ". Press Tab to cycle.");
752
+ return;
753
+ }
754
+ case "models": openModelPicker(); return;
755
+ case "connect": {
756
+ var pv = args[0]?.toLowerCase();
757
+ if (!pv) { openModal({ type: "provider" }); return; }
758
+ if (!PROVIDERS[pv]) { showToast("Unknown provider: " + pv + ". Try /connect to pick.", "error"); return; }
759
+ cfg.provider = pv; saveConfig(cfg); refreshProviderState(); openKeyModal(pv);
760
+ return;
761
+ }
762
+ case "key": openKeyModal(cfg.provider); return;
763
+ case "baseurl": openBaseUrlEditor(args[0] || cfg.provider); return;
764
+ case "model": {
765
+ if (args.length) {
766
+ sess.setModel(cfg.provider, args[0]);
767
+ refreshProviderState();
768
+ showToast("Model: " + args[0], "ok");
769
+ }
770
+ else openModelPicker();
771
+ return;
772
+ }
773
+ case "providers": showProvidersText(); return;
774
+ case "status": {
775
+ const { envNamesFor } = require("../providers/index.js");
776
+ var keySet = !!(cfg.apiKeys?.[cfg.provider] || (envNamesFor(cfg.provider) || []).some(n => !!process.env[n]));
777
+ appendMessage({ role: "system", content: "Provider: " + cfg.provider + "\nModel: " + (cfg.model?.[cfg.provider] || "default") + "\nKey: " + (keySet ? "configured" : "NOT SET") });
778
+ return;
779
+ }
780
+ case "memory": {
781
+ appendMessage({ role: "system", content: [
782
+ path.join(process.cwd(), "LOOM.md"),
783
+ path.join(os.homedir(), ".loom", "LOOM.md")
784
+ ].map(function(l) { return (fs.existsSync(l) ? "+" : "-") + " " + l; }).join("\n") });
785
+ appendMessage({ role: "system", content: plugin.editorCmd() });
786
+ return;
787
+ }
788
+ case "graph": {
789
+ openGraphModal();
790
+ return;
791
+ }
792
+ case "permissions": {
793
+ if (args[0] === "auto") {
794
+ const next = !sess.permissions.auto;
795
+ sess.permissions.setAuto(next);
796
+ setSessionAuto(next);
797
+ showToast("Auto-approve permissions: " + (next ? "ON" : "OFF") + (next ? " (asks are auto-approved, denies still block)" : ""), next ? "ok" : "info");
798
+ return;
799
+ }
800
+ var rules = sess.permissions.sessionRules;
801
+ if (args[0] === "reset") {
802
+ for (var rk of Array.from(rules.keys())) sess.permissions.clearRule(rk);
803
+ showToast("Permission rules cleared.", "ok");
804
+ return;
805
+ }
806
+ var lines: string[] = [];
807
+ rules.forEach(function(v: string, k: string) { lines.push(" " + k + " \u2192 " + v); });
808
+ if (!lines.length) lines.push(" (no saved rules)");
809
+ appendMessage({ role: "system", content: "Saved permission rules (set via \"Always allow\" in the popup):\n" + lines.join("\n") + "\nUse /permissions reset to clear." });
810
+ return;
811
+ }
812
+ case "budget": {
813
+ const { LEVELS, pickModel, describeLevel } = require("../core/model-router.js");
814
+ const { budgetStatus, setMonthlyBudget, setDailyAlert, dayStatus, requestOverride, formatUsd } = require("../core/usage.js");
815
+ const cur = cfg.budgetLevel || "auto";
816
+ const arg = args[0]?.toLowerCase();
817
+ if (arg === "override") {
818
+ requestOverride();
819
+ showToast("Budget override set — exactly one paid turn will go through despite the cap.", "ok");
820
+ refreshUsage();
821
+ return;
822
+ }
823
+ if (arg === "daily") {
824
+ // 0 is a valid value (disables the alert) — only a missing argument
825
+ // is invalid.
826
+ const num = args[1] === undefined ? NaN : parseFloat(args[1].replace(/^[$]/, ""));
827
+ if (!Number.isNaN(num)) {
828
+ setDailyAlert(num);
829
+ showToast("Daily spend alert: " + formatUsd(num) + " / day (0 disables)", "ok");
830
+ refreshUsage();
831
+ return;
832
+ }
833
+ const day = dayStatus();
834
+ showToast(
835
+ "Daily spend today: " + formatUsd(day.dayCostUsd) + (day.alertUsd > 0 ? " (alert at " + formatUsd(day.alertUsd) + ")" : " (no alert set)") + " — set one: /budget daily 3",
836
+ "ok"
837
+ );
838
+ return;
839
+ }
840
+ // /budget <number> — set the monthly spend cap (Phase 2 governor).
841
+ // 0 is a valid value (disables enforcement) — only a missing argument
842
+ // is invalid.
843
+ const num = arg === undefined ? NaN : parseFloat(arg.replace(/^[$]/, ""));
844
+ if (!Number.isNaN(num)) {
845
+ setMonthlyBudget(num);
846
+ showToast("Monthly budget: " + formatUsd(num), "ok");
847
+ refreshUsage();
848
+ return;
849
+ }
850
+ if (!arg) {
851
+ const info = describeLevel(cur);
852
+ const spend = budgetStatus();
853
+ const day = dayStatus();
854
+ const picked = info.picked ? info.picked.provider + " / " + info.picked.model : "(none available)";
855
+ const dayLine = "Day: " + formatUsd(day.dayCostUsd) + (day.alertUsd > 0 ? " today (alert at " + formatUsd(day.alertUsd) + ")" : " today") + (day.alert ? " \u26a0 daily alert reached" : "");
856
+ appendMessage({
857
+ role: "system",
858
+ content:
859
+ "=== Budget ===\n" +
860
+ "Level: " + cur + (cur === "auto" ? " (explicit picks, no routing)" : " (auto-routed per turn)") + "\n" +
861
+ "Would pick: " + picked + (info.freeAvailable ? "" : " \u2014 no free models reachable") + "\n" +
862
+ "Spend: " + formatUsd(spend.monthCostUsd) + " of " + formatUsd(spend.budgetUsd) + " this month (" + Math.round(spend.pct) + "%)" + (spend.over ? " \u26a0 cap reached \u2014 paid turns blocked" : "") + "\n" +
863
+ dayLine + "\n" +
864
+ "Levels: free (only $0 models) cheap (free + low-cost) best (frontier) auto (default)\n" +
865
+ "Try: /budget free \u2014 every turn stays free, paid models are blocked. /budget 50 \u2014 set the monthly cap. /budget override \u2014 one paid turn past the cap. /budget daily 3 \u2014 alert when a day spends $3."
866
+ });
867
+ return;
868
+ }
869
+ if (!LEVELS.includes(arg)) { showToast("Budget: free, cheap, best, auto — or a dollar cap, e.g. /budget 50", "error"); return; }
870
+ cfg.budgetLevel = arg; saveConfig(cfg); sess.config = cfg; refreshProviderState();
871
+ if (arg === "free") {
872
+ const info = describeLevel("free");
873
+ if (!info.picked) showToast("Budget: free \u2014 but NO free model reachable yet. Run /connect for a free provider.", "error");
874
+ else showToast("Budget: free \u2014 every turn now routes to " + info.picked.provider + " / " + info.picked.model, "ok");
875
+ } else {
876
+ showToast("Budget: " + arg, "ok");
877
+ }
878
+ return;
879
+ }
880
+ case "new": case "clear": {
881
+ // Warn before wiping the session — one wrong /clear loses the whole
882
+ // transcript. A compact confirm modal with explicit Keep/Clear options.
883
+ var msgCount = messages().length;
884
+ openModal({
885
+ type: "select", title: "Clear session?",
886
+ searchable: false,
887
+ options: [
888
+ { label: "Keep it", sub: "esc / enter to keep", value: "keep" },
889
+ { label: "Clear session (" + msgCount + " messages)", sub: "irreversible", value: "clear" },
890
+ ],
891
+ onPick: function(val: any) {
892
+ closeModal();
893
+ if (val !== "clear") { showToast("Session kept.", "info"); return; }
894
+ sess.reset(); setMessages([]); showToast("Session cleared.", "ok"); refreshUsage();
895
+ },
896
+ });
897
+ return;
898
+ }
899
+ case "restore": {
900
+ const points = listRestorePoints();
901
+ if (!points.length) {
902
+ showToast("No restore points yet. A point is saved automatically before every prompt.");
903
+ return;
904
+ }
905
+ openModal({
906
+ type: "select",
907
+ title: "Restore Point",
908
+ options: points.map(function(p) {
909
+ const t = new Date(p.at);
910
+ const pad = (n: number) => String(n).padStart(2, "0");
911
+ const when = pad(t.getMonth() + 1) + "-" + pad(t.getDate()) + " " + pad(t.getHours()) + ":" + pad(t.getMinutes());
912
+ const count = Object.keys(p.files).length;
913
+ return { label: (p.label ? p.label : "(no prompt)"), sub: when + " \u2014 " + count + " files", value: p.id };
914
+ }),
915
+ onPick: function(id) {
916
+ const r = restoreTo(id);
917
+ closeModal();
918
+ if (!r.ok) { closeModal(); showToast("Restore failed: " + r.error, "error"); return; }
919
+ var line = "Restored to earlier state: " + r.restored.length + " files written back";
920
+ if (r.deleted.length) line += ", " + r.deleted.length + " files removed";
921
+ if (r.errors.length) line += "\nErrors: " + r.errors.join("; ").slice(0, 300);
922
+ appendMessage({ role: "system", content: line });
923
+ invalidateFilesCache();
924
+ clearFileDiffs();
925
+ },
926
+ });
927
+ return;
928
+ }
929
+ case "usage": {
930
+ refreshUsage();
931
+ const { formatTokens, formatUsd, getUsage } = require("../core/usage.js");
932
+ const u = getUsage();
933
+ const meta = modelMeta();
934
+ const sess2 = getSession();
935
+ const ctx = meta?.context || 200000;
936
+ const ctxPct = ctx ? Math.round((sess2.tokensUsed / ctx) * 100) : 0;
937
+ const budgetPct = u.budgetUsd ? Math.round((u.month.costUsd / u.budgetUsd) * 100) : 0;
938
+ const priceLine = meta ? " ($" + (meta.priceIn || 0) + "/$" + (meta.priceOut || 0) + " per 1M in/out)" : "";
939
+ appendMessage({ role: "system", content:
940
+ "=== Usage ===\n" +
941
+ "Model: " + providerName() + " / " + modelName() + priceLine + "\n" +
942
+ "Session: " + formatTokens(sess2.tokensUsed) + " tokens (" + formatTokens(sess2.tokensIn) + " in / " + formatTokens(sess2.tokensOut) + " out) " + ctxPct + "% of " + formatTokens(ctx) + " context \u00B7 " + formatUsd(sess2.sessionCost) + "\n" +
943
+ "Lifetime: " + formatTokens(u.totalTokens) + " tokens \u00B7 " + formatUsd(u.totals.costUsd) + "\n" +
944
+ "Month " + u.monthKey + ": " + formatTokens(u.monthTokens) + " tokens \u00B7 " + formatUsd(u.month.costUsd) + " \u00B7 " + budgetPct + "% of " + formatUsd(u.budgetUsd) + " budget"
945
+ });
946
+ return;
947
+ }
948
+ case "skills": {
949
+ var sub = args[0];
950
+ if (sub === "install") { appendMessage({ role: "system", content: plugin.installSkillCmd(args.slice(1)) }); return; }
951
+ if (sub === "remove") { appendMessage({ role: "system", content: plugin.removeSkillCmd(args.slice(1)) }); return; }
952
+ if (sub === "help") { appendMessage({ role: "system", content: plugin.skillHelp() }); return; }
953
+ // Skill browser popup (same window as the model selector): grouped by
954
+ // source (global ~/.loom, agents ~/.agents, project .loom), Enter
955
+ // toggles on/off, and an "install" row opens the add flow.
956
+ var cfgNow = loadConfig();
957
+ var disabledNow = (cfgNow.skillDisabled || []);
958
+ var list = listSkills();
959
+ if (!list.length) { appendMessage({ role: "system", content: plugin.listSkillsText() }); return; }
960
+ var bySource: Record<string, any[]> = { global: [], agents: [], project: [] };
961
+ for (var sk of list) (bySource[sk.source] = bySource[sk.source] || []).push(sk);
962
+ var skillOptions: any[] = [];
963
+ var sourceTitles: Record<string, string> = { global: "Global (~/.loom/skills)", agents: "Agents (~/.agents/skills)", project: "Project (.loom/skills)" };
964
+ var firstHeader = true;
965
+ for (var srcKey of ["global", "agents", "project"]) {
966
+ if (!bySource[srcKey].length) continue;
967
+ skillOptions.push({ isHeader: true, header: sourceTitles[srcKey] });
968
+ firstHeader = false;
969
+ for (var sk3 of bySource[srcKey]) {
970
+ var off3 = disabledNow.includes(sk3.name);
971
+ skillOptions.push({ label: sk3.name, value: sk3.name,
972
+ sub: "[" + (off3 ? "off" : "on") + "] " + sk3.description.slice(0, 44), tags: [srcKey] });
973
+ }
974
+ }
975
+ skillOptions.push({ label: "+ Install skill", value: "__install__", sub: "from a local folder or git URL (--trust for remote)" });
976
+ openModal({
977
+ type: "select", title: "Skills — Enter toggle on/off",
978
+ searchable: true,
979
+ options: skillOptions,
980
+ onPick: function(val: any) {
981
+ if (val === "__install__") {
982
+ closeModal();
983
+ openModal({
984
+ type: "input", title: "Install skill",
985
+ placeholder: "folder path or git URL [--trust]",
986
+ onCancel: function() { setTimeout(function() { processSlash("/skills"); }, 10); },
987
+ onPick: function(target: string) {
988
+ if (!target.trim()) { closeModal(); return; }
989
+ var out = plugin.installSkillCmd(target.trim().split(/\s+/));
990
+ showToast(out.split("\n")[0].slice(0, 90), out.indexOf("blocked") >= 0 || out.indexOf("failed") >= 0 ? "error" : "ok", 6000);
991
+ appendMessage({ role: "system", content: out });
992
+ closeModal();
993
+ setTimeout(function() { processSlash("/skills"); }, 10); // reopen refreshed
994
+ },
995
+ });
996
+ return;
997
+ }
998
+ var cfg2 = loadConfig();
999
+ var d = (cfg2.skillDisabled || []);
1000
+ if (d.includes(val)) {
1001
+ cfg2.skillDisabled = d.filter(function(n: string) { return n !== val; });
1002
+ showToast("skill ON: " + val, "ok");
1003
+ } else {
1004
+ cfg2.skillDisabled = d.concat([val]);
1005
+ showToast("skill OFF: " + val, "ok");
1006
+ }
1007
+ saveConfig(cfg2);
1008
+ sess.config = cfg2;
1009
+ closeModal(); setTimeout(function() { processSlash("/skills"); }, 10); // reopen refreshed
1010
+ },
1011
+ });
1012
+ return;
1013
+ }
1014
+ case "mcp": {
1015
+ var sub2 = args[0];
1016
+ if (sub2 === "add") { appendMessage({ role: "system", content: plugin.mcpAddCmd(args.slice(1)) }); return; }
1017
+ if (sub2 === "remove") { appendMessage({ role: "system", content: plugin.mcpRemoveCmd(args.slice(1)) }); return; }
1018
+ if (sub2 === "toggle") { appendMessage({ role: "system", content: plugin.mcpToggleCmd(args.slice(1)) }); return; }
1019
+ if (sub2 === "help") { appendMessage({ role: "system", content: plugin.mcpHelp() }); return; }
1020
+ // MCP browser popup: list every server (defaults + added), toggle
1021
+ // on/off with Enter, add new servers with A.
1022
+ openModal({ type: "mcp" });
1023
+ return;
1024
+ }
1025
+ case "connectors": {
1026
+ var sub3 = args[0];
1027
+ if (sub3 === "add") { appendMessage({ role: "system", content: plugin.mcpAddCmd(args.slice(1)) }); return; }
1028
+ if (sub3 === "remove") { appendMessage({ role: "system", content: plugin.mcpRemoveCmd(args.slice(1)) }); return; }
1029
+ if (sub3 === "toggle") { appendMessage({ role: "system", content: plugin.mcpToggleCmd(args.slice(1)) }); return; }
1030
+ if (sub3 === "help") { appendMessage({ role: "system", content: plugin.mcpHelp() }); return; }
1031
+ // Connector browser: hosting/cloud services (Supabase, Railway, Vercel,
1032
+ // Netlify, Cloudflare, Next.js). Same server store as /mcp, different
1033
+ // preset list behind the "A" add flow.
1034
+ openModal({ type: "connectors" });
1035
+ return;
1036
+ }
1037
+ case "sessions": {
1038
+ const saved = listSessions();
1039
+ if (!saved.length) {
1040
+ appendMessage({ role: "system", content: "No saved sessions yet." });
1041
+ return;
1042
+ }
1043
+ openModal({
1044
+ type: "select",
1045
+ title: "Saved Sessions",
1046
+ searchable: true,
1047
+ options: saved.map(function(s) {
1048
+ const when = (s.updatedAt || s.createdAt || s.mtime || "").replace("T", " ").slice(0, 16);
1049
+ return { label: s.id, sub: when + " \u2014 " + s.messageCount + " msgs", value: s.id };
1050
+ }),
1051
+ onPick: function(id) {
1052
+ closeModal();
1053
+ resumeSessionById(id);
1054
+ },
1055
+ });
1056
+ return;
1057
+ }
1058
+ case "settings": openModal({ type: "settings" }); return;
1059
+ case "thinking": setShowThinking(function(v) { return !v; }); showToast("Thinking display: " + (showThinking() ? "on" : "off")); return;
1060
+ case "details": setShowToolDetails(function(v) { return !v; }); showToast("Tool details: " + (showToolDetails() ? "on" : "off")); return;
1061
+ case "theme": {
1062
+ // Live preview: moving the selection immediately shows the theme
1063
+ // behind the modal; Enter confirms and closes. Esc restores the
1064
+ // previous theme without saving it.
1065
+ var tOpts = themeOptions().map(function(t) { return { label: t.label, value: t.id, sub: t.desc }; });
1066
+ if (args.length) {
1067
+ if (setTheme(args[0].toLowerCase())) { showToast("Theme: " + args[0].toLowerCase(), "ok"); }
1068
+ else showToast("Unknown theme: " + args[0] + ". Try /theme to pick.", "error");
1069
+ return;
1070
+ }
1071
+ var prevTheme = themeName();
1072
+ openModal({
1073
+ type: "select", title: "Select Theme",
1074
+ options: tOpts,
1075
+ searchable: false,
1076
+ preview: true,
1077
+ onPick(val: any, opt: any) {
1078
+ if (setTheme(val)) { closeModal(); showToast("Theme: " + (opt?.label || val), "ok"); }
1079
+ },
1080
+ onPreview(val: any) { if (val) { try { setTheme(val); } catch {} } },
1081
+ onCancel() {
1082
+ // Restore the pre-picker theme if the user bailed with Esc.
1083
+ if (themeName() !== prevTheme) { try { setTheme(prevTheme); } catch {} }
1084
+ closeModal();
1085
+ },
1086
+ });
1087
+ return;
1088
+ }
1089
+ case "exit": {
1090
+ persistUi(); saveSession(syncSessionForSave());
1091
+ showToast("Session saved: " + (sess.conversationId || "???") + " \u2014 Goodbye!", "ok");
1092
+ setTimeout(function() { quit(); }, 500);
1093
+ return;
1094
+ }
1095
+ default: showToast("Unknown command: /" + cmd + ". Try /help.", "error"); return;
1096
+ }
1097
+ }
1098
+ // ═══════════════════════ Keybind-driven actions ═══════════════════════
1099
+ // The keyboard handler below is a pure dispatcher: it maps the incoming key
1100
+ // to a configured action (see keybinds.ts / docs/keybinds.md) and runs it
1101
+ // here, where all the app state lives.
1102
+ function editDelete(kind: "backspace" | "delete") {
1103
+ const ss = selStart(), se = selEnd();
1104
+ if (ss >= 0 && se > ss) {
1105
+ const v = input();
1106
+ setDraft(v.slice(0, ss) + v.slice(se), ss);
1107
+ clearSelection();
1108
+ updateAutocomplete(input());
1109
+ historyReset();
1110
+ return;
1111
+ }
1112
+ const p = Math.min(cursor(), input().length);
1113
+ const v = input();
1114
+ let n: string, np: number;
1115
+ if (kind === "backspace") { if (p === 0) return; n = v.slice(0, p - 1) + v.slice(p); np = p - 1; }
1116
+ else { if (p >= v.length) return; n = v.slice(0, p) + v.slice(p + 1); np = p; }
1117
+ setDraft(n, np);
1118
+ updateAutocomplete(n);
1119
+ historyReset();
1120
+ }
1121
+
1122
+ // Up/Down resolve by context: suggestion list first, then caret lines in a
1123
+ // multi-line draft, then prompt history (readline recall).
1124
+ function caretOrHistory(dir: number) {
1125
+ const s2 = suggestions();
1126
+ if (s2.length) {
1127
+ if (dir < 0) setAutoIndex(function(i) { return Math.max(0, i - 1); });
1128
+ else setAutoIndex(function(i) { return Math.min(s2.length - 1, i + 1); });
1129
+ return;
1130
+ }
1131
+ clearSelection();
1132
+ if (input().includes("\n")) {
1133
+ const text = input();
1134
+ const pos = Math.min(cursor(), text.length);
1135
+ const upto = text.slice(0, pos);
1136
+ const lineIdx = (upto.match(/\n/g) || []).length;
1137
+ const col = pos - (upto.lastIndexOf("\n") + 1);
1138
+ const rows = text.split("\n");
1139
+ const target = Math.max(0, Math.min(rows.length - 1, lineIdx + dir));
1140
+ let tp = 0;
1141
+ for (let li = 0; li < target; li++) tp += rows[li].length + 1;
1142
+ setCursor(tp + Math.min(col, rows[target].length));
1143
+ return;
1144
+ }
1145
+ const recall = dir < 0 ? historyPrev() : historyNext();
1146
+ if (recall !== null) setDraft(recall);
1147
+ }
1148
+
1149
+ function runAction(action: string, ks?: string) {
1150
+ const slash = kbs.slashFor(action);
1151
+ if (slash) { processSlash(slash); return; }
1152
+ switch (action) {
1153
+ case "app_exit": quit(); return;
1154
+ case "sidebar_toggle": setSidebarVisible(function(v) { return !v; }); return;
1155
+ case "sidebar_cycle_tab": setSidebarTab(function(t) { return (t + 1) % 3; }); return;
1156
+ case "input_select_all": {
1157
+ const len = input().length;
1158
+ setSelStart(0); setSelEnd(len);
1159
+ setCursor(len);
1160
+ return;
1161
+ }
1162
+ case "user_expand": {
1163
+ const msgs = messages();
1164
+ for (let i = msgs.length - 1; i >= 0; i--) {
1165
+ const um = msgs[i];
1166
+ if (um.role === "user" && estVisualLines(String(um.content || "")) > USER_PREVIEW_LINES) {
1167
+ setUserExpandedIdx(cur => (cur === i ? null : i));
1168
+ return;
1169
+ }
1170
+ }
1171
+ return;
1172
+ }
1173
+ case "subagent_list": {
1174
+ // Open (or re-open) the /subagents panel. The panel itself handles
1175
+ // close on Esc; refreshing history on every open keeps past-session
1176
+ // runs visible without requiring a restart.
1177
+ loadSubagentHistory({ limit: 200 });
1178
+ openModal({ type: "subagents" });
1179
+ return;
1180
+ }
1181
+ case "command_list": {
1182
+ if (!modal()) openModal({ type: "palette", onPick: function(cmd) { processSlash(cmd); } });
1183
+ return;
1184
+ }
1185
+ case "session_interrupt": {
1186
+ if (thinking()) {
1187
+ // Two-press confirm: the 1st ESC arms, the 2nd (within 2.5s) actually
1188
+ // interrupts — accidental key taps no longer kill a running task.
1189
+ const now = Date.now();
1190
+ if (escArmAt && now - escArmAt < 2500) {
1191
+ escArmAt = null;
1192
+ try { getSession().interrupt(); } catch {}
1193
+ setThinking(false);
1194
+ } else {
1195
+ escArmAt = now;
1196
+ showToast("Press ESC again to interrupt the running task");
1197
+ }
1198
+ return;
1199
+ }
1200
+ if (modal() && ks && kbs.is("modal_cancel", ks)) {
1201
+ const m = modal();
1202
+ closeModal();
1203
+ if (m && m.onCancel) m.onCancel();
1204
+ return;
1205
+ }
1206
+ setDraft(""); setSuggestions([]); setAutoKind("none"); setAutoIndex(0); historyReset(); clearSelection(); setPastedAt(0);
1207
+ return;
1208
+ }
1209
+ case "modal_cancel": {
1210
+ const m = modal();
1211
+ closeModal();
1212
+ if (m && m.onCancel) m.onCancel();
1213
+ return;
1214
+ }
1215
+ case "input_submit": {
1216
+ if (pickSuggestion()) return;
1217
+ var text = input().trim();
1218
+ if (!text) return;
1219
+ var wantsCmd = text.startsWith("/") || text.startsWith("!");
1220
+ // "# fact" \u2014 self-edit memory: save to project LOOM.md, send nothing.
1221
+ if (text.startsWith("# ") && text.slice(2).trim()) {
1222
+ try {
1223
+ const memR = require("../core/memory.js");
1224
+ const okR = memR.appendMemory(text.slice(2).trim(), "project");
1225
+ showToast(okR ? "Remembered \u2192 LOOM.md" : "Could not write LOOM.md", okR ? "ok" : "error");
1226
+ } catch { showToast("Could not write LOOM.md", "error"); }
1227
+ clearSelection();
1228
+ setDraft("");
1229
+ setPastedAt(0);
1230
+ return;
1231
+ }
1232
+ // Busy: queue the draft (claude-style) — it sends when the turn ends.
1233
+ if (thinking() && !wantsCmd) {
1234
+ queueDraft(text);
1235
+ clearSelection();
1236
+ setDraft("");
1237
+ setPastedAt(0);
1238
+ showToast("Queued \u2014 " + queuedDrafts().length + " waiting", "ok");
1239
+ return;
1240
+ }
1241
+ clearSelection();
1242
+ setDraft("");
1243
+ setPastedAt(0);
1244
+ submit(text);
1245
+ return;
1246
+ }
1247
+ case "input_newline": {
1248
+ clearSelection();
1249
+ setPastedAt(0);
1250
+ const p = Math.min(cursor(), input().length);
1251
+ const v = input();
1252
+ const n = v.slice(0, p) + "\n" + v.slice(p);
1253
+ setDraft(n, p + 1);
1254
+ historyReset();
1255
+ return;
1256
+ }
1257
+ case "input_move_left": clearSelection(); setCursor(function(c) { return Math.max(0, c - 1); }); return;
1258
+ case "input_move_right": clearSelection(); setCursor(function(c) { return Math.min(input().length, c + 1); }); return;
1259
+ case "line_home": clearSelection(); setCursor(0); return;
1260
+ case "line_end": clearSelection(); setCursor(input().length); return;
1261
+ case "input_backspace": setPastedAt(0); editDelete("backspace"); return;
1262
+ case "input_delete": setPastedAt(0); editDelete("delete"); return;
1263
+ case "prompt_autocomplete_next": {
1264
+ var s = suggestions();
1265
+ if (s.length) { setAutoIndex(function(i) { return Math.min(s.length - 1, (i || 0) + 1); }); return; }
1266
+ var modes = ["build", "plan", "chat"];
1267
+ var mi = modes.indexOf(inputMode());
1268
+ var nm = modes[(mi + 1) % 3];
1269
+ setInputMode(nm as any);
1270
+ getSession().setMode(nm);
1271
+ return;
1272
+ }
1273
+ case "up_context": caretOrHistory(-1); return;
1274
+ case "down_context": caretOrHistory(1); return;
1275
+ case "input_paste": return; // bracketed paste arrives via usePaste
1276
+ default: return;
1277
+ }
1278
+ }
1279
+
1280
+ // ═══════════════════════ Keyboard handler ═══════════════════════
1281
+ // Dispatcher: resolves the key against the configured keybinds (defaults
1282
+ // merged with ~/.loom/tui.json) and runs the action. Keys that match no
1283
+ // binding fall through to the typing branch at the bottom.
1284
+ useKeyboard(function(key) {
1285
+ var k = key.name;
1286
+ var ks = kbs.keyString(key);
1287
+ var ma = modal();
1288
+
1289
+ // Global exit; Ctrl+C copies the chatbox selection first when one exists.
1290
+ if (kbs.is("app_exit", ks)) {
1291
+ const ss = selStart(), se = selEnd();
1292
+ if (!ma && ss >= 0 && se > ss) {
1293
+ const selText = input().slice(ss, se);
1294
+ clearSelection();
1295
+ setCursor(se);
1296
+ if (selText) {
1297
+ copyText(selText);
1298
+ showToast("Copied \"" + selText.slice(0, 24) + (selText.length > 24 ? "\u2026" : "") + "\" to clipboard.", "ok");
1299
+ }
1300
+ return;
1301
+ }
1302
+ quit(); return;
1303
+ }
1304
+ if (kbs.is("sidebar_toggle", ks)) { runAction("sidebar_toggle"); return; }
1305
+
1306
+ // Ctrl+A — select the whole draft (readline-style).
1307
+ if (kbs.is("input_select_all", ks)) { runAction("input_select_all"); return; }
1308
+
1309
+ // Leader key (default ctrl+x): the next key runs a <leader>X binding.
1310
+ if (kbs.tapLeader(ks)) return;
1311
+ if (kbs.isLeaderPending()) {
1312
+ const la = kbs.leaderMatch(ks);
1313
+ if (la) { kbs.cancelLeader(); runAction(la, ks); return; }
1314
+ // Any other key while the leader is pending cancels it, then falls
1315
+ // through to normal processing (matches classic leader UX).
1316
+ kbs.cancelLeader();
1317
+ }
1318
+
1319
+ // Permission popup owns all keys while it is open (its own useKeyboard
1320
+ // handles up/down/enter/typing/esc) — the input bar must not receive them.
1321
+ if (permission()) return;
1322
+
1323
+ // Shift+Tab — toggle session-wide auto-approval (no per-command asks).
1324
+ // Plain Tab still cycles suggestions/modes; shift+tab is unbound, so this
1325
+ // never collides with the suggestion picker.
1326
+ if (k === "tab" && key.shift && !ma) {
1327
+ const next = !autoPerm();
1328
+ setSessionAuto(next);
1329
+ showToast(next
1330
+ ? "Auto-approve ON \u00B7 all commands allowed this session (Shift+Tab to toggle)"
1331
+ : "Auto-approve OFF \u00B7 per-command asks are back (Shift+Tab to toggle)",
1332
+ next ? "ok" : "info", 4000);
1333
+ return;
1334
+ }
1335
+
1336
+ // Ctrl+E — keyboard expand/collapse for the most recent collapsed user
1337
+ // bubble (clicking a bubble toggles it too).
1338
+ if (kbs.is("user_expand", ks)) { runAction("user_expand"); return; }
1339
+
1340
+ // Escape cancels a pending leader.
1341
+ if (k === "escape" && kbs.isLeaderPending()) { kbs.cancelLeader(); return; }
1342
+
1343
+ // Vim mode (/vim): Esc toggles NORMAL; in NORMAL, single keys edit the
1344
+ // draft without inserting. i/a/I/A return to INSERT (default behavior).
1345
+ if (vimMode() && !ma && !permission()) {
1346
+ if (k === "escape") { setVimNormal(function(v) { return !v; }); return; }
1347
+ if (vimNormal()) {
1348
+ const vl = input();
1349
+ const vc = Math.min(cursor(), vl.length);
1350
+ const wordFwd = () => { const m2 = vl.slice(vc).match(/^\W*\w+/); return m2 ? vc + m2[0].length : vl.length; };
1351
+ const wordBack = () => { const head = vl.slice(0, vc); const m2 = head.match(/\w+\W*$|\W+$/); return m2 ? vc - m2[0].length : 0; };
1352
+ switch (k) {
1353
+ case "h": if (vc > 0) setCursor(vc - 1); return;
1354
+ case "l": if (vc < vl.length) setCursor(vc + 1); return;
1355
+ case "0": setCursor(0); return;
1356
+ case "$": setCursor(vl.length); return;
1357
+ case "w": setCursor(wordFwd()); return;
1358
+ case "b": setCursor(wordBack()); return;
1359
+ case "x": setInput(vl.slice(0, vc) + vl.slice(vc + 1)); return;
1360
+ case "D": _vimReg = vl.slice(vc); setInput(vl.slice(0, vc)); return;
1361
+ case "S": _vimReg = vl; setInput(""); return;
1362
+ case "p": setInput(vl.slice(0, vc) + _vimReg + vl.slice(vc)); setCursor(vc + _vimReg.length); return;
1363
+ case "i": setVimNormal(false); return;
1364
+ case "a": setVimNormal(false); setCursor(Math.min(vl.length, vc + 1)); return;
1365
+ case "I": setVimNormal(false); setCursor(0); return;
1366
+ case "A": setVimNormal(false); setCursor(vl.length); return;
1367
+ }
1368
+ return; // swallow everything else while NORMAL
1369
+ }
1370
+ }
1371
+
1372
+ // Interrupt / draft-clear / modal cancel (default: ESC). A modal_cancel
1373
+ // key without an open modal behaves like an interrupt (legacy ESC UX).
1374
+ if (kbs.is("session_interrupt", ks) || kbs.is("modal_cancel", ks)) { runAction("session_interrupt", ks); return; }
1375
+
1376
+ // Ctrl+P palette
1377
+ if (kbs.is("command_list", ks)) { runAction("command_list"); return; }
1378
+
1379
+ // Modal active => halt
1380
+ if (ma) return;
1381
+
1382
+ // Sidebar tab
1383
+ if (kbs.is("sidebar_cycle_tab", ks)) { runAction("sidebar_cycle_tab"); return; }
1384
+
1385
+ // Tab — next suggestion, or cycle build/plan/chat when the list is empty.
1386
+ if (kbs.is("prompt_autocomplete_next", ks)) { runAction("prompt_autocomplete_next"); return; }
1387
+
1388
+ // Suggest nav / caret lines / history recall.
1389
+ if (kbs.is("up_context", ks)) { runAction("up_context"); return; }
1390
+ if (kbs.is("down_context", ks)) { runAction("down_context"); return; }
1391
+
1392
+ // Cursor movement: left/right always; up/down surf lines when the draft
1393
+ // is multi-line (otherwise they recall prompt history). Moving the caret
1394
+ // drops the selection (readline behavior).
1395
+ if (kbs.is("input_move_left", ks)) { runAction("input_move_left"); return; }
1396
+ if (kbs.is("input_move_right", ks)) { runAction("input_move_right"); return; }
1397
+ if (kbs.is("line_home", ks)) { runAction("line_home"); return; }
1398
+ if (kbs.is("line_end", ks)) { runAction("line_end"); return; }
1399
+
1400
+ // Enter (submit) and Shift+Enter (newline) — the chatbox grows with the
1401
+ // text (up to its limit) and scrolls beyond it.
1402
+ if (kbs.is("input_submit", ks)) { runAction("input_submit"); return; }
1403
+ if (kbs.is("input_newline", ks)) { runAction("input_newline"); return; }
1404
+
1405
+ // Backspace / Delete — operate at the caret, or drop the whole selection
1406
+ // when one is active.
1407
+ if (kbs.is("input_backspace", ks)) { runAction("input_backspace"); return; }
1408
+ if (kbs.is("input_delete", ks)) { runAction("input_delete"); return; }
1409
+
1410
+ // Typing — insert at the caret (or replace the selection). Sequences
1411
+ // containing ESC (e.g. arrow keys or \x1b]52; clipboard OSC) must never
1412
+ // be treated as text.
1413
+ if (!key.ctrl && !key.meta && key.sequence && key.sequence.indexOf("\x1b") < 0 && key.sequence.length <= 10 && key.sequence !== "\r" && key.sequence !== "\n" && key.sequence !== "\t") {
1414
+ const v = input();
1415
+ const ss = selStart(), se = selEnd();
1416
+ const hasSel = ss >= 0 && se > ss;
1417
+ const p = Math.min(cursor(), v.length);
1418
+ const n = hasSel ? v.slice(0, ss) + key.sequence + v.slice(se) : v.slice(0, p) + key.sequence + v.slice(p);
1419
+ clearSelection();
1420
+ setDraft(n, hasSel ? ss + key.sequence.length : p + key.sequence.length);
1421
+ setPastedAt(0);
1422
+ updateAutocomplete(n);
1423
+ historyReset();
1424
+ }
1425
+ });
1426
+
1427
+ // ═══════════════════════ Mouse selection → copy ═══════════════════════
1428
+ function copyText(text: string) {
1429
+ try { process.stdout.write("\x1b]52;c;" + Buffer.from(text, "utf8").toString("base64") + "\x07"); } catch {}
1430
+ if (process.platform === "win32" && process.env.LOOM_NO_CLIPBOARD !== "1") {
1431
+ try {
1432
+ execSync('powershell -NoProfile -NonInteractive -Command "$input | Set-Clipboard"', { input: text, stdio: ["pipe", "ignore", "ignore"], timeout: 8000, windowsHide: true });
1433
+ } catch {}
1434
+ }
1435
+ }
1436
+ useSelectionHandler(function(sel: any) {
1437
+ if (!sel || sel.isDragging || sel.isStart) return;
1438
+ const ax = Math.min(Number(sel.anchor.x), Number(sel.focus.x));
1439
+ const ay = Math.min(Number(sel.anchor.y), Number(sel.focus.y));
1440
+ const bx = Math.max(Number(sel.anchor.x), Number(sel.focus.x));
1441
+ const by = Math.max(Number(sel.anchor.y), Number(sel.focus.y));
1442
+ if (ax === bx && ay === by) return;
1443
+ try {
1444
+ const lines = new TextDecoder().decode(renderer.currentRenderBuffer.getRealCharBytes(true)).split("\n");
1445
+ const parts: string[] = [];
1446
+ for (let y = ay; y <= by; y++) parts.push((lines[y] || "").slice(ax, bx + 1));
1447
+ const text = parts.join("\n").replace(/\s+$/g, "").trimEnd();
1448
+ if (!text.trim()) return;
1449
+ copyText(text);
1450
+ showToast("Copied " + text.length + " chars to clipboard.", "ok");
1451
+ } catch {}
1452
+ });
1453
+
1454
+ // ═══════════════════════ Paste ═══════════════════════
1455
+ usePaste(event => {
1456
+ if (modal()) return;
1457
+ // Multi-line paste is kept: the chatbox grows up to its height limit and
1458
+ // scrolls beyond it, showing a "~N lines" indicator to save space.
1459
+ const txt = new TextDecoder().decode((event as any).bytes || "").replace(/\r\n?/g, "\n");
1460
+ if (!txt) return;
1461
+ const v = input();
1462
+ const ss = selStart(), se = selEnd();
1463
+ const hasSel = ss >= 0 && se > ss;
1464
+ const p = Math.min(cursor(), v.length);
1465
+ const n = hasSel ? v.slice(0, ss) + txt + v.slice(se) : v.slice(0, p) + txt + v.slice(p);
1466
+ clearSelection();
1467
+ setDraft(n, hasSel ? ss + txt.length : p + txt.length);
1468
+ setPastedAt(Date.now());
1469
+ updateAutocomplete(n);
1470
+ historyReset();
1471
+ });
1472
+
1473
+ // ═══════════════════════ Lifecycle ═══════════════════════
1474
+ let _skillToastOff: (() => void) | null = null;
1475
+ let _skillDoneOff: (() => void) | null = null;
1476
+ onMount(function() {
1477
+ refreshProviderState();
1478
+ refreshUsage();
1479
+ wireTodoEvents();
1480
+ // Hydrate the subagent history from disk so /subagents can show past runs.
1481
+ loadSubagentHistory({ limit: 200 });
1482
+ registerSuggestionPicker(function(label: string) {
1483
+ if (label.startsWith("/")) processSlash(label);
1484
+ else if (label.startsWith("!")) { appendMessage({ role: "user", content: label }); appendMessage({ role: "system", content: runShell(label.slice(1)) }); }
1485
+ else if (label.startsWith("@")) setDraft(label + " ");
1486
+ });
1487
+ if (props.resumeSession) {
1488
+ resumeSessionById(props.resumeSession);
1489
+ }
1490
+ if (props.initialPrompt) setTimeout(function() { submit(props.initialPrompt); }, 200);
1491
+ // One-time session-start prompt: "Allow all commands in this session?"
1492
+ // (Shift+Tab toggles auto-approval any time). Skipped when auto-approval
1493
+ // is already on, when a popup/modal is open, and in the test harness
1494
+ // (tests drive the popup explicitly via askSessionPermissions()).
1495
+ if (!process.env.LOOM_NO_SESSION_PROMPT && !autoPerm() && !permission() && !modal() && !props.resumeSession) {
1496
+ setTimeout(function() {
1497
+ if (!autoPerm() && !permission() && !modal()) askSessionPermissions();
1498
+ }, 500);
1499
+ }
1500
+ _skillToastOff = on("trigger:skill", function(d: any) {
1501
+ const names = (d?.skills || []).join(", ");
1502
+ showToast("skill: " + names, "ok", 5000);
1503
+ setSkillActive(d?.skills || []);
1504
+ });
1505
+ _skillDoneOff = on("turn:end", function(d: any) {
1506
+ if (d?.skills?.length) showToast("skill handled: " + d.skills.join(", "), "ok");
1507
+ });
1508
+ });
1509
+ onCleanup(function() { persistUi(); if (_skillToastOff) _skillToastOff(); if (_skillDoneOff) _skillDoneOff(); });
1510
+
1511
+ var showSplash = createMemo(function() { return messages().length === 0; });
1512
+
1513
+ // opencode hides completed tool parts when "tool details" are off
1514
+ // (shouldHide); running rows and errors always stay. Messages drive the
1515
+ // chat render, so filtering HERE (a memo over messages() + showToolDetails())
1516
+ // is the only path that reliably re-renders the chat on the toggle.
1517
+ var chatMessages = createMemo(function() {
1518
+ const msgs = messages();
1519
+ if (showToolDetails()) return msgs;
1520
+ return msgs.map(function(m: any) {
1521
+ if (m.role !== "assistant" || (!m.parts && !m.tools)) return m;
1522
+ return Object.assign({}, m, {
1523
+ parts: (m.parts || []).filter((p: any) => p.type !== "tool" || p.tool.status !== "done"),
1524
+ tools: (m.tools || []).filter((t: any) => t.status !== "done"),
1525
+ });
1526
+ });
1527
+ });
1528
+
1529
+ return (
1530
+ <box position="absolute" top={0} left={0} right={0} bottom={0} flexDirection="column" backgroundColor={ui.bg}>
1531
+ {showSplash() ? (
1532
+ <SplashScreen />
1533
+ ) : (
1534
+ <box flexDirection="column" flexGrow={1}>
1535
+ <BreadcrumbBar />
1536
+ <box flexDirection="row" flexGrow={1}>
1537
+ {/* Chat column: messages + input share one column so the input
1538
+ never visually bleeds under the sidebar. */}
1539
+ <box flexDirection="column" flexGrow={1}>
1540
+ <ChatArea messages={chatMessages} thinking={thinking()} />
1541
+ <InputBar />
1542
+ {/* Sidebar separated by a breathing gap — no shared bottom edge. */}
1543
+ </box>
1544
+ <Show when={sidebarVisible()}>
1545
+ <box width={39} flexShrink={0} marginLeft={1}>
1546
+ <Sidebar show={sidebarVisible()} />
1547
+ </box>
1548
+ </Show>
1549
+ </box>
1550
+ </box>
1551
+ )}
1552
+
1553
+ {(() => {
1554
+ // Snapshot the modal object once: closeModal() inside an onCancel / key
1555
+ // handler nulls the store mid-render, so reading modal() again in
1556
+ // props would throw "null is not an object" on every Escape.
1557
+ const m = modal();
1558
+ if (!m) return null;
1559
+ return (
1560
+ <box position="absolute" top={0} left={0} right={0} bottom={0} flexDirection="column" alignItems="center" justifyContent="center" zIndex={99}>
1561
+ {m.type === "provider" ? <ProviderPicker /> : null}
1562
+ {m.type === "select" ? <SelectModal title={m.title} options={m.options ?? []} onPick={m.onPick} searchable={m.searchable} onCancel={m.onCancel} onPreview={m.onPreview} /> : null}
1563
+ {m.type === "input" ? <InputModal title={m.title} placeholder={m.placeholder} onPick={m.onPick} isKey={m.isKey} value={m.value} caretStart={m.caretStart} onCancel={m.onCancel} /> : null}
1564
+ {m.type === "settings" ? <SettingsModal /> : null}
1565
+ {m.type === "palette" ? <PaletteModal onPick={m.onPick} /> : null}
1566
+ {m.type === "mcp" ? <McpModal /> : null}
1567
+ {m.type === "connectors" ? <ConnectorsModal /> : null}
1568
+ {m.type === "graph" ? <GraphModal graph={m.graph} err={m.graphError} /> : null}
1569
+ {m.type === "subagents" ? <SubagentPanel /> : null}
1570
+ {m.type === "subagent_detail" ? <SubagentDetailPanel /> : null}
1571
+ </box>
1572
+ );
1573
+ })()}
1574
+
1575
+ <ToastOverlay />
1576
+ </box>
1577
+ );
1578
+ }