@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
@@ -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
+ }
@@ -8,6 +8,7 @@ export const SciraConfigSchema = z.object({
8
8
  // last selected model per LLM provider, restored when switching back
9
9
  lastModels: z.record(z.string(), z.string()).default({}),
10
10
  approvalMode: ApprovalModeSchema.default("suggest"),
11
+ alwaysAllowLinks: z.boolean().default(false),
11
12
  runDirectory: z.string().default(".scira/runs"),
12
13
  maxSources: z.number().int().min(1).max(100).default(20),
13
14
  citationPolicy: z.enum(["strict", "balanced"]).default("strict"),
@@ -51,6 +51,7 @@ describe("SciraConfigSchema", () => {
51
51
  const config = SciraConfigSchema.parse({});
52
52
  expect(config.llmProvider).toBe("gateway");
53
53
  expect(config.approvalMode).toBe("suggest");
54
+ expect(config.alwaysAllowLinks).toBe(false);
54
55
  expect(config.runDirectory).toBe(".scira/runs");
55
56
  expect(config.maxSources).toBe(20);
56
57
  });
@@ -2,13 +2,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useCallback, useMemo, useRef, useState } from "react";
3
3
  import { Box, useApp, useStdout, useStdin } from "ink";
4
4
  import { CHAT_COMMANDS, MENU_VISIBLE } from "./constants.js";
5
- import { CWD_DISPLAY, wrapText, wrapInputWithCursor, loadInputHistory, saveInputHistory } from "./lib/utils.js";
5
+ import { CWD_DISPLAY, wrapText, wrapInputWithCursor, loadInputHistory, saveInputHistory, linkAtMouseColumn, openExternalUrl } from "./lib/utils.js";
6
6
  import { deleteRun } from "../../storage/run-store.js";
7
+ import { saveGlobalConfig } from "../../config/load-config.js";
7
8
  import { useMountEffect, TipCycler, AnimationTick, MouseTracker } from "./components/effects.js";
8
9
  import { useFeedLines, computeGroups } from "./hooks/use-feed-lines.js";
9
10
  import { feedToolItemId, isToolItemCollapsed } from "./lib/tool-result.js";
10
11
  import { useAgentTurn } from "./hooks/use-agent-turn.js";
11
- import { TopBar, InputBar, HintLine, CommandMenuBox, HelpBox, ApprovalBox, MenuDialog, McpDialog, buildMcpDialogRows } from "./components/overlays.js";
12
+ import { TopBar, InputBar, HintLine, CommandMenuBox, HelpBox, ApprovalBox, LinkOpenBox, MenuDialog, McpDialog, buildMcpDialogRows } from "./components/overlays.js";
12
13
  import { useMcpActions } from "./hooks/use-mcp-actions.js";
13
14
  import { useKeyboard } from "./hooks/use-keyboard.js";
14
15
  import { HomeScreen } from "./components/home-screen.js";
@@ -52,6 +53,9 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
52
53
  const fullModeRef = useRef(false);
53
54
  const [fullMode, setFullModeState] = useState(false);
54
55
  const setMode = useCallback((full) => { fullModeRef.current = full; setFullModeState(full); }, []);
56
+ const planModeRef = useRef(false);
57
+ const [planMode, setPlanModeState] = useState(false);
58
+ const setPlanMode = useCallback((active) => { planModeRef.current = active; setPlanModeState(active); }, []);
55
59
  const [usage, setUsage] = useState({});
56
60
  const turnsRef = useRef([]);
57
61
  const recordUsage = useCallback((model, u) => {
@@ -66,6 +70,27 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
66
70
  });
67
71
  }, []);
68
72
  const [approvalPending, setApprovalPending] = useState(null);
