@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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +128 -0
  3. package/dist/agent/research-agent.js +253 -0
  4. package/dist/agent/skills.js +265 -0
  5. package/dist/agent/tools.js +429 -0
  6. package/dist/agent/tools.test.js +27 -0
  7. package/dist/cli/commands/init.js +370 -0
  8. package/dist/cli/index.js +445 -0
  9. package/dist/cli/shell/shell.js +76 -0
  10. package/dist/cli/shell/tui.js +11 -0
  11. package/dist/config/env-store.js +47 -0
  12. package/dist/config/load-config.js +58 -0
  13. package/dist/export/formatters.js +37 -0
  14. package/dist/providers/llm/gateway.js +64 -0
  15. package/dist/providers/llm/huggingface.js +33 -0
  16. package/dist/providers/llm/models.js +97 -0
  17. package/dist/providers/llm/readiness.js +50 -0
  18. package/dist/providers/llm/registry.js +56 -0
  19. package/dist/storage/jsonl.js +29 -0
  20. package/dist/storage/jsonl.test.js +38 -0
  21. package/dist/storage/run-store.js +134 -0
  22. package/dist/storage/run-store.test.js +65 -0
  23. package/dist/tools/chrome-devtools-mcp.js +61 -0
  24. package/dist/tools/file-tools.js +128 -0
  25. package/dist/tools/mcp-bridge.js +118 -0
  26. package/dist/tools/mcp-oauth.js +276 -0
  27. package/dist/tools/open-url.js +99 -0
  28. package/dist/tools/search-web.js +153 -0
  29. package/dist/types/index.js +91 -0
  30. package/dist/types/schema.test.js +60 -0
  31. package/dist/ui/ink/SciraApp.js +274 -0
  32. package/dist/ui/ink/components/effects.js +44 -0
  33. package/dist/ui/ink/components/home-screen.js +69 -0
  34. package/dist/ui/ink/components/overlays.js +111 -0
  35. package/dist/ui/ink/constants.js +56 -0
  36. package/dist/ui/ink/hooks/use-agent-turn.js +186 -0
  37. package/dist/ui/ink/hooks/use-feed-lines.js +186 -0
  38. package/dist/ui/ink/hooks/use-feed.js +69 -0
  39. package/dist/ui/ink/hooks/use-keyboard.js +315 -0
  40. package/dist/ui/ink/hooks/use-mouse.js +31 -0
  41. package/dist/ui/ink/hooks/use-session.js +103 -0
  42. package/dist/ui/ink/hooks/use-settings.js +155 -0
  43. package/dist/ui/ink/hooks/use-submit.js +366 -0
  44. package/dist/ui/ink/hooks/use-suggestions.js +91 -0
  45. package/dist/ui/ink/lib/file-mentions.js +71 -0
  46. package/dist/ui/ink/lib/markdown.js +245 -0
  47. package/dist/ui/ink/lib/utils.js +224 -0
  48. package/dist/ui/ink/session-manager.js +160 -0
  49. package/dist/ui/ink/types.js +1 -0
  50. package/dist/utils/ids.js +15 -0
  51. package/dist/utils/markdown-joiner.js +249 -0
  52. package/dist/watch/runner.js +65 -0
  53. package/package.json +74 -0
