@scira/cli 0.1.3 → 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.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Scira CLI
2
2
 
3
+ ![Scira CLI](./docs/public/cli-demo.png)
4
+
3
5
  Terminal-native AI research and coding agent. Ask a question, get a grounded report with cited sources and verified claims — all stored locally and inspectable.
4
6
 
5
7
  **Documentation:** [docs site](./docs) (local: `cd docs && bun run dev`) · MDX sources in `docs/content/docs/`
@@ -261,6 +261,7 @@ export async function initCommand() {
261
261
  model: defaultModelFor(llmProvider),
262
262
  lastModels: {},
263
263
  approvalMode: "suggest",
264
+ alwaysAllowLinks: false,
264
265
  runDirectory: ".scira/runs",
265
266
  maxSources: 20,
266
267
  citationPolicy: "strict",
@@ -345,6 +346,7 @@ export async function initCommand() {
345
346
  model,
346
347
  lastModels: { [llmProvider]: model, ...(existingConfig?.lastModels || {}) },
347
348
  approvalMode: approvalMode,
349
+ alwaysAllowLinks: existingConfig?.alwaysAllowLinks ?? false,
348
350
  search: {
349
351
  provider: searchProvider,
350
352
  maxResults: existingConfig?.search?.maxResults || 8,
@@ -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";
@@ -69,6 +70,27 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
69
70
  });
70
71
  }, []);
71
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]);
72
94
  const [inputText, setInputText] = useState("");
73
95
  const [cursorPos, setCursorPos] = useState(0);
74
96
  const [inputHistory, setInputHistory] = useState([]);
@@ -204,15 +226,22 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
204
226
  }), [pushFeed, appendText, appendReasoning, finishReasoning, markToolDone, setBusy, setApprovalPending, setMode]);
205
227
  const runTurnRef = useRef(async () => { });