73
+ const [linkPending, setLinkPending] = useState(null);
74
+ const confirmLinkOpen = useCallback(() => {
75
+ setLinkPending((pending) => {
76
+ if (pending)
77
+ void openExternalUrl(pending.url);
78
+ return null;
79
+ });
80
+ }, []);
81
+ const enableAlwaysAllowLinks = useCallback(() => {
82
+ setLinkPending((pending) => {
83
+ if (pending) {
84
+ void (async () => {
85
+ const next = { ...config, alwaysAllowLinks: true };
86
+ setConfig(next);
87
+ await saveGlobalConfig(next);
88
+ void openExternalUrl(pending.url);
89
+ })();
90
+ }
91
+ return null;
92
+ });
93
+ }, [config, setConfig]);
69
94
  const [inputText, setInputText] = useState("");
70
95
  const [cursorPos, setCursorPos] = useState(0);
71
96
  const [inputHistory, setInputHistory] = useState([]);
@@ -201,15 +226,22 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
201
226
  }), [pushFeed, appendText, appendReasoning, finishReasoning, markToolDone, setBusy, setApprovalPending, setMode]);
202
227
  const runTurnRef = useRef(async () => { });
203
228
  const { refreshSessions, refreshRun, openRun: openRunBase } = useSession({
204
- config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef, runTurnRef,
229
+ config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef,
205
230
  setSessions, setRunState, setCurrentRunPath, setInputText, setCursorPos,
206
- setFeed, setUsage, setScrollOffset, setScreen, setMode,
231
+ setFeed, setUsage, setScrollOffset, setScreen, setMode, setPlanMode,
207
232
  setBusy, setApprovalPending, getSubscriber,
208
233
  });