@@ -0,0 +1,69 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ export function useFeed() {
3
+ const [feed, setFeed] = useState([]);
4
+ const feedRef = useRef([]);
5
+ const pushFeed = useCallback((item) => {
6
+ setFeed((f) => { const next = [...f, item]; feedRef.current = next; return next; });
7
+ }, []);
8
+ const appendText = useCallback((delta) => {
9
+ setFeed((f) => {
10
+ const next = [...f];
11
+ const last = next.at(-1);
12
+ if (last?.kind === "text") {
13
+ next[next.length - 1] = { ...last, text: last.text + delta };
14
+ }
15
+ else {
16
+ next.push({ kind: "text", text: delta });
17
+ }
18
+ feedRef.current = next;
19
+ return next;
20
+ });
21
+ }, []);
22
+ const appendReasoning = useCallback((delta) => {
23
+ setFeed((f) => {
24
+ const next = [...f];
25
+ const last = next.at(-1);
26
+ if (last?.kind === "reasoning" && last.durationMs === undefined) {
27
+ next[next.length - 1] = { ...last, text: last.text + delta };
28
+ }
29
+ else {
30
+ next.push({ kind: "reasoning", text: delta, startedAt: Date.now() });
31
+ }
32
+ feedRef.current = next;
33
+ return next;
34
+ });
35
+ }, []);
36
+ const finishReasoning = useCallback(() => {
37
+ setFeed((f) => {
38
+ let changed = false;
39
+ const ended = Date.now();
40
+ const next = f.map((it) => {
41
+ if (it.kind === "reasoning" && it.durationMs === undefined) {
42
+ changed = true;
43
+ const startedAt = it.startedAt ?? ended;
44
+ return { ...it, durationMs: ended - startedAt };
45
+ }
46
+ return it;
47
+ });
48
+ if (!changed)
49
+ return f;
50
+ feedRef.current = next;
51
+ return next;
52
+ });
53
+ }, []);
54
+ const markToolDone = useCallback((toolCallId, status, result) => {
55
+ setFeed((f) => {
56
+ const next = [...f];
57
+ for (let i = next.length - 1; i >= 0; i--) {
58
+ const item = next[i];
59
+ if (item.kind === "tool" && item.status === "running" && (item.toolCallId === toolCallId || !toolCallId)) {
60
+ next[i] = { ...item, status, result };
61
+ break;
62
+ }
63
+ }
64
+ feedRef.current = next;
65
+ return next;
66
+ });
67
+ }, []);
68
+ return { feed, setFeed, feedRef, pushFeed, appendText, appendReasoning, finishReasoning, markToolDone };
69
+ }
@@ -0,0 +1,315 @@
1
+ import { useRef } from "react";
2
+ import { useInput } from "ink";
3
+ export function useKeyboard(o) {
4
+ const { screen, setNotice, exit } = o;
5
+ const { text: inputText, setText: setInputText, cursorPos, setCursorPos, history: inputHistory, historyIndex, setHistoryIndex } = o.input;
6
+ const { approvalPending, setApprovalPending, menu, setMenu, applyMenuSelection, helpOpen, setHelpOpen, mcpOpen, setMcpOpen } = o.dialogs;
7
+ const { activeSuggestions, activeSuggestionKind, commandMenuIndex, setCommandMenuIndex, acceptActiveSuggestion } = o.suggestions;
8
+ const { setScrollOffset, contentRows, maxScrollOffset, pendingRerun, setPendingRerun, busy, stopTurn, submitChat, toggleAllGroups, toggleFocusedGroup, focusPrevGroup, focusNextGroup, unfocusGroup, hasFocusedGroup } = o.chat;
9
+ const { sessionsModalOpen, setSessionsModalOpen, sessionsModalIdx, setSessionsModalIdx, sessions, deleteSession, selectedIdx, setSelectedIdx, setHeroHidden, openRun, submitHome } = o.home;
10
+ const deleteArmedRef = useRef(null);
11
+ const editInput = (char, key) => {
12
+ const deleteWordBefore = () => {
13
+ const match = inputText.slice(0, cursorPos).match(/\S+\s*$/u);
14
+ const toDelete = match ? match[0].length : 0;
15
+ setInputText((c) => c.slice(0, cursorPos - toDelete) + c.slice(cursorPos));
16
+ setCursorPos((p) => Math.max(0, p - toDelete));
17
+ };
18
+ if (key.leftArrow) {
19
+ setCursorPos((p) => Math.max(0, p - 1));
20
+ return true;
21
+ }
22
+ if (key.rightArrow) {
23
+ setCursorPos((p) => Math.min(inputText.length, p + 1));
24
+ return true;
25
+ }
26
+ if (key.delete && !key.backspace) {
27
+ if (cursorPos < inputText.length) {
28
+ setInputText((c) => c.slice(0, cursorPos) + c.slice(cursorPos + 1));
29
+ }
30
+ setCommandMenuIndex(0);
31
+ if (screen === "home")
32
+ setSelectedIdx(0);
33
+ return true;
34
+ }
35
+ if (key.backspace) {
36
+ if (key.ctrl) {
37
+ deleteWordBefore();
38
+ }
39
+ else if (cursorPos > 0) {
40
+ setInputText((c) => c.slice(0, cursorPos - 1) + c.slice(cursorPos));
41
+ setCursorPos((p) => Math.max(0, p - 1));
42
+ }
43
+ setCommandMenuIndex(0);
44
+ if (screen === "home")
45
+ setSelectedIdx(0);
46
+ return true;
47
+ }
48
+ if (key.ctrl && (char === "a" || char === "b")) {
49
+ setCursorPos(0);
50
+ return true;
51
+ }
52
+ if (key.ctrl && (char === "e" || char === "f")) {
53
+ setCursorPos(inputText.length);
54
+ return true;
55
+ }
56
+ if (key.ctrl && char === "w") {
57
+ deleteWordBefore();
58
+ setCommandMenuIndex(0);
59
+ if (screen === "home")
60
+ setSelectedIdx(0);
61
+ return true;
62
+ }
63
+ if (key.escape) {
64
+ setInputText("");
65
+ setCursorPos(0);
66
+ setCommandMenuIndex(0);
67
+ setHistoryIndex(-1);
68
+ if (screen === "home")
69
+ setSelectedIdx(0);
70
+ return true;
71
+ }
72
+ if (char && !key.ctrl && !key.meta) {
73
+ setInputText((c) => c.slice(0, cursorPos) + char + c.slice(cursorPos));
74
+ setCursorPos((p) => p + char.length);
75
+ setCommandMenuIndex(0);
76
+ setHeroHidden(true);
77
+ setHistoryIndex(-1);
78
+ if (screen === "home")
79
+ setSelectedIdx(0);
80
+ return true;
81
+ }
82
+ return false;
83
+ };
84
+ useInput((char, key) => {
85
+ if (char && (char.includes("[<") || /^\d+;\d+;\d+[Mm]$/u.test(char)))
86
+ return;
87
+ if (approvalPending) {
88
+ if (char === "y" || char === "Y" || key.return) {
89
+ const p = approvalPending;
90
+ setApprovalPending(null);
91
+ p.resolve(true);
92
+ }
93
+ else if (char === "n" || char === "N" || key.escape) {
94
+ const p = approvalPending;
95
+ setApprovalPending(null);
96
+ p.resolve(false);
97
+ }
98
+ return;
99
+ }
100
+ if (menu) {
101
+ if (key.escape) {
102
+ setMenu(null);
103
+ return;
104
+ }
105
+ if (menu.loading)
106
+ return;
107
+ const mFiltered = menu.query
108
+ ? menu.items.filter((item) => item.toLowerCase().includes(menu.query.toLowerCase()))
109
+ : menu.items;
110
+ if (key.upArrow) {
111
+ setMenu((m) => (m ? { ...m, index: Math.max(0, Math.min(m.index, mFiltered.length - 1) - 1) } : m));
112
+ return;
113
+ }
114
+ if (key.downArrow) {
115
+ setMenu((m) => (m ? { ...m, index: Math.max(0, Math.min(mFiltered.length - 1, m.index + 1)) } : m));
116
+ return;
117
+ }
118
+ if (key.return) {
119
+ const value = mFiltered[menu.index];
120
+ if (value) {
121
+ setMenu(null);
122
+ void applyMenuSelection({ ...menu, items: [value], index: 0 });
123
+ }
124
+ return;
125
+ }
126
+ if (key.backspace || key.delete) {
127
+ setMenu((m) => (m ? { ...m, query: m.query.slice(0, -1), index: 0 } : m));
128
+ return;
129
+ }
130
+ if (char.length === 1 && !key.ctrl && !key.meta) {
131
+ setMenu((m) => (m ? { ...m, query: m.query + char, index: 0 } : m));
132
+ return;
133
+ }
134
+ return;
135
+ }
136
+ if (helpOpen) {
137
+ if (key.escape || key.return || char === "q")
138
+ setHelpOpen(false);
139
+ return;
140
+ }
141
+ if (mcpOpen) {
142
+ if (key.escape || key.return || char === "q")
143
+ setMcpOpen(false);
144
+ return;
145
+ }
146
+ if (screen === "chat" && busy && key.escape) {
147
+ stopTurn();
148
+ return;
149
+ }
150
+ if (activeSuggestions.length > 0) {
151
+ if (key.upArrow) {
152
+ setCommandMenuIndex((i) => Math.max(0, i - 1));
153
+ return;
154
+ }
155
+ if (key.downArrow) {
156
+ setCommandMenuIndex((i) => Math.min(activeSuggestions.length - 1, i + 1));
157
+ return;
158
+ }
159
+ if (key.tab) {
160
+ acceptActiveSuggestion(activeSuggestions[Math.min(commandMenuIndex, activeSuggestions.length - 1)] ?? inputText);
161
+ return;
162
+ }
163
+ }
164
+ if (screen === "chat" && (key.pageUp || key.pageDown)) {
165
+ setScrollOffset((off) => {
166
+ const step = Math.max(1, Math.floor(contentRows / 2));
167
+ if (key.pageUp)
168
+ return Math.min(off + step, maxScrollOffset);
169
+ return Math.max(0, off - step);
170
+ });
171
+ return;
172
+ }
173
+ // Cancel pendingRerun if user types something other than /rerun
174
+ if (pendingRerun && char && !(inputText + char).trim().startsWith("/rerun")) {
175
+ setPendingRerun(false);
176
+ }
177
+ if (screen === "chat" && inputHistory.length > 0) {
178
+ if (key.upArrow && !inputText) {
179
+ const idx = historyIndex === -1 ? inputHistory.length - 1 : Math.max(0, historyIndex - 1);
180
+ setHistoryIndex(idx);
181
+ setInputText(inputHistory[idx]);
182
+ setCursorPos(inputHistory[idx].length);
183
+ return;
184
+ }
185
+ if (key.downArrow && historyIndex >= 0) {
186
+ if (historyIndex === inputHistory.length - 1) {
187
+ setHistoryIndex(-1);
188
+ setInputText("");
189
+ setCursorPos(0);
190
+ }
191
+ else {
192
+ const idx = historyIndex + 1;
193
+ setHistoryIndex(idx);
194
+ setInputText(inputHistory[idx]);
195
+ setCursorPos(inputHistory[idx].length);
196
+ }
197
+ return;
198
+ }
199
+ }
200
+ if (sessionsModalOpen) {
201
+ if (key.upArrow) {
202
+ deleteArmedRef.current = null;
203
+ setSessionsModalIdx((i) => Math.max(0, i - 1));
204
+ }
205
+ else if (key.downArrow) {
206
+ deleteArmedRef.current = null;
207
+ setSessionsModalIdx((i) => Math.min(sessions.length - 1, i + 1));
208
+ }
209
+ else if (char === "d") {
210
+ if (deleteArmedRef.current === sessionsModalIdx) {
211
+ deleteArmedRef.current = null;
212
+ deleteSession(sessionsModalIdx);
213
+ }
214
+ else {
215
+ deleteArmedRef.current = sessionsModalIdx;
216
+ setNotice("Press d again to delete this session permanently.");
217
+ }
218
+ }
219
+ else if (key.return) {
220
+ deleteArmedRef.current = null;
221
+ const s = sessions[sessionsModalIdx];
222
+ if (s) {
223
+ setSessionsModalOpen(false);
224
+ void openRun(s.path);
225
+ }
226
+ }
227
+ else if (key.escape) {
228
+ deleteArmedRef.current = null;
229
+ setSessionsModalOpen(false);
230
+ }
231
+ return;
232
+ }
233
+ if (screen === "home") {
234
+ const hasBrowse = sessions.length > 0;
235
+ const sessionItems = Math.min(sessions.length, 6);
236
+ const browseIdx = hasBrowse ? sessionItems : -1;
237
+ const newIdx = hasBrowse ? sessionItems + 1 : sessionItems;
238
+ const quitIdx = hasBrowse ? sessionItems + 2 : sessionItems + 1;
239
+ const maxHomeIdx = quitIdx;
240
+ if (key.upArrow && !activeSuggestions.length)
241
+ setSelectedIdx((i) => Math.max(0, i - 1));
242
+ else if (key.downArrow && !activeSuggestions.length)
243
+ setSelectedIdx((i) => Math.min(maxHomeIdx, i + 1));
244
+ else if (key.return) {
245
+ if (activeSuggestions.length > 0 && activeSuggestionKind === "command" && inputText.trim().startsWith("/")) {
246
+ void submitHome(activeSuggestions[Math.min(commandMenuIndex, activeSuggestions.length - 1)] ?? inputText);
247
+ }
248
+ else if (activeSuggestions.length > 0 && activeSuggestionKind === "file") {
249
+ acceptActiveSuggestion(activeSuggestions[Math.min(commandMenuIndex, activeSuggestions.length - 1)] ?? inputText);
250
+ }
251
+ else if (activeSuggestions.length > 0 && activeSuggestionKind === "session") {
252
+ acceptActiveSuggestion(activeSuggestions[Math.min(commandMenuIndex, activeSuggestions.length - 1)] ?? inputText);
253
+ }
254
+ else if (inputText.trim()) {
255
+ void submitHome(inputText);
256
+ }
257
+ else if (selectedIdx === browseIdx) {
258
+ setSessionsModalOpen(true);
259
+ setSessionsModalIdx(0);
260
+ }
261
+ else if (selectedIdx === newIdx) {
262
+ setNotice("Type a question below to start a new research run.");
263
+ }
264
+ else if (selectedIdx === quitIdx) {
265
+ exit();
266
+ }
267
+ else {
268
+ void submitHome("");
269
+ }
270
+ }
271
+ else if (char === "q" && !inputText)
272
+ exit();
273
+ else if (key.ctrl && char === "d" && !inputText)
274
+ exit();
275
+ else
276
+ editInput(char, key);
277
+ }
278
+ else {
279
+ if (!inputText && !busy) {
280
+ if (char === "c") {
281
+ hasFocusedGroup ? toggleFocusedGroup() : toggleAllGroups();
282
+ return;
283
+ }
284
+ if (char === "[") {
285
+ focusPrevGroup();
286
+ return;
287
+ }
288
+ if (char === "]") {
289
+ focusNextGroup();
290
+ return;
291
+ }
292
+ }
293
+ if (key.escape && !inputText && hasFocusedGroup) {
294
+ unfocusGroup();
295
+ return;
296
+ }
297
+ if (key.return) {
298
+ if (activeSuggestions.length > 0 && activeSuggestionKind === "command" && inputText.trim().startsWith("/")) {
299
+ submitChat(activeSuggestions[Math.min(commandMenuIndex, activeSuggestions.length - 1)] ?? inputText);
300
+ }
301
+ else if (activeSuggestions.length > 0 && activeSuggestionKind === "file") {
302
+ acceptActiveSuggestion(activeSuggestions[Math.min(commandMenuIndex, activeSuggestions.length - 1)] ?? inputText);
303
+ }
304
+ else if (activeSuggestions.length > 0 && activeSuggestionKind === "session") {
305
+ acceptActiveSuggestion(activeSuggestions[Math.min(commandMenuIndex, activeSuggestions.length - 1)] ?? inputText);
306
+ }
307
+ else {
308
+ submitChat(inputText);
309
+ }
310
+ }
311
+ else
312
+ editInput(char, key);
313
+ }
314
+ }, { isActive: true });
315
+ }
@@ -0,0 +1,31 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ export function useMouse(onWheel) {
3
+ const clickMapRef = useRef(new Map());
4
+ const hoverMapRef = useRef(new Map());
5
+ const [hoveredIdx, setHoveredIdx] = useState(null);
6
+ const onWheelRef = useRef(onWheel);
7
+ onWheelRef.current = onWheel;
8
+ const handleMouseData = useCallback((data) => {
9
+ const s = data.toString("utf8");
10
+ const m = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/u.exec(s);
11
+ if (!m)
12
+ return;
13
+ const code = Number(m[1]);
14
+ const y = Number(m[3]);
15
+ if ((code & 64) !== 0) { // wheel event: 64 = up, 65 = down (modifier bits 4/8/16 may be set)
16
+ onWheelRef.current?.((code & 1) === 0 ? 1 : -1);
17
+ return;
18
+ }
19
+ if ((code & 32) !== 0) { // motion event → hover
20
+ const idx = hoverMapRef.current.get(y);
21
+ setHoveredIdx(idx ?? null);
22
+ return;
23
+ }
24
+ if (m[4] !== "M" || (code & 3) !== 0)
25
+ return; // left button press only
26
+ const action = clickMapRef.current.get(y);
27
+ if (action)
28
+ action();
29
+ }, []);
30
+ return { clickMapRef, hoverMapRef, hoveredIdx, setHoveredIdx, handleMouseData };
31
+ }
@@ -0,0 +1,103 @@
1
+ import { useCallback } from "react";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { listRuns, summarizeRun } from "../../../storage/run-store.js";
5
+ import { getSession, attachSubscriber } from "../session-manager.js";
6
+ export function useSession(o) {
7
+ const { config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef, runTurnRef, setSessions, setRunState, setCurrentRunPath, setInputText, setCursorPos, setFeed, setUsage, setScrollOffset, setScreen, setMode, setBusy, setApprovalPending, getSubscriber, } = o;
8
+ const refreshSessions = useCallback(async () => {
9
+ const runs = await listRuns(config);
10
+ setSessions(runs);
11
+ }, [config]);
12
+ const refreshRun = useCallback(async () => {
13
+ if (currentRunPath)
14
+ setRunState(await summarizeRun(currentRunPath));
15
+ }, [currentRunPath]);
16
+ const openRun = useCallback(async (runPath, initialQuestion) => {
17
+ setCurrentRunPath(runPath);
18
+ setInputText("");
19
+ setCursorPos(0);
20
+ // If a background session is already running for this path, reattach to it.
21
+ const live = getSession(runPath);
22
+ if (live) {
23
+ startedRef.current = runPath;
24
+ setScrollOffset(0);
25
+ setScreen("chat");
26
+ setMode(live.feedBuffer.some((it) => it.kind === "tool" || it.kind === "status"));
27
+ const buffered = attachSubscriber(runPath, getSubscriber());
28
+ if (buffered.length > 0) {
29
+ setFeed(buffered);
30
+ feedRef.current = buffered;
31
+ }
32
+ setBusy(live.busy);
33
+ if (live.approvalPending)
34
+ setApprovalPending(live.approvalPending);
35
+ const resumedState = await summarizeRun(runPath).catch(() => null);
36
+ setRunState(resumedState);
37
+ return;
38
+ }
39
+ try {
40
+ const raw = await readFile(join(runPath, "convo.json"), "utf8");
41
+ const saved = JSON.parse(raw);
42
+ if (saved.feed && saved.feed.length > 0) {
43
+ const filteredFeed = saved.feed.filter((item) => !(item.kind === "status" && item.text === "This will wipe the conversation history. Press /rerun again to confirm."));
44
+ const restoredFeed = [...filteredFeed, { kind: "status", text: "— resumed —" }];
45
+ setFeed(restoredFeed);
46
+ feedRef.current = restoredFeed;
47
+ conversationRef.current = saved.messages ?? [];
48
+ turnsRef.current = saved.usage?.turns ?? [];
49
+ setUsage(saved.usage?.byModel ?? {});
50
+ startedRef.current = runPath;
51
+ setScrollOffset(0);
52
+ setScreen("chat");
53
+ const resumedState = await summarizeRun(runPath).catch(() => null);
54
+ setRunState(resumedState);
55
+ setMode((resumedState?.claimCount ?? 0) > 0 || (resumedState?.sourceCount ?? 0) > 0);
56
+ return;
57
+ }
58
+ }
59
+ catch (e) {
60
+ if (e.code !== "ENOENT") {
61
+ conversationRef.current = [];
62
+ const errMsg = e instanceof Error ? e.message : String(e);
63
+ const errFeed = [{ kind: "status", text: `Could not restore session: ${errMsg}` }];
64
+ setFeed(errFeed);
65
+ feedRef.current = errFeed;
66
+ startedRef.current = runPath;
67
+ setScrollOffset(0);
68
+ setScreen("chat");
69
+ return;
70
+ }
71
+ }
72
+ conversationRef.current = [];
73
+ turnsRef.current = [];
74
+ setUsage({});
75
+ startedRef.current = runPath;
76
+ setMode(false);
77
+ const shortModel = config.model.includes("/") ? (config.model.split("/").pop() ?? config.model) : config.model;
78
+ const startStatus = [
79
+ shortModel,
80
+ `${config.search.provider} ×${config.search.maxResults}`,
81
+ `${config.approvalMode} approvals`,
82
+ ...(() => {
83
+ const mcpCount = (config.mcp.chromeDevtools.enabled ? 1 : 0) + config.mcp.servers.filter((s) => s.enabled).length;
84
+ return mcpCount > 0 ? [`${mcpCount} mcp`] : [];
85
+ })(),
86
+ ].join(" · ");
87
+ const freshFeed = initialQuestion
88
+ ? [{ kind: "user", text: initialQuestion, ts: Date.now() }, { kind: "status", text: startStatus }]
89
+ : [{ kind: "status", text: startStatus }];
90
+ setFeed(freshFeed);
91
+ feedRef.current = freshFeed;
92
+ setScrollOffset(0);
93
+ setScreen("chat");
94
+ void (async () => {
95
+ await summarizeRun(runPath).then(setRunState).catch(() => { });
96
+ // Attach subscriber BEFORE starting the turn so no items are emitted without a listener.
97
+ attachSubscriber(runPath, getSubscriber());
98
+ await runTurnRef.current("Answer my question concisely using web search. If it genuinely needs deep, multi-source, verifiable research, call requestFullResearch to ask me to approve the full research harness.");
99
+ })();
100
+ }, [config, setCurrentRunPath, setInputText, setCursorPos, setFeed, setUsage, setScrollOffset,
101
+ setScreen, setRunState, setMode, setBusy, setApprovalPending, getSubscriber]);
102
+ return { refreshSessions, refreshRun, openRun };
103
+ }
@@ -0,0 +1,155 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import { saveGlobalConfig } from "../../../config/load-config.js";
3
+ import { setEnvKey, isManagedEnvKey, MANAGED_ENV_KEYS } from "../../../config/env-store.js";
4
+ import { detectEnv } from "../../../providers/llm/readiness.js";
5
+ import { listModels } from "../../../providers/llm/models.js";
6
+ import { LLM_PROVIDERS, LLM_PROVIDER_LABELS, defaultModelFor } from "../../../providers/llm/registry.js";
7
+ import { PROVIDERS } from "../constants.js";
8
+ import { prettifyModelId } from "../lib/utils.js";
9
+ import { useMountEffect } from "../components/effects.js";
10
+ export function useSettings({ config, setConfig, screen, pushFeed, setNotice }) {
11
+ const [menu, setMenu] = useState(null);
12
+ const modelsRef = useRef(new Map());
13
+ const [modelName, setModelName] = useState(() => prettifyModelId(config.model));
14
+ const currentModelIdRef = useRef(config.model);
15
+ const resolveModelName = useCallback((id) => {
16
+ currentModelIdRef.current = id;
17
+ const cached = modelsRef.current.get(id);
18
+ if (cached) {
19
+ setModelName(cached);
20
+ return;
21
+ }
22
+ setModelName(prettifyModelId(id));
23
+ void listModels(config).then((models) => {
24
+ const map = new Map();
25
+ for (const m of models)
26
+ map.set(m.id, m.name && m.name.trim() ? m.name : prettifyModelId(m.id));
27
+ modelsRef.current = map;
28
+ if (currentModelIdRef.current === id)
29
+ setModelName(map.get(id) ?? prettifyModelId(id));
30
+ }).catch(() => { });
31
+ }, [config]);
32
+ useMountEffect(() => { resolveModelName(config.model); });
33
+ const applyModel = useCallback(async (id) => {
34
+ const next = { ...config, model: id, lastModels: { ...config.lastModels, [config.llmProvider]: id } };
35
+ setConfig(next);
36
+ resolveModelName(id);
37
+ await saveGlobalConfig(next);
38
+ return `Model set to ${id}.`;
39
+ }, [config, resolveModelName, setConfig]);
40
+ const applyProvider = useCallback(async (provider) => {
41
+ const next = { ...config, search: { ...config.search, provider } };
42
+ setConfig(next);
43
+ await saveGlobalConfig(next);
44
+ return `Search provider set to ${provider}.`;
45
+ }, [config, setConfig]);
46
+ const applyLlmProvider = useCallback(async (provider) => {
47
+ if (provider === config.llmProvider)
48
+ return `LLM provider already set to ${LLM_PROVIDER_LABELS[provider]}.`;
49
+ const model = config.lastModels[provider] ?? defaultModelFor(provider);
50
+ const next = {
51
+ ...config,
52
+ llmProvider: provider,
53
+ model,
54
+ lastModels: { ...config.lastModels, [config.llmProvider]: config.model }
55
+ };
56
+ setConfig(next);
57
+ modelsRef.current = new Map();
58
+ await saveGlobalConfig(next);
59
+ return `LLM provider set to ${LLM_PROVIDER_LABELS[provider]} (model: ${model}).`;
60
+ }, [config, setConfig]);
61
+ const openMenu = useCallback(async (type) => {
62
+ if (type === "provider") {
63
+ const idx = Math.max(0, PROVIDERS.indexOf(config.search.provider));
64
+ setMenu({ type, items: PROVIDERS, index: idx, query: "" });
65
+ return;
66
+ }
67
+ if (type === "llm") {
68
+ const idx = Math.max(0, LLM_PROVIDERS.indexOf(config.llmProvider));
69
+ setMenu({ type, items: LLM_PROVIDERS, index: idx, query: "" });
70
+ return;
71
+ }
72
+ setMenu({ type: "model", items: [], index: 0, loading: true, query: "" });
73
+ try {
74
+ const models = await listModels(config);
75
+ const ids = models.map((m) => m.id);
76
+ const idx = Math.max(0, ids.indexOf(config.model));
77
+ setMenu({ type: "model", items: ids, index: idx, query: "" });
78
+ }
79
+ catch (error) {
80
+ setMenu(null);
81
+ const msg = error instanceof Error ? error.message : String(error);
82
+ if (screen === "chat")
83
+ pushFeed({ kind: "status", text: msg });
84
+ else
85
+ setNotice(msg);
86
+ }
87
+ }, [config, pushFeed, screen, setNotice]);
88
+ const applyMenuSelection = useCallback(async (selected) => {
89
+ const value = selected.items[selected.index];
90
+ if (!value)
91
+ return;
92
+ const result = selected.type === "model"
93
+ ? await applyModel(value)
94
+ : selected.type === "llm"
95
+ ? await applyLlmProvider(value)
96
+ : await applyProvider(value);
97
+ if (selected.type === "llm")
98
+ resolveModelName(config.lastModels[value] ?? defaultModelFor(value));
99
+ if (screen === "chat")
100
+ pushFeed({ kind: "status", text: result });
101
+ else
102
+ setNotice(result);
103
+ }, [applyModel, applyProvider, applyLlmProvider, resolveModelName, pushFeed, screen, setNotice, config.lastModels]);
104
+ const handleSettings = useCallback(async (text) => {
105
+ const parts = text.split(/\s+/u);
106
+ const cmd = parts[0];
107
+ const rest = parts.slice(1);
108
+ const arg = rest.join(" ").trim();
109
+ if (cmd === "/model") {
110
+ if (!arg)
111
+ return `Current model: ${config.model}`;
112
+ const next = { ...config, model: arg, lastModels: { ...config.lastModels, [config.llmProvider]: arg } };
113
+ setConfig(next);
114
+ resolveModelName(arg);
115
+ await saveGlobalConfig(next);
116
+ return `Model set to ${arg}.`;
117
+ }
118
+ if (cmd === "/llm") {
119
+ if (!arg)
120
+ return `Current LLM provider: ${LLM_PROVIDER_LABELS[config.llmProvider]} (${config.llmProvider})\nOptions: ${LLM_PROVIDERS.join(", ")}`;
121
+ if (!LLM_PROVIDERS.includes(arg))
122
+ return `Unknown LLM provider "${arg}". Options: ${LLM_PROVIDERS.join(", ")}`;
123
+ const result = await applyLlmProvider(arg);
124
+ resolveModelName(config.lastModels[arg] ?? defaultModelFor(arg));
125
+ return result;
126
+ }
127
+ if (cmd === "/provider") {
128
+ if (!arg)
129
+ return `Current search provider: ${config.search.provider}\nOptions: ${PROVIDERS.join(", ")}`;
130
+ if (!PROVIDERS.includes(arg))
131
+ return `Unknown provider "${arg}". Options: ${PROVIDERS.join(", ")}`;
132
+ const next = { ...config, search: { ...config.search, provider: arg } };
133
+ setConfig(next);
134
+ await saveGlobalConfig(next);
135
+ return `Search provider set to ${arg}.`;
136
+ }
137
+ if (cmd === "/key") {
138
+ const name = (rest[0] ?? "").toUpperCase();
139
+ const value = rest.slice(1).join(" ").trim();
140
+ if (!name || !value)
141
+ return `Usage: /key <NAME> <value>\nManaged keys: ${MANAGED_ENV_KEYS.join(", ")}`;
142
+ if (!isManagedEnvKey(name))
143
+ return `Unknown key "${name}". Managed keys: ${MANAGED_ENV_KEYS.join(", ")}`;
144
+ await setEnvKey(name, value);
145
+ return `${name} saved to ~/.scira/.env and active for this session.`;
146
+ }
147
+ if (cmd === "/keys") {
148
+ return detectEnv(config.search.provider, config.llmProvider)
149
+ .map((c) => `${c.present ? "set " : "missing"} ${c.name}${c.required ? " (required)" : ""}`)
150
+ .join("\n");
151
+ }
152
+ return null;
153
+ }, [config, resolveModelName, setConfig, applyLlmProvider]);
154
+ return { menu, setMenu, modelName, resolveModelName, openMenu, applyMenuSelection, handleSettings };
155
+ }