@scira/cli 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/agent/harness-agent.js +216 -0
  2. package/dist/agent/{research-agent.js → main-agent.js} +30 -10
  3. package/dist/cli/commands/init.js +7 -5
  4. package/dist/cli/index.js +75 -14
  5. package/dist/cli/shell/shell.js +4 -5
  6. package/dist/cli/shell/tui.js +7 -4
  7. package/dist/config/env-guide.js +24 -0
  8. package/dist/config/env-store.js +5 -3
  9. package/dist/config/load-config.js +9 -14
  10. package/dist/providers/harness/local-sandbox.js +143 -0
  11. package/dist/providers/llm/gateway.js +5 -2
  12. package/dist/providers/llm/models.js +18 -4
  13. package/dist/providers/llm/readiness.js +5 -1
  14. package/dist/providers/llm/registry.js +24 -3
  15. package/dist/storage/jsonl.js +2 -2
  16. package/dist/storage/run-store.js +22 -15
  17. package/dist/tools/agent-tools.js +7 -7
  18. package/dist/tools/background-tasks.js +4 -5
  19. package/dist/tools/mcp-oauth.js +29 -25
  20. package/dist/tools/open-url.js +1 -2
  21. package/dist/tools/todos.js +3 -3
  22. package/dist/tools/workspace.js +15 -0
  23. package/dist/types/index.js +13 -1
  24. package/dist/ui/ink/SciraApp.js +14 -10
  25. package/dist/ui/ink/components/home-screen.js +2 -2
  26. package/dist/ui/ink/components/overlays.js +78 -17
  27. package/dist/ui/ink/constants.js +26 -7
  28. package/dist/ui/ink/hooks/use-agent-turn.js +14 -5
  29. package/dist/ui/ink/hooks/use-feed-lines.js +31 -6
  30. package/dist/ui/ink/hooks/use-keyboard.js +28 -5
  31. package/dist/ui/ink/hooks/use-session.js +7 -5
  32. package/dist/ui/ink/hooks/use-settings.js +20 -0
  33. package/dist/ui/ink/hooks/use-submit.js +15 -8
  34. package/dist/ui/ink/lib/file-mentions.js +1 -2
  35. package/dist/ui/ink/lib/tool-result.js +219 -4
  36. package/dist/ui/ink/lib/utils.js +54 -28
  37. package/dist/ui/ink/theme.js +5 -10
  38. package/dist/utils/update-check.js +63 -0
  39. package/dist/watch/runner.js +2 -2
  40. package/package.json +13 -11
  41. package/dist/agent/background-tasks.js +0 -173
  42. package/dist/agent/todos.js +0 -140
  43. package/dist/agent/tools.js +0 -432
  44. package/dist/agent/tools.test.js +0 -60
  45. package/dist/agent/workspace.js +0 -85
  46. package/dist/config/env-guide.test.js +0 -18
  47. package/dist/config/env-store.test.js +0 -60
  48. package/dist/storage/jsonl.test.js +0 -38
  49. package/dist/storage/run-store.test.js +0 -65
  50. package/dist/tools/bash-policy.test.js +0 -38
  51. package/dist/tools/search-web.test.js +0 -24
  52. package/dist/tools/workspace.test.js +0 -75
  53. package/dist/types/schema.test.js +0 -61
  54. package/dist/ui/ink/hooks/use-feed-lines.test.js +0 -16
  55. package/dist/ui/ink/lib/tool-result.test.js +0 -60
  56. package/dist/ui/ink/lib/utils.test.js +0 -48
  57. package/dist/ui/ink/session-manager.test.js +0 -31
  58. package/dist/ui/ink/terminal-probe.test.js +0 -12
  59. package/dist/ui/ink/theme.test.js +0 -68
@@ -1,5 +1,4 @@
1
1
  import process from "node:process";
2
- import { writeFile } from "node:fs/promises";
3
2
  import { join } from "node:path";
4
3
  import { Readability } from "@mozilla/readability";
5
4
  import { JSDOM } from "jsdom";
@@ -94,6 +93,6 @@ export async function openUrl(url, config) {
94
93
  }
95
94
  export async function writeSnapshot(snapshotsDir, sourceId, page) {
96
95
  const path = join(snapshotsDir, `${sourceId}.md`);
97
- await writeFile(path, `# ${page.title}\n\n${page.text}\n`);
96
+ await Bun.write(path, `# ${page.title}\n\n${page.text}\n`);
98
97
  return path;
99
98
  }
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile, mkdir } from "node:fs/promises";
1
+ import { mkdir } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { tool } from "ai";
4
4
  import { z } from "zod";
@@ -14,7 +14,7 @@ function nextTodoId(existing) {
14
14
  }