234
+ const { runTurn } = useAgentTurn({
235
+ config, currentRunPath, queuedPromptRef, fullModeRef, planModeRef, conversationRef, turnsRef, feedRef,
236
+ setBusy, setScrollOffset, refreshRun, recordUsage, setMode, setPlanMode, getSubscriber,
237
+ });
238
+ runTurnRef.current = runTurn;
209
239
  const openRun = useCallback(async (runPath, initialQuestion) => {
210
240
  setPendingRerun(false);
211
- await openRunBase(runPath, initialQuestion);
212
- }, [openRunBase, setPendingRerun]);
241
+ const start = await openRunBase(runPath, initialQuestion);
242
+ if (start)
243
+ await runTurn(start.startPrompt, runPath);
244
+ }, [openRunBase, runTurn, setPendingRerun]);
213
245
  useMountEffect(() => {
214
246
  if (!initialRunPath)
215
247
  void refreshSessions();
@@ -276,17 +308,16 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
276
308
  React.useEffect(() => {
277
309
  setMcpRowIdx((i) => Math.min(i, Math.max(0, mcpRowCount - 1)));
278
310
  }, [mcpRowCount]);
279
- const { runTurn } = useAgentTurn({
280
- config, currentRunPath, queuedPromptRef, fullModeRef, conversationRef, turnsRef, feedRef,
281
- setBusy, setScrollOffset, refreshRun, recordUsage, setMode, getSubscriber,
311
+ useMountEffect(() => {
312
+ if (initialRunPath)
313
+ void openRun(initialRunPath);
282
314
  });
283
- runTurnRef.current = runTurn;
284
315
  const { submitHome, submitChat, stopTurn } = useSubmit({
285
316
  state: { config, currentRunPath, sessions, selectedIdx, busy, usage, pendingRerun },
286
- refs: { queuedPromptRef, conversationRef, feedRef },
317
+ refs: { queuedPromptRef, fullModeRef, planModeRef, conversationRef, feedRef },
287
318
  setters: {
288
319
  setApprovalPending, setInputText, setCursorPos, setInputHistory, setHistoryIndex, setHelpOpen,
289
- setNotice, setBusy, setScreen, setFeed, setRunState, setPendingRerun, setMode, setConfig, setMcpOpen,
320
+ setNotice, setBusy, setScreen, setFeed, setRunState, setPendingRerun, setMode, setPlanMode, setConfig, setMcpOpen,
290
321
  setHeroHidden,
291
322
  },
292
323
  actions: { pushFeed, refreshSessions, openRun, openMenu, handleSettings, runTurn, exit },
@@ -297,8 +328,9 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
297
328
  const innerWidth = Math.max(20, cols - 4);
298
329
  const boxWidth = Math.max(20, cols - 4);
299
330
  const textWidth = Math.max(1, boxWidth - 6);
300
- const rawInputText = approvalPending ? "waiting for approval\u2026" : inputText;
301
- const showCursor = !busy && !approvalPending;
331
+ const inputBlocked = !!approvalPending || !!linkPending;
332
+ const rawInputText = approvalPending ? "waiting for approval\u2026" : linkPending ? "open link? a/y/n" : inputText;
333
+ const showCursor = !busy && !inputBlocked;
302
334
  const caret = Math.max(0, Math.min(cursorPos, inputText.length));
303
335
  const { lines: inputLines, cursorLine, cursorCol } = wrapInputWithCursor(rawInputText, textWidth, showCursor ? caret : -1);
304
336
  const commandMenuHeight = activeSuggestions.length > 0 ? Math.min(MENU_VISIBLE, activeSuggestions.length) + 3 : 0;
@@ -307,15 +339,20 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
307
339
  ? Math.min(5, wrapText(approvalPending.description, Math.max(10, innerWidth - 4)).length)
308
340
  : 0;
309
341
  const approvalHeight = approvalPending ? approvalPreviewLines + 5 : 0;
310
- const menuHeight = commandMenuHeight + helpHeight + approvalHeight;
342
+ const linkPreviewLines = linkPending
343
+ ? Math.min(4, wrapText(linkPending.url, Math.max(10, innerWidth - 4)).length)
344
+ : 0;
345
+ const linkHeight = linkPending ? linkPreviewLines + 5 : 0;
346
+ const menuHeight = commandMenuHeight + helpHeight + approvalHeight + linkHeight;
311
347
  const feedRows = Math.max(3, rows - 6 - inputLines.length - menuHeight);
312
348
  const hasRunningTool = feed.some((it) => it.kind === "tool" && it.status === "running");
313
- const { lines: feedLines, toggleAtLine, groupToggleAtLine } = useFeedLines(feed, innerWidth, reasoningTick, hasRunningTool ? frame : 0, collapsedGroups, focusedGroupKey, itemExpandState, hoveredIdx, config);
349
+ const { lines: feedLines, toggleAtLine, groupToggleAtLine, linkAtLine } = useFeedLines(feed, innerWidth, reasoningTick, hasRunningTool ? frame : 0, collapsedGroups, focusedGroupKey, itemExpandState, hoveredIdx, config);
314
350
  const contentRows = Math.max(1, feedRows);
315
351
  const maxScrollOffset = Math.max(0, feedLines.length - contentRows);
316
352
  wheelStateRef.current = { screen, maxScrollOffset };
317
353
  const clampedOffset = Math.min(scrollOffset, maxScrollOffset);
318
354
  const startIdx = Math.max(0, feedLines.length - contentRows - clampedOffset);
355
+ const hasLinkHover = hoveredIdx !== null && (linkAtLine.get(hoveredIdx)?.length ?? 0) > 0;
319
356
  const feedStartRow = 3;
320
357
  if (screen === "chat") {
321
358
  const clickMap = new Map();
@@ -325,11 +362,25 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
325
362
  if (vis < 0 || vis >= contentRows)
326
363
  return;
327
364
  const row = feedStartRow + vis;
328
- clickMap.set(row, onClick);
365
+ const prev = clickMap.get(row);
366
+ clickMap.set(row, prev ? (x) => { prev(x); onClick(x); } : onClick);
329
367
  hoverMap.set(row, lineIdx);
330
368
  };
331
369
  toggleAtLine.forEach((id, lineIdx) => registerLine(lineIdx, () => toggleToolItem(id)));
332
370
  groupToggleAtLine.forEach((groupKey, lineIdx) => registerLine(lineIdx, () => toggleGroup(groupKey)));
371
+ linkAtLine.forEach((links, lineIdx) => {
372
+ registerLine(lineIdx, (x) => {
373
+ if (approvalPending)
374
+ return;
375
+ const url = linkAtMouseColumn(links, x);
376
+ if (!url)
377
+ return;
378
+ if (config.alwaysAllowLinks)
379
+ void openExternalUrl(url);
380
+ else
381
+ setLinkPending({ url });
382
+ });
383
+ });
333
384
  clickMapRef.current = clickMap;
334
385
  hoverMapRef.current = hoverMap;
335
386
  }
@@ -343,7 +394,9 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
343
394
  exit,
344
395
  input: { text: inputText, setText: setInputText, cursorPos, setCursorPos, history: inputHistory, historyIndex, setHistoryIndex },
345
396
  dialogs: {
346
- approvalPending, setApprovalPending, menu, setMenu, applyMenuSelection, helpOpen, setHelpOpen,
397
+ approvalPending, setApprovalPending, linkPending, setLinkPending,
398
+ onConfirmLink: confirmLinkOpen, onAlwaysAllowLinks: enableAlwaysAllowLinks,
399
+ menu, setMenu, applyMenuSelection, helpOpen, setHelpOpen,
347
400
  mcpOpen, setMcpOpen, mcpRowIdx, setMcpRowIdx, mcpRowCount, toggleMcpRow, removeMcpRow,
348
401
  },
349
402
  suggestions: { activeSuggestions, activeSuggestionKind, commandMenuIndex, setCommandMenuIndex, acceptActiveSuggestion },
@@ -351,11 +404,11 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
351
404
  home: { sessionsModalOpen, setSessionsModalOpen, sessionsModalIdx, setSessionsModalIdx, sessions, deleteSession, selectedIdx, setSelectedIdx, setHeroHidden, openRun, submitHome },
352
405
  });
353
406
  const activeUsage = usage[config.model];
354
- const themed = (node) => (_jsx(ThemeProvider, { config: config, stdin: stdin, stdout: stdout, children: node }));
407
+ const themed = (node) => (_jsx(ThemeProvider, { config: config, children: node }));
355
408
  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 })] }));
