@scira/cli 0.1.0 → 0.1.2

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.
@@ -1,7 +1,7 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { stdin, stdout } from "node:process";
3
3
  import { ToolLoopAgent, isLoopFinished } from "ai";
4
- import ora from "ora";
4
+ import { Spinner } from "picospinner";
5
5
  import { getLanguageModel, requireLlmKeys } from "../providers/llm/registry.js";
6
6
  import { createResearchTools, createOneShotTools, createCodingTools } from "./tools.js";
7
7
  import { SKILL_CATALOG } from "./skills.js";
@@ -173,7 +173,7 @@ export async function createOneShotAgent(runPath, goal, config, onApprovalRequir
173
173
  * Run the research agent headlessly, streaming a compact timeline to stdout.
174
174
  */
175
175
  export async function runResearchAgent(runPath, goal, config, workspacePath) {
176
- const spinner = ora({ stream: stdout });
176
+ const spinner = new Spinner();
177
177
  const onApprovalRequired = async (toolName, description) => {
178
178
  spinner.stop();
179
179
  console.error(`\n⚠ ${toolName} needs approval`);
@@ -193,7 +193,8 @@ export async function runResearchAgent(runPath, goal, config, workspacePath) {
193
193
  const result = await bundle.agent.stream({ prompt: goal });
194
194
  for await (const part of result.fullStream) {
195
195
  if (part.type === "tool-call") {
196
- spinner.start(`${CODING_ICONS[part.toolName] ?? TOOL_ICONS[part.toolName] ?? "•"} ${part.toolName} ${summarize(part.input)}`);
196
+ spinner.setText(`${CODING_ICONS[part.toolName] ?? TOOL_ICONS[part.toolName] ?? "•"} ${part.toolName} ${summarize(part.input)}`);
197
+ spinner.start();
197
198
  }
198
199
  else if (part.type === "tool-result") {
199
200
  spinner.succeed(`${part.toolName}`);
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
2
2
  import { mkdir, writeFile, readFile } from "node:fs/promises";
3
3
  import { existsSync } from "node:fs";
4
4
  import { homedir } from "node:os";
@@ -244,6 +244,7 @@ export async function initCommand() {
244
244
  let models = [];
245
245
  try {
246
246
  const tempConfig = {
247
+ theme: "auto",
247
248
  llmProvider: llmProvider,
248
249
  model: defaultModelFor(llmProvider),
249
250
  lastModels: {},
@@ -327,6 +328,7 @@ export async function initCommand() {
327
328
  }
328
329
  // Write config file
329
330
  const config = {
331
+ theme: existingConfig?.theme ?? "auto",
330
332
  llmProvider: llmProvider,
331
333
  model,
332
334
  lastModels: { [llmProvider]: model, ...(existingConfig?.lastModels || {}) },
package/dist/cli/index.js CHANGED
@@ -1,11 +1,15 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
2
2
  import process from "node:process";
3
+ if (typeof Bun === "undefined") {
4
+ console.error("scira requires Bun. Install it from https://bun.sh and run: bun run dist/cli/index.js");
5
+ process.exit(1);
6
+ }
3
7
  import { readFileSync } from "node:fs";
4
8
  import { readFile } from "node:fs/promises";
5
9
  import { dirname, join } from "node:path";
6
10
  import { homedir } from "node:os";
7
11
  import { fileURLToPath } from "node:url";
8
- import { Command } from "commander";
12
+ import sade from "sade";
9
13
  const { version: pkgVersion } = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../package.json"), "utf8"));
10
14
  // Load keys from the global config dir (~/.scira/.env) so they work regardless
11
15
  // of where the CLI is invoked from (e.g. after pnpm link / global install).
@@ -29,15 +33,15 @@ import { createMcpBridge } from "../tools/mcp-bridge.js";
29
33
  import { saveGlobalMcpConfig } from "../config/load-config.js";
30
34
  import { runOAuthFlow } from "../tools/mcp-oauth.js";
31
35
  import { initCommand } from "./commands/init.js";
32
- const program = new Command();
33
- program
34
- .name("scira")
35
- .description("Terminal-native AI research agent.")
36
- .version(pkgVersion);
37
- program
38
- .argument("[question]", "research question or coding task")
39
- .option("--workspace <path>", "enable coding tools for this workspace directory")
40
- .action(async (question, options) => {
36
+ const prog = sade("scira");
37
+ prog
38
+ .version(pkgVersion)
39
+ .describe("Terminal-native AI research agent.");
40
+ prog
41
+ .command("*", "research question or coding task", { default: true })
42
+ .option("--workspace", "enable coding tools for this workspace directory")
43
+ .action(async (opts) => {
44
+ const question = opts._.length > 0 ? opts._.join(" ") : undefined;
41
45
  const config = await loadConfig();
42
46
  if (!question) {
43
47
  await openTuiHome(config);
@@ -46,31 +50,30 @@ program
46
50
  requireLlmKeys(config);
47
51
  const run = await createRun(question, config);
48
52
  console.log(`Run: ${run.path}`);
49
- if (options?.workspace) {
50
- console.log(`Workspace: ${options.workspace}`);
53
+ if (opts.workspace) {
54
+ console.log(`Workspace: ${opts.workspace}`);
51
55
  }
52
56
  console.log("");
53
- await runResearchAgent(run.path, question, config, options?.workspace);
57
+ await runResearchAgent(run.path, question, config, opts.workspace);
54
58
  console.log(`\nRun complete: ${run.path}`);
55
59
  });
56
- program.command("init")
57
- .description("initialize Scira with API keys and configuration")
60
+ prog
61
+ .command("init", "initialize Scira with API keys and configuration")
58
62
  .action(async () => {
59
63
  await initCommand();
60
64
  });
61
- program.command("new")
62
- .argument("<question>")
63
- .description("create a new interactive research run")
65
+ prog
66
+ .command("new <question>", "create a new interactive research run")
64
67
  .option("--no-shell", "create the run without opening the interactive shell")
65
68
  .option("--tui", "open the Ink TUI after creating the run")
66
69
  .option("--shell", "open the classic readline shell after creating the run")
67
- .action(async (question, options) => {
70
+ .action(async (question, opts) => {
68
71
  const config = await loadConfig();
69
72
  const run = await createRun(question, config);
70
- if (options.tui) {
73
+ if (opts.tui) {
71
74
  await openTui(run.path, config);
72
75
  }
73
- else if (options.shell) {
76
+ else if (opts.shell) {
74
77
  await openShell(run.path, config);
75
78
  }
76
79
  else {
@@ -78,37 +81,34 @@ program.command("new")
78
81
  console.log(`Open TUI: scira resume --tui ${run.id}`);
79
82
  }
80
83
  });
81
- program.command("resume")
82
- .argument("<run-id>")
83
- .description("resume an existing run")
84
+ prog
85
+ .command("resume <run-id>", "resume an existing run")
84
86
  .option("--shell", "resume in the classic readline shell")
85
87
  .option("--tui", "resume in the Ink TUI")
86
- .action(async (runId, options) => {
88
+ .action(async (runId, opts) => {
87
89
  const config = await loadConfig();
88
90
  const runPath = await findRun(runId, config);
89
- if (options.shell) {
91
+ if (opts.shell) {
90
92
  await openShell(runPath, config);
91
93
  }
92
94
  else {
93
95
  await openTui(runPath, config);
94
96
  }
95
97
  });
96
- program.command("list")
97
- .description("list runs")
98
+ prog
99
+ .command("list", "list runs")
98
100
  .action(async () => {
99
101
  const config = await loadConfig();
100
102
  console.table(await listRuns(config));
101
103
  });
102
- program.command("show")
103
- .argument("<run-id>")
104
- .description("show run status")
104
+ prog
105
+ .command("show <run-id>", "show run status")
105
106
  .action(async (runId) => {
106
107
  const config = await loadConfig();
107
108
  console.log(await summarizeRun(await findRun(runId, config)));
108
109
  });
109
- program.command("run")
110
- .argument("<run-id>")
111
- .description("run (or re-run) the research agent on an existing run")
110
+ prog
111
+ .command("run <run-id>", "run (or re-run) the research agent on an existing run")
112
112
  .action(async (runId) => {
113
113
  const config = await loadConfig();
114
114
  requireLlmKeys(config);
@@ -117,23 +117,21 @@ program.command("run")
117
117
  await runResearchAgent(runPath, goal, config);
118
118
  console.log(`\nRun complete: ${runPath}`);
119
119
  });
120
- program.command("verify")
121
- .argument("<run-id>")
122
- .description("show the verification report for a run's claims")
120
+ prog
121
+ .command("verify <run-id>", "show the verification report for a run's claims")
123
122
  .action(async (runId) => {
124
123
  const config = await loadConfig();
125
124
  const runPath = await findRun(runId, config);
126
125
  console.log(await verificationReport(runPath));
127
126
  });
128
- program.command("export")
129
- .argument("<run-id>")
130
- .option("--format <format>", "export format: md | json | csv", "md")
131
- .option("--output <file>", "write to file instead of stdout")
132
- .description("export run report (md, json, or csv)")
133
- .action(async (runId, options) => {
134
- const fmt = options.format.toLowerCase();
127
+ prog
128
+ .command("export <run-id>", "export run report (md, json, or csv)")
129
+ .option("--format", "export format: md | json | csv", "md")
130
+ .option("--output", "write to file instead of stdout")
131
+ .action(async (runId, opts) => {
132
+ const fmt = opts.format.toLowerCase();
135
133
  if (!["md", "json", "csv"].includes(fmt)) {
136
- throw new Error(`Unknown format "${options.format}". Supported: md, json, csv.`);
134
+ throw new Error(`Unknown format "${opts.format}". Supported: md, json, csv.`);
137
135
  }
138
136
  const config = await loadConfig();
139
137
  const runPath = await findRun(runId, config);
@@ -152,20 +150,19 @@ program.command("export")
152
150
  const bundle = { runId, goal, sources, claims };
153
151
  output = fmt === "json" ? toJson(bundle) : toCsv(bundle);
154
152
  }
155
- if (options.output) {
153
+ if (opts.output) {
156
154
  const { writeFile, mkdir } = await import("node:fs/promises");
157
155
  const { dirname } = await import("node:path");
158
- await mkdir(dirname(options.output), { recursive: true });
159
- await writeFile(options.output, output, "utf8");
160
- console.log(`Exported to ${options.output}`);
156
+ await mkdir(dirname(opts.output), { recursive: true });
157
+ await writeFile(opts.output, output, "utf8");
158
+ console.log(`Exported to ${opts.output}`);
161
159
  }
162
160
  else {
163
161
  console.log(output);
164
162
  }
165
163
  });
166
- const mcp = program.command("mcp").description("manage MCP servers in .scira/config.json");
167
- mcp.command("list")
168
- .description("list configured MCP servers")
164
+ prog
165
+ .command("mcp list", "list configured MCP servers")
169
166
  .action(async () => {
170
167
  const config = await loadConfig();
171
168
  const dt = config.mcp.chromeDevtools;
@@ -177,22 +174,19 @@ mcp.command("list")
177
174
  console.log(`${s.name} [${s.transport}]${authLabel}${oauthStatus} ${s.enabled ? "enabled" : "disabled"} ${loc}`);
178
175
  }
179
176
  });
180
- mcp.command("add")
181
- .argument("<transport>", "stdio | sse | http")
182
- .argument("<name>")
183
- .argument("<target>")
184
- .argument("[args...]")
185
- .option("--bearer <token>", "bearer token for Authorization header")
186
- .option("--header <name:value>", "custom header in name:value format")
177
+ prog
178
+ .command("mcp add <transport> <name> <target> [args...]", "add an MCP server")
179
+ .option("--bearer", "bearer token for Authorization header")
180
+ .option("--header", "custom header in name:value format")
187
181
  .option("--oauth", "use OAuth PKCE flow (requires --oauth-client-id)")
188
- .option("--oauth-client-id <id>", "OAuth client ID")
189
- .option("--oauth-client-secret <secret>", "OAuth client secret (optional for PKCE)")
190
- .option("--oauth-issuer <url>", "OAuth issuer URL for auto-discovery")
191
- .option("--oauth-auth-url <url>", "OAuth authorization endpoint URL")
192
- .option("--oauth-token-url <url>", "OAuth token endpoint URL")
193
- .option("--oauth-scopes <scopes>", "OAuth scopes (space-separated)")
194
- .description("add an MCP server")
182
+ .option("--oauth-client-id", "OAuth client ID")
183
+ .option("--oauth-client-secret", "OAuth client secret (optional for PKCE)")
184
+ .option("--oauth-issuer", "OAuth issuer URL for auto-discovery")
185
+ .option("--oauth-auth-url", "OAuth authorization endpoint URL")
186
+ .option("--oauth-token-url", "OAuth token endpoint URL")
187
+ .option("--oauth-scopes", "OAuth scopes (space-separated)")
195
188
  .action(async (transport, name, target, args, opts) => {
189
+ const restArgs = Array.isArray(args) ? args : args ? [args] : [];
196
190
  if (!["stdio", "sse", "http"].includes(transport)) {
197
191
  throw new Error("transport must be one of: stdio, sse, http");
198
192
  }
@@ -230,7 +224,7 @@ mcp.command("add")
230
224
  oauthScopes: opts.oauthScopes,
231
225
  };
232
226
  const entry = transport === "stdio"
233
- ? { ...base, transport: "stdio", command: target, args }
227
+ ? { ...base, transport: "stdio", command: target, args: restArgs }
234
228
  : { ...base, transport: transport, url: target, args: [] };
235
229
  const nextMcp = { ...config.mcp, servers: [...config.mcp.servers, entry] };
236
230
  await saveGlobalMcpConfig(nextMcp);
@@ -239,9 +233,8 @@ mcp.command("add")
239
233
  console.log(`Run: scira mcp oauth ${name} to authenticate`);
240
234
  }
241
235
  });
242
- mcp.command("oauth")
243
- .argument("<name>", "name of the OAuth MCP server to authenticate")
244
- .description("run OAuth PKCE flow for an MCP server")
236
+ prog
237
+ .command("mcp oauth <name>", "run OAuth PKCE flow for an MCP server")
245
238
  .action(async (name) => {
246
239
  const config = await loadConfig();
247
240
  const srv = config.mcp.servers.find((s) => s.name === name);
@@ -251,9 +244,8 @@ mcp.command("oauth")
251
244
  throw new Error(`"${name}" is not an OAuth server (authType: ${srv.authType})`);
252
245
  await runOAuthFlow(srv, config);
253
246
  });
254
- mcp.command("enable")
255
- .argument("<name>")
256
- .description("enable an MCP server")
247
+ prog
248
+ .command("mcp enable <name>", "enable an MCP server")
257
249
  .action(async (name) => {
258
250
  const config = await loadConfig();
259
251
  const nextMcp = name === "chromeDevtools" || name === "devtools"
@@ -262,9 +254,8 @@ mcp.command("enable")
262
254
  await saveGlobalMcpConfig(nextMcp);
263
255
  console.log(`Enabled MCP server "${name}"`);
264
256
  });
265
- mcp.command("disable")
266
- .argument("<name>")
267
- .description("disable an MCP server")
257
+ prog
258
+ .command("mcp disable <name>", "disable an MCP server")
268
259
  .action(async (name) => {
269
260
  const config = await loadConfig();
270
261
  const nextMcp = name === "chromeDevtools" || name === "devtools"
@@ -273,9 +264,8 @@ mcp.command("disable")
273
264
  await saveGlobalMcpConfig(nextMcp);
274
265
  console.log(`Disabled MCP server "${name}"`);
275
266
  });
276
- mcp.command("remove")
277
- .argument("<name>")
278
- .description("remove an MCP server from config")
267
+ prog
268
+ .command("mcp remove <name>", "remove an MCP server from config")
279
269
  .action(async (name) => {
280
270
  const config = await loadConfig();
281
271
  if (!config.mcp.servers.some((s) => s.name === name)) {
@@ -285,63 +275,63 @@ mcp.command("remove")
285
275
  await saveGlobalMcpConfig(nextMcp);
286
276
  console.log(`Removed MCP server "${name}" from ~/.scira/config.json`);
287
277
  });
288
- program.command("watch")
289
- .argument("<goal>", "research goal to monitor, e.g. \"AI search market\"")
278
+ prog
279
+ .command("watch <goal>", "monitor a topic by running research on a schedule and diffing reports")
290
280
  .option("--daily", "run once per day (default)")
291
281
  .option("--hourly", "run once per hour")
292
282
  .option("--weekly", "run once per week")
293
- .option("--interval <ms>", "custom interval in milliseconds")
294
- .option("--runs <n>", "stop after N runs (default: run forever)", (v) => parseInt(v, 10))
295
- .description("monitor a topic by running research on a schedule and diffing reports")
296
- .action(async (goal, options) => {
283
+ .option("--interval", "custom interval in milliseconds")
284
+ .option("--runs", "stop after N runs (default: run forever)")
285
+ .action(async (goal, opts) => {
297
286
  const config = await loadConfig();
298
287
  const INTERVALS = {
299
288
  hourly: 60 * 60 * 1000,
300
289
  daily: 24 * 60 * 60 * 1000,
301
290
  weekly: 7 * 24 * 60 * 60 * 1000,
302
291
  };
303
- const intervalMs = options.interval
304
- ? parseInt(options.interval, 10)
305
- : options.hourly ? INTERVALS.hourly
306
- : options.weekly ? INTERVALS.weekly
292
+ const intervalMs = opts.interval
293
+ ? parseInt(String(opts.interval), 10)
294
+ : opts.hourly ? INTERVALS.hourly
295
+ : opts.weekly ? INTERVALS.weekly
307
296
  : INTERVALS.daily;
308
297
  if (Number.isNaN(intervalMs) || intervalMs < 1000) {
309
298
  throw new Error("Interval must be at least 1000 ms.");
310
299
  }
300
+ const maxRuns = opts.runs != null ? parseInt(String(opts.runs), 10) : undefined;
311
301
  const { watchLoop } = await import("../watch/runner.js");
312
302
  const controller = new AbortController();
313
303
  process.on("SIGINT", () => { console.log("\nStopping watch…"); controller.abort(); });
314
304
  process.on("SIGTERM", () => { controller.abort(); });
315
305
  console.log(`Watching: "${goal}"`);
316
- console.log(`Interval: ${intervalMs / 1000}s${options.runs ? ` · max ${options.runs} runs` : ""}`);
306
+ console.log(`Interval: ${intervalMs / 1000}s${maxRuns ? ` · max ${maxRuns} runs` : ""}`);
317
307
  console.log("Press Ctrl-C to stop.\n");
318
308
  await watchLoop({
319
- goal, intervalMs, maxRuns: options.runs, config,
309
+ goal, intervalMs, maxRuns, config,
320
310
  onRunStart: (runPath, tick) => { console.log(`\n[tick ${tick + 1}] Starting run → ${runPath}`); },
321
311
  onRunComplete: (runPath, diffText, tick) => { console.log(`[tick ${tick + 1}] Done. Diff:\n${diffText}`); },
322
312
  onError: (err, tick) => { console.error(`[tick ${tick + 1}] Error: ${err.message}`); },
323
313
  }, controller.signal);
324
314
  console.log("Watch finished.");
325
315
  });
326
- program.command("models")
327
- .option("--provider <provider>", "gateway only: filter by model prefix such as anthropic, openai, or google")
328
- .description("list models for the configured LLM provider")
329
- .action(async (options) => {
316
+ prog
317
+ .command("models", "list models for the configured LLM provider")
318
+ .option("--provider", "gateway only: filter by model prefix such as anthropic, openai, or google")
319
+ .action(async (opts) => {
330
320
  const config = await loadConfig();
331
- const models = config.llmProvider === "gateway" && options.provider
332
- ? await listGatewayModels(options.provider)
321
+ const models = config.llmProvider === "gateway" && opts.provider
322
+ ? await listGatewayModels(opts.provider)
333
323
  : await listModels(config);
334
324
  for (const model of models) {
335
325
  console.log(model.id);
336
326
  }
337
327
  });
338
- program.command("config")
339
- .description("print resolved config")
328
+ prog
329
+ .command("config", "print resolved config")
340
330
  .action(async () => {
341
331
  console.log(JSON.stringify(await loadConfig(), null, 2));
342
332
  });
343
- program.command("doctor")
344
- .description("check local setup")
333
+ prog
334
+ .command("doctor", "check local setup")
345
335
  .action(async () => {
346
336
  const config = await loadConfig();
347
337
  const nodeCheck = checkNodeVersion(20);
@@ -437,7 +427,10 @@ async function commandResolves(command) {
437
427
  }
438
428
  }
439
429
  try {
440
- await program.parseAsync(process.argv);
430
+ const parsed = prog.parse(process.argv, { lazy: true });
431
+ if (parsed?.handler) {
432
+ await parsed.handler(...parsed.args);
433
+ }
441
434
  }
442
435
  catch (error) {
443
436
  console.error(error instanceof Error ? error.message : String(error));
@@ -1,6 +1,8 @@
1
1
  import { z } from "zod";
2
2
  export const ApprovalModeSchema = z.enum(["manual", "suggest", "auto"]);
3
+ export const ThemeSchema = z.enum(["dark", "light", "auto"]).default("auto");
3
4
  export const SciraConfigSchema = z.object({
5
+ theme: ThemeSchema,
4
6
  llmProvider: z.enum(["gateway", "xai", "workers-ai", "huggingface"]).default("gateway"),
5
7
  model: z.string().default("deepseek/deepseek-v4-flash"),
6
8
  // last selected model per LLM provider, restored when switching back
@@ -6,8 +6,10 @@ import { CWD_DISPLAY, wrapText, wrapInputWithCursor, loadInputHistory, saveInput
6
6
  import { deleteRun } from "../../storage/run-store.js";
7
7
  import { useMountEffect, TipCycler, AnimationTick, MouseTracker } from "./components/effects.js";
8
8
  import { useFeedLines, computeGroups } from "./hooks/use-feed-lines.js";
9
+ import { feedToolItemId, isToolItemCollapsed } from "./lib/tool-result.js";
9
10
  import { useAgentTurn } from "./hooks/use-agent-turn.js";
10
- import { TopBar, InputBar, HintLine, CommandMenuBox, HelpBox, ApprovalBox, MenuDialog, McpDialog } from "./components/overlays.js";
11
+ import { TopBar, InputBar, HintLine, CommandMenuBox, HelpBox, ApprovalBox, MenuDialog, McpDialog, buildMcpDialogRows } from "./components/overlays.js";
12
+ import { useMcpActions } from "./hooks/use-mcp-actions.js";
11
13
  import { useKeyboard } from "./hooks/use-keyboard.js";
12
14
  import { HomeScreen } from "./components/home-screen.js";
13
15
  import { useFeed } from "./hooks/use-feed.js";
@@ -16,6 +18,7 @@ import { useSuggestions } from "./hooks/use-suggestions.js";
16
18
  import { useSubmit } from "./hooks/use-submit.js";
17
19
  import { useSession } from "./hooks/use-session.js";
18
20
  import { useMouse } from "./hooks/use-mouse.js";
21
+ import { ThemeProvider, useTheme } from "./hooks/use-theme.js";
19
22
  export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
20
23
  const { exit } = useApp();
21
24
  const { stdout } = useStdout();
@@ -36,6 +39,7 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
36
39
  const [notice, setNotice] = useState("");
37
40
  const [pendingRerun, setPendingRerun] = useState(false);
38
41
  const [mcpOpen, setMcpOpen] = useState(false);
42
+ const [mcpRowIdx, setMcpRowIdx] = useState(0);
39
43
  const [sessions, setSessions] = useState([]);
40
44
  const [selectedIdx, setSelectedIdx] = useState(0);
41
45
  const [sessionsModalOpen, setSessionsModalOpen] = useState(false);
@@ -68,7 +72,7 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
68
72
  const [historyIndex, setHistoryIndex] = useState(-1);
69
73
  const [heroHidden, setHeroHidden] = useState(false);
70
74
  const [busy, setBusy] = useState(false);
71
- const [blink, setBlink] = useState(true);
75
+ const [, setBlink] = useState(true);
72
76
  const [frame, setFrame] = useState(0);
73
77
  const [reasoningTick, setReasoningTick] = useState(0);
74
78
  const [commandMenuIndex, setCommandMenuIndex] = useState(0);
@@ -77,6 +81,21 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
77
81
  const [collapsedGroups, setCollapsedGroups] = useState(new Set());
78
82
  const [focusedGroupKey, setFocusedGroupKey] = useState(null);
79
83
  const [pendingCollapse, setPendingCollapse] = useState(new Set());
84
+ const [itemExpandState, setItemExpandState] = useState(new Map());
85
+ React.useEffect(() => {
86
+ setItemExpandState(new Map());
87
+ }, [currentRunPath]);
88
+ const toggleToolItem = useCallback((id) => {
89
+ setItemExpandState((prev) => {
90
+ const tool = feed.find((it, i) => it.kind === "tool" && feedToolItemId(i, it.toolCallId) === id);
91
+ if (tool?.kind !== "tool")
92
+ return prev;
93
+ const next = new Map(prev);
94
+ const collapsed = isToolItemCollapsed(id, tool.name, tool.status, prev);
95
+ next.set(id, collapsed);
96
+ return next;
97
+ });
98
+ }, [feed]);
80
99
  const doneGroupKeys = useMemo(() => {
81
100
  const { groups } = computeGroups(feed);
82
101
  return [...groups.entries()].filter(([, g]) => !g.active).map(([k]) => k).sort((a, b) => a - b);
@@ -96,18 +115,21 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
96
115
  }));
97
116
  });
98
117
  }, [doneGroupKeys, feed]);
99
- const toggleFocusedGroup = useCallback(() => {
100
- if (focusedGroupKey === null)
101
- return;
118
+ const toggleGroup = useCallback((groupKey) => {
102
119
  setCollapsedGroups((prev) => {
103
120
  const next = new Set(prev);
104
- if (next.has(focusedGroupKey))
105
- next.delete(focusedGroupKey);
121
+ if (next.has(groupKey))
122
+ next.delete(groupKey);
106
123
  else
107
- next.add(focusedGroupKey);
124
+ next.add(groupKey);
108
125
  return next;
109
126
  });
110
- }, [focusedGroupKey]);
127
+ }, []);
128
+ const toggleFocusedGroup = useCallback(() => {
129
+ if (focusedGroupKey === null)
130
+ return;
131
+ toggleGroup(focusedGroupKey);
132
+ }, [focusedGroupKey, toggleGroup]);
111
133
  // Auto-collapse groups when they become inactive if they're in pendingCollapse
112
134
  React.useEffect(() => {
113
135
  if (pendingCollapse.size === 0)
@@ -212,6 +234,48 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
212
234
  const { menu, setMenu, modelName, openMenu, applyMenuSelection, handleSettings } = useSettings({
213
235
  config, setConfig, screen, pushFeed, setNotice,
214
236
  });
237
+ const notifyMcp = useCallback((message) => {
238
+ if (screen === "chat")
239
+ pushFeed({ kind: "status", text: message });
240
+ else
241
+ setNotice(message);
242
+ }, [screen, pushFeed]);
243
+ const { toggleMcp, removeMcp } = useMcpActions(config, setConfig, notifyMcp);
244
+ const mcpRows = useMemo(() => buildMcpDialogRows(config), [config]);
245
+ const mcpRowCount = mcpRows.length;
246
+ const toggleMcpRow = useCallback((idx) => {
247
+ const row = mcpRows[idx];
248
+ if (!row)
249
+ return;
250
+ void toggleMcp(row.key === "chromeDevtools"
251
+ ? { kind: "devtools" }
252
+ : { kind: "server", name: row.name });
253
+ }, [mcpRows, toggleMcp]);
254
+ const removeMcpRow = useCallback((idx) => {
255
+ const row = mcpRows[idx];
256
+ if (!row?.removable)
257
+ return;
258
+ void removeMcp(row.name);
259
+ setMcpRowIdx((i) => Math.max(0, Math.min(i, mcpRowCount - 2)));
260
+ }, [mcpRows, mcpRowCount, removeMcp]);
261
+ const handleMcpToggle = useCallback((row) => {
262
+ void toggleMcp(row.key === "chromeDevtools"
263
+ ? { kind: "devtools" }
264
+ : { kind: "server", name: row.name });
265
+ }, [toggleMcp]);
266
+ const handleMcpRemove = useCallback((row) => {
267
+ if (!row.removable)
268
+ return;
269
+ void removeMcp(row.name);
270
+ setMcpRowIdx((i) => Math.max(0, Math.min(i, mcpRowCount - 2)));
271
+ }, [mcpRowCount, removeMcp]);
272
+ React.useEffect(() => {
273
+ if (mcpOpen)
274
+ setMcpRowIdx(0);
275
+ }, [mcpOpen]);
276
+ React.useEffect(() => {
277
+ setMcpRowIdx((i) => Math.min(i, Math.max(0, mcpRowCount - 1)));
278
+ }, [mcpRowCount]);
215
279
  const { runTurn } = useAgentTurn({
216
280
  config, currentRunPath, queuedPromptRef, fullModeRef, conversationRef, turnsRef, feedRef,
217
281
  setBusy, setScrollOffset, refreshRun, recordUsage, setMode, getSubscriber,
@@ -246,12 +310,29 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
246
310
  const menuHeight = commandMenuHeight + helpHeight + approvalHeight;
247
311
  const feedRows = Math.max(3, rows - 6 - inputLines.length - menuHeight);
248
312
  const hasRunningTool = feed.some((it) => it.kind === "tool" && it.status === "running");
249
- const feedLines = useFeedLines(feed, innerWidth, reasoningTick, hasRunningTool ? frame : 0, collapsedGroups, focusedGroupKey);
313
+ const { lines: feedLines, toggleAtLine, groupToggleAtLine } = useFeedLines(feed, innerWidth, reasoningTick, hasRunningTool ? frame : 0, collapsedGroups, focusedGroupKey, itemExpandState, hoveredIdx, config);
250
314
  const contentRows = Math.max(1, feedRows);
251
315
  const maxScrollOffset = Math.max(0, feedLines.length - contentRows);
252
316
  wheelStateRef.current = { screen, maxScrollOffset };
253
317
  const clampedOffset = Math.min(scrollOffset, maxScrollOffset);
254
318
  const startIdx = Math.max(0, feedLines.length - contentRows - clampedOffset);
319
+ const feedStartRow = 3;
320
+ if (screen === "chat") {
321
+ const clickMap = new Map();
322
+ const hoverMap = new Map();
323
+ const registerLine = (lineIdx, onClick) => {
324
+ const vis = lineIdx - startIdx;
325
+ if (vis < 0 || vis >= contentRows)
326
+ return;
327
+ const row = feedStartRow + vis;
328
+ clickMap.set(row, onClick);
329
+ hoverMap.set(row, lineIdx);
330
+ };
331
+ toggleAtLine.forEach((id, lineIdx) => registerLine(lineIdx, () => toggleToolItem(id)));
332
+ groupToggleAtLine.forEach((groupKey, lineIdx) => registerLine(lineIdx, () => toggleGroup(groupKey)));
333
+ clickMapRef.current = clickMap;
334
+ hoverMapRef.current = hoverMap;
335
+ }
255
336
  const visibleLines = feedLines.slice(startIdx, startIdx + contentRows);
256
337
  const scrollLabel = clampedOffset > 0
257
338
  ? (startIdx > 0 ? `↑ ${startIdx} · ↓ ${clampedOffset} · wheel/⇞⇟` : `top · ↓ ${clampedOffset} · wheel/⇞⇟`)
@@ -261,14 +342,22 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
261
342
  setNotice,
262
343
  exit,
263
344
  input: { text: inputText, setText: setInputText, cursorPos, setCursorPos, history: inputHistory, historyIndex, setHistoryIndex },
264
- dialogs: { approvalPending, setApprovalPending, menu, setMenu, applyMenuSelection, helpOpen, setHelpOpen, mcpOpen, setMcpOpen },
345
+ dialogs: {
346
+ approvalPending, setApprovalPending, menu, setMenu, applyMenuSelection, helpOpen, setHelpOpen,
347
+ mcpOpen, setMcpOpen, mcpRowIdx, setMcpRowIdx, mcpRowCount, toggleMcpRow, removeMcpRow,
348
+ },
265
349
  suggestions: { activeSuggestions, activeSuggestionKind, commandMenuIndex, setCommandMenuIndex, acceptActiveSuggestion },
266
350
  chat: { setScrollOffset, contentRows, maxScrollOffset, pendingRerun, setPendingRerun, busy, stopTurn, submitChat, toggleAllGroups, toggleFocusedGroup, focusPrevGroup, focusNextGroup, unfocusGroup, hasFocusedGroup: focusedGroupKey !== null },
267
351
  home: { sessionsModalOpen, setSessionsModalOpen, sessionsModalIdx, setSessionsModalIdx, sessions, deleteSession, selectedIdx, setSelectedIdx, setHeroHidden, openRun, submitHome },
268
352
  });
269
353
  const activeUsage = usage[config.model];
354
+ const themed = (node) => (_jsx(ThemeProvider, { config: config, stdin: stdin, stdout: stdout, children: node }));
270
355
  if (screen === "home") {
271
- return (_jsxs(Box, { flexDirection: "column", width: cols, height: rows, paddingX: 2, children: [_jsx(TipCycler, { setTipIndex: setTipIndex }), !sessionsModalOpen && 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 }), _jsx(HomeScreen, { cols: cols, rows: rows, sessions: sessions, selectedIdx: selectedIdx, hoveredIdx: hoveredIdx, heroHidden: heroHidden, notice: notice, tipIndex: tipIndex, commandMenuHeight: commandMenuHeight, sessionsModalOpen: sessionsModalOpen, sessionsModalIdx: sessionsModalIdx, inputText: inputText, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef, setSelectedIdx: setSelectedIdx, setSessionsModalOpen: setSessionsModalOpen, setSessionsModalIdx: setSessionsModalIdx, setNotice: setNotice, openRun: openRun, submitHome: submitHome, exit: exit }), _jsxs(Box, { flexDirection: "column", backgroundColor: "#141414", paddingBottom: 1, children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: !!approvalPending, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName }), _jsx(HintLine, { screen: screen, busy: busy })] }), _jsx(MenuDialog, { menu: menu, cols: cols, rows: rows }), _jsx(McpDialog, { open: mcpOpen, config: config, cols: cols, rows: rows })] }));
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 })] }));
272
357
  }
273
- return (_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 }), _jsx(Box, { flexDirection: "column", flexGrow: 1, paddingTop: 1, overflow: "hidden", children: visibleLines }), _jsxs(Box, { flexDirection: "column", backgroundColor: "#141414", paddingBottom: 1, children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions }), _jsx(HelpBox, { open: helpOpen, innerWidth: innerWidth }), approvalPending && _jsx(ApprovalBox, { toolName: approvalPending.toolName, description: approvalPending.description, innerWidth: innerWidth }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: !!approvalPending, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName }), _jsx(HintLine, { screen: screen, busy: busy, scrollLabel: scrollLabel, hasDoneGroups: doneGroupKeys.length > 0, hasFocusedGroup: focusedGroupKey !== null })] }), _jsx(MenuDialog, { menu: menu, cols: cols, rows: rows }), _jsx(McpDialog, { open: mcpOpen, config: config, cols: cols, rows: rows })] }));
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 })] }));
359
+ }
360
+ function ChatInputChrome({ children }) {
361
+ const theme = useTheme();
362
+ return (_jsx(Box, { flexDirection: "column", ...(theme.background ? { backgroundColor: theme.background } : {}), paddingBottom: 1, children: children }));
274
363
  }