@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.
Files changed (38) hide show
  1. package/README.md +54 -10
  2. package/dist/agent/background-tasks.js +173 -0
  3. package/dist/agent/research-agent.js +95 -38
  4. package/dist/agent/todos.js +140 -0
  5. package/dist/agent/tools.js +146 -143
  6. package/dist/agent/tools.test.js +33 -0
  7. package/dist/agent/workspace.js +85 -0
  8. package/dist/cli/commands/init.js +51 -39
  9. package/dist/cli/index.js +30 -14
  10. package/dist/config/env-guide.js +151 -0
  11. package/dist/config/env-guide.test.js +18 -0
  12. package/dist/config/env-store.js +53 -0
  13. package/dist/config/env-store.test.js +60 -0
  14. package/dist/tools/agent-tools.js +621 -0
  15. package/dist/tools/background-tasks.js +261 -0
  16. package/dist/tools/bash-policy.test.js +38 -0
  17. package/dist/tools/file-tools.js +6 -1
  18. package/dist/tools/search-web.js +24 -6
  19. package/dist/tools/search-web.test.js +24 -0
  20. package/dist/tools/todos.js +140 -0
  21. package/dist/tools/workspace.js +91 -0
  22. package/dist/tools/workspace.test.js +75 -0
  23. package/dist/tools/x-search.js +142 -0
  24. package/dist/ui/ink/SciraApp.js +11 -8
  25. package/dist/ui/ink/components/overlays.js +4 -4
  26. package/dist/ui/ink/constants.js +11 -3
  27. package/dist/ui/ink/hooks/use-agent-turn.js +24 -5
  28. package/dist/ui/ink/hooks/use-keyboard.js +3 -0
  29. package/dist/ui/ink/hooks/use-session.js +5 -3
  30. package/dist/ui/ink/hooks/use-settings.js +10 -8
  31. package/dist/ui/ink/hooks/use-submit.js +13 -2
  32. package/dist/ui/ink/hooks/use-theme.js +1 -1
  33. package/dist/ui/ink/lib/tool-result.js +72 -5
  34. package/dist/ui/ink/lib/utils.js +40 -3
  35. package/dist/ui/ink/theme-context.js +29 -26
  36. package/dist/ui/ink/theme.js +36 -9
  37. package/dist/ui/ink/theme.test.js +32 -5
  38. 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
+ }
@@ -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, stdin: stdin, stdout: stdout, children: node }));
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(Text, { color: inputColor, wrap: "truncate", children: showCursor && i === cursorLine ? (_jsxs(_Fragment, { children: [line.slice(0, cursorCol), _jsx(Text, { backgroundColor: theme.cursorBackground, color: theme.cursorForeground, children: line[cursorCol] ?? " " }), line.slice(cursorCol + 1)] })) : (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: " ─╯" })] })] }));
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.text, 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) => {
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));
@@ -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 which required API keys are set.",
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 resolved = config.theme === "auto" ? detectTerminalTheme() : config.theme;
151
+ const terminal = detectTerminalTheme();
152
+ const resolved = resolveRenderingAppearance(config.theme, terminal);
151
153
  const mode = config.theme === "auto"
152
- ? "follows terminal picker"
153
- : "locked — run /theme auto to sync with picker";
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";