206
228
  const { refreshSessions, refreshRun, openRun: openRunBase } = useSession({
207
- config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef, runTurnRef,
229
+ config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef,
208
230
  setSessions, setRunState, setCurrentRunPath, setInputText, setCursorPos,
209
231
  setFeed, setUsage, setScrollOffset, setScreen, setMode, setPlanMode,
210
232
  setBusy, setApprovalPending, getSubscriber,
211
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;
212
239
  const openRun = useCallback(async (runPath, initialQuestion) => {
213
240
  setPendingRerun(false);
214
- await openRunBase(runPath, initialQuestion);
215
- }, [openRunBase, setPendingRerun]);
241
+ const start = await openRunBase(runPath, initialQuestion);
242
+ if (start)
243
+ await runTurn(start.startPrompt, runPath);
244
+ }, [openRunBase, runTurn, setPendingRerun]);
216
245
  useMountEffect(() => {
217
246
  if (!initialRunPath)
218
247
  void refreshSessions();
@@ -279,11 +308,10 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
279
308
  React.useEffect(() => {
280
309
  setMcpRowIdx((i) => Math.min(i, Math.max(0, mcpRowCount - 1)));
281
310
  }, [mcpRowCount]);
282
- const { runTurn } = useAgentTurn({
283
- config, currentRunPath, queuedPromptRef, fullModeRef, planModeRef, conversationRef, turnsRef, feedRef,
284
- setBusy, setScrollOffset, refreshRun, recordUsage, setMode, setPlanMode, getSubscriber,
311
+ useMountEffect(() => {
312
+ if (initialRunPath)
313
+ void openRun(initialRunPath);
285
314
  });
286
- runTurnRef.current = runTurn;
287
315
  const { submitHome, submitChat, stopTurn } = useSubmit({
288
316
  state: { config, currentRunPath, sessions, selectedIdx, busy, usage, pendingRerun },
289
317
  refs: { queuedPromptRef, fullModeRef, planModeRef, conversationRef, feedRef },
@@ -300,8 +328,9 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
300
328
  const innerWidth = Math.max(20, cols - 4);
301
329
  const boxWidth = Math.max(20, cols - 4);
302
330
  const textWidth = Math.max(1, boxWidth - 6);
303
- const rawInputText = approvalPending ? "waiting for approval\u2026" : inputText;
304
- 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;
305
334
  const caret = Math.max(0, Math.min(cursorPos, inputText.length));
306
335
  const { lines: inputLines, cursorLine, cursorCol } = wrapInputWithCursor(rawInputText, textWidth, showCursor ? caret : -1);
307
336
  const commandMenuHeight = activeSuggestions.length > 0 ? Math.min(MENU_VISIBLE, activeSuggestions.length) + 3 : 0;
@@ -310,15 +339,20 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
310
339
  ? Math.min(5, wrapText(approvalPending.description, Math.max(10, innerWidth - 4)).length)
311
340
  : 0;
312
341
  const approvalHeight = approvalPending ? approvalPreviewLines + 5 : 0;
313
- 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;
314
347
  const feedRows = Math.max(3, rows - 6 - inputLines.length - menuHeight);
315
348
  const hasRunningTool = feed.some((it) => it.kind === "tool" && it.status === "running");
316
- 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);
317
350
  const contentRows = Math.max(1, feedRows);
318
351
  const maxScrollOffset = Math.max(0, feedLines.length - contentRows);
319
352
  wheelStateRef.current = { screen, maxScrollOffset };
320
353
  const clampedOffset = Math.min(scrollOffset, maxScrollOffset);
321
354
  const startIdx = Math.max(0, feedLines.length - contentRows - clampedOffset);
355
+ const hasLinkHover = hoveredIdx !== null && (linkAtLine.get(hoveredIdx)?.length ?? 0) > 0;
322
356
  const feedStartRow = 3;
323
357
  if (screen === "chat") {
324
358
  const clickMap = new Map();
@@ -328,11 +362,25 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
328
362
  if (vis < 0 || vis >= contentRows)
329
363
  return;
330
364
  const row = feedStartRow + vis;
331
- clickMap.set(row, onClick);
365
+ const prev = clickMap.get(row);
366
+ clickMap.set(row, prev ? (x) => { prev(x); onClick(x); } : onClick);
332
367
  hoverMap.set(row, lineIdx);
333
368
  };
334
369
  toggleAtLine.forEach((id, lineIdx) => registerLine(lineIdx, () => toggleToolItem(id)));
335
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
+ });
336
384
  clickMapRef.current = clickMap;
337
385
  hoverMapRef.current = hoverMap;
338
386
  }
@@ -346,7 +394,9 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
346
394
  exit,
347
395
  input: { text: inputText, setText: setInputText, cursorPos, setCursorPos, history: inputHistory, historyIndex, setHistoryIndex },
348
396
  dialogs: {
349
- approvalPending, setApprovalPending, menu, setMenu, applyMenuSelection, helpOpen, setHelpOpen,
397
+ approvalPending, setApprovalPending, linkPending, setLinkPending,
398
+ onConfirmLink: confirmLinkOpen, onAlwaysAllowLinks: enableAlwaysAllowLinks,
399
+ menu, setMenu, applyMenuSelection, helpOpen, setHelpOpen,
350
400
  mcpOpen, setMcpOpen, mcpRowIdx, setMcpRowIdx, mcpRowCount, toggleMcpRow, removeMcpRow,
351
401
  },
352
402
  suggestions: { activeSuggestions, activeSuggestionKind, commandMenuIndex, setCommandMenuIndex, acceptActiveSuggestion },
@@ -356,9 +406,9 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
356
406
  const activeUsage = usage[config.model];
357
407
  const themed = (node) => (_jsx(ThemeProvider, { config: config, children: node }));
358
408
  if (screen === "home") {
359
- return themed(_jsxs(Box, { flexDirection: "column", width: cols, height: rows, paddingX: 2, children: [_jsx(TipCycler, { setTipIndex: setTipIndex }), (!sessionsModalOpen || mcpOpen) && stdout !== undefined && stdin !== undefined && (_jsx(MouseTracker, { stdout: stdout, stdin: stdin, onData: handleMouseData, onUnmount: () => setHoveredIdx(null) })), busy && _jsx(AnimationTick, { setBlink: setBlink, setFrame: setFrame, setReasoningTick: setReasoningTick }), _jsx(TopBar, { screen: screen, runState: runState, fullMode: fullMode, planMode: planMode, activeUsage: activeUsage, busy: busy, frame: frame, cwdDisplay: CWD_DISPLAY, config: config }), _jsx(HomeScreen, { cols: cols, rows: rows, sessions: sessions, selectedIdx: selectedIdx, hoveredIdx: hoveredIdx, heroHidden: heroHidden, notice: notice, tipIndex: tipIndex, commandMenuHeight: commandMenuHeight, mcpOpen: mcpOpen, sessionsModalOpen: sessionsModalOpen, sessionsModalIdx: sessionsModalIdx, inputText: inputText, config: config, modelName: modelName, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef, setSelectedIdx: setSelectedIdx, setSessionsModalOpen: setSessionsModalOpen, setSessionsModalIdx: setSessionsModalIdx, setNotice: setNotice, openRun: openRun, submitHome: submitHome, exit: exit }), _jsxs(Box, { flexDirection: "column", paddingBottom: 1, children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions, config: config }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: !!approvalPending, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName, config: config }), _jsx(HintLine, { screen: screen, busy: busy, config: config })] }), _jsx(MenuDialog, { menu: menu, cols: cols, rows: rows, config: config }), _jsx(McpDialog, { open: mcpOpen, config: config, cols: cols, rows: rows, selectedIdx: mcpRowIdx, hoveredIdx: hoveredIdx, onToggle: handleMcpToggle, onRemove: handleMcpRemove, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef })] }));
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 })] }));
360
410
  }
