@scira/cli 0.1.2 → 0.1.4

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