15
15
  async function loadTodos(path) {
16
16
  try {
17
- const raw = await readFile(path, "utf8");
17
+ const raw = await Bun.file(path).text();
18
18
  const parsed = JSON.parse(raw);
19
19
  if (!Array.isArray(parsed))
20
20
  return [];
@@ -26,7 +26,7 @@ async function loadTodos(path) {
26
26
  }
27
27
  async function saveTodos(path, items) {
28
28
  await mkdir(dirname(path), { recursive: true });
29
- await writeFile(path, JSON.stringify(items, null, 2) + "\n");
29
+ await Bun.write(path, JSON.stringify(items, null, 2) + "\n");
30
30
  }
31
31
  function formatTodoList(items) {
32
32
  if (items.length === 0)
@@ -55,6 +55,14 @@ function projectRootFromPath(absPath) {
55
55
  return normalized.slice(0, -"/.scira".length) || "/";
56
56
  return undefined;
57
57
  }
58
+ /** Absolute form of `candidate` if it lands inside the run directory, else null. */
59
+ function absInsideRun(runPath, workspacePath, raw) {
60
+ if (!isAbsolute(raw) && !workspacePath)
61
+ return null; // can't resolve a relative path without a root
62
+ const abs = isAbsolute(raw) ? resolve(raw) : resolve(workspacePath, raw);
63
+ const rel = relative(resolve(runPath), abs);
64
+ return !rel.startsWith("..") && !isAbsolute(rel) ? abs : null;
65
+ }
58
66
  export function resolveToolPath(runPath, workspacePath, candidate) {
59
67
  const raw = candidate.trim();
60
68
  if (raw.startsWith("run:")) {
@@ -62,6 +70,13 @@ export function resolveToolPath(runPath, workspacePath, candidate) {
62
70
  const abs = resolveInsideRun(runPath, inner);
63
71
  return { abs, displayPath: inner, scope: "run" };
64
72
  }
73
+ // A full/absolute path that points into the run directory routes to the run —
74
+ // so the model can use the run dir path we hand it, not only bare names.
75
+ const runAbs = absInsideRun(runPath, workspacePath, raw);
76
+ if (runAbs) {
77
+ const display = relative(resolve(runPath), runAbs) || ".";
78
+ return { abs: runAbs, displayPath: display, scope: "run" };
79
+ }
65
80
  if (workspacePath && !isRunArtifactPath(raw)) {
66
81
  const abs = resolveInsideWorkspace(workspacePath, raw);
67
82
  return { abs, displayPath: raw, scope: "workspace" };
@@ -3,11 +3,23 @@ export const ApprovalModeSchema = z.enum(["manual", "suggest", "auto"]);
3
3
  export const ThemeSchema = z.enum(["dark", "light", "auto"]).default("auto");
4
4
  export const SciraConfigSchema = z.object({
5
5
  theme: ThemeSchema,
6
- llmProvider: z.enum(["gateway", "xai", "workers-ai", "huggingface"]).default("gateway"),
6
+ llmProvider: z.enum(["gateway", "xai", "workers-ai", "huggingface", "claude-code", "codex"]).default("gateway"),
7
7
  model: z.string().default("deepseek/deepseek-v4-flash"),
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
+ // Settings for the local agent harnesses (claude-code / codex providers).
12
+ harness: z.object({
13
+ // Claude Code extended-thinking control.
14
+ thinking: z.enum(["off", "on", "adaptive"]).default("adaptive"),
15
+ // Codex reasoning effort for reasoning-capable models.
16
+ reasoningEffort: z.enum(["low", "medium", "high"]).default("medium"),
17
+ // Hard cap on Claude Code internal turns per prompt (unset = CLI default).
18
+ maxTurns: z.number().int().positive().optional()
19
+ }).default({
20
+ thinking: "adaptive",
21
+ reasoningEffort: "medium"
22
+ }),
11
23
  alwaysAllowLinks: z.boolean().default(false),
12
24
  runDirectory: z.string().default(".scira/runs"),
13
25
  maxSources: z.number().int().min(1).max(100).default(20),
@@ -1,7 +1,7 @@
1
1
  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, Text, useApp, useStdout, useStdin } from "ink";
4
- import { CHAT_COMMANDS, MENU_VISIBLE, SPINNER_FRAMES, LOADING_PHRASES } from "./constants.js";
4
+ import { COMMAND_GROUPS, KEY_HINTS, MENU_VISIBLE, SPINNER_FRAMES, LOADING_PHRASES } from "./constants.js";
5
5
  import { CWD_DISPLAY, wrapText, wrapInputWithCursor, loadInputHistory, saveInputHistory, linkAtMouseColumn, openExternalUrl } from "./lib/utils.js";
6
6
  import { deleteRun } from "../../storage/run-store.js";
7
7
  import { saveGlobalConfig } from "../../config/load-config.js";
@@ -20,7 +20,7 @@ import { useSubmit } from "./hooks/use-submit.js";
20
20
  import { useSession } from "./hooks/use-session.js";
21
21
  import { useMouse } from "./hooks/use-mouse.js";
22
22
  import { ThemeProvider, useTheme } from "./hooks/use-theme.js";
23
- export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
23
+ export function SciraApp({ runPath: initialRunPath, config: initialConfig, updateNotice }) {
24
24
  const { exit } = useApp();
25
25
  const { stdout } = useStdout();
26
26
  const { stdin } = useStdin();
@@ -37,7 +37,7 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
37
37
  const [screen, setScreen] = useState(initialRunPath ? "chat" : "home");
38
38
  const [currentRunPath, setCurrentRunPath] = useState(initialRunPath);
39
39
  const [config, setConfig] = useState(initialConfig);
40
- const [notice, setNotice] = useState("");
40
+ const [notice, setNotice] = useState(updateNotice ?? "");
41
41
  const [pendingRerun, setPendingRerun] = useState(false);
42
42
  const [mcpOpen, setMcpOpen] = useState(false);
43
43
  const [mcpRowIdx, setMcpRowIdx] = useState(0);
@@ -56,6 +56,10 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
56
56
  const planModeRef = useRef(false);
57
57
  const [planMode, setPlanModeState] = useState(false);
58
58
  const setPlanMode = useCallback((active) => { planModeRef.current = active; setPlanModeState(active); }, []);
59
+ // Plan-mode preference armed from the home screen, applied when the next run opens.
60
+ const pendingPlanModeRef = useRef(false);
61
+ const [pendingPlanMode, setPendingPlanModeState] = useState(false);
62
+ const setPendingPlanMode = useCallback((active) => { pendingPlanModeRef.current = active; setPendingPlanModeState(active); }, []);
59
63
  const [usage, setUsage] = useState({});
60
64
  const turnsRef = useRef([]);
61
65
  const recordUsage = useCallback((model, u) => {
@@ -226,9 +230,9 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
226
230
  }), [pushFeed, appendText, appendReasoning, finishReasoning, markToolDone, setBusy, setApprovalPending, setMode]);
227
231
  const runTurnRef = useRef(async () => { });
228
232
  const { refreshSessions, refreshRun, openRun: openRunBase } = useSession({
229
- config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef,
233
+ config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef, pendingPlanModeRef,
230
234
  setSessions, setRunState, setCurrentRunPath, setInputText, setCursorPos,
231
- setFeed, setUsage, setScrollOffset, setScreen, setMode, setPlanMode,
235
+ setFeed, setUsage, setScrollOffset, setScreen, setMode, setPlanMode, setPendingPlanMode,
232
236
  setBusy, setApprovalPending, getSubscriber,
233
237
  });
234
238
  const { runTurn } = useAgentTurn({
@@ -314,10 +318,10 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
314
318
  });
315
319
  const { submitHome, submitChat, stopTurn } = useSubmit({
316
320
  state: { config, currentRunPath, sessions, selectedIdx, busy, usage, pendingRerun },
317
- refs: { queuedPromptRef, fullModeRef, planModeRef, conversationRef, feedRef },
321
+ refs: { queuedPromptRef, fullModeRef, planModeRef, pendingPlanModeRef, conversationRef, feedRef },
318
322
  setters: {
319
323
  setApprovalPending, setInputText, setCursorPos, setInputHistory, setHistoryIndex, setHelpOpen,
320
- setNotice, setBusy, setScreen, setFeed, setRunState, setPendingRerun, setMode, setPlanMode, setConfig, setMcpOpen,
324
+ setNotice, setBusy, setScreen, setFeed, setRunState, setPendingRerun, setMode, setPlanMode, setPendingPlanMode, setConfig, setMcpOpen,
321
325
  setHeroHidden,
322
326
  },
323
327
  actions: { pushFeed, refreshSessions, openRun, openMenu, handleSettings, runTurn, exit },
@@ -334,7 +338,7 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
334
338
  const caret = Math.max(0, Math.min(cursorPos, inputText.length));
335
339
  const { lines: inputLines, cursorLine, cursorCol } = wrapInputWithCursor(rawInputText, textWidth, showCursor ? caret : -1);
336
340
  const commandMenuHeight = activeSuggestions.length > 0 ? Math.min(MENU_VISIBLE, activeSuggestions.length) + 3 : 0;
337
- const helpHeight = helpOpen ? Math.min(14, CHAT_COMMANDS.length + 4) : 0;
341
+ const helpHeight = helpOpen ? KEY_HINTS.length + COMMAND_GROUPS.length + 7 : 0;
338
342
  const approvalPreviewLines = approvalPending
339
343
  ? Math.min(5, wrapText(approvalPending.description, Math.max(10, innerWidth - 4)).length)
340
344
  : 0;
@@ -443,9 +447,9 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
443
447
  const activeUsage = usage[config.model];
444
448
  const themed = (node) => (_jsx(ThemeProvider, { config: config, children: node }));
445
449
  if (screen === "home") {
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 })] }));
450
+ 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 + helpHeight, 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(HelpBox, { open: helpOpen, innerWidth: innerWidth, config: config }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: inputBlocked, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName, planMode: planMode, config: config }), _jsx(HintLine, { screen: screen, busy: busy, modeLabel: pendingPlanMode ? "PLAN MODE" : "", modeColor: "cyan", 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 })] }));
447
451
  }
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 })] }));
452
+ 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: feedLines.length > contentRows ? "flex-end" : "flex-start", 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, planMode: planMode, config: config }), _jsx(HintLine, { screen: screen, busy: busy, modeLabel: fullMode ? "FULL RESEARCH" : planMode ? "PLAN MODE" : "", modeColor: fullMode ? "magenta" : "cyan", 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 })] }));
449
453
  }