361
- return themed(_jsxs(Box, { flexDirection: "column", width: cols, height: rows, paddingX: 2, children: [stdout !== undefined && stdin !== undefined && (_jsx(MouseTracker, { stdout: stdout, stdin: stdin, onData: handleMouseData, onUnmount: () => setHoveredIdx(null) })), busy && _jsx(AnimationTick, { setBlink: setBlink, setFrame: setFrame, setReasoningTick: setReasoningTick }), _jsx(TopBar, { screen: screen, runState: runState, fullMode: fullMode, planMode: planMode, activeUsage: activeUsage, busy: busy, frame: frame, cwdDisplay: CWD_DISPLAY, config: config }), _jsx(Box, { flexDirection: "column", flexGrow: 1, paddingTop: 1, overflow: "hidden", children: visibleLines }), _jsxs(ChatInputChrome, { children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions, config: config }), _jsx(HelpBox, { open: helpOpen, innerWidth: innerWidth, config: config }), approvalPending && _jsx(ApprovalBox, { toolName: approvalPending.toolName, description: approvalPending.description, innerWidth: innerWidth, config: config }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: !!approvalPending, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName, config: config }), _jsx(HintLine, { screen: screen, busy: busy, scrollLabel: scrollLabel, hasDoneGroups: doneGroupKeys.length > 0, hasFocusedGroup: focusedGroupKey !== null, config: config })] }), _jsx(MenuDialog, { menu: menu, cols: cols, rows: rows, config: config }), _jsx(McpDialog, { open: mcpOpen, config: config, cols: cols, rows: rows, selectedIdx: mcpRowIdx, hoveredIdx: hoveredIdx, onToggle: handleMcpToggle, onRemove: handleMcpRemove, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef })] }));
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 })] }));
362
412
  }
