@scira/cli 0.1.3 → 0.1.5

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
  });
@@ -1,14 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useCallback, useMemo, useRef, useState } from "react";
3
- import { Box, useApp, useStdout, useStdin } from "ink";
4
- import { CHAT_COMMANDS, MENU_VISIBLE } from "./constants.js";
5
- import { CWD_DISPLAY, wrapText, wrapInputWithCursor, loadInputHistory, saveInputHistory } from "./lib/utils.js";
3
+ import { Box, Text, useApp, useStdout, useStdin } from "ink";
4
+ import { CHAT_COMMANDS, MENU_VISIBLE, SPINNER_FRAMES, LOADING_PHRASES } from "./constants.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,33 @@ 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, lastUserLineStart } = 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
- const clampedOffset = Math.min(scrollOffset, maxScrollOffset);
321
- const startIdx = Math.max(0, feedLines.length - contentRows - clampedOffset);
353
+ // scrollOffset < 0 is a sentinel meaning "pin the most recent user message to
354
+ // the top of the viewport" (with empty space below for the incoming reply).
355
+ // Once the reply grows past the viewport we fall back to bottom-anchoring so
356
+ // the streaming output stays visible. Any manual scroll clears the sentinel.
357
+ const pinUserToTop = scrollOffset < 0 && lastUserLineStart >= 0;
358
+ let startIdx;
359
+ if (pinUserToTop) {
360
+ const fitsBelow = feedLines.length - lastUserLineStart <= contentRows;
361
+ startIdx = fitsBelow ? lastUserLineStart : Math.max(0, feedLines.length - contentRows);
362
+ }
363
+ else {
364
+ const off = Math.min(Math.max(0, scrollOffset), maxScrollOffset);
365
+ startIdx = Math.max(0, feedLines.length - contentRows - off);
366
+ }
367
+ const clampedOffset = Math.max(0, feedLines.length - contentRows - startIdx);
368
+ const hasLinkHover = hoveredIdx !== null && (linkAtLine.get(hoveredIdx)?.length ?? 0) > 0;
322
369
  const feedStartRow = 3;
323
370
  if (screen === "chat") {
324
371
  const clickMap = new Map();
@@ -328,15 +375,53 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
328
375
  if (vis < 0 || vis >= contentRows)
329
376
  return;
330
377
  const row = feedStartRow + vis;
331
- clickMap.set(row, onClick);
378
+ const prev = clickMap.get(row);
379
+ clickMap.set(row, prev ? (x) => { prev(x); onClick(x); } : onClick);
332
380
  hoverMap.set(row, lineIdx);
333
381
  };
334
382
  toggleAtLine.forEach((id, lineIdx) => registerLine(lineIdx, () => toggleToolItem(id)));
335
383
  groupToggleAtLine.forEach((groupKey, lineIdx) => registerLine(lineIdx, () => toggleGroup(groupKey)));
384
+ linkAtLine.forEach((links, lineIdx) => {
385
+ registerLine(lineIdx, (x) => {
386
+ if (approvalPending)
387
+ return;
388
+ const url = linkAtMouseColumn(links, x);
389
+ if (!url)
390
+ return;
391
+ if (config.alwaysAllowLinks)
392
+ void openExternalUrl(url);
393
+ else
394
+ setLinkPending({ url });
395
+ });
396
+ });
336
397
  clickMapRef.current = clickMap;
337
398
  hoverMapRef.current = hoverMap;
338
399
  }
