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.
- package/.env.example +25 -0
- package/CHANGELOG.md +402 -0
- package/LICENSE +21 -0
- package/LOOM.md +235 -0
- package/README.md +433 -0
- package/bin/loom-tui.js +43 -0
- package/bin/loom.js +44 -0
- package/docs/acp.md +151 -0
- package/docs/web.md +205 -0
- package/package.json +97 -0
- package/scripts/acp-smoke.js +146 -0
- package/src/acp/acp-server.js +287 -0
- package/src/config/provider-cmd.js +37 -0
- package/src/config/settings.js +164 -0
- package/src/core/agents.js +361 -0
- package/src/core/background-tasks.js +103 -0
- package/src/core/cli.js +579 -0
- package/src/core/custom-commands.js +70 -0
- package/src/core/errors.js +29 -0
- package/src/core/events.js +24 -0
- package/src/core/file-diffs.js +282 -0
- package/src/core/format.js +206 -0
- package/src/core/graph.js +257 -0
- package/src/core/hooks.js +82 -0
- package/src/core/lsp.js +385 -0
- package/src/core/memory.js +87 -0
- package/src/core/model-router.js +87 -0
- package/src/core/permissions.js +327 -0
- package/src/core/platform.js +33 -0
- package/src/core/plugin-cmd.js +380 -0
- package/src/core/restore.js +207 -0
- package/src/core/session-store.js +167 -0
- package/src/core/session.js +910 -0
- package/src/core/subagent-log.js +134 -0
- package/src/core/tokens.js +31 -0
- package/src/core/update.js +6 -0
- package/src/core/usage.js +166 -0
- package/src/index.js +41 -0
- package/src/mcp/mcp-client.js +201 -0
- package/src/mcp/mcp-manager.js +193 -0
- package/src/providers/anthropic.js +243 -0
- package/src/providers/google.js +29 -0
- package/src/providers/index.js +175 -0
- package/src/providers/local.js +27 -0
- package/src/providers/nvidia.js +85 -0
- package/src/providers/openai-compat.js +269 -0
- package/src/providers/openai.js +35 -0
- package/src/providers/openrouter.js +43 -0
- package/src/providers/registry.js +196 -0
- package/src/providers/tokenrouter.js +19 -0
- package/src/skills/skill-matcher.js +133 -0
- package/src/skills/skills-manager.js +213 -0
- package/src/tools/index.js +543 -0
- package/src/tui/App.tsx +1578 -0
- package/src/tui/components/BreadcrumbBar.tsx +34 -0
- package/src/tui/components/ChatArea.tsx +518 -0
- package/src/tui/components/InputBar.tsx +354 -0
- package/src/tui/components/MdText.tsx +105 -0
- package/src/tui/components/Modals.tsx +851 -0
- package/src/tui/components/PermissionPopup.tsx +264 -0
- package/src/tui/components/Sidebar.tsx +182 -0
- package/src/tui/components/SplashScreen.tsx +51 -0
- package/src/tui/components/SubagentPanel.tsx +217 -0
- package/src/tui/components/ToastOverlay.tsx +34 -0
- package/src/tui/keybinds.ts +318 -0
- package/src/tui/mcp-presets.ts +189 -0
- package/src/tui/md-render.ts +228 -0
- package/src/tui/store.ts +714 -0
- package/src/tui/suite-home.ts +20 -0
- package/src/tui/theme.ts +313 -0
- package/src/tui/themes.generated.ts +968 -0
- package/src/tui/tool-display.ts +176 -0
- package/src/tui/toolname.ts +60 -0
- package/src/tui/tui-config.ts +28 -0
- package/src/tui-open.tsx +51 -0
- package/src/web/attach.js +242 -0
- package/src/web/graph-view.html +262 -0
- package/src/web/index.html +824 -0
- package/src/web/web-server.js +470 -0
package/src/tui/App.tsx
ADDED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
}
|