450
454
  function ChatInputChrome({ children }) {
451
455
  const theme = useTheme();
@@ -97,7 +97,7 @@ export function HomeScreen({ cols, rows, sessions, selectedIdx, hoveredIdx, hero
97
97
  if (inputText.trim())
98
98
  void submitHome(inputText);
99
99
  else
100
- setNotice("Type a question below to start a new research run.");
100
+ setNotice("Type a question below to start a new session.");
101
101
  });
102
102
  hoverMap.set(rowBase, newIdx);
103
103
  clickMap.set(rowBase + 1, (_x) => exit());
@@ -122,5 +122,5 @@ export function HomeScreen({ cols, rows, sessions, selectedIdx, hoveredIdx, hero
122
122
  const display = (s.title || s.goal || s.id).slice(0, cardW - 22);
123
123
  return (_jsxs(Box, { children: [_jsx(Text, { color: active ? theme.text : theme.textDim, children: active ? "❯ [ " : " [ " }), _jsx(Text, { bold: active, color: active ? theme.text : theme.textDim, wrap: "truncate", children: display }), _jsx(Box, { flexGrow: 1 }), s.isFull && _jsx(Text, { color: s.reportDirty ? theme.warning : theme.success, children: s.reportDirty ? "draft " : "ready " }), _jsx(Text, { color: theme.textDim, children: relativeTime(s.updatedAt) }), _jsx(Text, { color: active ? theme.text : theme.textDim, children: " ]" })] }, s.id));
124
124
  }), below > 0 && _jsx(Text, { color: theme.textDim, children: ` ↓ ${below} more below` })] }));
125
- })() : heroHidden || !heroLayout ? null : (_jsxs(Box, { flexDirection: "column", alignItems: "center", width: cardW, children: [_jsxs(Box, { borderStyle: "round", borderColor: theme.border, width: cardW, flexDirection: "row", paddingX: 2, paddingY: 1, children: [heroLayout.showArt ? (_jsx(Box, { flexDirection: "column", justifyContent: "center", marginRight: 3, children: [" ·:::· ", "·: ", " ·:::· ", " ·:", " ·:::· "].map((line, i) => (_jsx(Text, { color: theme.accent, children: line }, i))) })) : null, _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { gap: heroLayout.showSubtitle || heroLayout.showVersion ? 2 : 0, marginBottom: 1, children: [_jsx(Text, { bold: true, color: theme.text, children: "scira" }), heroLayout.showSubtitle ? _jsx(Text, { color: theme.textDim, children: "research agent" }) : null, heroLayout.showVersion ? _jsxs(Text, { color: theme.textDim, children: ["v", pkgVersion] }) : null] }), heroLayout.showTagline ? (_jsx(Text, { color: theme.text, wrap: "truncate", children: "Research and coding agent with real sources and tools." })) : null, heroLayout.showHint ? (_jsx(Text, { color: theme.textDim, wrap: "truncate", children: "Type a question below to start, or use # to browse past sessions." })) : null, (heroLayout.showConfig || heroLayout.showConfigDetail) ? _jsx(Box, { height: 1 }) : null, heroLayout.showConfig ? (_jsxs(Text, { color: theme.textDim, wrap: "truncate", children: [_jsx(Text, { color: theme.accent, children: "model " }), modelName, _jsxs(Text, { color: theme.textDim, children: [" \u00B7 ", providerLabel] })] })) : null, heroLayout.showConfigDetail ? (_jsxs(Text, { color: theme.textDim, wrap: "truncate", children: [_jsx(Text, { color: theme.accent, children: "search " }), config.search.provider, " \u00D7", config.search.maxResults, _jsxs(Text, { color: theme.textDim, children: [" \u00B7 theme ", config.theme, " \u00B7 ", config.approvalMode] }), mcpCount > 0 ? _jsxs(Text, { color: theme.textDim, children: [" \u00B7 ", mcpCount, " mcp"] }) : null] })) : null, _jsx(Box, { height: 1 }), _jsxs(Box, { children: [_jsx(Text, { bold: newActive, color: newActive ? theme.text : theme.textDim, children: "New research" }), heroLayout.showActionHints ? (_jsxs(_Fragment, { children: [_jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: theme.textDim, children: "\u23CE enter" })] })) : null] }), _jsxs(Box, { children: [_jsx(Text, { bold: quitActive, color: quitActive ? theme.text : theme.textDim, children: "Quit" }), heroLayout.showActionHints ? (_jsxs(_Fragment, { children: [_jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: theme.textDim, children: "ctrl+d" })] })) : null] })] })] }), notice ? (_jsx(Box, { paddingTop: 1, children: _jsx(Text, { color: theme.warning, children: notice }) })) : null, heroLayout.showTip ? (_jsx(Box, { paddingTop: 2, width: cardW, children: _jsxs(Text, { color: theme.textDim, wrap: "truncate", children: ["Tip: ", HOME_TIPS[tipIndex % HOME_TIPS.length]] }) })) : null] })) }));
125
+ })() : heroHidden || !heroLayout ? null : (_jsxs(Box, { flexDirection: "column", alignItems: "center", width: cardW, children: [_jsxs(Box, { borderStyle: "round", borderColor: theme.border, width: cardW, flexDirection: "row", paddingX: 2, paddingY: 1, children: [heroLayout.showArt ? (_jsx(Box, { flexDirection: "column", justifyContent: "center", marginRight: 3, children: [" ·:::· ", "·: ", " ·:::· ", " ·:", " ·:::· "].map((line, i) => (_jsx(Text, { color: theme.accent, children: line }, i))) })) : null, _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { gap: heroLayout.showSubtitle || heroLayout.showVersion ? 2 : 0, marginBottom: 1, children: [_jsx(Text, { bold: true, color: theme.text, children: "scira" }), heroLayout.showSubtitle ? _jsx(Text, { color: theme.textDim, children: "research & coding agent" }) : null, heroLayout.showVersion ? _jsxs(Text, { color: theme.textDim, children: ["v", pkgVersion] }) : null] }), heroLayout.showTagline ? (_jsx(Text, { color: theme.text, wrap: "truncate", children: "Research and coding agent with real sources and tools." })) : null, heroLayout.showHint ? (_jsx(Text, { color: theme.textDim, wrap: "truncate", children: "Type a question below to start, or use # to browse past sessions." })) : null, (heroLayout.showConfig || heroLayout.showConfigDetail) ? _jsx(Box, { height: 1 }) : null, heroLayout.showConfig ? (_jsxs(Text, { color: theme.textDim, wrap: "truncate", children: [_jsx(Text, { color: theme.accent, children: "model " }), modelName, _jsxs(Text, { color: theme.textDim, children: [" \u00B7 ", providerLabel] })] })) : null, heroLayout.showConfigDetail ? (_jsxs(Text, { color: theme.textDim, wrap: "truncate", children: [_jsx(Text, { color: theme.accent, children: "search " }), config.search.provider, " \u00D7", config.search.maxResults, _jsxs(Text, { color: theme.textDim, children: [" \u00B7 theme ", config.theme, " \u00B7 ", config.approvalMode] }), mcpCount > 0 ? _jsxs(Text, { color: theme.textDim, children: [" \u00B7 ", mcpCount, " mcp"] }) : null] })) : null, _jsx(Box, { height: 1 }), _jsxs(Box, { children: [_jsx(Text, { bold: newActive, color: newActive ? theme.text : theme.textDim, children: "New session" }), heroLayout.showActionHints ? (_jsxs(_Fragment, { children: [_jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: theme.textDim, children: "\u23CE enter" })] })) : null] }), _jsxs(Box, { children: [_jsx(Text, { bold: quitActive, color: quitActive ? theme.text : theme.textDim, children: "Quit" }), heroLayout.showActionHints ? (_jsxs(_Fragment, { children: [_jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: theme.textDim, children: "ctrl+d" })] })) : null] })] })] }), notice ? (_jsx(Box, { paddingTop: 1, children: _jsx(Text, { color: theme.warning, children: notice }) })) : null, heroLayout.showTip ? (_jsx(Box, { paddingTop: 2, width: cardW, children: _jsxs(Text, { color: theme.textDim, wrap: "truncate", children: ["Tip: ", HOME_TIPS[tipIndex % HOME_TIPS.length]] }) })) : null] })) }));
126
126
  }