409
+ 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: inputBlocked, 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
410
  }
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 })] }));
411
+ 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", height: contentRows, flexShrink: 0, justifyContent: "flex-end", 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 }), linkPending && _jsx(LinkOpenBox, { url: linkPending.url, innerWidth: innerWidth, config: config }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: inputBlocked, 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, hasLinkHover: hasLinkHover || !!linkPending, alwaysAllowLinks: config.alwaysAllowLinks, 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
412
  }
360
413
  function ChatInputChrome({ children }) {
361
414
  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,12 +17,14 @@ 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
- export function HintLine({ screen, busy, scrollLabel, hasDoneGroups, hasFocusedGroup, config }) {
22
+ export function HintLine({ screen, busy, scrollLabel, hasDoneGroups, hasFocusedGroup, hasLinkHover, alwaysAllowLinks, config }) {
23
23
  const theme = useTheme();
24
24
  if (screen === "chat") {
25
- return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: theme.textDim, children: _jsx(Text, { bold: true, color: theme.accent, children: "/HELP" }) }), _jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: theme.textDim, children: _jsx(Text, { bold: true, color: theme.accent, children: "/REPORT" }) }), _jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: theme.textDim, children: _jsx(Text, { bold: true, color: theme.accent, children: "/NEW" }) }), busy && (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: theme.textDim, children: _jsx(Text, { bold: true, color: theme.accent, children: "/STOP" }) })] })), hasDoneGroups && !busy ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: theme.textDim, children: hasFocusedGroup
25
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: theme.textDim, children: _jsx(Text, { bold: true, color: theme.accent, children: "/HELP" }) }), _jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: theme.textDim, children: _jsx(Text, { bold: true, color: theme.accent, children: "/REPORT" }) }), _jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: theme.textDim, children: _jsx(Text, { bold: true, color: theme.accent, children: "/NEW" }) }), hasLinkHover && !busy ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: theme.textDim, children: alwaysAllowLinks
26
+ ? "click link to open"
27
+ : _jsxs(_Fragment, { children: ["click link \u00B7 ", _jsx(Text, { bold: true, color: theme.accent, children: "a" }), " always \u00B7 ", _jsx(Text, { bold: true, color: theme.accent, children: "y" }), " open \u00B7 ", _jsx(Text, { bold: true, color: theme.accent, children: "n" }), " cancel"] }) })] })) : null, busy && (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: theme.textDim, children: _jsx(Text, { bold: true, color: theme.accent, children: "/STOP" }) })] })), hasDoneGroups && !busy ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: theme.textDim, children: hasFocusedGroup
26
28
  ? _jsxs(_Fragment, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "C" }), " toggle \u00B7 ", _jsx(Text, { bold: true, color: theme.accent, children: "ESC" }), " unfocus"] })
