@scira/cli 0.1.2 → 0.1.4
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/README.md +56 -10
- package/dist/agent/background-tasks.js +173 -0
- package/dist/agent/research-agent.js +95 -38
- package/dist/agent/todos.js +140 -0
- package/dist/agent/tools.js +146 -143
- package/dist/agent/tools.test.js +33 -0
- package/dist/agent/workspace.js +85 -0
- package/dist/cli/commands/init.js +53 -39
- package/dist/cli/index.js +30 -14
- package/dist/config/env-guide.js +151 -0
- package/dist/config/env-guide.test.js +18 -0
- package/dist/config/env-store.js +53 -0
- package/dist/config/env-store.test.js +60 -0
- package/dist/tools/agent-tools.js +621 -0
- package/dist/tools/background-tasks.js +261 -0
- package/dist/tools/bash-policy.test.js +38 -0
- package/dist/tools/file-tools.js +6 -1
- package/dist/tools/search-web.js +24 -6
- package/dist/tools/search-web.test.js +24 -0
- package/dist/tools/todos.js +140 -0
- package/dist/tools/workspace.js +91 -0
- package/dist/tools/workspace.test.js +75 -0
- package/dist/tools/x-search.js +142 -0
- package/dist/types/index.js +1 -0
- package/dist/types/schema.test.js +1 -0
- package/dist/ui/ink/SciraApp.js +74 -21
- package/dist/ui/ink/components/overlays.js +15 -9
- package/dist/ui/ink/constants.js +13 -4
- package/dist/ui/ink/hooks/use-agent-turn.js +26 -7
- package/dist/ui/ink/hooks/use-feed-lines.js +33 -6
- package/dist/ui/ink/hooks/use-keyboard.js +16 -1
- package/dist/ui/ink/hooks/use-session.js +15 -14
- package/dist/ui/ink/hooks/use-settings.js +30 -8
- package/dist/ui/ink/hooks/use-submit.js +14 -3
- package/dist/ui/ink/hooks/use-theme.js +1 -1
- package/dist/ui/ink/lib/tool-result.js +73 -5
- package/dist/ui/ink/lib/tool-result.test.js +3 -3
- package/dist/ui/ink/lib/utils.js +104 -5
- package/dist/ui/ink/lib/utils.test.js +18 -1
- package/dist/ui/ink/theme-context.js +29 -26
- package/dist/ui/ink/theme.js +36 -9
- package/dist/ui/ink/theme.test.js +32 -5
- package/package.json +6 -2
|
@@ -2,17 +2,21 @@ import { useCallback, useRef } from "react";
|
|
|
2
2
|
import { writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { createResearchAgent, createOneShotAgent } from "../../../agent/research-agent.js";
|
|
5
|
+
import { createBackgroundTaskManager } from "../../../tools/background-tasks.js";
|
|
6
|
+
import { resolveProjectRoot } from "../../../tools/workspace.js";
|
|
5
7
|
import { generateWithGateway } from "../../../providers/llm/gateway.js";
|
|
6
8
|
import { setRunTitle, summarizeRun } from "../../../storage/run-store.js";
|
|
7
9
|
import { fmtDuration, fmtTokens, aggregateTurns, wantsFullResearch, summarizeToolInput } from "../lib/utils.js";
|
|
8
10
|
import { promptWithFileMentions } from "../lib/file-mentions.js";
|
|
9
11
|
import { markdownJoinerTransform } from "../../../utils/markdown-joiner.js";
|
|
10
12
|
import { createSession, getSession, removeSession, attachSubscriber, sessionPushFeed, sessionSetBusy, sessionSetApproval, sessionFinishReasoning, sessionNotifyEscalate, sessionNotifyModeChange, mergeFeedToolResults, getSessionFeedBuffer, } from "../session-manager.js";
|
|
11
|
-
export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullModeRef, conversationRef, turnsRef, feedRef, setBusy, setScrollOffset, refreshRun, recordUsage, setMode, getSubscriber, }) {
|
|
12
|
-
const
|
|
13
|
-
|
|
13
|
+
export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullModeRef, planModeRef, conversationRef, turnsRef, feedRef, setBusy, setScrollOffset, refreshRun, recordUsage, setMode, setPlanMode, getSubscriber, }) {
|
|
14
|
+
const bgManagersRef = useRef(new Map());
|
|
15
|
+
const runTurn = useCallback(async (prompt, runPathOverride) => {
|
|
16
|
+
const runPath = runPathOverride ?? currentRunPath;
|
|
14
17
|
if (!runPath)
|
|
15
18
|
return;
|
|
19
|
+
const workspacePath = resolveProjectRoot(runPath);
|
|
16
20
|
const existing = getSession(runPath);
|
|
17
21
|
if (existing?.busy)
|
|
18
22
|
return;
|
|
@@ -111,13 +115,24 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
|
|
|
111
115
|
let messages = [...conversationRef.current, { role: "user", content: mentioned.prompt }];
|
|
112
116
|
let finalText = "";
|
|
113
117
|
if (!fullModeRef.current && wantsFullResearch(prompt)) {
|
|
118
|
+
setPlanMode(false);
|
|
114
119
|
setMode(true);
|
|
115
120
|
fullModeRef.current = true;
|
|
116
121
|
sessionNotifyModeChange(runPath, true);
|
|
117
122
|
sessionPushFeed(runPath, { kind: "status", text: "Detected a research request — switching to the full research harness." });
|
|
118
123
|
}
|
|
124
|
+
let bgManager = bgManagersRef.current.get(runPath);
|
|
125
|
+
if (!bgManager) {
|
|
126
|
+
bgManager = createBackgroundTaskManager(runPath, workspacePath);
|
|
127
|
+
bgManagersRef.current.set(runPath, bgManager);
|
|
128
|
+
}
|
|
129
|
+
const agentOptions = {
|
|
130
|
+
workspacePath,
|
|
131
|
+
getPlanMode: () => planModeRef.current && !fullModeRef.current,
|
|
132
|
+
backgroundTasks: bgManager
|
|
133
|
+
};
|
|
119
134
|
if (fullModeRef.current) {
|
|
120
|
-
const bundle = await createResearchAgent(runPath, summary.goal, config, onApprovalRequired);
|
|
135
|
+
const bundle = await createResearchAgent(runPath, summary.goal, config, onApprovalRequired, agentOptions);
|
|
121
136
|
try {
|
|
122
137
|
finalText = await consume(await bundle.agent.stream({ messages, abortSignal: controller.signal, experimental_transform: markdownJoinerTransform() }));
|
|
123
138
|
}
|
|
@@ -127,7 +142,7 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
|
|
|
127
142
|
}
|
|
128
143
|
else {
|
|
129
144
|
const escalate = { requested: false };
|
|
130
|
-
const oneShot = await createOneShotAgent(runPath, summary.goal, config, onApprovalRequired, () => { escalate.requested = true; });
|
|
145
|
+
const oneShot = await createOneShotAgent(runPath, summary.goal, config, onApprovalRequired, () => { escalate.requested = true; }, agentOptions);
|
|
131
146
|
try {
|
|
132
147
|
finalText = await consume(await oneShot.agent.stream({ messages, abortSignal: controller.signal, experimental_transform: markdownJoinerTransform() }));
|
|
133
148
|
}
|
|
@@ -135,6 +150,7 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
|
|
|
135
150
|
await oneShot.close();
|
|
136
151
|
}
|
|
137
152
|
if (escalate.requested && !controller.signal.aborted) {
|
|
153
|
+
setPlanMode(false);
|
|
138
154
|
setMode(true);
|
|
139
155
|
fullModeRef.current = true;
|
|
140
156
|
sessionNotifyEscalate(runPath);
|
|
@@ -145,7 +161,10 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
|
|
|
145
161
|
{ role: "assistant", content: finalText },
|
|
146
162
|
{ role: "user", content: "Approved. Now run the full research harness: discover skills, write plan.md, gather and read grounded sources, extract and verify claims, write sources.jsonl and a complete report.md, then give a short summary." }
|
|
147
163
|
];
|
|
148
|
-
const full = await createResearchAgent(runPath, summary.goal, config, onApprovalRequired
|
|
164
|
+
const full = await createResearchAgent(runPath, summary.goal, config, onApprovalRequired, {
|
|
165
|
+
...agentOptions,
|
|
166
|
+
getPlanMode: () => false
|
|
167
|
+
});
|
|
149
168
|
try {
|
|
150
169
|
finalText = await consume(await full.agent.stream({ messages, abortSignal: controller.signal, experimental_transform: markdownJoinerTransform() }));
|
|
151
170
|
}
|
|
@@ -196,7 +215,7 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
|
|
|
196
215
|
void runTurnRef.current(queued);
|
|
197
216
|
}
|
|
198
217
|
}
|
|
199
|
-
}, [config, currentRunPath, refreshRun, recordUsage, setMode, getSubscriber]);
|
|
218
|
+
}, [config, currentRunPath, refreshRun, recordUsage, setMode, setPlanMode, getSubscriber, fullModeRef, planModeRef]);
|
|
200
219
|
const runTurnRef = useRef(runTurn);
|
|
201
220
|
runTurnRef.current = runTurn;
|
|
202
221
|
return { runTurn, runTurnRef };
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo } from "react";
|
|
2
|
+
import React, { useMemo } from "react";
|
|
3
3
|
import { Text } from "ink";
|
|
4
|
+
import Link from "ink-link";
|
|
4
5
|
import { S_BAR, TOOL_ICONS, SPINNER_FRAMES } from "../constants.js";
|
|
5
|
-
import { formatTime, fmtDuration, wrapText,
|
|
6
|
+
import { formatTime, fmtDuration, wrapText, computeLineLinks, displayWidth } from "../lib/utils.js";
|
|
6
7
|
import { formatToolResultLines, formatToolResultPreview, feedToolItemId, isCollapsibleToolName, isToolItemCollapsed, } from "../lib/tool-result.js";
|
|
7
8
|
import { markdownToSegLines } from "../lib/markdown.js";
|
|
8
9
|
import { useTheme } from "./use-theme.js";
|
|
@@ -41,6 +42,17 @@ export function computeGroups(feed) {
|
|
|
41
42
|
return { groupOf, groups };
|
|
42
43
|
}
|
|
43
44
|
const isGH = (item) => item._tag === "gh";
|
|
45
|
+
function renderSegNodes(segs, theme, defaultColor) {
|
|
46
|
+
return segs.map((s, i) => {
|
|
47
|
+
const inner = (_jsx(Text, { color: s.url ? (s.color ?? theme.accent) : (s.color ?? defaultColor), bold: s.bold, italic: s.italic, underline: s.url ? true : s.underline, dimColor: s.dim, children: s.text }));
|
|
48
|
+
// For URL segments, emit an OSC 8 terminal hyperlink so the terminal itself makes the
|
|
49
|
+
// text clickable (Cmd/Ctrl-click). fallback={false} keeps the visible text unchanged so
|
|
50
|
+
// the pre-computed line widths still hold on terminals without hyperlink support.
|
|
51
|
+
return s.url
|
|
52
|
+
? _jsx(Link, { url: s.url, fallback: false, children: inner }, i)
|
|
53
|
+
: React.cloneElement(inner, { key: i });
|
|
54
|
+
});
|
|
55
|
+
}
|
|
44
56
|
export function useFeedLines(feed, innerWidth,
|
|
45
57
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
46
58
|
reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState, hoveredLineIdx, config) {
|
|
@@ -50,6 +62,7 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState,
|
|
|
50
62
|
const lines = [];
|
|
51
63
|
const toggleAtLine = new Map();
|
|
52
64
|
const groupToggleAtLine = new Map();
|
|
65
|
+
const linkAtLine = new Map();
|
|
53
66
|
let key = 0;
|
|
54
67
|
const { groupOf, groups } = computeGroups(feed);
|
|
55
68
|
const eff = [];
|
|
@@ -161,7 +174,12 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState,
|
|
|
161
174
|
lines.push(_jsx(Text, { color: theme.textDim, children: S_BAR }, key++));
|
|
162
175
|
continue;
|
|
163
176
|
}
|
|
164
|
-
|
|
177
|
+
const prefix = `${S_BAR} `;
|
|
178
|
+
const lineIdx = lines.length;
|
|
179
|
+
const links = computeLineLinks(row, displayWidth(prefix));
|
|
180
|
+
if (links.length > 0)
|
|
181
|
+
linkAtLine.set(lineIdx, links);
|
|
182
|
+
lines.push(_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: theme.textDim, children: prefix }), renderSegNodes(row, theme, theme.textDim)] }, key++));
|
|
165
183
|
}
|
|
166
184
|
}
|
|
167
185
|
else if (fi.kind === "user") {
|
|
@@ -193,7 +211,12 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState,
|
|
|
193
211
|
lines.push(_jsx(Text, { color: theme.textDim, children: S_BAR }, key++));
|
|
194
212
|
continue;
|
|
195
213
|
}
|
|
196
|
-
|
|
214
|
+
const prefix = "│ ";
|
|
215
|
+
const lineIdx = lines.length;
|
|
216
|
+
const links = computeLineLinks(segLine, displayWidth(prefix));
|
|
217
|
+
if (links.length > 0)
|
|
218
|
+
linkAtLine.set(lineIdx, links);
|
|
219
|
+
lines.push(_jsxs(Text, { color: theme.textDim, italic: true, wrap: "truncate-end", children: [_jsx(Text, { color: theme.textDim, children: prefix }), renderSegNodes(segLine, theme, theme.textDim)] }, key++));
|
|
197
220
|
}
|
|
198
221
|
}
|
|
199
222
|
else {
|
|
@@ -202,11 +225,15 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState,
|
|
|
202
225
|
lines.push(_jsx(Text, { children: " " }, key++));
|
|
203
226
|
continue;
|
|
204
227
|
}
|
|
205
|
-
|
|
228
|
+
const lineIdx = lines.length;
|
|
229
|
+
const links = computeLineLinks(segLine, 0);
|
|
230
|
+
if (links.length > 0)
|
|
231
|
+
linkAtLine.set(lineIdx, links);
|
|
232
|
+
lines.push(_jsx(Text, { wrap: "truncate-end", children: renderSegNodes(segLine, theme, theme.text) }, key++));
|
|
206
233
|
}
|
|
207
234
|
}
|
|
208
235
|
});
|
|
209
|
-
return { lines, toggleAtLine, groupToggleAtLine };
|
|
236
|
+
return { lines, toggleAtLine, groupToggleAtLine, linkAtLine };
|
|
210
237
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
211
238
|
}, [feed, innerWidth, reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState, hoveredLineIdx, config, theme]);
|
|
212
239
|
}
|
|
@@ -11,7 +11,7 @@ function completeCommandWithArgSuffix(selected, inputText, acceptActiveSuggestio
|
|
|
11
11
|
export function useKeyboard(o) {
|
|
12
12
|
const { screen, setNotice, exit } = o;
|
|
13
13
|
const { text: inputText, setText: setInputText, cursorPos, setCursorPos, history: inputHistory, historyIndex, setHistoryIndex } = o.input;
|
|
14
|
-
const { approvalPending, setApprovalPending, menu, setMenu, applyMenuSelection, helpOpen, setHelpOpen, mcpOpen, setMcpOpen, mcpRowIdx, setMcpRowIdx, mcpRowCount, toggleMcpRow, removeMcpRow, } = o.dialogs;
|
|
14
|
+
const { approvalPending, setApprovalPending, linkPending, setLinkPending, onConfirmLink, onAlwaysAllowLinks, menu, setMenu, applyMenuSelection, helpOpen, setHelpOpen, mcpOpen, setMcpOpen, mcpRowIdx, setMcpRowIdx, mcpRowCount, toggleMcpRow, removeMcpRow, } = o.dialogs;
|
|
15
15
|
const { activeSuggestions, activeSuggestionKind, commandMenuIndex, setCommandMenuIndex, acceptActiveSuggestion } = o.suggestions;
|
|
16
16
|
const { setScrollOffset, contentRows, maxScrollOffset, pendingRerun, setPendingRerun, busy, stopTurn, submitChat, toggleAllGroups, toggleFocusedGroup, focusPrevGroup, focusNextGroup, unfocusGroup, hasFocusedGroup } = o.chat;
|
|
17
17
|
const { sessionsModalOpen, setSessionsModalOpen, sessionsModalIdx, setSessionsModalIdx, sessions, deleteSession, selectedIdx, setSelectedIdx, setHeroHidden, openRun, submitHome } = o.home;
|
|
@@ -92,6 +92,9 @@ export function useKeyboard(o) {
|
|
|
92
92
|
useInput((char, key) => {
|
|
93
93
|
if (char && (char.includes("[<") || /^\d+;\d+;\d+[Mm]$/u.test(char)))
|
|
94
94
|
return;
|
|
95
|
+
// OSC background-color query responses leak as stdin when terminals reply to theme probes.
|
|
96
|
+
if (char && (/\]11;rgb:/u.test(char) || /^11;rgb:/u.test(char)))
|
|
97
|
+
return;
|
|
95
98
|
if (approvalPending) {
|
|
96
99
|
if (char === "y" || char === "Y" || key.return) {
|
|
97
100
|
const p = approvalPending;
|
|
@@ -105,6 +108,18 @@ export function useKeyboard(o) {
|
|
|
105
108
|
}
|
|
106
109
|
return;
|
|
107
110
|
}
|
|
111
|
+
if (linkPending) {
|
|
112
|
+
if (char === "a" || char === "A") {
|
|
113
|
+
onAlwaysAllowLinks();
|
|
114
|
+
}
|
|
115
|
+
else if (char === "y" || char === "Y" || key.return) {
|
|
116
|
+
onConfirmLink();
|
|
117
|
+
}
|
|
118
|
+
else if (char === "n" || char === "N" || key.escape) {
|
|
119
|
+
setLinkPending(null);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
108
123
|
if (menu) {
|
|
109
124
|
if (key.escape) {
|
|
110
125
|
setMenu(null);
|
|
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { listRuns, summarizeRun } from "../../../storage/run-store.js";
|
|
5
5
|
import { getSession, attachSubscriber } from "../session-manager.js";
|
|
6
6
|
export function useSession(o) {
|
|
7
|
-
const { config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef,
|
|
7
|
+
const { config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef, setSessions, setRunState, setCurrentRunPath, setInputText, setCursorPos, setFeed, setUsage, setScrollOffset, setScreen, setMode, setPlanMode, setBusy, setApprovalPending, getSubscriber, } = o;
|
|
8
8
|
const refreshSessions = useCallback(async () => {
|
|
9
9
|
const runs = await listRuns(config);
|
|
10
10
|
setSessions(runs);
|
|
@@ -14,6 +14,8 @@ export function useSession(o) {
|
|
|
14
14
|
setRunState(await summarizeRun(currentRunPath));
|
|
15
15
|
}, [currentRunPath]);
|
|
16
16
|
const openRun = useCallback(async (runPath, initialQuestion) => {
|
|
17
|
+
if (runPath !== currentRunPath)
|
|
18
|
+
setPlanMode(false);
|
|
17
19
|
setCurrentRunPath(runPath);
|
|
18
20
|
setInputText("");
|
|
19
21
|
setCursorPos(0);
|
|
@@ -34,7 +36,7 @@ export function useSession(o) {
|
|
|
34
36
|
setApprovalPending(live.approvalPending);
|
|
35
37
|
const resumedState = await summarizeRun(runPath).catch(() => null);
|
|
36
38
|
setRunState(resumedState);
|
|
37
|
-
return;
|
|
39
|
+
return undefined;
|
|
38
40
|
}
|
|
39
41
|
try {
|
|
40
42
|
const raw = await readFile(join(runPath, "convo.json"), "utf8");
|
|
@@ -53,7 +55,7 @@ export function useSession(o) {
|
|
|
53
55
|
const resumedState = await summarizeRun(runPath).catch(() => null);
|
|
54
56
|
setRunState(resumedState);
|
|
55
57
|
setMode((resumedState?.claimCount ?? 0) > 0 || (resumedState?.sourceCount ?? 0) > 0);
|
|
56
|
-
return;
|
|
58
|
+
return undefined;
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
61
|
catch (e) {
|
|
@@ -66,7 +68,7 @@ export function useSession(o) {
|
|
|
66
68
|
startedRef.current = runPath;
|
|
67
69
|
setScrollOffset(0);
|
|
68
70
|
setScreen("chat");
|
|
69
|
-
return;
|
|
71
|
+
return undefined;
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
74
|
conversationRef.current = [];
|
|
@@ -84,20 +86,19 @@ export function useSession(o) {
|
|
|
84
86
|
return mcpCount > 0 ? [`${mcpCount} mcp`] : [];
|
|
85
87
|
})(),
|
|
86
88
|
].join(" · ");
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
+
const summary = await summarizeRun(runPath).catch(() => null);
|
|
90
|
+
const prompt = initialQuestion ?? summary?.goal;
|
|
91
|
+
const freshFeed = prompt
|
|
92
|
+
? [{ kind: "user", text: prompt, ts: Date.now() }, { kind: "status", text: startStatus }]
|
|
89
93
|
: [{ kind: "status", text: startStatus }];
|
|
90
94
|
setFeed(freshFeed);
|
|
91
95
|
feedRef.current = freshFeed;
|
|
92
96
|
setScrollOffset(0);
|
|
93
97
|
setScreen("chat");
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
})();
|
|
100
|
-
}, [config, setCurrentRunPath, setInputText, setCursorPos, setFeed, setUsage, setScrollOffset,
|
|
101
|
-
setScreen, setRunState, setMode, setBusy, setApprovalPending, getSubscriber]);
|
|
98
|
+
if (summary)
|
|
99
|
+
setRunState(summary);
|
|
100
|
+
return prompt ? { startPrompt: prompt } : undefined;
|
|
101
|
+
}, [config, currentRunPath, setCurrentRunPath, setInputText, setCursorPos, setFeed, setUsage, setScrollOffset,
|
|
102
|
+
setScreen, setRunState, setMode, setPlanMode, setBusy, setApprovalPending, getSubscriber]);
|
|
102
103
|
return { refreshSessions, refreshRun, openRun };
|
|
103
104
|
}
|
|
@@ -2,11 +2,12 @@ import { useCallback, useRef, useState } from "react";
|
|
|
2
2
|
import { saveGlobalConfig } from "../../../config/load-config.js";
|
|
3
3
|
import { setEnvKey, isManagedEnvKey, MANAGED_ENV_KEYS } from "../../../config/env-store.js";
|
|
4
4
|
import { detectEnv } from "../../../providers/llm/readiness.js";
|
|
5
|
+
import { formatKeysStatus } from "../../../config/env-guide.js";
|
|
5
6
|
import { listModels } from "../../../providers/llm/models.js";
|
|
6
7
|
import { LLM_PROVIDERS, LLM_PROVIDER_LABELS, defaultModelFor } from "../../../providers/llm/registry.js";
|
|
7
8
|
import { PROVIDERS } from "../constants.js";
|
|
8
9
|
import { prettifyModelId } from "../lib/utils.js";
|
|
9
|
-
import { detectTerminalTheme } from "../theme.js";
|
|
10
|
+
import { detectTerminalTheme, resolveRenderingAppearance } from "../theme.js";
|
|
10
11
|
import { useMountEffect } from "../components/effects.js";
|
|
11
12
|
export function useSettings({ config, setConfig, screen, pushFeed, setNotice }) {
|
|
12
13
|
const [menu, setMenu] = useState(null);
|
|
@@ -143,14 +144,17 @@ export function useSettings({ config, setConfig, screen, pushFeed, setNotice })
|
|
|
143
144
|
if (!isManagedEnvKey(name))
|
|
144
145
|
return `Unknown key "${name}". Managed keys: ${MANAGED_ENV_KEYS.join(", ")}`;
|
|
145
146
|
await setEnvKey(name, value);
|
|
146
|
-
return `${name} saved to ~/.scira/.env and active for this session.`;
|
|
147
|
+
return `${name} saved to ~/.scira/.env and active for this session. Use .scira/.env in a project to scope keys to that repo.`;
|
|
147
148
|
}
|
|
148
149
|
if (cmd === "/theme") {
|
|
149
150
|
if (!arg) {
|
|
150
|
-
const
|
|
151
|
+
const terminal = detectTerminalTheme();
|
|
152
|
+
const resolved = resolveRenderingAppearance(config.theme, terminal);
|
|
151
153
|
const mode = config.theme === "auto"
|
|
152
|
-
? "follows terminal
|
|
153
|
-
:
|
|
154
|
+
? "follows terminal"
|
|
155
|
+
: config.theme !== resolved
|
|
156
|
+
? `locked ${config.theme}, but terminal is ${terminal} — rendering ${resolved}`
|
|
157
|
+
: `locked ${config.theme}`;
|
|
154
158
|
return `Current theme: ${config.theme} (rendering ${resolved})\n${mode}\nOptions: dark, light, auto`;
|
|
155
159
|
}
|
|
156
160
|
if (!["dark", "light", "auto"].includes(arg))
|
|
@@ -160,10 +164,28 @@ export function useSettings({ config, setConfig, screen, pushFeed, setNotice })
|
|
|
160
164
|
await saveGlobalConfig(next);
|
|
161
165
|
return `Theme set to ${arg}.`;
|
|
162
166
|
}
|
|
167
|
+
if (cmd === "/links") {
|
|
168
|
+
if (!arg) {
|
|
169
|
+
return config.alwaysAllowLinks
|
|
170
|
+
? "Links open without confirmation. Use /links ask to require confirmation again."
|
|
171
|
+
: "Links ask before opening. Use /links always to skip confirmation.";
|
|
172
|
+
}
|
|
173
|
+
if (arg === "always") {
|
|
174
|
+
const next = { ...config, alwaysAllowLinks: true };
|
|
175
|
+
setConfig(next);
|
|
176
|
+
await saveGlobalConfig(next);
|
|
177
|
+
return "Links will open on click without confirmation.";
|
|
178
|
+
}
|
|
179
|
+
if (arg === "ask") {
|
|
180
|
+
const next = { ...config, alwaysAllowLinks: false };
|
|
181
|
+
setConfig(next);
|
|
182
|
+
await saveGlobalConfig(next);
|
|
183
|
+
return "Links will ask for confirmation before opening.";
|
|
184
|
+
}
|
|
185
|
+
return `Unknown /links option "${arg}". Options: always, ask`;
|
|
186
|
+
}
|
|
163
187
|
if (cmd === "/keys") {
|
|
164
|
-
return detectEnv(config.search.provider, config.llmProvider)
|
|
165
|
-
.map((c) => `${c.present ? "set " : "missing"} ${c.name}${c.required ? " (required)" : ""}`)
|
|
166
|
-
.join("\n");
|
|
188
|
+
return formatKeysStatus(detectEnv(config.search.provider, config.llmProvider));
|
|
167
189
|
}
|
|
168
190
|
return null;
|
|
169
191
|
}, [config, resolveModelName, setConfig, applyLlmProvider]);
|
|
@@ -7,8 +7,8 @@ import { detachSubscriber, abortSession } from "../session-manager.js";
|
|
|
7
7
|
import { saveGlobalMcpConfig } from "../../../config/load-config.js";
|
|
8
8
|
export function useSubmit(o) {
|
|
9
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;
|
|
10
|
+
const { queuedPromptRef, fullModeRef, planModeRef, conversationRef, feedRef } = o.refs;
|
|
11
|
+
const { setApprovalPending, setInputText, setCursorPos, setInputHistory, setHistoryIndex, setHelpOpen, setNotice, setBusy, setScreen, setFeed, setRunState, setPendingRerun, setMode, setPlanMode, setConfig, setMcpOpen, setHeroHidden, } = o.setters;
|
|
12
12
|
const { pushFeed, refreshSessions, openRun, openMenu, handleSettings, runTurn, exit } = o.actions;
|
|
13
13
|
const rerunConfirmRef = useRef(false);
|
|
14
14
|
const abortTurn = useCallback(() => {
|
|
@@ -77,7 +77,7 @@ export function useSubmit(o) {
|
|
|
77
77
|
const run = await createRun(text, config);
|
|
78
78
|
await refreshSessions();
|
|
79
79
|
setBusy(false);
|
|
80
|
-
|
|
80
|
+
await openRun(run.path, text);
|
|
81
81
|
}
|
|
82
82
|
catch (error) {
|
|
83
83
|
setNotice(error instanceof Error ? error.message : String(error));
|
|
@@ -339,12 +339,23 @@ export function useSubmit(o) {
|
|
|
339
339
|
})();
|
|
340
340
|
return;
|
|
341
341
|
}
|
|
342
|
+
if (text === "/plan") {
|
|
343
|
+
if (fullModeRef.current) {
|
|
344
|
+
pushFeed({ kind: "status", text: "Plan mode applies to coding/quick turns only. It is disabled during full research." });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const next = !planModeRef.current;
|
|
348
|
+
setPlanMode(next);
|
|
349
|
+
pushFeed({ kind: "status", text: next ? "Plan mode on. Agent will explore and plan before making changes." : "Plan mode off. Agent can execute changes." });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
342
352
|
if (text === "/rerun") {
|
|
343
353
|
if (busy)
|
|
344
354
|
return;
|
|
345
355
|
if (rerunConfirmRef.current) {
|
|
346
356
|
rerunConfirmRef.current = false;
|
|
347
357
|
conversationRef.current = [];
|
|
358
|
+
setPlanMode(false);
|
|
348
359
|
setMode(true); // explicit deep re-run uses the full harness
|
|
349
360
|
setFeed([{ kind: "status", text: "Re-running research…" }]);
|
|
350
361
|
void runTurn("Re-run the research from scratch. Plan, gather grounded sources, and rewrite report.md, then summarize.");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { ThemeProvider, useTheme } from "../theme-context.js";
|
|
1
|
+
export { ThemeProvider, useTheme, useTerminalAppearance, useRenderingAppearance, } from "../theme-context.js";
|
|
@@ -2,13 +2,16 @@ import { markdownToSegLines } from "./markdown.js";
|
|
|
2
2
|
import { wrapText } from "./utils.js";
|
|
3
3
|
/** Tools that start collapsed in the timeline (long output). */
|
|
4
4
|
export const DEFAULT_COLLAPSED_TOOLS = new Set([
|
|
5
|
+
"webSearch",
|
|
5
6
|
"readUrl",
|
|
6
7
|
"readFile",
|
|
7
8
|
"readWorkspaceFile",
|
|
8
9
|
"readSkill",
|
|
9
10
|
"bash",
|
|
10
11
|
"runWorkspaceCommand",
|
|
12
|
+
"todo",
|
|
11
13
|
"grepWorkspace",
|
|
14
|
+
"xSearch",
|
|
12
15
|
]);
|
|
13
16
|
export function feedToolItemId(feedIndex, toolCallId) {
|
|
14
17
|
return toolCallId ?? `feed-${feedIndex}`;
|
|
@@ -80,9 +83,15 @@ function searchHitToMarkdown(hit) {
|
|
|
80
83
|
}
|
|
81
84
|
function webSearchQueriesMarkdown(groups) {
|
|
82
85
|
const queries = groups.map((g) => g.query?.trim()).filter((q) => Boolean(q));
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
const errors = groups.map((g) => g.error?.trim()).filter((e) => Boolean(e));
|
|
87
|
+
const parts = [];
|
|
88
|
+
if (queries.length > 0) {
|
|
89
|
+
parts.push(`## Queries\n\n${queries.map((q, i) => `${i + 1}. ${q}`).join("\n")}`);
|
|
90
|
+
}
|
|
91
|
+
if (errors.length > 0) {
|
|
92
|
+
parts.push(`## Errors\n\n${errors.map((e, i) => `${i + 1}. ${e}`).join("\n")}`);
|
|
93
|
+
}
|
|
94
|
+
return parts.join("\n\n");
|
|
86
95
|
}
|
|
87
96
|
function webSearchSourcesMarkdown(hits) {
|
|
88
97
|
if (hits.length === 0)
|
|
@@ -199,10 +208,57 @@ function formatGrep(result, width, theme) {
|
|
|
199
208
|
return plainLines(row, width, { color: theme.textDim });
|
|
200
209
|
});
|
|
201
210
|
}
|
|
211
|
+
function xPostToMarkdown(p) {
|
|
212
|
+
const label = p.handle ? `@${p.handle}` : p.url;
|
|
213
|
+
let line = `- [${label}](${p.url})`;
|
|
214
|
+
if (p.text) {
|
|
215
|
+
const snippet = p.text.replace(/\s+/gu, " ").trim();
|
|
216
|
+
if (snippet)
|
|
217
|
+
line += `\n *${snippet}*`;
|
|
218
|
+
}
|
|
219
|
+
return line;
|
|
220
|
+
}
|
|
221
|
+
function xSearchPostsMarkdown(groups) {
|
|
222
|
+
const queries = groups.map((g) => g.query?.trim()).filter((q) => Boolean(q));
|
|
223
|
+
const errors = groups.map((g) => g.error?.trim()).filter((e) => Boolean(e));
|
|
224
|
+
const allPosts = groups.flatMap((g) => g.posts ?? []);
|
|
225
|
+
const dateRange = groups[0]?.dateRange;
|
|
226
|
+
const parts = [];
|
|
227
|
+
if (queries.length > 0) {
|
|
228
|
+
parts.push(`## Queries\n\n${queries.map((q, i) => `${i + 1}. ${q}`).join("\n")}`);
|
|
229
|
+
}
|
|
230
|
+
if (dateRange) {
|
|
231
|
+
parts.push(`*${dateRange}*`);
|
|
232
|
+
}
|
|
233
|
+
if (errors.length > 0) {
|
|
234
|
+
parts.push(`## Errors\n\n${errors.map((e, i) => `${i + 1}. ${e}`).join("\n")}`);
|
|
235
|
+
}
|
|
236
|
+
if (allPosts.length > 0) {
|
|
237
|
+
const postLines = allPosts.map(xPostToMarkdown).join("\n\n");
|
|
238
|
+
parts.push(`## Posts (${allPosts.length})\n\n${postLines}`);
|
|
239
|
+
}
|
|
240
|
+
return parts.join("\n\n");
|
|
241
|
+
}
|
|
242
|
+
function formatXSearch(result, width, theme) {
|
|
243
|
+
try {
|
|
244
|
+
const groups = JSON.parse(result);
|
|
245
|
+
if (!Array.isArray(groups))
|
|
246
|
+
return plainLines(result, width, { color: theme.textDim });
|
|
247
|
+
const md = xSearchPostsMarkdown(groups);
|
|
248
|
+
if (!md.trim())
|
|
249
|
+
return plainLines(result, width, { color: theme.textDim });
|
|
250
|
+
return markdownToSegLines(md, width, theme);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return plainLines(result, width, { color: theme.textDim });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
202
256
|
function formatBody(name, result, width, theme) {
|
|
203
257
|
switch (name) {
|
|
204
258
|
case "webSearch":
|
|
205
259
|
return formatWebSearch(result, width, theme);
|
|
260
|
+
case "xSearch":
|
|
261
|
+
return formatXSearch(result, width, theme);
|
|
206
262
|
case "readUrl":
|
|
207
263
|
return formatReadUrl(result, width, theme);
|
|
208
264
|
case "listSkills":
|
|
@@ -262,6 +318,18 @@ export function formatToolResultPreview(name, inputSummary, result, status) {
|
|
|
262
318
|
}
|
|
263
319
|
catch { /* fall through */ }
|
|
264
320
|
}
|
|
321
|
+
if (name === "xSearch") {
|
|
322
|
+
try {
|
|
323
|
+
const groups = JSON.parse(result);
|
|
324
|
+
if (Array.isArray(groups)) {
|
|
325
|
+
const queries = groups.map((g) => g.query?.trim()).filter(Boolean);
|
|
326
|
+
const total = groups.reduce((n, g) => n + (g.posts?.length ?? 0), 0);
|
|
327
|
+
const q = queries.length > 0 ? queries.slice(0, 2).join(" · ") + (queries.length > 2 ? ` +${queries.length - 2}` : "") : input;
|
|
328
|
+
return q ? `${q} · ${total} posts` : `${total} posts`;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch { /* fall through */ }
|
|
332
|
+
}
|
|
265
333
|
if (name === "readFile" || name === "readWorkspaceFile") {
|
|
266
334
|
const lines = result.split("\n").length;
|
|
267
335
|
return input ? `${input} · ${lines} lines` : `${lines} lines`;
|
|
@@ -280,12 +348,12 @@ export function formatToolResultLines(name, inputSummary, result, status, conten
|
|
|
280
348
|
const width = Math.max(16, contentWidth);
|
|
281
349
|
const lines = [];
|
|
282
350
|
const input = inputSummary.replace(/\s+/gu, " ").trim();
|
|
283
|
-
const skipInput = name === "webSearch" && status === "done" && Boolean(result?.trim());
|
|
351
|
+
const skipInput = (name === "webSearch" || name === "xSearch") && status === "done" && Boolean(result?.trim());
|
|
284
352
|
if (input && !skipInput) {
|
|
285
353
|
if (name === "bash" || name === "runWorkspaceCommand") {
|
|
286
354
|
lines.push([seg("$ ", { color: theme.accent }), seg(input, { color: theme.text })]);
|
|
287
355
|
}
|
|
288
|
-
else if (name === "webSearch") {
|
|
356
|
+
else if (name === "webSearch" || name === "xSearch") {
|
|
289
357
|
lines.push(...markdownToSegLines(webSearchRunningMarkdown(input), width, theme));
|
|
290
358
|
}
|
|
291
359
|
else if (name === "readUrl") {
|
|
@@ -50,11 +50,11 @@ describe("formatToolResultLines", () => {
|
|
|
50
50
|
expect(formatToolResultLines("readUrl", "https://example.com", result, "done", 80, DARK_THEME, false)).toEqual([]);
|
|
51
51
|
expect(formatToolResultPreview("readUrl", "https://example.com", result, "done")).toContain("Example");
|
|
52
52
|
});
|
|
53
|
-
it("defaults readUrl
|
|
53
|
+
it("defaults readUrl and webSearch collapsed", () => {
|
|
54
54
|
expect(defaultCollapsedToolName("readUrl")).toBe(true);
|
|
55
|
-
expect(defaultCollapsedToolName("webSearch")).toBe(
|
|
55
|
+
expect(defaultCollapsedToolName("webSearch")).toBe(true);
|
|
56
56
|
expect(isToolItemCollapsed("id", "readUrl", "done", new Map())).toBe(true);
|
|
57
|
-
expect(isToolItemCollapsed("id", "webSearch", "done", new Map())).toBe(
|
|
57
|
+
expect(isToolItemCollapsed("id", "webSearch", "done", new Map())).toBe(true);
|
|
58
58
|
expect(isToolItemCollapsed("id", "readUrl", "done", new Map([["id", true]]))).toBe(false);
|
|
59
59
|
});
|
|
60
60
|
});
|