@@ -1,34 +1,37 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { SPINNER_FRAMES, CHAT_COMMANDS, COMMAND_DESCRIPTIONS, MENU_VISIBLE } from "../constants.js";
4
- import { fmtTokens, wrapText } from "../lib/utils.js";
3
+ import { SPINNER_FRAMES, COMMAND_DESCRIPTIONS, COMMAND_GROUPS, KEY_HINTS, MENU_VISIBLE } from "../constants.js";
4
+ import { fmtTokens, wrapText, displayWidth } from "../lib/utils.js";
5
5
  import { LLM_PROVIDER_LABELS } from "../../../providers/llm/registry.js";
6
6
  import { useTheme } from "../hooks/use-theme.js";
7
7
  export function TopBar({ screen, runState, fullMode, planMode, activeUsage, busy, frame, cwdDisplay, config }) {
8
8
  const theme = useTheme();
9
9
  return (_jsxs(Box, { paddingTop: 1, justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, minWidth: 0, marginRight: 2, children: _jsx(Text, { color: theme.textDim, wrap: "truncate-end", children: screen === "chat" ? (runState?.title || runState?.goal || cwdDisplay) : cwdDisplay }) }), screen === "chat" && (_jsxs(Box, { flexShrink: 0, gap: 1, children: [_jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: fullMode ? "magenta" : theme.accent, children: fullMode ? "full" : "quick" }), planMode && _jsx(Text, { color: "cyan", children: "plan" }), activeUsage && (_jsx(Text, { color: theme.textDim, children: `↑${fmtTokens(activeUsage.input)} ↓${fmtTokens(activeUsage.output)}` })), fullMode && (_jsxs(Text, { color: theme.textDim, children: [`src:${runState?.sourceCount ?? 0}`, (runState?.claimCount ?? 0) > 0 ? ` · claims:${runState?.claimCount}` : ""] })), fullMode && (_jsx(Text, { color: runState?.reportDirty ? theme.warning : theme.success, children: runState?.reportDirty ? "draft" : "ready" })), busy && _jsx(Text, { color: theme.accent, children: SPINNER_FRAMES[frame % SPINNER_FRAMES.length] }), _jsx(Text, { color: theme.textDim, children: "|" })] }))] }));
10
10
  }
11
- export function InputBar({ inputLines, cursorLine, cursorCol, showCursor, approvalPending, busy, frame, boxWidth, modelName, config }) {
11
+ export function InputBar({ inputLines, cursorLine, cursorCol, showCursor, approvalPending, busy, frame, boxWidth, modelName, planMode, config }) {
12
12
  const theme = useTheme();
13
- const borderColor = approvalPending ? theme.warning : busy ? theme.accentDim : theme.textDim;
14
- const promptColor = approvalPending ? theme.warning : busy ? theme.accentDim : theme.accent;
13
+ // Plan mode tints the whole input box (unless an approval/busy state takes precedence).
14
+ const borderColor = approvalPending ? theme.warning : busy ? theme.accentDim : planMode ? "cyan" : theme.textDim;
15
+ const promptColor = approvalPending ? theme.warning : busy ? theme.accentDim : planMode ? "cyan" : theme.accent;
15
16
  const inputColor = approvalPending ? theme.textDim : theme.inputText;
16
- const borderLabel = busy ? `${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ${modelName}` : modelName;
17
+ const planTag = planMode ? "plan " : "";
18
+ const borderLabel = busy ? `${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ${planTag}${modelName}` : `${planTag}${modelName}`;
17
19
  const labelMax = Math.max(0, boxWidth - 6);
18
20
  const label = borderLabel.length > labelMax ? borderLabel.slice(0, labelMax) : borderLabel;
19
21
  const dashCount = Math.max(1, boxWidth - label.length - 5);
20
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: borderColor, children: "╭" + "─".repeat(Math.max(0, boxWidth - 2)) + "╮" }), inputLines.map((line, i) => (_jsxs(Box, { width: boxWidth, children: [_jsx(Text, { color: borderColor, children: "│ " }), _jsx(Text, { color: i === 0 ? promptColor : borderColor, children: i === 0 ? "❯ " : " " }), _jsx(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: " ─╯" })] })] }));
22
+ 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: planMode ? "cyan" : theme.accent, children: label }), _jsx(Text, { color: borderColor, children: " ─╯" })] })] }));
21
23
  }