363
413
  function ChatInputChrome({ children }) {
364
414
  const theme = useTheme();
@@ -19,10 +19,12 @@ export function InputBar({ inputLines, cursorLine, cursorCol, showCursor, approv
19
19
  const dashCount = Math.max(1, boxWidth - label.length - 5);
20
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));
@@ -3,9 +3,9 @@ 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", "/plan", "/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).",
@@ -24,6 +24,7 @@ export const COMMAND_DESCRIPTIONS = {
24
24
  "/llm": "Switch the LLM provider (gateway, xai, workers-ai).",
25
25
  "/provider": "Open the search provider selector.",
26
26
  "/theme": "Set UI theme: /theme dark · /theme light · /theme auto",
27
+ "/links": "Link opens: /links always · /links ask",
27
28
  "/key": "Save an API key, e.g. /key EXA_API_KEY ...",
28
29
  "/keys": "Show API key status and where to get missing keys.",
29
30
  "/stop": "Abort the currently running agent turn.",
@@ -12,8 +12,8 @@ import { markdownJoinerTransform } from "../../../utils/markdown-joiner.js";
12
12
  import { createSession, getSession, removeSession, attachSubscriber, sessionPushFeed, sessionSetBusy, sessionSetApproval, sessionFinishReasoning, sessionNotifyEscalate, sessionNotifyModeChange, mergeFeedToolResults, getSessionFeedBuffer, } from "../session-manager.js";
13
13
  export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullModeRef, planModeRef, conversationRef, turnsRef, feedRef, setBusy, setScrollOffset, refreshRun, recordUsage, setMode, setPlanMode, getSubscriber, }) {
14
14
  const bgManagersRef = useRef(new Map());
15
- const runTurn = useCallback(async (prompt) => {
16
- const runPath = currentRunPath;
15
+ const runTurn = useCallback(async (prompt, runPathOverride) => {
16
+ const runPath = runPathOverride ?? currentRunPath;
17
17
  if (!runPath)
18
18
  return;
19
19
  const workspacePath = resolveProjectRoot(runPath);
@@ -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;
@@ -108,6 +108,18 @@ export function useKeyboard(o) {
108
108
  }
109
109
  return;
110
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
+ }
111
123
  if (menu) {
112
124
  if (key.escape) {
113
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, setPlanMode, 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);
@@ -36,7 +36,7 @@ export function useSession(o) {
36
36
  setApprovalPending(live.approvalPending);
37
37
  const resumedState = await summarizeRun(runPath).catch(() => null);
38
38
  setRunState(resumedState);
39
- return;
39
+ return undefined;
40
40
  }
41
41
  try {
42
42
  const raw = await readFile(join(runPath, "convo.json"), "utf8");
@@ -55,7 +55,7 @@ export function useSession(o) {
55
55
  const resumedState = await summarizeRun(runPath).catch(() => null);
56
56
  setRunState(resumedState);
57
57
  setMode((resumedState?.claimCount ?? 0) > 0 || (resumedState?.sourceCount ?? 0) > 0);
58
- return;
58
+ return undefined;
59
59
  }
60
60
  }
61
61
  catch (e) {
@@ -68,7 +68,7 @@ export function useSession(o) {
68
68
  startedRef.current = runPath;
69
69
  setScrollOffset(0);
70
70
  setScreen("chat");
71
- return;
71
+ return undefined;
72
72
  }
73
73
  }
74
74
  conversationRef.current = [];
@@ -86,19 +86,18 @@ export function useSession(o) {
86
86
  return mcpCount > 0 ? [`${mcpCount} mcp`] : [];
87
87
  })(),
88
88
  ].join(" · ");
89
- const freshFeed = initialQuestion
90
- ? [{ 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 }]
91
93
  : [{ kind: "status", text: startStatus }];
92
94
  setFeed(freshFeed);
93
95
  feedRef.current = freshFeed;
94
96
  setScrollOffset(0);
95
97
  setScreen("chat");
96
- void (async () => {
97
- await summarizeRun(runPath).then(setRunState).catch(() => { });
98
- // Attach subscriber BEFORE starting the turn so no items are emitted without a listener.
99
- attachSubscriber(runPath, getSubscriber());
100
- await runTurnRef.current("Answer my question concisely using web search. If it genuinely needs deep, multi-source, verifiable research, call requestFullResearch to ask me to approve the full research harness.");
101
- })();
98
+ if (summary)
99
+ setRunState(summary);
100
+ return prompt ? { startPrompt: prompt } : undefined;
102
101
  }, [config, currentRunPath, setCurrentRunPath, setInputText, setCursorPos, setFeed, setUsage, setScrollOffset,
103
102
  setScreen, setRunState, setMode, setPlanMode, setBusy, setApprovalPending, getSubscriber]);
104
103
  return { refreshSessions, refreshRun, openRun };
@@ -164,6 +164,26 @@ export function useSettings({ config, setConfig, screen, pushFeed, setNotice })
164
164
  await saveGlobalConfig(next);
165
165
  return `Theme set to ${arg}.`;
166
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
+ }
167
187
  if (cmd === "/keys") {
168
188
  return formatKeysStatus(detectEnv(config.search.provider, config.llmProvider));
169
189
  }
@@ -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));
@@ -2,6 +2,7 @@ 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",
@@ -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
  });
@@ -142,11 +142,73 @@ export function relativeTime(ms) {
142
142
  return `${weeks}w ago`;
143
143
  return new Date(ms).toLocaleDateString();
144
144
  }
