@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 +2 -0
- package/dist/cli/commands/init.js +2 -0
- package/dist/types/index.js +1 -0
- package/dist/types/schema.test.js +1 -0
- package/dist/ui/ink/SciraApp.js +67 -17
- package/dist/ui/ink/components/overlays.js +11 -5
- package/dist/ui/ink/constants.js +3 -2
- package/dist/ui/ink/hooks/use-agent-turn.js +2 -2
- package/dist/ui/ink/hooks/use-feed-lines.js +33 -6
- package/dist/ui/ink/hooks/use-keyboard.js +13 -1
- package/dist/ui/ink/hooks/use-session.js +11 -12
- package/dist/ui/ink/hooks/use-settings.js +20 -0
- package/dist/ui/ink/hooks/use-submit.js +1 -1
- package/dist/ui/ink/lib/tool-result.js +1 -0
- package/dist/ui/ink/lib/tool-result.test.js +3 -3
- package/dist/ui/ink/lib/utils.js +64 -2
- package/dist/ui/ink/lib/utils.test.js +18 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Scira CLI
|
|
2
2
|
|
|
3
|
+

|
|
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,
|
package/dist/types/index.js
CHANGED
|
@@ -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
|
});
|
package/dist/ui/ink/SciraApp.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
304
|
-
const
|
|
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
|
|
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.
|
|
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,
|
|
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:
|
|
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",
|
|
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" }) }),
|
|
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,
|
|
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,
|
|
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,
|
|
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));
|
package/dist/ui/ink/constants.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
90
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
|
53
|
+
it("defaults readUrl and webSearch collapsed", () => {
|
|
54
54
|
expect(defaultCollapsedToolName("readUrl")).toBe(true);
|
|
55
|
-
expect(defaultCollapsedToolName("webSearch")).toBe(
|
|
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(
|
|
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
|
});
|
package/dist/ui/ink/lib/utils.js
CHANGED
|
@@ -142,11 +142,73 @@ export function relativeTime(ms) {
|
|
|
142
142
|
return `${weeks}w ago`;
|
|
143
143
|
return new Date(ms).toLocaleDateString();
|
|
144
144
|
}
|
|
145
|
-
|
|
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
|
|
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
|
+
"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",
|