22
- export function HintLine({ screen, busy, scrollLabel, hasDoneGroups, hasFocusedGroup, hasLinkHover, alwaysAllowLinks, config }) {
24
+ export function HintLine({ screen, busy, scrollLabel, hasDoneGroups, hasFocusedGroup, hasLinkHover, alwaysAllowLinks, modeLabel, modeColor, config }) {
23
25
  const theme = useTheme();
26
+ const modeChip = modeLabel ? _jsx(Text, { backgroundColor: modeColor ?? "cyan", color: theme.background, bold: true, children: ` ${modeLabel} ` }) : null;
24
27
  if (screen === "chat") {
25
28
  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
29
  ? "click link to open"
27
30
  : _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
28
31
  ? _jsxs(_Fragment, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "C" }), " toggle \u00B7 ", _jsx(Text, { bold: true, color: theme.accent, children: "ESC" }), " unfocus"] })
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] }));
32
+ : _jsxs(_Fragment, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "[ ]" }), " \u00B7 ", _jsx(Text, { bold: true, color: theme.accent, children: "C" }), " groups"] }) })] })) : null, (modeChip || scrollLabel) ? _jsx(Box, { flexGrow: 1 }) : null, modeChip, scrollLabel ? _jsx(Text, { color: theme.textDim, children: scrollLabel }) : null] }));
30
33
  }
31
- return (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: theme.textDim, children: [_jsx(Text, { bold: true, color: theme.accent, children: "\u2191\u2193" }), ":navigate"] }), _jsx(Text, { color: theme.textDim, children: "|" }), _jsxs(Text, { color: theme.textDim, children: [_jsx(Text, { bold: true, color: theme.accent, children: "\u23CE" }), ":open"] }), _jsx(Text, { color: theme.textDim, children: "|" }), _jsxs(Text, { color: theme.textDim, children: [_jsx(Text, { bold: true, color: theme.accent, children: "ESC" }), ":close"] }), _jsx(Text, { color: theme.textDim, children: "|" }), _jsxs(Text, { color: theme.textDim, children: [_jsx(Text, { bold: true, color: theme.accent, children: "Q" }), ":quit"] })] }));
34
+ return (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: theme.textDim, children: [_jsx(Text, { bold: true, color: theme.accent, children: "\u2191\u2193" }), ":navigate"] }), _jsx(Text, { color: theme.textDim, children: "|" }), _jsxs(Text, { color: theme.textDim, children: [_jsx(Text, { bold: true, color: theme.accent, children: "\u23CE" }), ":open"] }), _jsx(Text, { color: theme.textDim, children: "|" }), _jsxs(Text, { color: theme.textDim, children: [_jsx(Text, { bold: true, color: theme.accent, children: "ESC" }), ":close"] }), _jsx(Text, { color: theme.textDim, children: "|" }), _jsxs(Text, { color: theme.textDim, children: [_jsx(Text, { bold: true, color: theme.accent, children: "^D" }), ":quit"] }), modeChip ? _jsxs(_Fragment, { children: [_jsx(Box, { flexGrow: 1 }), modeChip] }) : null] }));
32
35
  }
33
36
  export function CommandMenuBox({ activeSuggestions, activeSuggestionKind, commandMenuIndex, innerWidth, sessions, config }) {
34
37
  const theme = useTheme();
@@ -75,7 +78,10 @@ export function HelpBox({ open, innerWidth, config }) {
75
78
  const theme = useTheme();
76
79
  if (!open)
77
80
  return null;
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)))] }));
81
+ const rule = "─".repeat(Math.max(10, innerWidth - 6));
82
+ const keyW = Math.max(...KEY_HINTS.map((h) => h.keys.length));
83
+ const labelW = Math.max(...COMMAND_GROUPS.map((g) => g.label.length));
84
+ 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: "\u00B7 esc to close \u00B7 type / to search a command" })] }), _jsx(Text, { color: theme.textDim, children: rule }), _jsx(Text, { bold: true, color: theme.textDim, children: "keys" }), KEY_HINTS.map((h) => (_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: theme.accent, children: h.keys.padEnd(keyW) }), _jsx(Text, { color: theme.textDim, children: h.action })] }, h.keys))), _jsx(Text, { color: theme.textDim, children: rule }), _jsx(Text, { bold: true, color: theme.textDim, children: "commands" }), COMMAND_GROUPS.map((g) => (_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: theme.textDim, children: g.label.padEnd(labelW) }), _jsx(Text, { color: theme.accent, wrap: "truncate", children: g.commands.join(" ") })] }, g.label)))] }));
79
85
  }