339
- const visibleLines = feedLines.slice(startIdx, startIdx + contentRows);
400
+ const slicedLines = feedLines.slice(startIdx, startIdx + contentRows);
401
+ // The feed box is bottom-aligned (justifyContent="flex-end"). When pinning the
402
+ // user message to the top, pad blank lines below it so short content is pushed
403
+ // up — the user message sits at the top with empty room below for the reply.
404
+ const blankLine = (k) => _jsx(Text, { children: " " }, k);
405
+ // Show an animated loading line whenever the agent is still doing non-text
406
+ // work (reasoning, tool calls, status) — i.e. the latest feed item isn't the
407
+ // streamed answer text. It stays pinned below the latest content for the whole
408
+ // turn, and the phrase rotates slowly so it doesn't feel frozen. The feed box
409
+ // is bottom-aligned with overflow hidden, so when the timeline is long the
410
+ // loader stays visible and the oldest lines scroll off the top instead.
411
+ const lastItem = feed[feed.length - 1];
412
+ const showLoader = busy && lastItem !== undefined && lastItem.kind !== "text";
413
+ const phrase = LOADING_PHRASES[Math.floor(frame / 24) % LOADING_PHRASES.length];
414
+ const loadingLine = (_jsx(Text, { dimColor: true, children: `${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ${phrase}` }, "loading"));
415
+ const contentWithLoader = showLoader
416
+ ? (slicedLines.length > 0 ? [...slicedLines, blankLine("loading-gap"), loadingLine] : [loadingLine])
417
+ : slicedLines;
418
+ const visibleLines = pinUserToTop && contentWithLoader.length < contentRows
419
+ ? [
420
+ blankLine("pad-top"),
421
+ ...contentWithLoader,
422
+ ...Array.from({ length: Math.max(0, contentRows - contentWithLoader.length - 1) }, (_, i) => blankLine(`pad-${i}`)),
423
+ ]
424
+ : contentWithLoader;
340
425
  const scrollLabel = clampedOffset > 0
341
426
  ? (startIdx > 0 ? `↑ ${startIdx} · ↓ ${clampedOffset} · wheel/⇞⇟` : `top · ↓ ${clampedOffset} · wheel/⇞⇟`)
342
427
  : "";
@@ -346,7 +431,9 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
346
431
  exit,
347
432
  input: { text: inputText, setText: setInputText, cursorPos, setCursorPos, history: inputHistory, historyIndex, setHistoryIndex },
348
433
  dialogs: {
349
- approvalPending, setApprovalPending, menu, setMenu, applyMenuSelection, helpOpen, setHelpOpen,
434
+ approvalPending, setApprovalPending, linkPending, setLinkPending,
435
+ onConfirmLink: confirmLinkOpen, onAlwaysAllowLinks: enableAlwaysAllowLinks,
436
+ menu, setMenu, applyMenuSelection, helpOpen, setHelpOpen,
350
437
  mcpOpen, setMcpOpen, mcpRowIdx, setMcpRowIdx, mcpRowCount, toggleMcpRow, removeMcpRow,
351
438
  },
352
439
  suggestions: { activeSuggestions, activeSuggestionKind, commandMenuIndex, setCommandMenuIndex, acceptActiveSuggestion },
@@ -356,9 +443,9 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
356
443
  const activeUsage = usage[config.model];
357
444
  const themed = (node) => (_jsx(ThemeProvider, { config: config, children: node }));
358
445
  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 })] }));
446
+ 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
447
  }
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 })] }));
448
+ 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
449
  }
363
450
  function ChatInputChrome({ children }) {
364
451
  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.",
@@ -50,6 +51,33 @@ export const TOOL_ICONS = {
50
51
  grepWorkspace: "⌕"
51
52
  };
52
53
  export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
54
+ export const LOADING_PHRASES = [
55
+ "Thinking it through…",
56
+ "Digging into it…",
57
+ "Connecting the dots…",
58
+ "Gathering context…",
59
+ "Working through it…",
60
+ "Sifting the details…",
61
+ "Putting it together…",
62
+ "Chasing down answers…",
63
+ "Mulling it over…",
64
+ "Lining things up…",
65
+ "Reading the room…",
66
+ "Scanning the sources…",
67
+ "Cross-checking facts…",
68
+ "Tracing the threads…",
69
+ "Weighing the options…",
70
+ "Following the trail…",
71
+ "Piecing it together…",
72
+ "Untangling the details…",
73
+ "Skimming the fine print…",
74
+ "Joining the dots…",
75
+ "Hunting for specifics…",
76
+ "Sorting signal from noise…",
77
+ "Drafting the answer…",
78
+ "Double-checking the work…",
79
+ "Wrapping my head around it…",
80
+ ];
53
81
  export const HOME_TIPS = [
54
82
  "Type a question and press ⏎ to start a new research run.",
55
83
  "Say \"deep research …\" or \"compare …\" to trigger the full research harness.",
@@ -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);
@@ -26,7 +26,9 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
26
26
  const controller = new AbortController();
27
27
  session.abort = controller;
28
28
  setBusy(true);
29
- setScrollOffset(0);
29
+ // Pin the just-sent user message to the top of the viewport, leaving room
30
+ // below for the incoming assistant reply (-1 sentinel; see SciraApp).
31
+ setScrollOffset(-1);
30
32
  sessionSetBusy(runPath, true);
31
33
  const modelId = config.model;
32
34
  const turnStartedAt = Date.now();
@@ -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,8 @@ 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();
66
+ let lastUserLineStart = -1;
53
67
  let key = 0;
54
68
  const { groupOf, groups } = computeGroups(feed);
55
69
  const eff = [];
@@ -161,7 +175,12 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState,
161
175
  lines.push(_jsx(Text, { color: theme.textDim, children: S_BAR }, key++));
162
176
  continue;
163
177
  }
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++));
178
+ const prefix = `${S_BAR} `;
179
+ const lineIdx = lines.length;
180
+ const links = computeLineLinks(row, displayWidth(prefix));
181
+ if (links.length > 0)
182
+ linkAtLine.set(lineIdx, links);
183
+ lines.push(_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: theme.textDim, children: prefix }), renderSegNodes(row, theme, theme.textDim)] }, key++));
165
184
  }