27
29
  : _jsxs(_Fragment, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "[ ]" }), " \u00B7 ", _jsx(Text, { bold: true, color: theme.accent, children: "C" }), " groups"] }) })] })) : null, scrollLabel ? (_jsxs(_Fragment, { children: [_jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: theme.textDim, children: scrollLabel })] })) : null] }));
28
30
  }
@@ -56,7 +58,7 @@ export function CommandMenuBox({ activeSuggestions, activeSuggestionKind, comman
56
58
  bits.push(`${s.claimCount} claims`);
57
59
  return bits.join(" · ");
58
60
  };
59
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, marginX: 1, children: [_jsxs(Text, { color: theme.textDim, children: [header, windowStart > 0 ? " ↑" : "", windowStart + MENU_VISIBLE < total ? " ↓" : ""] }), visible.map((item, i) => {
61
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, children: [_jsxs(Text, { color: theme.textDim, children: [header, windowStart > 0 ? " ↑" : "", windowStart + MENU_VISIBLE < total ? " ↓" : ""] }), visible.map((item, i) => {
60
62
  const gi = windowStart + i;
61
63
  const active = gi === clampedIdx;
62
64
  const name = isSessionMenu && item.length > nameWidth ? item.slice(0, Math.max(0, nameWidth - 1)) + "…" : item;
@@ -73,11 +75,15 @@ export function HelpBox({ open, innerWidth, config }) {
73
75
  const theme = useTheme();
74
76
  if (!open)
75
77
  return null;
76
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, marginX: 1, children: [_jsxs(Text, { bold: true, color: theme.text, children: ["help ", _jsx(Text, { color: theme.textDim, children: "esc close" })] }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(10, innerWidth - 6)) }), _jsx(Text, { color: theme.textDim, children: "scroll \u2191/\u2193 k/j u/d pgup/pgdn" }), _jsx(Text, { color: theme.textDim, children: "autocomplete / commands \u00B7 @ files \u00B7 # sessions" }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(10, innerWidth - 6)) }), CHAT_COMMANDS.map((cmd) => (_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: theme.accent, children: cmd }), _jsx(Text, { color: theme.textDim, children: COMMAND_DESCRIPTIONS[cmd] })] }, cmd)))] }));
78
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, children: [_jsxs(Text, { bold: true, color: theme.text, children: ["help ", _jsx(Text, { color: theme.textDim, children: "esc close" })] }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(10, innerWidth - 6)) }), _jsx(Text, { color: theme.textDim, children: "scroll \u2191/\u2193 k/j u/d pgup/pgdn" }), _jsx(Text, { color: theme.textDim, children: "autocomplete / commands \u00B7 @ files \u00B7 # sessions" }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(10, innerWidth - 6)) }), CHAT_COMMANDS.map((cmd) => (_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: theme.accent, children: cmd }), _jsx(Text, { color: theme.textDim, children: COMMAND_DESCRIPTIONS[cmd] })] }, cmd)))] }));
79
+ }
80
+ export function LinkOpenBox({ url, innerWidth, config }) {
81
+ const theme = useTheme();
82
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, children: [_jsxs(Text, { bold: true, color: theme.accent, children: ["\u2197 Open in browser?", _jsx(Text, { color: theme.textDim, children: " a always \u00B7 y open \u00B7 n cancel" })] }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(10, innerWidth - 6)) }), wrapText(url, Math.max(10, innerWidth - 4)).slice(0, 4).map((line, i) => (_jsx(Text, { color: theme.text, wrap: "truncate", children: line }, i)))] }));
77
83
  }