145
- /** Wrap text in an OSC 8 terminal hyperlink (clickable in supported terminals). */
145
+ function colorToAnsi(color) {
146
+ if (!color)
147
+ return [];
148
+ const hex = /^#([0-9a-f]{6})$/i.exec(color);
149
+ if (hex) {
150
+ const n = hex[1];
151
+ return [38, 2, parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
152
+ }
153
+ const ansi256 = /^ansi256\((\d+)\)$/i.exec(color);
154
+ if (ansi256)
155
+ return [38, 5, parseInt(ansi256[1], 10)];
156
+ const named = {
157
+ red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36,
158
+ gray: 90, white: 97, black: 30,
159
+ };
160
+ const code = named[color.toLowerCase()];
161
+ return code ? [code] : [];
162
+ }
163
+ /** OSC 8 link with inline ANSI styling — avoids Ink Text props breaking the escape sequence. */
164
+ export function ansiHyperlink(text, url, style) {
165
+ const params = [];
166
+ if (style?.bold)
167
+ params.push(1);
168
+ if (style?.dim)
169
+ params.push(2);
170
+ if (style?.italic)
171
+ params.push(3);
172
+ if (style?.underline !== false)
173
+ params.push(4);
174
+ params.push(...colorToAnsi(style?.color));
175
+ const styled = params.length > 0 ? `\x1b[${params.join(";")}m${text}\x1b[0m` : text;
176
+ return `\x1b]8;;${url}\x1b\\${styled}\x1b]8;;\x1b\\`;
177
+ }
146
178
  export function hyperlink(text, url) {
147
179
  if (!url)
148
180
  return text;
149
- return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
181
+ return ansiHyperlink(text, url, { underline: true });
182
+ }
183
+ export function computeLineLinks(segs, prefixCols = 0) {
184
+ const links = [];
185
+ let col = prefixCols;
186
+ for (const s of segs) {
187
+ const w = displayWidth(s.text);
188
+ if (s.url && w > 0)
189
+ links.push({ start: col, end: col + w - 1, url: s.url });
190
+ col += w;
191
+ }
192
+ return links;
193
+ }
194
+ /** Match an SGR mouse column (1-based) against link regions from computeLineLinks. */
195
+ export function linkAtMouseColumn(links, x) {
196
+ for (const l of links) {
197
+ if (x >= l.start + 1 && x <= l.end + 1)
198
+ return l.url;
199
+ }
200
+ return undefined;
201
+ }
202
+ /** Open a URL in the system browser. */
203
+ export function openExternalUrl(url) {
204
+ return new Promise((res) => {
205
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
206
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
207
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
208
+ child.on("error", () => res(false));
209
+ child.on("close", (code) => res(code === 0));
210
+ child.unref();
211
+ });
150
212
  }
151
213
  /** True if the prompt clearly asks for full, report-grade research. */
152
214
  export function wantsFullResearch(prompt) {
@@ -1,5 +1,22 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { summarizeToolInput, summarizeToolOutput } from "./utils.js";
2
+ import { summarizeToolInput, summarizeToolOutput, ansiHyperlink, computeLineLinks, linkAtMouseColumn } from "./utils.js";
3
+ describe("hyperlink helpers", () => {
4
+ it("wraps OSC 8 around styled link text", () => {
5
+ const out = ansiHyperlink("docs", "https://example.com", { color: "#FFE0C2", underline: true });
6
+ expect(out).toContain("\x1b]8;;https://example.com\x1b\\");
7
+ expect(out).toContain("docs");
8
+ expect(out).toContain("\x1b]8;;\x1b\\");
9
+ });
10
+ it("maps mouse column to link url", () => {
11
+ const links = computeLineLinks([
12
+ { text: "see " },
13
+ { text: "docs", url: "https://example.com" },
14
+ ], 2);
15
+ expect(links).toEqual([{ start: 6, end: 9, url: "https://example.com" }]);
16
+ expect(linkAtMouseColumn(links, 7)).toBe("https://example.com");
17
+ expect(linkAtMouseColumn(links, 3)).toBeUndefined();
18
+ });
19
+ });
3
20
  describe("summarizeToolInput", () => {
4
21
  it("formats webSearch queries", () => {
5
22
  expect(summarizeToolInput("webSearch", { queries: ["a", "b", "c"] })).toBe("a · b +1");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scira/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Scira — terminal-native AI research agent with grounded sources, verified claims, and local run storage.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,6 +51,7 @@
51
51
  "exa-js": "^2.13.0",
52
52
  "files-sdk": "^1.8.0",
53
53
  "ink": "^7.0.5",
54
+ "ink-link": "^5.0.0",
54
55
  "jsdom": "^29.1.1",
55
56
  "parallel-web": "^1.1.0",
56
57
  "picospinner": "^3.0.0",