166
185
  }
167
186
  else if (fi.kind === "user") {
@@ -170,6 +189,7 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState,
170
189
  const rightPad = time ? time.length + 1 : 0;
171
190
  const wrapped = wrapText(fi.text, Math.max(10, bandW - 4 - rightPad));
172
191
  const blank = " ".repeat(bandW);
192
+ lastUserLineStart = lines.length;
173
193
  lines.push(_jsx(Text, { ...bandBg, children: blank }, key++));
174
194
  wrapped.forEach((l, idx) => {
175
195
  const isFirst = idx === 0;
@@ -193,7 +213,12 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState,
193
213
  lines.push(_jsx(Text, { color: theme.textDim, children: S_BAR }, key++));
194
214
  continue;
195
215
  }
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++));
216
+ const prefix = "│ ";
217
+ const lineIdx = lines.length;
218
+ const links = computeLineLinks(segLine, displayWidth(prefix));
219
+ if (links.length > 0)
220
+ linkAtLine.set(lineIdx, links);
221
+ 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
222
  }
198
223
  }
199
224
  else {
@@ -202,11 +227,15 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState,
202
227
  lines.push(_jsx(Text, { children: " " }, key++));
203
228
  continue;
204
229
  }
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++));
230
+ const lineIdx = lines.length;
231
+ const links = computeLineLinks(segLine, 0);
232
+ if (links.length > 0)
233
+ linkAtLine.set(lineIdx, links);
234
+ lines.push(_jsx(Text, { wrap: "truncate-end", children: renderSegNodes(segLine, theme, theme.text) }, key++));
206
235
  }
207
236
  }
208
237
  });
209
- return { lines, toggleAtLine, groupToggleAtLine };
238
+ return { lines, toggleAtLine, groupToggleAtLine, linkAtLine, lastUserLineStart };
210
239
  // eslint-disable-next-line react-hooks/exhaustive-deps
211
240
  }, [feed, innerWidth, reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState, hoveredLineIdx, config, theme]);
212
241
  }
@@ -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",
@@ -19,6 +20,10 @@ export function isCollapsibleToolName(name) {
19
20
  return name.length > 0;
20
21
  }
21
22
  export function defaultCollapsedToolName(name) {
23
+ // Chrome DevTools MCP tools (prefixed `devtools_`) produce long browser
24
+ // snapshots/output, so collapse them by default like the built-in tools.
25
+ if (name.startsWith("devtools_"))
26
+ return true;
22
27
  return DEFAULT_COLLAPSED_TOOLS.has(name);
23
28
  }
24
29
  export function isToolItemCollapsed(id, name, status, expandState) {
@@ -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.5",
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",
@@ -39,18 +39,19 @@
39
39
  "test:watch": "vitest"
40
40
  },
41
41
  "dependencies": {
42
- "@ai-sdk/mcp": "^1.0.48",
43
- "@ai-sdk/openai-compatible": "^2.0.49",
44
- "@ai-sdk/xai": "^3.0.94",
42
+ "@ai-sdk/mcp": "^1.0.49",
43
+ "@ai-sdk/openai-compatible": "^2.0.50",
44
+ "@ai-sdk/xai": "^3.0.95",
45
45
  "@clack/prompts": "^1.5.1",
46
46
  "@mendable/firecrawl-js": "^4.25.3",
47
47
  "@modelcontextprotocol/sdk": "^1.29.0",
48
48
  "@mozilla/readability": "^0.6.0",
49
- "ai": "^6.0.202",
49
+ "ai": "^6.0.203",
50
50
  "diff": "^9.0.0",
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",