80
86
  export function LinkOpenBox({ url, innerWidth, config }) {
81
87
  const theme = useTheme();
@@ -106,11 +112,66 @@ export function MenuDialog({ menu, cols, rows, config }) {
106
112
  const dialogLeft = Math.max(0, Math.floor((cols - 4 - DIALOG_W) / 2));
107
113
  const dialogH = 5 + (menu.loading ? 1 : Math.min(DIALOG_ITEMS, menuFiltered.length) + (menuStart > 0 ? 1 : 0) + (menuFiltered.length - (menuStart + DIALOG_ITEMS) > 0 ? 1 : 0));
108
114
  const dialogTop = Math.max(1, Math.floor((rows - dialogH) / 2));
109
- return (_jsxs(Box, { position: "absolute", marginLeft: dialogLeft, marginTop: dialogTop, width: DIALOG_W, flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, children: [_jsxs(Text, { bold: true, color: theme.text, children: [menu.type === "model" ? "Select model" : menu.type === "llm" ? "Select LLM provider" : "Select search provider", " ", _jsx(Text, { color: theme.textDim, children: "\u2191\u2193 navigate \u00B7 \u23CE apply \u00B7 esc close" })] }), !menu.loading && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: "⌕ " }), _jsx(Text, { color: theme.inputText, children: menu.query }), !menu.query && _jsx(Text, { color: theme.textDim, children: "type to filter\u2026" })] }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(4, DIALOG_W - 4)) })] })), menu.loading ? (_jsx(Text, { color: theme.textDim, children: " loading models\u2026" })) : menuFiltered.length === 0 ? (_jsxs(Text, { color: theme.textDim, children: [" no matches for \"", menu.query, "\""] })) : (_jsxs(_Fragment, { children: [menuStart > 0 && _jsxs(Text, { color: theme.textDim, children: [" \u2191 ", menuStart, " more"] }), menuFiltered.slice(menuStart, menuStart + DIALOG_ITEMS).map((item, i) => {
110
- const idx = menuStart + i;
111
- const active = idx === menu.index;
112
- return (_jsxs(Text, { color: active ? theme.accent : theme.textDim, bold: active, wrap: "truncate", children: [active ? "❯ " : " ", displayName(item), menu.type === "llm" ? _jsx(Text, { color: theme.textDim, children: " " + item }) : null] }, item));
113
- }), menuFiltered.length - (menuStart + DIALOG_ITEMS) > 0 && (_jsxs(Text, { color: theme.textDim, children: [" \u2193 ", menuFiltered.length - (menuStart + DIALOG_ITEMS), " more"] }))] }))] }));
115
+ const bg = theme.userBandBackground ? { backgroundColor: theme.userBandBackground } : {};
116
+ const innerW = DIALOG_W - 2; // cells between the two border columns
117
+ // Draw the border as characters inside full-width background lines (like
118
+ // InputBar). Ink's box border + backgroundColor leaves unfilled gaps, so we
119
+ // compose each line ourselves: every line is one solid Text spanning DIALOG_W.
120
+ const line = (key, visibleLen, content) => (_jsxs(Text, { ...bg, wrap: "truncate", children: [_jsx(Text, { color: theme.accent, children: "\u2502" }), _jsx(Text, { children: " " }), content, _jsx(Text, { children: " ".repeat(Math.max(0, innerW - 1 - visibleLen)) }), _jsx(Text, { color: theme.accent, children: "\u2502" })] }, key));
121
+ // Clip a string to at most `max` display columns (so a row never overruns the
122
+ // border on narrow terminals — wrap="truncate" would eat the closing │).
123
+ const clip = (s, max) => {
124
+ if (max <= 0)
125
+ return "";
126
+ if (displayWidth(s) <= max)
127
+ return s;
128
+ let out = "", w = 0;
129
+ for (const ch of s) {
130
+ const cw = displayWidth(ch);
131
+ if (w + cw > max - 1)
132
+ break; // leave a column for the ellipsis
133
+ out += ch;
134
+ w += cw;
135
+ }
136
+ return out + "…";
137
+ };
138
+ const avail = innerW - 1; // usable columns after the leading space
139
+ const title = menu.type === "model" ? "Select model" : menu.type === "llm" ? "Select LLM provider" : "Select search provider";
140
+ const hint = "↑↓ navigate · ⏎ apply · esc close";
141
+ const dialogLines = [];
142
+ // Title + hint, dropping/clipping the (secondary) hint when space is tight.
143
+ const titleC = clip(title, avail);
144
+ const hintRoom = avail - displayWidth(titleC) - 2;
145
+ const hintC = hintRoom >= 6 ? clip(hint, hintRoom) : "";
146
+ dialogLines.push(line("title", displayWidth(titleC) + (hintC ? 2 + displayWidth(hintC) : 0), (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, color: theme.text, children: titleC }), hintC ? _jsx(Text, { color: theme.textDim, children: " " + hintC }) : null] }))));
147
+ if (!menu.loading) {
148
+ const filterC = clip(menu.query || "type to filter…", avail - 2);
149
+ dialogLines.push(line("filter", 2 + displayWidth(filterC), (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.accent, children: "⌕ " }), menu.query ? _jsx(Text, { color: theme.inputText, children: filterC }) : _jsx(Text, { color: theme.textDim, children: filterC })] }))));
150
+ dialogLines.push(line("divider", innerW - 1, _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(4, innerW - 1)) })));
151
+ }
152
+ if (menu.loading) {
153
+ dialogLines.push(line("loading", displayWidth("loading models…"), _jsx(Text, { color: theme.textDim, children: "loading models\u2026" })));
154
+ }
155
+ else if (menuFiltered.length === 0) {
156
+ const msg = clip(`no matches for "${menu.query}"`, avail);
157
+ dialogLines.push(line("empty", displayWidth(msg), _jsx(Text, { color: theme.textDim, children: msg })));
158
+ }
159
+ else {
160
+ if (menuStart > 0)
161
+ dialogLines.push(line("up", displayWidth(`↑ ${menuStart} more`), _jsx(Text, { color: theme.textDim, children: `↑ ${menuStart} more` })));
162
+ menuFiltered.slice(menuStart, menuStart + DIALOG_ITEMS).forEach((item, i) => {
163
+ const active = menuStart + i === menu.index;
164
+ const marker = active ? "❯ " : " ";
165
+ const label = clip(displayName(item), avail - displayWidth(marker));
166
+ const suffixRoom = avail - displayWidth(marker + label);
167
+ const suffix = menu.type === "llm" && suffixRoom >= 4 ? clip(" " + item, suffixRoom) : "";
168
+ dialogLines.push(line(item, displayWidth(marker + label) + displayWidth(suffix), (_jsxs(_Fragment, { children: [_jsx(Text, { color: active ? theme.accent : theme.textDim, bold: active, children: marker + label }), suffix ? _jsx(Text, { color: theme.textDim, children: suffix }) : null] }))));
169
+ });
170
+ const moreBelow = menuFiltered.length - (menuStart + DIALOG_ITEMS);
171
+ if (moreBelow > 0)
172
+ dialogLines.push(line("down", displayWidth(`↓ ${moreBelow} more`), _jsx(Text, { color: theme.textDim, children: `↓ ${moreBelow} more` })));
173
+ }
174
+ return (_jsxs(Box, { position: "absolute", marginLeft: dialogLeft, marginTop: dialogTop, width: DIALOG_W, flexDirection: "column", children: [_jsx(Text, { ...bg, children: _jsx(Text, { color: theme.accent, children: "╭" + "─".repeat(innerW) + "╮" }) }), dialogLines, _jsx(Text, { ...bg, children: _jsx(Text, { color: theme.accent, children: "╰" + "─".repeat(innerW) + "╯" }) })] }));
114
175
  }