78
84
  export function ApprovalBox({ toolName, description, innerWidth, config }) {
79
85
  const theme = useTheme();
80
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.warning, paddingX: 1, marginX: 1, children: [_jsxs(Text, { bold: true, color: theme.warning, children: ["\u26A0 ", toolName, _jsx(Text, { color: theme.textDim, children: " y approve \u00B7 n reject" })] }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(10, innerWidth - 6)) }), wrapText(description, Math.max(10, innerWidth - 4)).slice(0, 6).map((line, i) => {
86
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.warning, paddingX: 1, children: [_jsxs(Text, { bold: true, color: theme.warning, children: ["\u26A0 ", toolName, _jsx(Text, { color: theme.textDim, children: " y approve \u00B7 n reject" })] }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(10, innerWidth - 6)) }), wrapText(description, Math.max(10, innerWidth - 4)).slice(0, 6).map((line, i) => {
81
87
  const isAdded = line.startsWith("+ ");
82
88
  const isRemoved = line.startsWith("- ");
83
89
  return (_jsx(Text, { color: isAdded ? theme.success : isRemoved ? theme.error : theme.textDim, wrap: "truncate", children: line }, i));
@@ -100,7 +106,7 @@ export function MenuDialog({ menu, cols, rows, config }) {
100
106
  const dialogLeft = Math.max(0, Math.floor((cols - 4 - DIALOG_W) / 2));
101
107
  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
108
  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) => {
109
+ 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
110
  const idx = menuStart + i;
105
111
  const active = idx === menu.index;
106
112
  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", "/links", "/key", "/keys", "/stop", "/back", "/quit"];
7
7
  /** Slash commands that take an argument; ⏎ from the menu appends a space instead of running. */
8
- export const COMMANDS_NEEDING_ARGS = new Set(["/theme", "/key", "/rename", "/why"]);
8
+ export const COMMANDS_NEEDING_ARGS = new Set(["/theme", "/key", "/rename", "/why", "/links"]);
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.",
@@ -23,14 +24,16 @@ export const COMMAND_DESCRIPTIONS = {
23
24
  "/llm": "Switch the LLM provider (gateway, xai, workers-ai).",
24
25
  "/provider": "Open the search provider selector.",
25
26
  "/theme": "Set UI theme: /theme dark · /theme light · /theme auto",
27
+ "/links": "Link opens: /links always · /links ask",
26
28
  "/key": "Save an API key, e.g. /key EXA_API_KEY ...",
27
- "/keys": "Show which required API keys are set.",
29
+ "/keys": "Show API key status and where to get missing keys.",
28
30
  "/stop": "Abort the currently running agent turn.",
29
31
  "/back": "Return to the sessions list.",
30
32
  "/quit": "Quit the TUI."
31
33
  };
32
34
  export const TOOL_ICONS = {
33
35
  bash: "$",
36
+ runBash: "$",
34
37
  writeFile: "✎",
35
38
  editFile: "✎",
36
39
  readFile: "▤",
@@ -39,7 +42,13 @@ export const TOOL_ICONS = {
39
42
  webSearch: "⌕",
40
43
  readUrl: "↗",
41
44
  listSkills: "★",
42
- readSkill: "★"
45
+ readSkill: "★",
46
+ todo: "☐",
47
+ readWorkspaceFile: "▤",
48
+ writeWorkspaceFile: "✎",
49
+ editWorkspaceFile: "✎",
50
+ listWorkspaceDir: "▤",
51
+ grepWorkspace: "⌕"
43
52
  };
44
53
  export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
45
54
  export const HOME_TIPS = [