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/store.ts
ADDED
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
// Central store — SolidJS reactive state, OpenCode-style.
|
|
2
|
+
import { createSignal } from "solid-js";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import { loadTuiJson, saveTuiJson } from "./tui-config.ts";
|
|
7
|
+
|
|
8
|
+
let _sess: any = null;
|
|
9
|
+
export function getSession(): any {
|
|
10
|
+
if (!_sess) {
|
|
11
|
+
const { Session } = require("../core/session.js");
|
|
12
|
+
_sess = new Session();
|
|
13
|
+
}
|
|
14
|
+
return _sess;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function setSessionAuto(auto: boolean): void {
|
|
18
|
+
getSession().permissions.setAuto(auto);
|
|
19
|
+
setAutoPerm(auto);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Permission auto-approve state (muted "auto" indicator in the status row) ───
|
|
23
|
+
export const [autoPerm, setAutoPerm] = createSignal(false);
|
|
24
|
+
|
|
25
|
+
// ─── Chat state ───
|
|
26
|
+
export const [messages, setMessages] = createSignal<any[]>([]);
|
|
27
|
+
export const [input, setInput] = createSignal("");
|
|
28
|
+
export const [thinking, setThinking] = createSignal(false);
|
|
29
|
+
export const [thinkStart, setThinkStart] = createSignal<number | null>(null);
|
|
30
|
+
// Index (into messages()) of the user bubble whose collapsed preview is
|
|
31
|
+
// expanded. Ctrl+E toggles the most recent collapsed one; clicking a bubble
|
|
32
|
+
// also sets this. Null = everything collapsed.
|
|
33
|
+
export const [userExpandedIdx, setUserExpandedIdx] = createSignal<number | null>(null);
|
|
34
|
+
// Settled reasoning parts (message index → part indices) the user expanded.
|
|
35
|
+
// Clicking a settled "+ Thought" row toggles its part; absent = all collapsed.
|
|
36
|
+
export const [thoughtExpanded, setThoughtExpanded] = createSignal<Map<number, Set<number>>>(new Map());
|
|
37
|
+
// While a turn is RUNNING its thinking streams open by default; clicking the
|
|
38
|
+
// "⠋ Thinking" header collapses it live (opencode toggles reasoning at any
|
|
39
|
+
// time). Indices of running thoughts the user collapsed manually.
|
|
40
|
+
export const [thoughtClosed, setThoughtClosed] = createSignal<Set<number>>(new Set());
|
|
41
|
+
|
|
42
|
+
// ─── Prompt history (Up/Down recall of earlier prompts) ───
|
|
43
|
+
export const [promptHistory, setPromptHistory] = createSignal<string[]>([]);
|
|
44
|
+
export const [historyIndex, setHistoryIndex] = createSignal(-1);
|
|
45
|
+
let historyDraft = "";
|
|
46
|
+
|
|
47
|
+
// Record every submitted prompt (deduped, capped at 50) and reset navigation
|
|
48
|
+
// so the next Up arrow starts from the newest entry.
|
|
49
|
+
export function recordPrompt(text: string) {
|
|
50
|
+
const t = text.trim();
|
|
51
|
+
if (!t) return;
|
|
52
|
+
setPromptHistory(h => (h[h.length - 1] === t ? h : [...h.slice(-49), t]));
|
|
53
|
+
setHistoryIndex(-1);
|
|
54
|
+
historyDraft = "";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Up arrow: walk toward older prompts; the first Up saves the current draft so
|
|
58
|
+
// Down past the end restores it.
|
|
59
|
+
export function historyPrev(): string | null {
|
|
60
|
+
const h = promptHistory();
|
|
61
|
+
if (!h.length) return null;
|
|
62
|
+
if (historyIndex() === -1) historyDraft = input();
|
|
63
|
+
const ni = historyIndex() === -1 ? h.length - 1 : Math.max(0, historyIndex() - 1);
|
|
64
|
+
setHistoryIndex(ni);
|
|
65
|
+
return h[ni];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Down arrow: walk toward newer prompts; past the newest restores the draft.
|
|
69
|
+
export function historyNext(): string | null {
|
|
70
|
+
const h = promptHistory();
|
|
71
|
+
if (historyIndex() === -1) return null;
|
|
72
|
+
const ni = historyIndex() + 1;
|
|
73
|
+
if (ni >= h.length) { setHistoryIndex(-1); return historyDraft; }
|
|
74
|
+
setHistoryIndex(ni);
|
|
75
|
+
return h[ni];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Typing fresh text (or escaping) abandons history navigation.
|
|
79
|
+
export function historyReset() { setHistoryIndex(-1); historyDraft = ""; }
|
|
80
|
+
|
|
81
|
+
// ─── Autocomplete (slash / @file / !shell) ───
|
|
82
|
+
export type AutoKind = "none" | "slash" | "file" | "shell" | "at";
|
|
83
|
+
export type Suggestion = { label: string; desc?: string };
|
|
84
|
+
export const [suggestions, setSuggestions] = createSignal<Suggestion[]>([]);
|
|
85
|
+
export const [autoKind, setAutoKind] = createSignal<AutoKind>("none");
|
|
86
|
+
export const [autoIndex, setAutoIndex] = createSignal(0);
|
|
87
|
+
|
|
88
|
+
// App registers a handler that executes the picked suggestion (slash / shell / file).
|
|
89
|
+
let _suggestionPicker: ((label: string) => void) | null = null;
|
|
90
|
+
export function registerSuggestionPicker(fn: ((label: string) => void) | null) { _suggestionPicker = fn; }
|
|
91
|
+
|
|
92
|
+
export function selectSuggestionAt(i: number) {
|
|
93
|
+
setAutoIndex(Math.max(0, Math.min(suggestions().length - 1, i)));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function moveSuggestionIndex(delta: number) {
|
|
97
|
+
const n = suggestions().length;
|
|
98
|
+
if (!n) return;
|
|
99
|
+
setAutoIndex(i => Math.max(0, Math.min(n - 1, i + delta)));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Compute the start index of a visible window that keeps `selected` in view.
|
|
103
|
+
// `visible` rows are shown; selection stays inside with ~1/3 of the window above it.
|
|
104
|
+
// If `selected` is already inside the current window, the window is NOT moved —
|
|
105
|
+
// this keeps the popup stable during mouse down/up (clicking must not shift rows
|
|
106
|
+
// between press and release, or the release lands on a different row).
|
|
107
|
+
export function windowFor(selected: number, total: number, visible: number, currentStart: number = 0): number {
|
|
108
|
+
if (total <= visible) return 0;
|
|
109
|
+
const maxStart = total - visible;
|
|
110
|
+
const c = Math.max(0, Math.min(currentStart, maxStart));
|
|
111
|
+
if (selected >= c && selected < c + visible) return c;
|
|
112
|
+
const target = selected - Math.floor(visible / 3);
|
|
113
|
+
return Math.max(0, Math.min(maxStart, target));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function pickSuggestionAt(i: number): boolean {
|
|
117
|
+
selectSuggestionAt(i);
|
|
118
|
+
return pickSuggestion();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function pickSuggestion(): boolean {
|
|
122
|
+
const list = suggestions();
|
|
123
|
+
const pick = list[autoIndex()];
|
|
124
|
+
if (!pick) return false;
|
|
125
|
+
const label = pick.label;
|
|
126
|
+
setInput(""); setSuggestions([]); setAutoKind("none"); setAutoIndex(0);
|
|
127
|
+
if (_suggestionPicker) _suggestionPicker(label);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Modal ───
|
|
132
|
+
export const [modal, setModal] = createSignal<any>(null);
|
|
133
|
+
export function openModal(m: any) { setModal(m); }
|
|
134
|
+
export function closeModal() { setModal(null); }
|
|
135
|
+
|
|
136
|
+
// ─── Permission popup (model wants to run a command / change a file) ───
|
|
137
|
+
// The same popup hosts QUESTIONS (the ask tool): isQuestion flips it into
|
|
138
|
+
// question mode, where the user picks one of the provided options or types
|
|
139
|
+
// their own answer instead of the Allow/Always/Deny choices.
|
|
140
|
+
export type PermissionRequest = {
|
|
141
|
+
tool: string;
|
|
142
|
+
command: string;
|
|
143
|
+
label: string;
|
|
144
|
+
isQuestion?: boolean;
|
|
145
|
+
options?: string[];
|
|
146
|
+
sessionStart?: boolean;
|
|
147
|
+
resolve: (approved: boolean, note?: string) => void;
|
|
148
|
+
};
|
|
149
|
+
export const [permission, setPermission] = createSignal<PermissionRequest | null>(null);
|
|
150
|
+
|
|
151
|
+
// Draft caret position (index into input) — the chatbox is a real editing
|
|
152
|
+
// surface: left/right arrows move it, typing inserts at it, backspace deletes
|
|
153
|
+
// before it. Kept in the store so history/paste/submit paths can sync it.
|
|
154
|
+
export const [cursor, setCursor] = createSignal(0);
|
|
155
|
+
|
|
156
|
+
// Set the draft and place the cursor (defaults to the end).
|
|
157
|
+
export function setDraft(text: string, pos?: number) {
|
|
158
|
+
setInput(text);
|
|
159
|
+
setCursor(typeof pos === "number" ? Math.max(0, Math.min(text.length, pos)) : text.length);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Draft text selection (readline-style): selStart/selEnd are indices into
|
|
163
|
+
// input; both -1 = no selection. Ctrl+A marks everything, Ctrl+C copies the
|
|
164
|
+
// highlighted span, typing/backspace replace it.
|
|
165
|
+
export const [selStart, setSelStart] = createSignal(-1);
|
|
166
|
+
export const [selEnd, setSelEnd] = createSignal(-1);
|
|
167
|
+
export function clearSelection() { setSelStart(-1); setSelEnd(-1); }
|
|
168
|
+
|
|
169
|
+
// Paste-vs-typing marker: set when a paste lands in the input and cleared the
|
|
170
|
+
// moment the user edits. Pasted drafts past 10 lines render compressed
|
|
171
|
+
// ("pasted ~N lines") instead of blowing the chatbox up to its scroll limit.
|
|
172
|
+
export const [pastedAt, setPastedAt] = createSignal(0);
|
|
173
|
+
|
|
174
|
+
// Typing an answer to a QUESTION popup (the ask tool) switches the popup into
|
|
175
|
+
// its inline answer editor; the text lives here so the reconciler can remount
|
|
176
|
+
// the popup on signal changes without wiping the typed answer.
|
|
177
|
+
export const [questionOpen, setQuestionOpen] = createSignal(false);
|
|
178
|
+
export const [questionText, setQuestionText] = createSignal("");
|
|
179
|
+
|
|
180
|
+
export function openQuestion(initial: string) {
|
|
181
|
+
setQuestionText(initial);
|
|
182
|
+
setQuestionOpen(true);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function closeQuestion() {
|
|
186
|
+
setQuestionOpen(false);
|
|
187
|
+
setQuestionText("");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Raised by the session's onPermissionRequest callback; resolves once the user
|
|
191
|
+
// answers the popup (Allow / Always allow / Deny, or a question option/answer).
|
|
192
|
+
// The promise resolves { approved, note } — the session turns questions into
|
|
193
|
+
// the answer (note) and permissions into an approve/deny verdict.
|
|
194
|
+
export function requestPermission(tool: string, command: string, label: string, isQuestion?: boolean, options?: string[]): Promise<{ approved: boolean; note: string }> {
|
|
195
|
+
return new Promise((resolve) => {
|
|
196
|
+
setPermission({
|
|
197
|
+
tool, command, label, isQuestion, options,
|
|
198
|
+
resolve: (approved, note) => { setPermission(null); resolve({ approved: !!approved, note: note || "" }); },
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// One-time prompt shown at the start of a new session: "Allow all commands in
|
|
204
|
+
// this session?" — picking "Allow all commands" flips on session-wide
|
|
205
|
+
// auto-approval (Shift+Tab also toggles it), "Ask each time" keeps per-command
|
|
206
|
+
// asks. Fire-and-forget: the popup resolves itself once the user answers.
|
|
207
|
+
export function askSessionPermissions(): Promise<void> {
|
|
208
|
+
return new Promise((resolve) => {
|
|
209
|
+
setPermission({
|
|
210
|
+
tool: "session",
|
|
211
|
+
command: "Allow all commands in this session?",
|
|
212
|
+
label: "",
|
|
213
|
+
isQuestion: true,
|
|
214
|
+
options: ["Allow all commands", "Ask each time"],
|
|
215
|
+
sessionStart: true,
|
|
216
|
+
resolve: () => { setPermission(null); resolve(); },
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Floating toasts ───
|
|
222
|
+
// Transient confirmations (copied, model switched, key saved, ...) render as a
|
|
223
|
+
// small floating box above the input bar and auto-dismiss — they are NOT chat
|
|
224
|
+
// messages, so they never pollute the conversation history.
|
|
225
|
+
export type Toast = { id: number; text: string; kind: "info" | "ok" | "error" };
|
|
226
|
+
export const [toasts, setToasts] = createSignal<Toast[]>([]);
|
|
227
|
+
let toastSeq = 0;
|
|
228
|
+
export function showToast(text: string, kind: "info" | "ok" | "error" = "info", durationMs: number = 3000) {
|
|
229
|
+
const id = ++toastSeq;
|
|
230
|
+
setToasts(t => [...t, { id, text, kind }]);
|
|
231
|
+
setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), durationMs);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── Sidebar ───
|
|
235
|
+
export const [sidebarVisible, setSidebarVisible] = createSignal(true);
|
|
236
|
+
export const [sidebarTab, setSidebarTab] = createSignal(0);
|
|
237
|
+
|
|
238
|
+
// ─── Live model speed (sidebar "Speed" row) ───
|
|
239
|
+
export type SpeedSnapshot = {
|
|
240
|
+
live: { elapsedMs: number; firstTokenMs: number | null; tokensPerSec: number } | null;
|
|
241
|
+
last: { latencyMs: number | null; tokensPerSec: number | null; durationMs: number | null; tokens: number | null; model: string } | null;
|
|
242
|
+
};
|
|
243
|
+
export const [speedStats, setSpeedStats] = createSignal<SpeedSnapshot>({ live: null, last: null });
|
|
244
|
+
|
|
245
|
+
// ─── Settings toggles ───
|
|
246
|
+
export const [showToolDetails, setShowToolDetails] = createSignal(true);
|
|
247
|
+
export const [showThinking, setShowThinking] = createSignal(true);
|
|
248
|
+
export const [inputMode, setInputMode] = createSignal<"build" | "plan" | "chat">("build");
|
|
249
|
+
|
|
250
|
+
// ─── Provider state ───
|
|
251
|
+
export const [providerName, setProviderName] = createSignal("");
|
|
252
|
+
export const [modelName, setModelName] = createSignal("");
|
|
253
|
+
export const [providerKeyOk, setProviderKeyOk] = createSignal(false);
|
|
254
|
+
export const [sessionId, setSessionId] = createSignal("");
|
|
255
|
+
|
|
256
|
+
// ─── Todos ───
|
|
257
|
+
export const [todos, setTodos] = createSignal<any[]>([]);
|
|
258
|
+
|
|
259
|
+
// ─── Usage & billing ───
|
|
260
|
+
export const [sessionUsage, setSessionUsage] = createSignal<{ tokens: number; pct: number; cost: number }>({ tokens: 0, pct: 0, cost: 0 });
|
|
261
|
+
export const [lifetimeUsage, setLifetimeUsage] = createSignal<{ tokens: number; cost: number; monthCost: number; pct: number; budget: number }>({ tokens: 0, cost: 0, monthCost: 0, pct: 0, budget: 25 });
|
|
262
|
+
export const [modelMeta, setModelMeta] = createSignal<any>(null);
|
|
263
|
+
export const [budgetLevel, setBudgetLevel] = createSignal<string>("auto");
|
|
264
|
+
// Phase 2.4: the last skill(s) that fired this turn — drives the sidebar Skills row.
|
|
265
|
+
export const [skillActive, setSkillActive] = createSignal<string[]>([]);
|
|
266
|
+
|
|
267
|
+
const { PROVIDERS, PROVIDER_ORDER, PROVIDER_LABELS } = require("../providers/index.js");
|
|
268
|
+
export { PROVIDERS, PROVIDER_ORDER, PROVIDER_LABELS };
|
|
269
|
+
|
|
270
|
+
export function refreshProviderState() {
|
|
271
|
+
const s = getSession();
|
|
272
|
+
const cfg = s.config || {};
|
|
273
|
+
const prov = s.provider?.active?.name || cfg.provider || "anthropic";
|
|
274
|
+
const model = cfg.model?.[prov] || "default";
|
|
275
|
+
setProviderName(prov);
|
|
276
|
+
setModelName(model);
|
|
277
|
+
setSessionId(s.conversationId || "");
|
|
278
|
+
setBudgetLevel(cfg.budgetLevel || "auto");
|
|
279
|
+
const { envNamesFor } = require("../providers/index.js");
|
|
280
|
+
const hasKey = !!(cfg.apiKeys?.[prov]) || (envNamesFor(prov) || []).some(n => !!process.env[n]);
|
|
281
|
+
setProviderKeyOk(!!hasKey);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Refresh the session + lifetime usage/billing signals from the session counters and ~/.loom/usage.json.
|
|
285
|
+
export function refreshUsage() {
|
|
286
|
+
refreshProviderState();
|
|
287
|
+
const s = getSession();
|
|
288
|
+
const { getModelMeta } = require("../providers/index.js");
|
|
289
|
+
const meta = getModelMeta(providerName(), modelName());
|
|
290
|
+
const ctx = meta?.context || 200000;
|
|
291
|
+
setModelMeta(meta);
|
|
292
|
+
setSessionUsage({
|
|
293
|
+
tokens: s.tokensUsed,
|
|
294
|
+
pct: ctx ? (s.tokensUsed / ctx) * 100 : 0,
|
|
295
|
+
cost: s.sessionCost,
|
|
296
|
+
});
|
|
297
|
+
const { getUsage } = require("../core/usage.js");
|
|
298
|
+
const u = getUsage();
|
|
299
|
+
setLifetimeUsage({
|
|
300
|
+
tokens: u.totalTokens,
|
|
301
|
+
cost: u.totals.costUsd,
|
|
302
|
+
monthCost: u.month.costUsd,
|
|
303
|
+
pct: u.budgetUsd ? (u.month.costUsd / u.budgetUsd) * 100 : 0,
|
|
304
|
+
budget: u.budgetUsd,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function modelOptionsForProvider(provider: string) {
|
|
309
|
+
return PROVIDERS[provider]?.models || [];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function allModelOptions() {
|
|
313
|
+
const out: any[] = [];
|
|
314
|
+
const { getRecentModels, hasApiKey } = require("../config/settings.js");
|
|
315
|
+
const { BUILTIN_PROVIDERS } = require("../providers/index.js");
|
|
316
|
+
const recents = getRecentModels();
|
|
317
|
+
const seen = new Set<string>();
|
|
318
|
+
if (recents.length) {
|
|
319
|
+
out.push({ header: "Recent", value: "__header_recent", isHeader: true });
|
|
320
|
+
for (const r of recents) {
|
|
321
|
+
if (!r || !r.provider || !r.model) continue;
|
|
322
|
+
const mods = modelOptionsForProvider(r.provider);
|
|
323
|
+
const m = mods.find(x => x.id === r.model);
|
|
324
|
+
if (!m) continue;
|
|
325
|
+
const key = r.provider + "/" + r.model;
|
|
326
|
+
seen.add(key);
|
|
327
|
+
out.push({ label: m.name, value: m.id, sub: m.id, provider: r.provider, tags: m.tags, recent: true });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
for (const p of PROVIDER_ORDER) {
|
|
331
|
+
const mods = modelOptionsForProvider(p);
|
|
332
|
+
if (!mods.length) continue;
|
|
333
|
+
// models.dev-scale: only providers with a key list their full model set —
|
|
334
|
+
// the rest stay in /connect until a key is added. Built-ins always show.
|
|
335
|
+
if (!BUILTIN_PROVIDERS.includes(p) && !hasApiKey(p)) continue;
|
|
336
|
+
out.push({ header: PROVIDER_LABELS[p] || p, value: `__header__${p}`, isHeader: true });
|
|
337
|
+
for (const m of mods) {
|
|
338
|
+
const key = p + "/" + m.id;
|
|
339
|
+
if (seen.has(key)) continue;
|
|
340
|
+
out.push({ label: m.name, value: m.id, sub: m.id, provider: p, tags: m.tags });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
out.push({ label: "(custom model ID)", value: "__custom__" });
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ─── Message helpers ───
|
|
348
|
+
export function appendMessage(m: any) { setMessages(l => [...l, m]); }
|
|
349
|
+
|
|
350
|
+
// ─── Vim mode ───
|
|
351
|
+
// Optional modal editing (config `vimMode`, toggled via /vim). Normal-mode
|
|
352
|
+
// keys are intercepted in the App's central key handler; insert mode behaves
|
|
353
|
+
// exactly like the default input.
|
|
354
|
+
export const [vimMode, setVimMode] = createSignal(!!loadTuiJson().vimMode);
|
|
355
|
+
export const [vimNormal, setVimNormal] = createSignal(false);
|
|
356
|
+
export function toggleVim(): boolean {
|
|
357
|
+
const next = !vimMode();
|
|
358
|
+
setVimMode(next);
|
|
359
|
+
setVimNormal(false);
|
|
360
|
+
saveTuiJson({ vimMode: next });
|
|
361
|
+
return next;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ─── Queued drafts ───
|
|
365
|
+
// Messages typed while a turn runs are queued (claude-style) and flushed
|
|
366
|
+
// FIFO when the turn ends. Plain strings; submitted verbatim.
|
|
367
|
+
export const [queuedDrafts, setQueuedDrafts] = createSignal<string[]>([]);
|
|
368
|
+
export function queueDraft(text: string): void { setQueuedDrafts(q => q.concat(text)); }
|
|
369
|
+
export function dequeueDraft(): string | null {
|
|
370
|
+
const q = queuedDrafts();
|
|
371
|
+
if (!q.length) return null;
|
|
372
|
+
setQueuedDrafts(q.slice(1));
|
|
373
|
+
return q[0];
|
|
374
|
+
}
|
|
375
|
+
export function patchLastMessage(patch: any) {
|
|
376
|
+
setMessages(l => {
|
|
377
|
+
const i = l.length - 1;
|
|
378
|
+
if (i < 0) return l;
|
|
379
|
+
return l.map((m, j) => j === i ? { ...m, ...patch } : m);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
export function patchMessageAt(idx: number, patch: any) {
|
|
383
|
+
setMessages(l => l.map((m, i) => (i === idx ? { ...m, ...patch } : m)));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Matches bare markers ("[x] task") AND markdown checklist lines
|
|
387
|
+
// ("- [x] task", "* [ ] task", "1. [~] task") so the sidebar mirrors todo
|
|
388
|
+
// lists the model writes in its reply even without the todowrite tool.
|
|
389
|
+
const TODO_RX = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([x+~ \-])\]\s+(.+)/i;
|
|
390
|
+
export function recomputeTodos() {
|
|
391
|
+
// Real todo state from the session (todowrite tool persists there) wins.
|
|
392
|
+
const sess = getSession();
|
|
393
|
+
const real = sess.todos;
|
|
394
|
+
if (Array.isArray(real) && real.length) {
|
|
395
|
+
setTodos(real.map(t => ({
|
|
396
|
+
done: t.status === "completed",
|
|
397
|
+
inProgress: t.status === "in_progress",
|
|
398
|
+
cancelled: t.status === "cancelled",
|
|
399
|
+
text: t.content,
|
|
400
|
+
})));
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
// Fallback: scan replies for [ ] [x] [+] markers (resumed/old sessions).
|
|
404
|
+
const out: any[] = [];
|
|
405
|
+
for (const m of messages()) {
|
|
406
|
+
if (m.role !== "assistant" || !m.content) continue;
|
|
407
|
+
for (const line of String(m.content).split("\n")) {
|
|
408
|
+
const hit = line.match(TODO_RX);
|
|
409
|
+
if (hit) {
|
|
410
|
+
const st = hit[1].toLowerCase();
|
|
411
|
+
out.push({ done: st === "x" || st === "+", inProgress: st === "+" || st === "~", cancelled: st === "-", text: hit[2].trim() });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
setTodos(out);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ─── File helpers ───
|
|
419
|
+
const IGNORE = /(^|[\/])(node_modules|\.git|dist|build|\.next|\.venv|venv|coverage|__pycache__|\.loom|\.idea|\.vscode)([\/]|$)/i;
|
|
420
|
+
let filesCache: string[] | null = null;
|
|
421
|
+
// Reactive re-calc engine: bumping filesVersion invalidates the cache AND
|
|
422
|
+
// re-runs every reactive caller (the Sidebar's file list) because
|
|
423
|
+
// getProjectFiles() reads the signal. Bump after writes/create/rm calls.
|
|
424
|
+
const [filesVersion, setFilesVersion] = createSignal(0);
|
|
425
|
+
|
|
426
|
+
export function getProjectFiles(): string[] {
|
|
427
|
+
filesVersion();
|
|
428
|
+
if (filesCache) return filesCache;
|
|
429
|
+
const cwd = process.cwd();
|
|
430
|
+
const out: string[] = [];
|
|
431
|
+
function walk(dir: string, depth: number) {
|
|
432
|
+
if (depth > 4 || out.length > 500) return;
|
|
433
|
+
let entries;
|
|
434
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
435
|
+
for (const e of entries) {
|
|
436
|
+
const full = path.join(dir, e.name);
|
|
437
|
+
if (IGNORE.test(full)) continue;
|
|
438
|
+
const rel = path.relative(cwd, full).replace(/\\/g, "/");
|
|
439
|
+
if (e.isDirectory()) walk(full, depth + 1);
|
|
440
|
+
else out.push(rel);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
walk(cwd, 0);
|
|
444
|
+
filesCache = out.sort();
|
|
445
|
+
return filesCache;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function fuzzyFiles(query: string): string[] {
|
|
449
|
+
if (!query) return getProjectFiles().slice(0, 12);
|
|
450
|
+
const q = query.toLowerCase();
|
|
451
|
+
return getProjectFiles()
|
|
452
|
+
.filter((f: string) => f.toLowerCase().includes(q))
|
|
453
|
+
.sort((a: string, b: string) => {
|
|
454
|
+
const ai = a.toLowerCase().indexOf(q);
|
|
455
|
+
const bi = b.toLowerCase().indexOf(q);
|
|
456
|
+
if (ai !== bi) return ai - bi;
|
|
457
|
+
return a.length - b.length;
|
|
458
|
+
})
|
|
459
|
+
.slice(0, 12);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function invalidateFilesCache() { filesCache = null; }
|
|
463
|
+
|
|
464
|
+
export function bumpFilesVersion() { setFilesVersion(v => v + 1); invalidateFilesCache(); }
|
|
465
|
+
|
|
466
|
+
// Recompute the sidebar's file list when a known mutator lands. Called by the
|
|
467
|
+
// tool-execution layer after write/edit/bash so the Files tab is always fresh.
|
|
468
|
+
export function refreshFilesIfNeeded(src?: string) {
|
|
469
|
+
if (src === "write" || src === "edit" || src === "bash" || src === "mcp") bumpFilesVersion();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Live agent todos: Session.setTodos → core event "todos:changed" → here.
|
|
473
|
+
// This _replaces_ any markdown-scan fallback so the sidebar mirrors the
|
|
474
|
+
// model's real todowrite output without waiting for a full assistant re-render.
|
|
475
|
+
// Registration is idempotent: App remounts must not stack duplicate listeners.
|
|
476
|
+
let _todoEvOff: (() => void) | null = null;
|
|
477
|
+
export function wireTodoEvents() {
|
|
478
|
+
if (_todoEvOff) return;
|
|
479
|
+
const ev = require("../core/events.js");
|
|
480
|
+
_todoEvOff = ev.on("todos:changed", (todos: any[]) => {
|
|
481
|
+
const sess = getSession();
|
|
482
|
+
const real = Array.isArray(todos) && todos.length ? todos : sess.todos;
|
|
483
|
+
setTodos(
|
|
484
|
+
(Array.isArray(real) ? real : []).map((t: any) => ({
|
|
485
|
+
done: t.status === "completed",
|
|
486
|
+
inProgress: t.status === "in_progress",
|
|
487
|
+
cancelled: t.status === "cancelled",
|
|
488
|
+
text: String(t.content || ""),
|
|
489
|
+
}))
|
|
490
|
+
);
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ─── Slash commands (for autocomplete) ───
|
|
495
|
+
export interface SlashCmd { cmd: string; desc: string; args?: string; }
|
|
496
|
+
|
|
497
|
+
export const SLASH_LIST: SlashCmd[] = [
|
|
498
|
+
{ cmd: "help", desc: "Show help dialog" },
|
|
499
|
+
{ cmd: "agents", desc: "List agents — primaries + subagents (@mention or task tool)" },
|
|
500
|
+
{ cmd: "build", desc: "Build mode — full agent tools (Tab cycles)" },
|
|
501
|
+
{ cmd: "plan", desc: "Plan mode — read-only analysis, no file changes" },
|
|
502
|
+
{ cmd: "chat", desc: "Chat mode — conversation only, no tools" },
|
|
503
|
+
{ cmd: "connect", desc: "Add/connect a provider", args: "[provider]" },
|
|
504
|
+
{ cmd: "key", desc: "Edit API key" },
|
|
505
|
+
{ cmd: "baseurl", desc: "Set provider base URL", args: "[provider] [url]" },
|
|
506
|
+
{ cmd: "model", desc: "Pick the active model", args: "[model-id]" },
|
|
507
|
+
{ cmd: "models", desc: "Open the model picker (grouped by provider)" },
|
|
508
|
+
{ cmd: "providers", desc: "List supported providers" },
|
|
509
|
+
{ cmd: "status", desc: "Show connection status" },
|
|
510
|
+
{ cmd: "usage", desc: "Show token usage and billing" },
|
|
511
|
+
{ cmd: "budget", desc: "Budget level: free | cheap | best | auto", args: "[level]" },
|
|
512
|
+
{ cmd: "new", desc: "Start a new session" },
|
|
513
|
+
{ cmd: "clear", desc: "Clear the chat" },
|
|
514
|
+
{ cmd: "restore", desc: "Restore project to an earlier state" },
|
|
515
|
+
{ cmd: "settings", desc: "Toggle details/thinking display" },
|
|
516
|
+
{ cmd: "sessions", desc: "Browse saved sessions (jump to one)" },
|
|
517
|
+
{ cmd: "thinking", desc: "Toggle thinking visibility" },
|
|
518
|
+
{ cmd: "details", desc: "Toggle tool detail visibility" },
|
|
519
|
+
{ cmd: "theme", desc: "Switch UI theme" },
|
|
520
|
+
{ cmd: "graph", desc: "View the memory graph (nodes + links)" },
|
|
521
|
+
{ cmd: "skills", desc: "Manage skills", args: "install <dir|git> | remove <name>" },
|
|
522
|
+
{ cmd: "mcp", desc: "Manage MCP servers", args: "add <name> <cmd> | remove | toggle" },
|
|
523
|
+
{ cmd: "connectors", desc: "Manage connectors (hosting & cloud services)", args: "add <name> <cmd> | remove | toggle" },
|
|
524
|
+
{ cmd: "permissions", desc: "Show saved permission rules", args: "| reset | auto" },
|
|
525
|
+
{ cmd: "exit", desc: "Quit Loom Code" },
|
|
526
|
+
// Newer commands sit at the END so early rows (help/connect/…) keep their
|
|
527
|
+
// popup positions — the interactive suite and muscle memory rely on it.
|
|
528
|
+
{ cmd: "subagents", desc: "View subagent runs (live + history, cancel, details)" },
|
|
529
|
+
{ cmd: "context", desc: "Show ~token breakdown of the current context window" },
|
|
530
|
+
{ cmd: "think", desc: "Thinking budget", args: "off|low|medium|high" },
|
|
531
|
+
{ cmd: "approve", desc: "Approve the plan & switch to Build (Plan mode)" },
|
|
532
|
+
{ cmd: "tasks", desc: "List background tasks (bash background:true)" },
|
|
533
|
+
{ cmd: "rewind", desc: "Pick a restore point and rewind files" },
|
|
534
|
+
{ cmd: "share", desc: "Export this chat as a self-contained HTML file" },
|
|
535
|
+
{ cmd: "worktree", desc: "New git worktree + branch for parallel work", args: "<name>" },
|
|
536
|
+
{ cmd: "style", desc: "Output style preset or free text; empty clears" },
|
|
537
|
+
{ cmd: "vim", desc: "Toggle vim modal editing (Esc = NORMAL)" },
|
|
538
|
+
{ cmd: "remember", desc: "Save a fact to project LOOM.md (or type # fact)", args: "<fact>" },
|
|
539
|
+
];
|
|
540
|
+
|
|
541
|
+
// ─── TUI prefs ───
|
|
542
|
+
const _p = loadTuiJson();
|
|
543
|
+
if (_p.sidebarVisible !== undefined) setSidebarVisible(_p.sidebarVisible);
|
|
544
|
+
if (_p.showToolDetails !== undefined) setShowToolDetails(_p.showToolDetails);
|
|
545
|
+
if (_p.showThinking !== undefined) setShowThinking(_p.showThinking);
|
|
546
|
+
|
|
547
|
+
// ─── First-run welcome tips (small sidebar card, dismissible with ✕) ───
|
|
548
|
+
// Visible only until the user dismisses them once; the flag persists in
|
|
549
|
+
// tui.json so existing users never see the card again.
|
|
550
|
+
export const [welcomeTipSeen, setWelcomeTipSeen] = createSignal(!!_p.welcomeTipSeen);
|
|
551
|
+
export function dismissWelcomeTips() {
|
|
552
|
+
setWelcomeTipSeen(true);
|
|
553
|
+
saveTuiJson({ welcomeTipSeen: true });
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function persistUi() {
|
|
557
|
+
saveTuiJson({
|
|
558
|
+
sidebarVisible: sidebarVisible(),
|
|
559
|
+
showToolDetails: showToolDetails(),
|
|
560
|
+
showThinking: showThinking(),
|
|
561
|
+
welcomeTipSeen: welcomeTipSeen(),
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ─── Convenience getters ───
|
|
566
|
+
export function cwdShort(): string {
|
|
567
|
+
const cwd = process.cwd().replace(/\\/g, "/");
|
|
568
|
+
return cwd.split("/").filter(Boolean).slice(-2).join("/");
|
|
569
|
+
}
|
|
570
|
+
export function username(): string { return os.userInfo().username || "you"; }
|
|
571
|
+
|
|
572
|
+
// ─── Subagent tracker ───
|
|
573
|
+
// Active subagent runs are kept in a Solid signal so the /subagents panel can
|
|
574
|
+
// render live status (running/done/error/cancelled, elapsed time, last tool).
|
|
575
|
+
// Completed runs are also persisted to disk via saveSubagentRun and merged
|
|
576
|
+
// into subagentHistory so the panel can show runs from past sessions.
|
|
577
|
+
export interface SubagentEntry {
|
|
578
|
+
runId: string;
|
|
579
|
+
agent: string;
|
|
580
|
+
agentId: string;
|
|
581
|
+
prompt: string;
|
|
582
|
+
status: "running" | "done" | "error" | "cancelled";
|
|
583
|
+
startTime: number;
|
|
584
|
+
endTime: number | null;
|
|
585
|
+
durationMs: number;
|
|
586
|
+
tokensIn: number;
|
|
587
|
+
tokensOut: number;
|
|
588
|
+
costUsd: number;
|
|
589
|
+
interrupted: boolean;
|
|
590
|
+
content: string;
|
|
591
|
+
toolLog: string[];
|
|
592
|
+
sessionId?: string;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Active (in-flight + just-completed this session). Map keyed by runId; the
|
|
596
|
+
// signal value is replaced (not mutated) so Solid sees the change.
|
|
597
|
+
export const [activeSubagents, setActiveSubagents] = createSignal<Map<string, SubagentEntry>>(new Map());
|
|
598
|
+
|
|
599
|
+
export function startSubagent(opts: { runId: string; agent: string; agentId: string; prompt: string; sessionId?: string }): void {
|
|
600
|
+
const now = Date.now();
|
|
601
|
+
const entry: SubagentEntry = {
|
|
602
|
+
runId: opts.runId,
|
|
603
|
+
agent: opts.agent,
|
|
604
|
+
agentId: opts.agentId,
|
|
605
|
+
prompt: String(opts.prompt || ""),
|
|
606
|
+
status: "running",
|
|
607
|
+
startTime: now,
|
|
608
|
+
endTime: null,
|
|
609
|
+
durationMs: 0,
|
|
610
|
+
tokensIn: 0,
|
|
611
|
+
tokensOut: 0,
|
|
612
|
+
costUsd: 0,
|
|
613
|
+
interrupted: false,
|
|
614
|
+
content: "",
|
|
615
|
+
toolLog: [],
|
|
616
|
+
sessionId: opts.sessionId,
|
|
617
|
+
};
|
|
618
|
+
setActiveSubagents(prev => {
|
|
619
|
+
const next = new Map(prev);
|
|
620
|
+
next.set(opts.runId, entry);
|
|
621
|
+
return next;
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Append to content / toolLog and patch scalar fields. Solid needs a new Map
|
|
626
|
+
// to detect the change — we copy on every update.
|
|
627
|
+
export function updateSubagent(runId: string, patch: { contentAppend?: string; toolLogAppend?: string; durationMs?: number }): void {
|
|
628
|
+
setActiveSubagents(prev => {
|
|
629
|
+
const cur = prev.get(runId);
|
|
630
|
+
if (!cur) return prev;
|
|
631
|
+
const next = new Map(prev);
|
|
632
|
+
const live: SubagentEntry = {
|
|
633
|
+
...cur,
|
|
634
|
+
durationMs: patch.durationMs != null ? patch.durationMs : (Date.now() - cur.startTime),
|
|
635
|
+
};
|
|
636
|
+
if (patch.contentAppend) live.content = cur.content + patch.contentAppend;
|
|
637
|
+
if (patch.toolLogAppend) live.toolLog = cur.toolLog.concat(patch.toolLogAppend);
|
|
638
|
+
next.set(runId, live);
|
|
639
|
+
return next;
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Mark a run done/error/cancelled and (when done) copy into the in-memory
|
|
644
|
+
// history so the panel can keep showing it after activeSubagents evicts it.
|
|
645
|
+
export function endSubagent(runId: string, final: { status: "done" | "error" | "cancelled"; endTime?: number; tokensIn?: number; tokensOut?: number; costUsd?: number; durationMs?: number; interrupted?: boolean; content?: string }): SubagentEntry | null {
|
|
646
|
+
let finished: SubagentEntry | null = null;
|
|
647
|
+
setActiveSubagents(prev => {
|
|
648
|
+
const cur = prev.get(runId);
|
|
649
|
+
if (!cur) return prev;
|
|
650
|
+
const next = new Map(prev);
|
|
651
|
+
finished = {
|
|
652
|
+
...cur,
|
|
653
|
+
status: final.status,
|
|
654
|
+
endTime: final.endTime != null ? final.endTime : Date.now(),
|
|
655
|
+
durationMs: final.durationMs != null ? final.durationMs : (Date.now() - cur.startTime),
|
|
656
|
+
tokensIn: final.tokensIn != null ? final.tokensIn : cur.tokensIn,
|
|
657
|
+
tokensOut: final.tokensOut != null ? final.tokensOut : cur.tokensOut,
|
|
658
|
+
costUsd: final.costUsd != null ? final.costUsd : cur.costUsd,
|
|
659
|
+
interrupted: final.interrupted != null ? final.interrupted : cur.interrupted,
|
|
660
|
+
content: final.content != null ? final.content : cur.content,
|
|
661
|
+
};
|
|
662
|
+
next.set(runId, finished);
|
|
663
|
+
return next;
|
|
664
|
+
});
|
|
665
|
+
if (finished) {
|
|
666
|
+
// Snapshot into in-memory history so the panel can show it even if the
|
|
667
|
+
// disk write fails or the panel is opened in the same session.
|
|
668
|
+
setSubagentHistory(prev => [finished!, ...prev].slice(0, 500));
|
|
669
|
+
}
|
|
670
|
+
return finished;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export function getSubagent(runId: string): SubagentEntry | undefined {
|
|
674
|
+
return activeSubagents().get(runId);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Cancel a live subagent by runId. Delegates to core/agents.js so the
|
|
678
|
+
// child's interrupt() actually fires; the run will resolve as interrupted
|
|
679
|
+
// and endSubagent will be called with status 'cancelled' / interrupted=true.
|
|
680
|
+
export function cancelSubagentRun(runId: string): boolean {
|
|
681
|
+
try {
|
|
682
|
+
const { cancelSubagent } = require("../core/agents.js");
|
|
683
|
+
return !!cancelSubagent(runId);
|
|
684
|
+
} catch {
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// History from disk (loaded on startup; refreshed when the panel opens).
|
|
690
|
+
export const [subagentHistory, setSubagentHistory] = createSignal<SubagentEntry[]>([]);
|
|
691
|
+
|
|
692
|
+
// Synchronous load (the log file is small — a few hundred entries max — and
|
|
693
|
+
// the panel needs the data immediately on first paint).
|
|
694
|
+
export function loadSubagentHistory(opts?: { since?: number; sessionId?: string; limit?: number }): SubagentEntry[] {
|
|
695
|
+
try {
|
|
696
|
+
const { loadSubagentRuns } = require("../core/subagent-log.js");
|
|
697
|
+
const rows = loadSubagentRuns(opts) as SubagentEntry[];
|
|
698
|
+
setSubagentHistory(rows);
|
|
699
|
+
return rows;
|
|
700
|
+
} catch {
|
|
701
|
+
return [];
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Persist a finished run to the on-disk log. Best-effort — a write failure
|
|
706
|
+
// must not break the TUI.
|
|
707
|
+
export function persistSubagent(entry: SubagentEntry): boolean {
|
|
708
|
+
try {
|
|
709
|
+
const { saveSubagentRun } = require("../core/subagent-log.js");
|
|
710
|
+
return !!saveSubagentRun(entry);
|
|
711
|
+
} catch {
|
|
712
|
+
return false;
|
|
713
|
+
}
|
|
714
|
+
}
|