115
176
  export function buildMcpDialogRows(config) {
116
177
  const dt = config.mcp.chromeDevtools;
@@ -163,7 +224,7 @@ export function McpDialog({ open, config, cols, rows, selectedIdx, hoveredIdx, o
163
224
  });
164
225
  clickMapRef.current = clickMap;
165
226
  hoverMapRef.current = hoverMap;
166
- return (_jsxs(Box, { position: "absolute", marginLeft: left, marginTop: top, width: W, flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, paddingY: 1, children: [_jsxs(Text, { bold: true, color: theme.text, children: ["MCP servers ", _jsx(Text, { color: theme.textDim, children: "\u2191\u2193 \u00B7 space toggle \u00B7 x remove \u00B7 esc close" })] }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(4, W - 4)) }), entries.map((row, i) => {
227
+ return (_jsxs(Box, { position: "absolute", marginLeft: left, marginTop: top, width: W, flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, paddingY: 1, ...(theme.userBandBackground ? { backgroundColor: theme.userBandBackground } : {}), children: [_jsxs(Text, { bold: true, color: theme.text, children: ["MCP servers ", _jsx(Text, { color: theme.textDim, children: "\u2191\u2193 \u00B7 space toggle \u00B7 x remove \u00B7 esc close" })] }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(4, W - 4)) }), entries.map((row, i) => {
167
228
  const active = i === selectedIdx || hoveredIdx === i;
168
229
  const check = row.enabled ? "x" : " ";
169
230
  return (_jsxs(Box, { width: W - 2, flexDirection: "row", children: [_jsx(Box, { flexGrow: 1, minWidth: 0, children: _jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: active ? theme.text : theme.textDim, children: active ? "❯ " : " " }), _jsxs(Text, { color: active ? theme.accent : theme.textDim, bold: active, children: ["[", check, "]"] }), _jsxs(Text, { color: theme.text, children: [" ", row.name, " "] }), _jsxs(Text, { color: theme.accent, children: ["[", row.transport, "] "] }), _jsx(Text, { color: theme.textDim, children: row.target })] }) }), row.removable ? (_jsx(Text, { color: active ? theme.warning : theme.textDim, children: " [x]" })) : null] }, row.key));
@@ -3,15 +3,30 @@ 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", "/links", "/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", "/thinking", "/reasoning", "/theme", "/links", "/key", "/keys", "/stop", "/back", "/quit"];
7
+ /** Commands grouped by purpose, for the /help reference. */
8
+ export const COMMAND_GROUPS = [
9
+ { label: "Model", commands: ["/llm", "/model", "/provider", "/plan", "/thinking", "/reasoning"] },
10
+ { label: "Session", commands: ["/new", "/rerun", "/rename", "/report", "/sources", "/claims", "/why", "/copy", "/usage"] },
11
+ { label: "Setup", commands: ["/key", "/keys", "/mcp", "/theme", "/links"] },
12
+ { label: "Go", commands: ["/home", "/back", "/stop", "/quit"] },
13
+ ];
14
+ /** Keyboard shortcuts shown in /help (not discoverable via the `/` autocomplete). */
15
+ export const KEY_HINTS = [
16
+ { keys: "↑↓ jk u/d pgup/dn", action: "scroll" },
17
+ { keys: "^C ^C / ^D", action: "quit" },
18
+ { keys: "esc", action: "clear input / close" },
19
+ { keys: "/ @ #", action: "commands · files · sessions" },
20
+ { keys: "[ ] C", action: "navigate / toggle tool groups" },
21
+ ];
7
22
  /** Slash commands that take an argument; ⏎ from the menu appends a space instead of running. */
8
23
  export const COMMANDS_NEEDING_ARGS = new Set(["/theme", "/key", "/rename", "/why", "/links"]);
9
24
  export const COMMAND_DESCRIPTIONS = {
10
25
  "/help": "Show command and keyboard shortcuts.",
11
26
  "/home": "Go to the home screen (or show the welcome card on home).",
12
- "/new": "Go to the home screen to start a new research run.",
27
+ "/new": "Go to the home screen to start a new session.",
13
28
  "/plan": "Toggle plan mode (explore and plan before making changes).",
14
- "/rerun": "Run the research agent again for this run.",
29
+ "/rerun": "Run the research agent again for this session.",
15
30
  "/report": "Show the generated report.md in the timeline.",
16
31
  "/sources": "List the run's gathered sources with links.",
17
32
  "/claims": "List all claims with id, confidence, status, and text.",
@@ -21,8 +36,10 @@ export const COMMAND_DESCRIPTIONS = {
21
36
  "/usage": "Show token usage per model for this session.",
22
37
  "/rename": "Set a title for this session, e.g. /rename SpaceX IPO analysis",
23
38
  "/model": "Open the model selector dropup.",
24
- "/llm": "Switch the LLM provider (gateway, xai, workers-ai).",
39
+ "/llm": "Switch the LLM provider (gateway, xai, workers-ai, claude-code, codex).",
25
40
  "/provider": "Open the search provider selector.",
41
+ "/thinking": "Claude Code thinking: /thinking off · on · adaptive",
42
+ "/reasoning": "Codex reasoning effort: /reasoning low · medium · high",
26
43
  "/theme": "Set UI theme: /theme dark · /theme light · /theme auto",
27
44
  "/links": "Link opens: /links always · /links ask",
28
45
  "/key": "Save an API key, e.g. /key EXA_API_KEY ...",
@@ -40,6 +57,8 @@ export const TOOL_ICONS = {
40
57
  createClaim: "◎",
41
58
  verifyClaim: "✓",
42
59
  webSearch: "⌕",
60
+ multiWebSearch: "⌕",
61
+ fileChange: "✎",
43
62
  readUrl: "↗",
44
63
  listSkills: "★",
45
64
  readSkill: "★",
@@ -79,11 +98,11 @@ export const LOADING_PHRASES = [
79
98
  "Wrapping my head around it…",
80
99
  ];
81
100
  export const HOME_TIPS = [
82
- "Type a question and press ⏎ to start a new research run.",
101
+ "Type a question and press ⏎ to start a new session.",
83
102
  "Say \"deep research …\" or \"compare …\" to trigger the full research harness.",
84
- "↑↓ navigate · ⏎ open · type to start a new run.",
103
+ "↑↓ navigate · ⏎ open · type to start a new session.",
85
104
  "/model · /provider · /key NAME value to configure.",
86
- "Browse all sessions to find older runs.",
105
+ "Browse all sessions to find older ones.",
87
106
  "ready / draft badges show full-research runs and their report state."
88
107
  ];
89
108
  export const FULL_MODE_TRIGGERS = [
@@ -1,7 +1,6 @@
1
1
  import { useCallback, useRef } from "react";
2
- import { writeFile } from "node:fs/promises";
3
2
  import { join } from "node:path";
4
- import { createResearchAgent, createOneShotAgent } from "../../../agent/research-agent.js";
3
+ import { createResearchAgent, createOneShotAgent } from "../../../agent/main-agent.js";
5
4
  import { createBackgroundTaskManager } from "../../../tools/background-tasks.js";
6
5
  import { resolveProjectRoot } from "../../../tools/workspace.js";
7
6
  import { generateWithGateway } from "../../../providers/llm/gateway.js";
@@ -62,9 +61,19 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
62
61
  case "reasoning-end":
63
62
  sessionFinishReasoning(runPath);
64
63
  break;
65
- case "tool-call":
66
- sessionPushFeed(runPath, { kind: "tool", name: part.toolName, toolCallId: part.toolCallId, summary: summarizeToolInput(part.toolName, part.input), status: "running" });
64
+ case "tool-call": {
65
+ // Stash the raw input (capped) so dedicated tool renderers (diffs,
66
+ // checklists, …) can reconstruct it from the feed.
67
+ let inputJson;
68
+ try {
69
+ inputJson = JSON.stringify(part.input)?.slice(0, 32_000);
70
+ }
71
+ catch {
72
+ inputJson = undefined;
73
+ }
74
+ sessionPushFeed(runPath, { kind: "tool", name: part.toolName, toolCallId: part.toolCallId, summary: summarizeToolInput(part.toolName, part.input), status: "running", input: inputJson });
67
75
  break;
76
+ }
68
77
  case "tool-result": {
69
78
  if (part.preliminary)
70
79
  break;
@@ -206,7 +215,7 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
206
215
  .filter((item) => !(item.kind === "status" && item.text === "This will wipe the conversation history. Press /rerun again to confirm."))
207
216
  .map((item) => item.kind === "tool" && item.status === "running" ? { ...item, status: "error" } : item);
208
217
  try {
209
- await writeFile(join(runPath, "convo.json"), JSON.stringify({ feed: snapshot, messages: conversationRef.current, usage: aggregateTurns(turnsRef.current) }, null, 2));
218
+ await Bun.write(join(runPath, "convo.json"), JSON.stringify({ feed: snapshot, messages: conversationRef.current, usage: aggregateTurns(turnsRef.current) }, null, 2));
210
219
  }
211
220
  catch { /* non-fatal */ }
212
221
  removeSession(runPath);
@@ -4,8 +4,25 @@ import { Text } from "ink";
4
4
  import Link from "ink-link";
5
5
  import { S_BAR, TOOL_ICONS, SPINNER_FRAMES } from "../constants.js";
6
6
  import { formatTime, fmtDuration, wrapText, computeLineLinks, displayWidth } from "../lib/utils.js";
7
- import { formatToolResultLines, formatToolResultPreview, feedToolItemId, isCollapsibleToolName, isToolItemCollapsed, } from "../lib/tool-result.js";
7
+ import { formatToolResultLines, formatToolResultPreview, feedToolItemId, isCollapsibleToolName, isToolItemCollapsed, canonicalToolName, displayToolName, } from "../lib/tool-result.js";
8
8
  import { markdownToSegLines } from "../lib/markdown.js";
9
+ // Formatting a tool body (wrapping/parsing the result) is the expensive part of
10
+ // building feed lines. The feed re-renders on every spinner/reasoning tick
11
+ // (~20fps while busy), so without caching a large result would be re-parsed
12
+ // dozens of times a second. Cache by content identity; completed items hit the
13
+ // cache and only the live item (whose result is still growing) recomputes.
14
+ const toolBodyCache = new Map();
15
+ function cachedToolBody(themeKey, name, callId, summary, result, status, width, theme, expanded, input) {
16
+ const key = `${themeKey}|${callId}|${name}|${status}|${width}|${expanded}|${result?.length ?? 0}|${input?.length ?? 0}`;
17
+ const hit = toolBodyCache.get(key);
18
+ if (hit)
19
+ return hit;
20
+ const v = formatToolResultLines(name, summary, result, status, width, theme, expanded, input);
21
+ toolBodyCache.set(key, v);
22
+ if (toolBodyCache.size > 400)
23
+ toolBodyCache.delete(toolBodyCache.keys().next().value);
24
+ return v;
25
+ }
9
26
  import { useTheme } from "./use-theme.js";
10
27
  export function computeGroups(feed) {
11
28
  const groupOf = new Array(feed.length).fill(-1);
@@ -155,21 +172,29 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState,
155
172
  const running = fi.status === "running";
156
173
  const failed = fi.status === "error";
157
174
  const itemId = feedToolItemId(feedIdx, fi.toolCallId);
158
- const collapsible = isCollapsibleToolName(fi.name) && !running;
159
- const collapsed = collapsible && isToolItemCollapsed(itemId, fi.name, fi.status, itemExpandState);
175
+ // Route rendering through the Scira-canonical name (Claude/Codex builtins
176
+ // and `mcp__harness-tools__*` host tools map onto our renderers), but show
177
+ // the cleaned real name in the header.
178
+ const canon = canonicalToolName(fi.name);
179
+ const shownName = displayToolName(fi.name);
180
+ const collapsible = isCollapsibleToolName(canon) && !running;
181
+ const collapsed = collapsible && isToolItemCollapsed(itemId, canon, fi.status, itemExpandState);
160
182
  const headerLineIdx = lines.length;
161
183
  const hovered = hoveredLineIdx === headerLineIdx;
162
184
  const toolIcon = running
163
185
  ? SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length]
164
- : TOOL_ICONS[fi.name] ?? "·";
186
+ : TOOL_ICONS[canon] ?? "·";
165
187
  const symColor = failed ? theme.error : theme.accentDim;
166
188
  const nameColor = running ? theme.text : failed ? theme.error : theme.textDim;
167
189
  const panelWidth = innerWidth - 4;
190
+ // Pass the raw name so dedicated built-in renderers (Edit diff, TodoWrite
191
+ // checklist, …) can key off it; the formatters canonicalize internally
192
+ // for the generic path.
168
193
  const preview = formatToolResultPreview(fi.name, fi.summary, fi.result, fi.status);
169
- const bodyLines = formatToolResultLines(fi.name, fi.summary, fi.result, fi.status, panelWidth, theme, !collapsed);
194
+ const bodyLines = cachedToolBody(theme.accent, fi.name, fi.toolCallId ?? String(feedIdx), fi.summary, fi.result, fi.status, panelWidth, theme, !collapsed, fi.input);
170
195
  if (collapsible)
171
196
  toggleAtLine.set(headerLineIdx, itemId);
172
- lines.push(_jsxs(Text, { wrap: "truncate", children: [collapsible ? (_jsx(Text, { color: hovered ? theme.accent : theme.textDim, bold: hovered, children: collapsed ? "▶ " : "▼ " })) : null, _jsx(Text, { color: symColor, bold: running, children: toolIcon }), _jsxs(Text, { color: nameColor, bold: running || failed || hovered, children: [" ", fi.name] }), failed ? _jsx(Text, { color: theme.error, children: " failed" }) : null, running ? _jsx(Text, { color: theme.textDim, children: " \u2026" }) : null, !running && !failed && collapsed && preview ? (_jsxs(Text, { color: theme.textDim, children: [" ", preview] })) : null] }, key++));
197
+ lines.push(_jsxs(Text, { wrap: "truncate", children: [collapsible ? (_jsx(Text, { color: hovered ? theme.accent : theme.textDim, bold: hovered, children: collapsed ? "▶ " : "▼ " })) : null, _jsx(Text, { color: symColor, bold: running, children: toolIcon }), _jsxs(Text, { color: nameColor, bold: running || failed || hovered, children: [" ", shownName] }), failed ? _jsx(Text, { color: theme.error, children: " failed" }) : null, running ? _jsx(Text, { color: theme.textDim, children: " \u2026" }) : null, !running && !failed && collapsed && preview ? (_jsxs(Text, { color: theme.textDim, children: [" ", preview] })) : null] }, key++));
173
198
  for (const row of bodyLines) {
174
199
  if (row.length === 0) {
175
200
  lines.push(_jsx(Text, { color: theme.textDim, children: S_BAR }, key++));