@scira/cli 0.1.1 → 0.1.3
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 +54 -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 +51 -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/ui/ink/SciraApp.js +11 -8
- package/dist/ui/ink/components/overlays.js +4 -4
- package/dist/ui/ink/constants.js +11 -3
- package/dist/ui/ink/hooks/use-agent-turn.js +24 -5
- package/dist/ui/ink/hooks/use-keyboard.js +3 -0
- package/dist/ui/ink/hooks/use-session.js +5 -3
- package/dist/ui/ink/hooks/use-settings.js +10 -8
- package/dist/ui/ink/hooks/use-submit.js +13 -2
- package/dist/ui/ink/hooks/use-theme.js +1 -1
- package/dist/ui/ink/lib/tool-result.js +72 -5
- package/dist/ui/ink/lib/utils.js +40 -3
- 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 +9 -6
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { harnessBasename, isRunArtifactPath, resolveInsideRun, resolveProjectRoot, resolveToolPath } from "./workspace.js";
|
|
3
|
+
const RUN = "/tmp/scira-test-run";
|
|
4
|
+
const PROJECT = "/Users/me/my-app";
|
|
5
|
+
const RUN_UNDER_SCIRA = `${PROJECT}/.scira/runs/2024-test-abc`;
|
|
6
|
+
describe("resolveInsideRun", () => {
|
|
7
|
+
it("resolves a relative path inside the run dir", () => {
|
|
8
|
+
expect(resolveInsideRun(RUN, "notes.md")).toBe(`${RUN}/notes.md`);
|
|
9
|
+
});
|
|
10
|
+
it("resolves a nested relative path inside the run dir", () => {
|
|
11
|
+
expect(resolveInsideRun(RUN, "artifacts/output.txt")).toBe(`${RUN}/artifacts/output.txt`);
|
|
12
|
+
});
|
|
13
|
+
it("resolves an absolute path that is inside the run dir", () => {
|
|
14
|
+
expect(resolveInsideRun(RUN, `${RUN}/plan.md`)).toBe(`${RUN}/plan.md`);
|
|
15
|
+
});
|
|
16
|
+
it("throws for a path that escapes with ../", () => {
|
|
17
|
+
expect(() => resolveInsideRun(RUN, "../outside.txt")).toThrow("outside the run directory");
|
|
18
|
+
});
|
|
19
|
+
it("throws for a deep escape path", () => {
|
|
20
|
+
expect(() => resolveInsideRun(RUN, "a/../../outside.txt")).toThrow("outside the run directory");
|
|
21
|
+
});
|
|
22
|
+
it("throws for an absolute path outside the run dir", () => {
|
|
23
|
+
expect(() => resolveInsideRun(RUN, "/etc/passwd")).toThrow("outside the run directory");
|
|
24
|
+
});
|
|
25
|
+
it("throws for a home-dir escape", () => {
|
|
26
|
+
const home = `${process.env.HOME ?? "/root"}/evil.sh`;
|
|
27
|
+
expect(() => resolveInsideRun(RUN, home)).toThrow("outside the run directory");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe("resolveProjectRoot", () => {
|
|
31
|
+
it("returns parent of .scira when run is under .scira/runs", () => {
|
|
32
|
+
expect(resolveProjectRoot(RUN_UNDER_SCIRA)).toBe(PROJECT);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe("harnessBasename", () => {
|
|
36
|
+
it("strips run: and ./ prefixes", () => {
|
|
37
|
+
expect(harnessBasename("run:report.md")).toBe("report.md");
|
|
38
|
+
expect(harnessBasename("./plan.md")).toBe("plan.md");
|
|
39
|
+
expect(harnessBasename("notes.md")).toBe("notes.md");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe("isRunArtifactPath", () => {
|
|
43
|
+
it("treats bare harness filenames as run artifacts", () => {
|
|
44
|
+
expect(isRunArtifactPath("plan.md")).toBe(true);
|
|
45
|
+
expect(isRunArtifactPath("notes.md")).toBe(true);
|
|
46
|
+
expect(isRunArtifactPath("src/foo.ts")).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
it("does not treat nested paths as run artifacts by basename", () => {
|
|
49
|
+
expect(isRunArtifactPath("docs/notes.md")).toBe(false);
|
|
50
|
+
expect(isRunArtifactPath("src/plan.md")).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
it("treats run: prefix as run artifact", () => {
|
|
53
|
+
expect(isRunArtifactPath("run:custom.md")).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe("resolveToolPath", () => {
|
|
57
|
+
it("routes source paths to workspace", () => {
|
|
58
|
+
const resolved = resolveToolPath(RUN_UNDER_SCIRA, PROJECT, "src/index.ts");
|
|
59
|
+
expect(resolved.scope).toBe("workspace");
|
|
60
|
+
expect(resolved.abs).toBe(`${PROJECT}/src/index.ts`);
|
|
61
|
+
});
|
|
62
|
+
it("routes plan.md to run directory", () => {
|
|
63
|
+
const resolved = resolveToolPath(RUN_UNDER_SCIRA, PROJECT, "plan.md");
|
|
64
|
+
expect(resolved.scope).toBe("run");
|
|
65
|
+
expect(resolved.abs).toBe(`${RUN_UNDER_SCIRA}/plan.md`);
|
|
66
|
+
});
|
|
67
|
+
it("routes nested notes.md to workspace not run", () => {
|
|
68
|
+
const resolved = resolveToolPath(RUN_UNDER_SCIRA, PROJECT, "docs/notes.md");
|
|
69
|
+
expect(resolved.scope).toBe("workspace");
|
|
70
|
+
expect(resolved.abs).toBe(`${PROJECT}/docs/notes.md`);
|
|
71
|
+
});
|
|
72
|
+
it("blocks writes into .scira from workspace paths", () => {
|
|
73
|
+
expect(() => resolveToolPath(RUN_UNDER_SCIRA, PROJECT, ".scira/config.json")).toThrow("inside .scira");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { generateText, tool, stepCountIs } from "ai";
|
|
2
|
+
import { xai } from "@ai-sdk/xai";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { getTweet } from "react-tweet/api";
|
|
5
|
+
import { logEvent } from "../storage/run-store.js";
|
|
6
|
+
const XSEARCH_MODEL = "grok-4.20-0309-non-reasoning";
|
|
7
|
+
function sanitizeHandle(h) {
|
|
8
|
+
return h.replace(/^@+/u, "").trim();
|
|
9
|
+
}
|
|
10
|
+
function toYMD(d) {
|
|
11
|
+
return d.toISOString().slice(0, 10);
|
|
12
|
+
}
|
|
13
|
+
function extractTweetId(url) {
|
|
14
|
+
return url.match(/status\/(\d+)/u)?.[1] ?? null;
|
|
15
|
+
}
|
|
16
|
+
function canonicalLink(id, fallback) {
|
|
17
|
+
return id ? `https://x.com/i/status/${id}` : fallback;
|
|
18
|
+
}
|
|
19
|
+
export function createXSearchTool(runPath) {
|
|
20
|
+
return tool({
|
|
21
|
+
description: "Search X (formerly Twitter) for recent posts. Best for current events, public reactions, announcements, breaking news, and real-time opinions. Searches the last 7 days by default. Use 1–3 targeted queries per call.",
|
|
22
|
+
inputSchema: z
|
|
23
|
+
.object({
|
|
24
|
+
queries: z
|
|
25
|
+
.array(z.string())
|
|
26
|
+
.min(1)
|
|
27
|
+
.max(5)
|
|
28
|
+
.describe("Search queries for X posts. 1–3 targeted queries recommended."),
|
|
29
|
+
startDate: z
|
|
30
|
+
.string()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Start date YYYY-MM-DD (default: 7 days ago)."),
|
|
33
|
+
endDate: z
|
|
34
|
+
.string()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe("End date YYYY-MM-DD (default: today)."),
|
|
37
|
+
includeXHandles: z
|
|
38
|
+
.array(z.string())
|
|
39
|
+
.max(10)
|
|
40
|
+
.optional()
|
|
41
|
+
.describe("Only include posts from these X handles (max 10). Cannot be combined with excludeXHandles."),
|
|
42
|
+
excludeXHandles: z
|
|
43
|
+
.array(z.string())
|
|
44
|
+
.max(10)
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("Exclude posts from these X handles (max 10). Cannot be combined with includeXHandles."),
|
|
47
|
+
})
|
|
48
|
+
.refine((data) => {
|
|
49
|
+
const hasInclude = data.includeXHandles && data.includeXHandles.length > 0;
|
|
50
|
+
const hasExclude = data.excludeXHandles && data.excludeXHandles.length > 0;
|
|
51
|
+
return !(hasInclude && hasExclude);
|
|
52
|
+
}, { message: "Cannot specify both includeXHandles and excludeXHandles", path: ["includeXHandles"] }),
|
|
53
|
+
execute: async ({ queries, startDate, endDate, includeXHandles, excludeXHandles }) => {
|
|
54
|
+
await logEvent(runPath, "tool.xSearch", { queries });
|
|
55
|
+
const today = new Date();
|
|
56
|
+
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
57
|
+
const effectiveStart = startDate?.trim() || toYMD(sevenDaysAgo);
|
|
58
|
+
const effectiveEnd = endDate?.trim() || toYMD(today);
|
|
59
|
+
const dateRange = `${effectiveStart} to ${effectiveEnd}`;
|
|
60
|
+
const normalizedInclude = includeXHandles?.map(sanitizeHandle).filter(Boolean);
|
|
61
|
+
const normalizedExclude = excludeXHandles?.map(sanitizeHandle).filter(Boolean);
|
|
62
|
+
const results = await Promise.all(queries.map(async (query) => {
|
|
63
|
+
try {
|
|
64
|
+
const searchConfig = {
|
|
65
|
+
fromDate: effectiveStart,
|
|
66
|
+
toDate: effectiveEnd,
|
|
67
|
+
};
|
|
68
|
+
if (normalizedInclude?.length)
|
|
69
|
+
searchConfig.allowedXHandles = normalizedInclude;
|
|
70
|
+
if (normalizedExclude?.length)
|
|
71
|
+
searchConfig.excludedXHandles = normalizedExclude;
|
|
72
|
+
const { sources } = await generateText({
|
|
73
|
+
model: xai.responses(XSEARCH_MODEL),
|
|
74
|
+
system: "Run the x_search tool for the given query and stop immediately. Do not output any text.",
|
|
75
|
+
messages: [{ role: "user", content: query }],
|
|
76
|
+
maxOutputTokens: 5,
|
|
77
|
+
stopWhen: stepCountIs(1),
|
|
78
|
+
tools: { x_search: xai.tools.xSearch(searchConfig) },
|
|
79
|
+
});
|
|
80
|
+
const citations = (Array.isArray(sources) ? sources : []);
|
|
81
|
+
// Deduplicate citation URLs within this query before fetching
|
|
82
|
+
const seenIds = new Set();
|
|
83
|
+
const uniqueCitations = citations.filter((c) => {
|
|
84
|
+
if (c.sourceType !== "url" || !c.url)
|
|
85
|
+
return false;
|
|
86
|
+
const id = extractTweetId(c.url) ?? c.url;
|
|
87
|
+
if (seenIds.has(id))
|
|
88
|
+
return false;
|
|
89
|
+
seenIds.add(id);
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
// Hydrate each citation URL with full tweet content
|
|
93
|
+
const posts = (await Promise.all(uniqueCitations.map(async (c) => {
|
|
94
|
+
const rawUrl = c.url;
|
|
95
|
+
const tweetId = extractTweetId(rawUrl);
|
|
96
|
+
try {
|
|
97
|
+
if (!tweetId)
|
|
98
|
+
return { url: rawUrl };
|
|
99
|
+
const data = await getTweet(tweetId);
|
|
100
|
+
if (!data)
|
|
101
|
+
return { url: canonicalLink(tweetId, rawUrl), id: tweetId };
|
|
102
|
+
const handle = data.user?.screen_name ?? undefined;
|
|
103
|
+
return {
|
|
104
|
+
url: handle
|
|
105
|
+
? `https://x.com/${handle}/status/${tweetId}`
|
|
106
|
+
: canonicalLink(tweetId, rawUrl),
|
|
107
|
+
id: tweetId,
|
|
108
|
+
handle,
|
|
109
|
+
text: data.text,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { url: canonicalLink(tweetId, rawUrl), id: tweetId ?? undefined };
|
|
114
|
+
}
|
|
115
|
+
}))).filter((p) => p !== null);
|
|
116
|
+
return { query, dateRange, posts };
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
return { query, dateRange, posts: [], error: String(error) };
|
|
120
|
+
}
|
|
121
|
+
}));
|
|
122
|
+
// Cross-query dedup by tweet ID or URL
|
|
123
|
+
const seenKeys = new Set();
|
|
124
|
+
const deduped = results.map((r) => ({
|
|
125
|
+
...r,
|
|
126
|
+
posts: r.posts.filter((p) => {
|
|
127
|
+
const key = p.id ?? p.url;
|
|
128
|
+
if (seenKeys.has(key))
|
|
129
|
+
return false;
|
|
130
|
+
seenKeys.add(key);
|
|
131
|
+
return true;
|
|
132
|
+
}),
|
|
133
|
+
}));
|
|
134
|
+
const allFailed = deduped.every((r) => r.posts.length === 0 && r.error);
|
|
135
|
+
if (allFailed) {
|
|
136
|
+
const errors = deduped.map((r) => r.error).filter(Boolean).join(" | ");
|
|
137
|
+
throw new Error(`X search failed: ${errors}`);
|
|
138
|
+
}
|
|
139
|
+
return JSON.stringify(deduped, null, 2);
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
package/dist/ui/ink/SciraApp.js
CHANGED
|
@@ -52,6 +52,9 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
|
|
|
52
52
|
const fullModeRef = useRef(false);
|
|
53
53
|
const [fullMode, setFullModeState] = useState(false);
|
|
54
54
|
const setMode = useCallback((full) => { fullModeRef.current = full; setFullModeState(full); }, []);
|
|
55
|
+
const planModeRef = useRef(false);
|
|
56
|
+
const [planMode, setPlanModeState] = useState(false);
|
|
57
|
+
const setPlanMode = useCallback((active) => { planModeRef.current = active; setPlanModeState(active); }, []);
|
|
55
58
|
const [usage, setUsage] = useState({});
|
|
56
59
|
const turnsRef = useRef([]);
|
|
57
60
|
const recordUsage = useCallback((model, u) => {
|
|
@@ -203,7 +206,7 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
|
|
|
203
206
|
const { refreshSessions, refreshRun, openRun: openRunBase } = useSession({
|
|
204
207
|
config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef, runTurnRef,
|
|
205
208
|
setSessions, setRunState, setCurrentRunPath, setInputText, setCursorPos,
|
|
206
|
-
setFeed, setUsage, setScrollOffset, setScreen, setMode,
|
|
209
|
+
setFeed, setUsage, setScrollOffset, setScreen, setMode, setPlanMode,
|
|
207
210
|
setBusy, setApprovalPending, getSubscriber,
|
|
208
211
|
});
|
|
209
212
|
const openRun = useCallback(async (runPath, initialQuestion) => {
|
|
@@ -277,16 +280,16 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
|
|
|
277
280
|
setMcpRowIdx((i) => Math.min(i, Math.max(0, mcpRowCount - 1)));
|
|
278
281
|
}, [mcpRowCount]);
|
|
279
282
|
const { runTurn } = useAgentTurn({
|
|
280
|
-
config, currentRunPath, queuedPromptRef, fullModeRef, conversationRef, turnsRef, feedRef,
|
|
281
|
-
setBusy, setScrollOffset, refreshRun, recordUsage, setMode, getSubscriber,
|
|
283
|
+
config, currentRunPath, queuedPromptRef, fullModeRef, planModeRef, conversationRef, turnsRef, feedRef,
|
|
284
|
+
setBusy, setScrollOffset, refreshRun, recordUsage, setMode, setPlanMode, getSubscriber,
|
|
282
285
|
});
|
|
283
286
|
runTurnRef.current = runTurn;
|
|
284
287
|
const { submitHome, submitChat, stopTurn } = useSubmit({
|
|
285
288
|
state: { config, currentRunPath, sessions, selectedIdx, busy, usage, pendingRerun },
|
|
286
|
-
refs: { queuedPromptRef, conversationRef, feedRef },
|
|
289
|
+
refs: { queuedPromptRef, fullModeRef, planModeRef, conversationRef, feedRef },
|
|
287
290
|
setters: {
|
|
288
291
|
setApprovalPending, setInputText, setCursorPos, setInputHistory, setHistoryIndex, setHelpOpen,
|
|
289
|
-
setNotice, setBusy, setScreen, setFeed, setRunState, setPendingRerun, setMode, setConfig, setMcpOpen,
|
|
292
|
+
setNotice, setBusy, setScreen, setFeed, setRunState, setPendingRerun, setMode, setPlanMode, setConfig, setMcpOpen,
|
|
290
293
|
setHeroHidden,
|
|
291
294
|
},
|
|
292
295
|
actions: { pushFeed, refreshSessions, openRun, openMenu, handleSettings, runTurn, exit },
|
|
@@ -351,11 +354,11 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
|
|
|
351
354
|
home: { sessionsModalOpen, setSessionsModalOpen, sessionsModalIdx, setSessionsModalIdx, sessions, deleteSession, selectedIdx, setSelectedIdx, setHeroHidden, openRun, submitHome },
|
|
352
355
|
});
|
|
353
356
|
const activeUsage = usage[config.model];
|
|
354
|
-
const themed = (node) => (_jsx(ThemeProvider, { config: config,
|
|
357
|
+
const themed = (node) => (_jsx(ThemeProvider, { config: config, children: node }));
|
|
355
358
|
if (screen === "home") {
|
|
356
|
-
return themed(_jsxs(Box, { flexDirection: "column", width: cols, height: rows, paddingX: 2, children: [_jsx(TipCycler, { setTipIndex: setTipIndex }), (!sessionsModalOpen || mcpOpen) && stdout !== undefined && stdin !== undefined && (_jsx(MouseTracker, { stdout: stdout, stdin: stdin, onData: handleMouseData, onUnmount: () => setHoveredIdx(null) })), busy && _jsx(AnimationTick, { setBlink: setBlink, setFrame: setFrame, setReasoningTick: setReasoningTick }), _jsx(TopBar, { screen: screen, runState: runState, fullMode: fullMode, activeUsage: activeUsage, busy: busy, frame: frame, cwdDisplay: CWD_DISPLAY, config: config }), _jsx(HomeScreen, { cols: cols, rows: rows, sessions: sessions, selectedIdx: selectedIdx, hoveredIdx: hoveredIdx, heroHidden: heroHidden, notice: notice, tipIndex: tipIndex, commandMenuHeight: commandMenuHeight, mcpOpen: mcpOpen, sessionsModalOpen: sessionsModalOpen, sessionsModalIdx: sessionsModalIdx, inputText: inputText, config: config, modelName: modelName, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef, setSelectedIdx: setSelectedIdx, setSessionsModalOpen: setSessionsModalOpen, setSessionsModalIdx: setSessionsModalIdx, setNotice: setNotice, openRun: openRun, submitHome: submitHome, exit: exit }), _jsxs(Box, { flexDirection: "column", paddingBottom: 1, children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions, config: config }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: !!approvalPending, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName, config: config }), _jsx(HintLine, { screen: screen, busy: busy, config: config })] }), _jsx(MenuDialog, { menu: menu, cols: cols, rows: rows, config: config }), _jsx(McpDialog, { open: mcpOpen, config: config, cols: cols, rows: rows, selectedIdx: mcpRowIdx, hoveredIdx: hoveredIdx, onToggle: handleMcpToggle, onRemove: handleMcpRemove, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef })] }));
|
|
359
|
+
return themed(_jsxs(Box, { flexDirection: "column", width: cols, height: rows, paddingX: 2, children: [_jsx(TipCycler, { setTipIndex: setTipIndex }), (!sessionsModalOpen || mcpOpen) && stdout !== undefined && stdin !== undefined && (_jsx(MouseTracker, { stdout: stdout, stdin: stdin, onData: handleMouseData, onUnmount: () => setHoveredIdx(null) })), busy && _jsx(AnimationTick, { setBlink: setBlink, setFrame: setFrame, setReasoningTick: setReasoningTick }), _jsx(TopBar, { screen: screen, runState: runState, fullMode: fullMode, planMode: planMode, activeUsage: activeUsage, busy: busy, frame: frame, cwdDisplay: CWD_DISPLAY, config: config }), _jsx(HomeScreen, { cols: cols, rows: rows, sessions: sessions, selectedIdx: selectedIdx, hoveredIdx: hoveredIdx, heroHidden: heroHidden, notice: notice, tipIndex: tipIndex, commandMenuHeight: commandMenuHeight, mcpOpen: mcpOpen, sessionsModalOpen: sessionsModalOpen, sessionsModalIdx: sessionsModalIdx, inputText: inputText, config: config, modelName: modelName, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef, setSelectedIdx: setSelectedIdx, setSessionsModalOpen: setSessionsModalOpen, setSessionsModalIdx: setSessionsModalIdx, setNotice: setNotice, openRun: openRun, submitHome: submitHome, exit: exit }), _jsxs(Box, { flexDirection: "column", paddingBottom: 1, children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions, config: config }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: !!approvalPending, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName, config: config }), _jsx(HintLine, { screen: screen, busy: busy, config: config })] }), _jsx(MenuDialog, { menu: menu, cols: cols, rows: rows, config: config }), _jsx(McpDialog, { open: mcpOpen, config: config, cols: cols, rows: rows, selectedIdx: mcpRowIdx, hoveredIdx: hoveredIdx, onToggle: handleMcpToggle, onRemove: handleMcpRemove, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef })] }));
|
|
357
360
|
}
|
|
358
|
-
return themed(_jsxs(Box, { flexDirection: "column", width: cols, height: rows, paddingX: 2, children: [stdout !== undefined && stdin !== undefined && (_jsx(MouseTracker, { stdout: stdout, stdin: stdin, onData: handleMouseData, onUnmount: () => setHoveredIdx(null) })), busy && _jsx(AnimationTick, { setBlink: setBlink, setFrame: setFrame, setReasoningTick: setReasoningTick }), _jsx(TopBar, { screen: screen, runState: runState, fullMode: fullMode, activeUsage: activeUsage, busy: busy, frame: frame, cwdDisplay: CWD_DISPLAY, config: config }), _jsx(Box, { flexDirection: "column", flexGrow: 1, paddingTop: 1, overflow: "hidden", children: visibleLines }), _jsxs(ChatInputChrome, { children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions, config: config }), _jsx(HelpBox, { open: helpOpen, innerWidth: innerWidth, config: config }), approvalPending && _jsx(ApprovalBox, { toolName: approvalPending.toolName, description: approvalPending.description, innerWidth: innerWidth, config: config }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: !!approvalPending, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName, config: config }), _jsx(HintLine, { screen: screen, busy: busy, scrollLabel: scrollLabel, hasDoneGroups: doneGroupKeys.length > 0, hasFocusedGroup: focusedGroupKey !== null, config: config })] }), _jsx(MenuDialog, { menu: menu, cols: cols, rows: rows, config: config }), _jsx(McpDialog, { open: mcpOpen, config: config, cols: cols, rows: rows, selectedIdx: mcpRowIdx, hoveredIdx: hoveredIdx, onToggle: handleMcpToggle, onRemove: handleMcpRemove, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef })] }));
|
|
361
|
+
return themed(_jsxs(Box, { flexDirection: "column", width: cols, height: rows, paddingX: 2, children: [stdout !== undefined && stdin !== undefined && (_jsx(MouseTracker, { stdout: stdout, stdin: stdin, onData: handleMouseData, onUnmount: () => setHoveredIdx(null) })), busy && _jsx(AnimationTick, { setBlink: setBlink, setFrame: setFrame, setReasoningTick: setReasoningTick }), _jsx(TopBar, { screen: screen, runState: runState, fullMode: fullMode, planMode: planMode, activeUsage: activeUsage, busy: busy, frame: frame, cwdDisplay: CWD_DISPLAY, config: config }), _jsx(Box, { flexDirection: "column", flexGrow: 1, paddingTop: 1, overflow: "hidden", children: visibleLines }), _jsxs(ChatInputChrome, { children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions, config: config }), _jsx(HelpBox, { open: helpOpen, innerWidth: innerWidth, config: config }), approvalPending && _jsx(ApprovalBox, { toolName: approvalPending.toolName, description: approvalPending.description, innerWidth: innerWidth, config: config }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: !!approvalPending, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName, config: config }), _jsx(HintLine, { screen: screen, busy: busy, scrollLabel: scrollLabel, hasDoneGroups: doneGroupKeys.length > 0, hasFocusedGroup: focusedGroupKey !== null, config: config })] }), _jsx(MenuDialog, { menu: menu, cols: cols, rows: rows, config: config }), _jsx(McpDialog, { open: mcpOpen, config: config, cols: cols, rows: rows, selectedIdx: mcpRowIdx, hoveredIdx: hoveredIdx, onToggle: handleMcpToggle, onRemove: handleMcpRemove, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef })] }));
|
|
359
362
|
}
|
|
360
363
|
function ChatInputChrome({ children }) {
|
|
361
364
|
const theme = useTheme();
|
|
@@ -4,9 +4,9 @@ import { SPINNER_FRAMES, CHAT_COMMANDS, COMMAND_DESCRIPTIONS, MENU_VISIBLE } fro
|
|
|
4
4
|
import { fmtTokens, wrapText } from "../lib/utils.js";
|
|
5
5
|
import { LLM_PROVIDER_LABELS } from "../../../providers/llm/registry.js";
|
|
6
6
|
import { useTheme } from "../hooks/use-theme.js";
|
|
7
|
-
export function TopBar({ screen, runState, fullMode, activeUsage, busy, frame, cwdDisplay, config }) {
|
|
7
|
+
export function TopBar({ screen, runState, fullMode, planMode, activeUsage, busy, frame, cwdDisplay, config }) {
|
|
8
8
|
const theme = useTheme();
|
|
9
|
-
return (_jsxs(Box, { paddingTop: 1, justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, minWidth: 0, marginRight: 2, children: _jsx(Text, { color: theme.textDim, wrap: "truncate-end", children: screen === "chat" ? (runState?.title || runState?.goal || cwdDisplay) : cwdDisplay }) }), screen === "chat" && (_jsxs(Box, { flexShrink: 0, gap: 1, children: [_jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: fullMode ? "magenta" : theme.accent, children: fullMode ? "full" : "quick" }), activeUsage && (_jsx(Text, { color: theme.textDim, children: `↑${fmtTokens(activeUsage.input)} ↓${fmtTokens(activeUsage.output)}` })), fullMode && (_jsxs(Text, { color: theme.textDim, children: [`src:${runState?.sourceCount ?? 0}`, (runState?.claimCount ?? 0) > 0 ? ` · claims:${runState?.claimCount}` : ""] })), fullMode && (_jsx(Text, { color: runState?.reportDirty ? theme.warning : theme.success, children: runState?.reportDirty ? "draft" : "ready" })), busy && _jsx(Text, { color: theme.accent, children: SPINNER_FRAMES[frame % SPINNER_FRAMES.length] }), _jsx(Text, { color: theme.textDim, children: "|" })] }))] }));
|
|
9
|
+
return (_jsxs(Box, { paddingTop: 1, justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, minWidth: 0, marginRight: 2, children: _jsx(Text, { color: theme.textDim, wrap: "truncate-end", children: screen === "chat" ? (runState?.title || runState?.goal || cwdDisplay) : cwdDisplay }) }), screen === "chat" && (_jsxs(Box, { flexShrink: 0, gap: 1, children: [_jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: fullMode ? "magenta" : theme.accent, children: fullMode ? "full" : "quick" }), planMode && _jsx(Text, { color: "cyan", children: "plan" }), activeUsage && (_jsx(Text, { color: theme.textDim, children: `↑${fmtTokens(activeUsage.input)} ↓${fmtTokens(activeUsage.output)}` })), fullMode && (_jsxs(Text, { color: theme.textDim, children: [`src:${runState?.sourceCount ?? 0}`, (runState?.claimCount ?? 0) > 0 ? ` · claims:${runState?.claimCount}` : ""] })), fullMode && (_jsx(Text, { color: runState?.reportDirty ? theme.warning : theme.success, children: runState?.reportDirty ? "draft" : "ready" })), busy && _jsx(Text, { color: theme.accent, children: SPINNER_FRAMES[frame % SPINNER_FRAMES.length] }), _jsx(Text, { color: theme.textDim, children: "|" })] }))] }));
|
|
10
10
|
}
|
|
11
11
|
export function InputBar({ inputLines, cursorLine, cursorCol, showCursor, approvalPending, busy, frame, boxWidth, modelName, config }) {
|
|
12
12
|
const theme = useTheme();
|
|
@@ -17,7 +17,7 @@ export function InputBar({ inputLines, cursorLine, cursorCol, showCursor, approv
|
|
|
17
17
|
const labelMax = Math.max(0, boxWidth - 6);
|
|
18
18
|
const label = borderLabel.length > labelMax ? borderLabel.slice(0, labelMax) : borderLabel;
|
|
19
19
|
const dashCount = Math.max(1, boxWidth - label.length - 5);
|
|
20
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: borderColor, children: "╭" + "─".repeat(Math.max(0, boxWidth - 2)) + "╮" }), inputLines.map((line, i) => (_jsxs(Box, { width: boxWidth, children: [_jsx(Text, { color: borderColor, children: "│ " }), _jsx(Text, { color: i === 0 ? promptColor : borderColor, children: i === 0 ? "❯ " : " " }), _jsx(
|
|
20
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: borderColor, children: "╭" + "─".repeat(Math.max(0, boxWidth - 2)) + "╮" }), inputLines.map((line, i) => (_jsxs(Box, { width: boxWidth, children: [_jsx(Text, { color: borderColor, children: "│ " }), _jsx(Text, { color: i === 0 ? promptColor : borderColor, children: i === 0 ? "❯ " : " " }), _jsx(Box, { flexGrow: 1, minWidth: 0, children: showCursor && i === cursorLine ? (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: inputColor, children: line.slice(0, cursorCol) }), _jsx(Text, { backgroundColor: theme.cursorBackground, color: theme.cursorForeground, children: line[cursorCol] ?? " " }), _jsx(Text, { color: inputColor, children: line.slice(cursorCol + 1) })] })) : (_jsx(Text, { color: inputColor, wrap: "truncate", children: line })) }), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: borderColor, children: " \u2502" })] }, i))), _jsxs(Box, { width: boxWidth, children: [_jsx(Text, { color: borderColor, children: "╰" + "─".repeat(dashCount) + " " }), _jsx(Text, { color: theme.accent, children: label }), _jsx(Text, { color: borderColor, children: " ─╯" })] })] }));
|
|
21
21
|
}
|
|
22
22
|
export function HintLine({ screen, busy, scrollLabel, hasDoneGroups, hasFocusedGroup, config }) {
|
|
23
23
|
const theme = useTheme();
|
|
@@ -100,7 +100,7 @@ export function MenuDialog({ menu, cols, rows, config }) {
|
|
|
100
100
|
const dialogLeft = Math.max(0, Math.floor((cols - 4 - DIALOG_W) / 2));
|
|
101
101
|
const dialogH = 5 + (menu.loading ? 1 : Math.min(DIALOG_ITEMS, menuFiltered.length) + (menuStart > 0 ? 1 : 0) + (menuFiltered.length - (menuStart + DIALOG_ITEMS) > 0 ? 1 : 0));
|
|
102
102
|
const dialogTop = Math.max(1, Math.floor((rows - dialogH) / 2));
|
|
103
|
-
return (_jsxs(Box, { position: "absolute", marginLeft: dialogLeft, marginTop: dialogTop, width: DIALOG_W, flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, children: [_jsxs(Text, { bold: true, color: theme.text, children: [menu.type === "model" ? "Select model" : menu.type === "llm" ? "Select LLM provider" : "Select search provider", " ", _jsx(Text, { color: theme.textDim, children: "\u2191\u2193 navigate \u00B7 \u23CE apply \u00B7 esc close" })] }), !menu.loading && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: "⌕ " }), _jsx(Text, { color: theme.
|
|
103
|
+
return (_jsxs(Box, { position: "absolute", marginLeft: dialogLeft, marginTop: dialogTop, width: DIALOG_W, flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, children: [_jsxs(Text, { bold: true, color: theme.text, children: [menu.type === "model" ? "Select model" : menu.type === "llm" ? "Select LLM provider" : "Select search provider", " ", _jsx(Text, { color: theme.textDim, children: "\u2191\u2193 navigate \u00B7 \u23CE apply \u00B7 esc close" })] }), !menu.loading && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: "⌕ " }), _jsx(Text, { color: theme.inputText, children: menu.query }), !menu.query && _jsx(Text, { color: theme.textDim, children: "type to filter\u2026" })] }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(4, DIALOG_W - 4)) })] })), menu.loading ? (_jsx(Text, { color: theme.textDim, children: " loading models\u2026" })) : menuFiltered.length === 0 ? (_jsxs(Text, { color: theme.textDim, children: [" no matches for \"", menu.query, "\""] })) : (_jsxs(_Fragment, { children: [menuStart > 0 && _jsxs(Text, { color: theme.textDim, children: [" \u2191 ", menuStart, " more"] }), menuFiltered.slice(menuStart, menuStart + DIALOG_ITEMS).map((item, i) => {
|
|
104
104
|
const idx = menuStart + i;
|
|
105
105
|
const active = idx === menu.index;
|
|
106
106
|
return (_jsxs(Text, { color: active ? theme.accent : theme.textDim, bold: active, wrap: "truncate", children: [active ? "❯ " : " ", displayName(item), menu.type === "llm" ? _jsx(Text, { color: theme.textDim, children: " " + item }) : null] }, item));
|
package/dist/ui/ink/constants.js
CHANGED
|
@@ -3,13 +3,14 @@ export const MENU_VISIBLE = 8;
|
|
|
3
3
|
export const FILE_MENTION_MAX_CHARS = 20000;
|
|
4
4
|
export const FILE_MENTION_SKIP = new Set([".git", "node_modules", "dist", ".scira"]);
|
|
5
5
|
export const PROVIDERS = ["parallel", "exa", "firecrawl"];
|
|
6
|
-
export const CHAT_COMMANDS = ["/help", "/home", "/new", "/rerun", "/report", "/sources", "/claims", "/why", "/mcp", "/copy", "/usage", "/rename", "/model", "/llm", "/provider", "/theme", "/key", "/keys", "/stop", "/back", "/quit"];
|
|
6
|
+
export const CHAT_COMMANDS = ["/help", "/home", "/new", "/plan", "/rerun", "/report", "/sources", "/claims", "/why", "/mcp", "/copy", "/usage", "/rename", "/model", "/llm", "/provider", "/theme", "/key", "/keys", "/stop", "/back", "/quit"];
|
|
7
7
|
/** Slash commands that take an argument; ⏎ from the menu appends a space instead of running. */
|
|
8
8
|
export const COMMANDS_NEEDING_ARGS = new Set(["/theme", "/key", "/rename", "/why"]);
|
|
9
9
|
export const COMMAND_DESCRIPTIONS = {
|
|
10
10
|
"/help": "Show command and keyboard shortcuts.",
|
|
11
11
|
"/home": "Go to the home screen (or show the welcome card on home).",
|
|
12
12
|
"/new": "Go to the home screen to start a new research run.",
|
|
13
|
+
"/plan": "Toggle plan mode (explore and plan before making changes).",
|
|
13
14
|
"/rerun": "Run the research agent again for this run.",
|
|
14
15
|
"/report": "Show the generated report.md in the timeline.",
|
|
15
16
|
"/sources": "List the run's gathered sources with links.",
|
|
@@ -24,13 +25,14 @@ export const COMMAND_DESCRIPTIONS = {
|
|
|
24
25
|
"/provider": "Open the search provider selector.",
|
|
25
26
|
"/theme": "Set UI theme: /theme dark · /theme light · /theme auto",
|
|
26
27
|
"/key": "Save an API key, e.g. /key EXA_API_KEY ...",
|
|
27
|
-
"/keys": "Show
|
|
28
|
+
"/keys": "Show API key status and where to get missing keys.",
|
|
28
29
|
"/stop": "Abort the currently running agent turn.",
|
|
29
30
|
"/back": "Return to the sessions list.",
|
|
30
31
|
"/quit": "Quit the TUI."
|
|
31
32
|
};
|
|
32
33
|
export const TOOL_ICONS = {
|
|
33
34
|
bash: "$",
|
|
35
|
+
runBash: "$",
|
|
34
36
|
writeFile: "✎",
|
|
35
37
|
editFile: "✎",
|
|
36
38
|
readFile: "▤",
|
|
@@ -39,7 +41,13 @@ export const TOOL_ICONS = {
|
|
|
39
41
|
webSearch: "⌕",
|
|
40
42
|
readUrl: "↗",
|
|
41
43
|
listSkills: "★",
|
|
42
|
-
readSkill: "★"
|
|
44
|
+
readSkill: "★",
|
|
45
|
+
todo: "☐",
|
|
46
|
+
readWorkspaceFile: "▤",
|
|
47
|
+
writeWorkspaceFile: "✎",
|
|
48
|
+
editWorkspaceFile: "✎",
|
|
49
|
+
listWorkspaceDir: "▤",
|
|
50
|
+
grepWorkspace: "⌕"
|
|
43
51
|
};
|
|
44
52
|
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
45
53
|
export const HOME_TIPS = [
|
|
@@ -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, }) {
|
|
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());
|
|
12
15
|
const runTurn = useCallback(async (prompt) => {
|
|
13
16
|
const runPath = 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 };
|
|
@@ -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;
|
|
@@ -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, runTurnRef, setSessions, setRunState, setCurrentRunPath, setInputText, setCursorPos, setFeed, setUsage, setScrollOffset, setScreen, setMode, setBusy, setApprovalPending, getSubscriber, } = o;
|
|
7
|
+
const { config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef, runTurnRef, 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);
|
|
@@ -97,7 +99,7 @@ export function useSession(o) {
|
|
|
97
99
|
attachSubscriber(runPath, getSubscriber());
|
|
98
100
|
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
101
|
})();
|
|
100
|
-
}, [config, setCurrentRunPath, setInputText, setCursorPos, setFeed, setUsage, setScrollOffset,
|
|
101
|
-
setScreen, setRunState, setMode, setBusy, setApprovalPending, getSubscriber]);
|
|
102
|
+
}, [config, currentRunPath, setCurrentRunPath, setInputText, setCursorPos, setFeed, setUsage, setScrollOffset,
|
|
103
|
+
setScreen, setRunState, setMode, setPlanMode, setBusy, setApprovalPending, getSubscriber]);
|
|
102
104
|
return { refreshSessions, refreshRun, openRun };
|
|
103
105
|
}
|
|
@@ -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))
|
|
@@ -161,9 +165,7 @@ export function useSettings({ config, setConfig, screen, pushFeed, setNotice })
|
|
|
161
165
|
return `Theme set to ${arg}.`;
|
|
162
166
|
}
|
|
163
167
|
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");
|
|
168
|
+
return formatKeysStatus(detectEnv(config.search.provider, config.llmProvider));
|
|
167
169
|
}
|
|
168
170
|
return null;
|
|
169
171
|
}, [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(() => {
|
|
@@ -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";
|