@scira/cli 0.1.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/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/agent/research-agent.js +253 -0
- package/dist/agent/skills.js +265 -0
- package/dist/agent/tools.js +429 -0
- package/dist/agent/tools.test.js +27 -0
- package/dist/cli/commands/init.js +370 -0
- package/dist/cli/index.js +445 -0
- package/dist/cli/shell/shell.js +76 -0
- package/dist/cli/shell/tui.js +11 -0
- package/dist/config/env-store.js +47 -0
- package/dist/config/load-config.js +58 -0
- package/dist/export/formatters.js +37 -0
- package/dist/providers/llm/gateway.js +64 -0
- package/dist/providers/llm/huggingface.js +33 -0
- package/dist/providers/llm/models.js +97 -0
- package/dist/providers/llm/readiness.js +50 -0
- package/dist/providers/llm/registry.js +56 -0
- package/dist/storage/jsonl.js +29 -0
- package/dist/storage/jsonl.test.js +38 -0
- package/dist/storage/run-store.js +134 -0
- package/dist/storage/run-store.test.js +65 -0
- package/dist/tools/chrome-devtools-mcp.js +61 -0
- package/dist/tools/file-tools.js +128 -0
- package/dist/tools/mcp-bridge.js +118 -0
- package/dist/tools/mcp-oauth.js +276 -0
- package/dist/tools/open-url.js +99 -0
- package/dist/tools/search-web.js +153 -0
- package/dist/types/index.js +91 -0
- package/dist/types/schema.test.js +60 -0
- package/dist/ui/ink/SciraApp.js +274 -0
- package/dist/ui/ink/components/effects.js +44 -0
- package/dist/ui/ink/components/home-screen.js +69 -0
- package/dist/ui/ink/components/overlays.js +111 -0
- package/dist/ui/ink/constants.js +56 -0
- package/dist/ui/ink/hooks/use-agent-turn.js +186 -0
- package/dist/ui/ink/hooks/use-feed-lines.js +186 -0
- package/dist/ui/ink/hooks/use-feed.js +69 -0
- package/dist/ui/ink/hooks/use-keyboard.js +315 -0
- package/dist/ui/ink/hooks/use-mouse.js +31 -0
- package/dist/ui/ink/hooks/use-session.js +103 -0
- package/dist/ui/ink/hooks/use-settings.js +155 -0
- package/dist/ui/ink/hooks/use-submit.js +366 -0
- package/dist/ui/ink/hooks/use-suggestions.js +91 -0
- package/dist/ui/ink/lib/file-mentions.js +71 -0
- package/dist/ui/ink/lib/markdown.js +245 -0
- package/dist/ui/ink/lib/utils.js +224 -0
- package/dist/ui/ink/session-manager.js +160 -0
- package/dist/ui/ink/types.js +1 -0
- package/dist/utils/ids.js +15 -0
- package/dist/utils/markdown-joiner.js +249 -0
- package/dist/watch/runner.js +65 -0
- package/package.json +74 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createRun, getRunPaths, setRunTitle } from "../../../storage/run-store.js";
|
|
4
|
+
import { readJsonl } from "../../../storage/jsonl.js";
|
|
5
|
+
import { fmtDuration, fmtTokens, copyToClipboard } from "../lib/utils.js";
|
|
6
|
+
import { detachSubscriber, abortSession } from "../session-manager.js";
|
|
7
|
+
import { saveGlobalMcpConfig } from "../../../config/load-config.js";
|
|
8
|
+
export function useSubmit(o) {
|
|
9
|
+
const { config, currentRunPath, sessions, selectedIdx, busy, usage, pendingRerun } = o.state;
|
|
10
|
+
const { queuedPromptRef, conversationRef, feedRef } = o.refs;
|
|
11
|
+
const { setApprovalPending, setInputText, setCursorPos, setInputHistory, setHistoryIndex, setHelpOpen, setNotice, setBusy, setScreen, setFeed, setRunState, setPendingRerun, setMode, setConfig, setMcpOpen, setHeroHidden, } = o.setters;
|
|
12
|
+
const { pushFeed, refreshSessions, openRun, openMenu, handleSettings, runTurn, exit } = o.actions;
|
|
13
|
+
const rerunConfirmRef = useRef(false);
|
|
14
|
+
const abortTurn = useCallback(() => {
|
|
15
|
+
queuedPromptRef.current = null;
|
|
16
|
+
setApprovalPending((p) => { p?.resolve(false); return null; });
|
|
17
|
+
if (currentRunPath)
|
|
18
|
+
abortSession(currentRunPath);
|
|
19
|
+
}, [currentRunPath, setApprovalPending]);
|
|
20
|
+
const submitHome = useCallback(async (value) => {
|
|
21
|
+
const text = value.trim();
|
|
22
|
+
setInputText("");
|
|
23
|
+
setCursorPos(0);
|
|
24
|
+
if (!text) {
|
|
25
|
+
const selected = sessions[selectedIdx];
|
|
26
|
+
if (selected)
|
|
27
|
+
void openRun(selected.path);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (text === "q" || text === "/quit" || text === "/q") {
|
|
31
|
+
exit();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (text === "/help") {
|
|
35
|
+
setHelpOpen(true);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (text === "/back" || text === "/new") {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (text === "/home") {
|
|
42
|
+
setHeroHidden(false);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (text === "/model") {
|
|
46
|
+
void openMenu("model");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (text === "/provider") {
|
|
50
|
+
void openMenu("provider");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (text === "/llm") {
|
|
54
|
+
void openMenu("llm");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (text === "/mcp" || text.startsWith("/mcp ")) {
|
|
58
|
+
const sub = text.slice(5).trim();
|
|
59
|
+
if (!sub || sub === "list") {
|
|
60
|
+
setMcpOpen(true);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
setNotice("Open a research session first to use /mcp enable/disable/add.");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (text.startsWith("/")) {
|
|
67
|
+
const result = await handleSettings(text);
|
|
68
|
+
setNotice(result ?? `Unknown command "${text}". Try /model, /llm, /provider, /key, /keys, /help.`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (busy) {
|
|
72
|
+
setNotice("Already starting a run…");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
setBusy(true);
|
|
76
|
+
try {
|
|
77
|
+
const run = await createRun(text, config);
|
|
78
|
+
await refreshSessions();
|
|
79
|
+
setBusy(false);
|
|
80
|
+
void openRun(run.path, text);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
84
|
+
setBusy(false);
|
|
85
|
+
}
|
|
86
|
+
}, [busy, config, exit, handleSettings, openMenu, openRun, refreshSessions, selectedIdx, sessions]);
|
|
87
|
+
const stopTurn = useCallback(() => {
|
|
88
|
+
queuedPromptRef.current = null;
|
|
89
|
+
setApprovalPending((p) => { p?.resolve(false); return null; });
|
|
90
|
+
if (currentRunPath)
|
|
91
|
+
abortSession(currentRunPath);
|
|
92
|
+
pushFeed({ kind: "status", text: "Stopped." });
|
|
93
|
+
setBusy(false);
|
|
94
|
+
}, [currentRunPath, pushFeed, setApprovalPending]);
|
|
95
|
+
const submitChat = useCallback((value) => {
|
|
96
|
+
if (!currentRunPath)
|
|
97
|
+
return;
|
|
98
|
+
const text = value.trim();
|
|
99
|
+
if (!text)
|
|
100
|
+
return;
|
|
101
|
+
if (!text.startsWith("/")) {
|
|
102
|
+
setInputHistory((h) => [...h.slice(-50), text]);
|
|
103
|
+
setHistoryIndex(-1);
|
|
104
|
+
}
|
|
105
|
+
setInputText("");
|
|
106
|
+
setCursorPos(0);
|
|
107
|
+
if (text === "/quit" || text === "/q") {
|
|
108
|
+
abortTurn();
|
|
109
|
+
exit();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (text === "/help") {
|
|
113
|
+
setHelpOpen(true);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (text === "/stop") {
|
|
117
|
+
stopTurn();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (text === "/back" || text === "/new") {
|
|
121
|
+
if (currentRunPath)
|
|
122
|
+
detachSubscriber(currentRunPath);
|
|
123
|
+
setScreen("home");
|
|
124
|
+
void refreshSessions();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (text === "/model") {
|
|
128
|
+
void openMenu("model");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (text === "/llm") {
|
|
132
|
+
void openMenu("llm");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (text === "/provider") {
|
|
136
|
+
void openMenu("provider");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (["/key", "/keys", "/llm"].includes(text.split(/\s+/u)[0])) {
|
|
140
|
+
void (async () => {
|
|
141
|
+
const result = await handleSettings(text);
|
|
142
|
+
if (result)
|
|
143
|
+
pushFeed({ kind: "status", text: result });
|
|
144
|
+
})();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (text === "/report") {
|
|
148
|
+
void (async () => {
|
|
149
|
+
try {
|
|
150
|
+
const report = await readFile(getRunPaths(currentRunPath).report, "utf8");
|
|
151
|
+
pushFeed({ kind: "text", text: report });
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
pushFeed({ kind: "status", text: "No report.md yet." });
|
|
155
|
+
}
|
|
156
|
+
})();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (text === "/sources") {
|
|
160
|
+
void (async () => {
|
|
161
|
+
const sources = await readJsonl(getRunPaths(currentRunPath).sources).catch(() => []);
|
|
162
|
+
if (sources.length === 0) {
|
|
163
|
+
pushFeed({ kind: "status", text: "No sources recorded yet." });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const lines = sources
|
|
167
|
+
.map((s) => `- **${s.id}** [${s.title || s.url}](${s.url})${s.kind !== "unknown" ? ` — ${s.kind}` : ""}`)
|
|
168
|
+
.join("\n");
|
|
169
|
+
pushFeed({ kind: "text", text: `## Sources (${sources.length})\n\n${lines}` });
|
|
170
|
+
})();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (text === "/claims") {
|
|
174
|
+
void (async () => {
|
|
175
|
+
const claims = await readJsonl(getRunPaths(currentRunPath).claims).catch(() => []);
|
|
176
|
+
if (claims.length === 0) {
|
|
177
|
+
pushFeed({ kind: "status", text: "No claims recorded yet." });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const STATUS_ICON = {
|
|
181
|
+
verified: "✓", weak: "~", contradicted: "✗", needs_review: "?", draft: "○"
|
|
182
|
+
};
|
|
183
|
+
const CONF_LABEL = { high: "high", medium: "med", low: "low" };
|
|
184
|
+
const lines = claims.map((c) => `- **${c.id}** [${CONF_LABEL[c.confidence] ?? c.confidence}] ${STATUS_ICON[c.status] ?? c.status} ${c.text}`).join("\n");
|
|
185
|
+
pushFeed({ kind: "text", text: `## Claims (${claims.length})\n\n${lines}\n\nUse \`/why <id>\` to see full detail for a claim.` });
|
|
186
|
+
})();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (text.startsWith("/why ") || text === "/why") {
|
|
190
|
+
const claimId = text.slice(5).trim();
|
|
191
|
+
if (!claimId) {
|
|
192
|
+
pushFeed({ kind: "status", text: "Usage: /why <claim-id>" });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
void (async () => {
|
|
196
|
+
const claims = await readJsonl(getRunPaths(currentRunPath).claims).catch(() => []);
|
|
197
|
+
const claim = claims.find((c) => c.id === claimId || c.id.includes(claimId));
|
|
198
|
+
if (!claim) {
|
|
199
|
+
pushFeed({ kind: "status", text: `No claim found with id matching "${claimId}". Use /claims to list all.` });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const sourceList = claim.sourceIds.length > 0
|
|
203
|
+
? `\n\n**Sources:** ${claim.sourceIds.join(", ")}`
|
|
204
|
+
: "";
|
|
205
|
+
const reason = claim.reason ? `\n\n**Reason:** ${claim.reason}` : "";
|
|
206
|
+
pushFeed({ kind: "text", text: `## Claim ${claim.id}\n\n${claim.text}\n\n**Status:** ${claim.status} **Confidence:** ${claim.confidence} **Created:** ${claim.createdAt}${reason}${sourceList}` });
|
|
207
|
+
})();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (text === "/mcp" || text.startsWith("/mcp ")) {
|
|
211
|
+
const sub = text.slice(5).trim();
|
|
212
|
+
void (async () => {
|
|
213
|
+
const cfg = config;
|
|
214
|
+
const dt = cfg.mcp.chromeDevtools;
|
|
215
|
+
const servers = cfg.mcp.servers;
|
|
216
|
+
if (!sub || sub === "list") {
|
|
217
|
+
setMcpOpen(true);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (sub === "enable chromeDevtools" || sub === "enable devtools") {
|
|
221
|
+
const next = { ...cfg, mcp: { ...cfg.mcp, chromeDevtools: { ...dt, enabled: true } } };
|
|
222
|
+
setConfig(next);
|
|
223
|
+
await saveGlobalMcpConfig(next.mcp);
|
|
224
|
+
pushFeed({ kind: "status", text: "chromeDevtools MCP enabled. Restart the session to apply." });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (sub === "disable chromeDevtools" || sub === "disable devtools") {
|
|
228
|
+
const next = { ...cfg, mcp: { ...cfg.mcp, chromeDevtools: { ...dt, enabled: false } } };
|
|
229
|
+
setConfig(next);
|
|
230
|
+
await saveGlobalMcpConfig(next.mcp);
|
|
231
|
+
pushFeed({ kind: "status", text: "chromeDevtools MCP disabled." });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const enableMatch = sub.match(/^enable (.+)$/u);
|
|
235
|
+
if (enableMatch) {
|
|
236
|
+
const name = enableMatch[1].trim();
|
|
237
|
+
const idx = servers.findIndex((s) => s.name === name);
|
|
238
|
+
if (idx === -1) {
|
|
239
|
+
pushFeed({ kind: "status", text: `No server named "${name}". Use /mcp list to see servers.` });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const next = { ...cfg, mcp: { ...cfg.mcp, servers: servers.map((s, i) => i === idx ? { ...s, enabled: true } : s) } };
|
|
243
|
+
setConfig(next);
|
|
244
|
+
await saveGlobalMcpConfig(next.mcp);
|
|
245
|
+
pushFeed({ kind: "status", text: `"${name}" enabled. Restart the session to apply.` });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const disableMatch = sub.match(/^disable (.+)$/u);
|
|
249
|
+
if (disableMatch) {
|
|
250
|
+
const name = disableMatch[1].trim();
|
|
251
|
+
const idx = servers.findIndex((s) => s.name === name);
|
|
252
|
+
if (idx === -1) {
|
|
253
|
+
pushFeed({ kind: "status", text: `No server named "${name}". Use /mcp list to see servers.` });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const next = { ...cfg, mcp: { ...cfg.mcp, servers: servers.map((s, i) => i === idx ? { ...s, enabled: false } : s) } };
|
|
257
|
+
setConfig(next);
|
|
258
|
+
await saveGlobalMcpConfig(next.mcp);
|
|
259
|
+
pushFeed({ kind: "status", text: `"${name}" disabled.` });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const addMatch = sub.match(/^add (stdio|sse|http) (\S+) (.+)$/u);
|
|
263
|
+
if (addMatch) {
|
|
264
|
+
const transport = addMatch[1];
|
|
265
|
+
const name = addMatch[2];
|
|
266
|
+
const rest = addMatch[3].trim();
|
|
267
|
+
if (servers.some((s) => s.name === name)) {
|
|
268
|
+
pushFeed({ kind: "status", text: `A server named "${name}" already exists. Use /mcp disable to disable it.` });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
let newEntry;
|
|
272
|
+
if (transport === "stdio") {
|
|
273
|
+
const parts = rest.split(/\s+/u);
|
|
274
|
+
newEntry = { name, transport, command: parts[0], args: parts.slice(1), toolPrefix: "", env: {}, enabled: true, authType: "none" };
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
newEntry = { name, transport, url: rest, args: [], toolPrefix: "", env: {}, enabled: true, authType: "none" };
|
|
278
|
+
}
|
|
279
|
+
const next = { ...cfg, mcp: { ...cfg.mcp, servers: [...servers, newEntry] } };
|
|
280
|
+
setConfig(next);
|
|
281
|
+
await saveGlobalMcpConfig(next.mcp);
|
|
282
|
+
pushFeed({ kind: "status", text: `Server "${name}" added. Restart the session to connect.` });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
pushFeed({ kind: "status", text: "Usage: /mcp list · /mcp enable <name> · /mcp disable <name> · /mcp add stdio <name> <command> [args…] · /mcp add http <name> <url>" });
|
|
286
|
+
})();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (text === "/copy") {
|
|
290
|
+
void (async () => {
|
|
291
|
+
const currentSession = sessions.find(s => s.path === currentRunPath);
|
|
292
|
+
const report = currentSession?.isFull
|
|
293
|
+
? await readFile(getRunPaths(currentRunPath).report, "utf8").catch(() => "")
|
|
294
|
+
: "";
|
|
295
|
+
const lastText = [...feedRef.current].reverse().find((it) => it.kind === "text")?.text ?? "";
|
|
296
|
+
const content = report.trim() || lastText;
|
|
297
|
+
if (!content) {
|
|
298
|
+
pushFeed({ kind: "status", text: "Nothing to copy yet." });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const ok = await copyToClipboard(content);
|
|
302
|
+
pushFeed({ kind: "status", text: ok ? `Copied ${report.trim() ? "report.md" : "last answer"} to clipboard.` : "Clipboard unavailable." });
|
|
303
|
+
})();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (text === "/usage") {
|
|
307
|
+
const entries = Object.entries(usage);
|
|
308
|
+
const reasoningTotal = feedRef.current.reduce((n, it) => n + (it.kind === "reasoning" ? (it.durationMs ?? 0) : 0), 0);
|
|
309
|
+
const reasoningCount = feedRef.current.filter((it) => it.kind === "reasoning" && (it.durationMs ?? 0) > 0).length;
|
|
310
|
+
if (entries.length === 0 && reasoningTotal === 0) {
|
|
311
|
+
pushFeed({ kind: "status", text: "No token usage recorded yet." });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const lines = entries
|
|
315
|
+
.map(([model, u]) => `- **${model}** — ↑${fmtTokens(u.input)} in · ↓${fmtTokens(u.output)} out · ${fmtTokens(u.total)} total (${u.turns} ${u.turns === 1 ? "turn" : "turns"})`)
|
|
316
|
+
.join("\n");
|
|
317
|
+
const grand = entries.reduce((n, [, u]) => n + u.total, 0);
|
|
318
|
+
const thinking = reasoningTotal > 0
|
|
319
|
+
? `\n\n**Reasoning:** ${fmtDuration(reasoningTotal)} across ${reasoningCount} ${reasoningCount === 1 ? "block" : "blocks"}`
|
|
320
|
+
: "";
|
|
321
|
+
pushFeed({ kind: "text", text: `## Token usage\n\n${lines}\n\n**Session total:** ${fmtTokens(grand)} tokens${thinking}` });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (text.startsWith("/rename ")) {
|
|
325
|
+
const title = text.slice(8).trim();
|
|
326
|
+
if (!title) {
|
|
327
|
+
pushFeed({ kind: "status", text: "Usage: /rename <title>" });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
void (async () => {
|
|
331
|
+
try {
|
|
332
|
+
await setRunTitle(currentRunPath, title);
|
|
333
|
+
setRunState((prev) => prev ? { ...prev, title } : prev);
|
|
334
|
+
pushFeed({ kind: "status", text: `Session title set to "${title}".` });
|
|
335
|
+
}
|
|
336
|
+
catch (e) {
|
|
337
|
+
pushFeed({ kind: "status", text: `Failed to set title: ${e instanceof Error ? e.message : String(e)}` });
|
|
338
|
+
}
|
|
339
|
+
})();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (text === "/rerun") {
|
|
343
|
+
if (busy)
|
|
344
|
+
return;
|
|
345
|
+
if (rerunConfirmRef.current) {
|
|
346
|
+
rerunConfirmRef.current = false;
|
|
347
|
+
conversationRef.current = [];
|
|
348
|
+
setMode(true); // explicit deep re-run uses the full harness
|
|
349
|
+
setFeed([{ kind: "status", text: "Re-running research…" }]);
|
|
350
|
+
void runTurn("Re-run the research from scratch. Plan, gather grounded sources, and rewrite report.md, then summarize.");
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
pushFeed({ kind: "status", text: "This will wipe the conversation history. Press /rerun again to confirm." });
|
|
354
|
+
rerunConfirmRef.current = true;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (busy) {
|
|
358
|
+
queuedPromptRef.current = text;
|
|
359
|
+
pushFeed({ kind: "status", text: "Queued — will send when the current turn finishes." });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
pushFeed({ kind: "user", text, ts: Date.now() });
|
|
363
|
+
void runTurn(text);
|
|
364
|
+
}, [abortTurn, stopTurn, busy, currentRunPath, exit, handleSettings, openMenu, pushFeed, refreshSessions, runTurn, usage, setMode, pendingRerun, setConfig, config]);
|
|
365
|
+
return { submitHome, submitChat, stopTurn };
|
|
366
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useRef } from "react";
|
|
2
|
+
import { CHAT_COMMANDS } from "../constants.js";
|
|
3
|
+
import { listMentionableFiles, activeFileMention } from "../lib/file-mentions.js";
|
|
4
|
+
export function sessionLabel(s) {
|
|
5
|
+
return (s.title ?? s.goal ?? s.id).replace(/\s+/gu, " ").trim();
|
|
6
|
+
}
|
|
7
|
+
export function useSuggestions({ inputText, setInputText, setCursorPos, sessions, openRun, refreshSessions }) {
|
|
8
|
+
const cachedFilesRef = useRef(null);
|
|
9
|
+
const sessionMatchesRef = useRef([]);
|
|
10
|
+
const commandSuggestions = useMemo(() => {
|
|
11
|
+
const match = inputText.match(/(?:^|\s)(\/[a-z]*)$/u);
|
|
12
|
+
if (!match)
|
|
13
|
+
return [];
|
|
14
|
+
const fragment = match[1];
|
|
15
|
+
return CHAT_COMMANDS.filter((cmd) => cmd.startsWith(fragment));
|
|
16
|
+
}, [inputText]);
|
|
17
|
+
const fileMentionSuggestions = useMemo(() => {
|
|
18
|
+
const mention = activeFileMention(inputText);
|
|
19
|
+
if (!mention)
|
|
20
|
+
return [];
|
|
21
|
+
const fragment = mention.fragment.toLowerCase();
|
|
22
|
+
if (!cachedFilesRef.current)
|
|
23
|
+
cachedFilesRef.current = listMentionableFiles();
|
|
24
|
+
return cachedFilesRef.current
|
|
25
|
+
.filter((file) => file.toLowerCase().includes(fragment))
|
|
26
|
+
.slice(0, 50);
|
|
27
|
+
}, [inputText]);
|
|
28
|
+
const sessionSuggestions = useMemo(() => {
|
|
29
|
+
if (!inputText.startsWith("#")) {
|
|
30
|
+
sessionMatchesRef.current = [];
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
const fragment = inputText.slice(1).trim().toLowerCase();
|
|
34
|
+
const matches = sessions
|
|
35
|
+
.filter((s) => fragment === "" || sessionLabel(s).toLowerCase().includes(fragment))
|
|
36
|
+
.slice(0, 50);
|
|
37
|
+
sessionMatchesRef.current = matches;
|
|
38
|
+
return matches.map(sessionLabel);
|
|
39
|
+
}, [inputText, sessions]);
|
|
40
|
+
// Refresh sessions when typing # if the list is empty
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
if (inputText.startsWith("#") && sessions.length === 0) {
|
|
43
|
+
void refreshSessions();
|
|
44
|
+
}
|
|
45
|
+
}, [inputText, sessions.length, refreshSessions]);
|
|
46
|
+
const activeSuggestions = fileMentionSuggestions.length > 0
|
|
47
|
+
? fileMentionSuggestions
|
|
48
|
+
: sessionSuggestions.length > 0
|
|
49
|
+
? sessionSuggestions
|
|
50
|
+
: commandSuggestions;
|
|
51
|
+
const activeSuggestionKind = fileMentionSuggestions.length > 0 ? "file"
|
|
52
|
+
: sessionSuggestions.length > 0 ? "session"
|
|
53
|
+
: commandSuggestions.length > 0 ? "command" : null;
|
|
54
|
+
const acceptCommandSuggestion = useCallback((cmd) => {
|
|
55
|
+
setInputText((text) => {
|
|
56
|
+
const match = text.match(/(?:^|\s)(\/[a-z]*)$/u);
|
|
57
|
+
const next = !match || match.index === undefined
|
|
58
|
+
? cmd
|
|
59
|
+
: `${text.slice(0, match.index + match[0].length - match[1].length)}${cmd}`;
|
|
60
|
+
setCursorPos(next.length);
|
|
61
|
+
return next;
|
|
62
|
+
});
|
|
63
|
+
}, [setCursorPos, setInputText]);
|
|
64
|
+
const acceptFileMentionSuggestion = useCallback((file) => {
|
|
65
|
+
setInputText((text) => {
|
|
66
|
+
const mention = activeFileMention(text);
|
|
67
|
+
const next = !mention
|
|
68
|
+
? `@${file}`
|
|
69
|
+
: `${text.slice(0, mention.start)}@${file}${text.slice(mention.start + mention.fragment.length + 1)}`;
|
|
70
|
+
setCursorPos(next.length);
|
|
71
|
+
return next;
|
|
72
|
+
});
|
|
73
|
+
}, [setCursorPos, setInputText]);
|
|
74
|
+
const acceptSessionSuggestion = useCallback((label) => {
|
|
75
|
+
const match = sessionMatchesRef.current.find((s) => sessionLabel(s) === label) ?? sessionMatchesRef.current[0];
|
|
76
|
+
if (!match)
|
|
77
|
+
return;
|
|
78
|
+
setInputText("");
|
|
79
|
+
setCursorPos(0);
|
|
80
|
+
void openRun(match.path);
|
|
81
|
+
}, [openRun, setInputText, setCursorPos]);
|
|
82
|
+
const acceptActiveSuggestion = useCallback((value) => {
|
|
83
|
+
if (activeSuggestionKind === "file")
|
|
84
|
+
acceptFileMentionSuggestion(value);
|
|
85
|
+
else if (activeSuggestionKind === "session")
|
|
86
|
+
acceptSessionSuggestion(value);
|
|
87
|
+
else
|
|
88
|
+
acceptCommandSuggestion(value);
|
|
89
|
+
}, [acceptCommandSuggestion, acceptFileMentionSuggestion, acceptSessionSuggestion, activeSuggestionKind]);
|
|
90
|
+
return { activeSuggestions, activeSuggestionKind, acceptActiveSuggestion };
|
|
91
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { FILE_MENTION_SKIP, FILE_MENTION_MAX_CHARS } from "../constants.js";
|
|
5
|
+
export function listMentionableFiles(root = process.cwd(), max = 300) {
|
|
6
|
+
const out = [];
|
|
7
|
+
const walk = (dir, prefix) => {
|
|
8
|
+
if (out.length >= max)
|
|
9
|
+
return;
|
|
10
|
+
let entries;
|
|
11
|
+
try {
|
|
12
|
+
entries = readdirSync(dir);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
for (const entry of entries.sort((a, b) => a.localeCompare(b))) {
|
|
18
|
+
if (out.length >= max || FILE_MENTION_SKIP.has(entry))
|
|
19
|
+
continue;
|
|
20
|
+
const abs = join(dir, entry);
|
|
21
|
+
let stat;
|
|
22
|
+
try {
|
|
23
|
+
stat = statSync(abs);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const rel = prefix ? `${prefix}/${entry}` : entry;
|
|
29
|
+
if (stat.isDirectory())
|
|
30
|
+
walk(abs, rel);
|
|
31
|
+
else if (stat.isFile())
|
|
32
|
+
out.push(rel);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
walk(root, "");
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
export function activeFileMention(input) {
|
|
39
|
+
const match = input.match(/(?:^|\s)@([^\s]*)$/u);
|
|
40
|
+
if (!match || match.index === undefined)
|
|
41
|
+
return null;
|
|
42
|
+
return { fragment: match[1] ?? "", start: match.index + match[0].indexOf("@") };
|
|
43
|
+
}
|
|
44
|
+
export function extractFileMentions(input) {
|
|
45
|
+
return Array.from(new Set(Array.from(input.matchAll(/(?:^|\s)@([^\s]+)/gu)).map((m) => m[1]).filter(Boolean)));
|
|
46
|
+
}
|
|
47
|
+
export async function promptWithFileMentions(prompt) {
|
|
48
|
+
const files = extractFileMentions(prompt);
|
|
49
|
+
if (files.length === 0)
|
|
50
|
+
return { prompt, files: [] };
|
|
51
|
+
const blocks = [];
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
const abs = join(process.cwd(), file);
|
|
54
|
+
try {
|
|
55
|
+
const content = await readFile(abs, "utf8");
|
|
56
|
+
const body = content.length > FILE_MENTION_MAX_CHARS
|
|
57
|
+
? `${content.slice(0, FILE_MENTION_MAX_CHARS)}\n...[truncated ${content.length - FILE_MENTION_MAX_CHARS} chars]`
|
|
58
|
+
: content;
|
|
59
|
+
blocks.push(`### @${file}\n\n\`\`\`\n${body}\n\`\`\``);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
blocks.push(`### @${file}\n\n[Could not read this file.]`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (blocks.length === 0)
|
|
66
|
+
return { prompt, files: [] };
|
|
67
|
+
return {
|
|
68
|
+
prompt: `${prompt}\n\nThe user mentioned these project files. Use them as context:\n\n${blocks.join("\n\n")}`,
|
|
69
|
+
files
|
|
70
|
+
};
|
|
71
|
+
}
|