@scira/cli 0.1.5 → 0.1.6

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 (57) hide show
  1. package/dist/agent/harness-agent.js +206 -0
  2. package/dist/agent/{research-agent.js → main-agent.js} +20 -1
  3. package/dist/cli/commands/init.js +7 -5
  4. package/dist/cli/index.js +52 -11
  5. package/dist/cli/shell/shell.js +4 -5
  6. package/dist/cli/shell/tui.js +5 -2
  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 +13 -0
  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 +15 -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/types/index.js +13 -1
  23. package/dist/ui/ink/SciraApp.js +10 -6
  24. package/dist/ui/ink/components/home-screen.js +2 -2
  25. package/dist/ui/ink/components/overlays.js +73 -15
  26. package/dist/ui/ink/constants.js +10 -7
  27. package/dist/ui/ink/hooks/use-agent-turn.js +14 -5
  28. package/dist/ui/ink/hooks/use-feed-lines.js +31 -6
  29. package/dist/ui/ink/hooks/use-keyboard.js +28 -5
  30. package/dist/ui/ink/hooks/use-session.js +7 -5
  31. package/dist/ui/ink/hooks/use-settings.js +20 -0
  32. package/dist/ui/ink/hooks/use-submit.js +15 -8
  33. package/dist/ui/ink/lib/file-mentions.js +1 -2
  34. package/dist/ui/ink/lib/tool-result.js +201 -2
  35. package/dist/ui/ink/lib/utils.js +52 -28
  36. package/dist/ui/ink/theme.js +5 -10
  37. package/dist/watch/runner.js +2 -2
  38. package/package.json +13 -11
  39. package/dist/agent/background-tasks.js +0 -173
  40. package/dist/agent/todos.js +0 -140
  41. package/dist/agent/tools.js +0 -432
  42. package/dist/agent/tools.test.js +0 -60
  43. package/dist/agent/workspace.js +0 -85
  44. package/dist/config/env-guide.test.js +0 -18
  45. package/dist/config/env-store.test.js +0 -60
  46. package/dist/storage/jsonl.test.js +0 -38
  47. package/dist/storage/run-store.test.js +0 -65
  48. package/dist/tools/bash-policy.test.js +0 -38
  49. package/dist/tools/search-web.test.js +0 -24
  50. package/dist/tools/workspace.test.js +0 -75
  51. package/dist/types/schema.test.js +0 -61
  52. package/dist/ui/ink/hooks/use-feed-lines.test.js +0 -16
  53. package/dist/ui/ink/lib/tool-result.test.js +0 -60
  54. package/dist/ui/ink/lib/utils.test.js +0 -48
  55. package/dist/ui/ink/session-manager.test.js +0 -31
  56. package/dist/ui/ink/terminal-probe.test.js +0 -12
  57. package/dist/ui/ink/theme.test.js +0 -68
@@ -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 },
@@ -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, 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, 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
3
  import { SPINNER_FRAMES, CHAT_COMMANDS, COMMAND_DESCRIPTIONS, MENU_VISIBLE } from "../constants.js";
4
- import { fmtTokens, wrapText } from "../lib/utils.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();
@@ -106,11 +109,66 @@ export function MenuDialog({ menu, cols, rows, config }) {
106
109
  const dialogLeft = Math.max(0, Math.floor((cols - 4 - DIALOG_W) / 2));
107
110
  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
111
  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"] }))] }))] }));
112
+ const bg = theme.userBandBackground ? { backgroundColor: theme.userBandBackground } : {};
113
+ const innerW = DIALOG_W - 2; // cells between the two border columns
114
+ // Draw the border as characters inside full-width background lines (like
115
+ // InputBar). Ink's box border + backgroundColor leaves unfilled gaps, so we
116
+ // compose each line ourselves: every line is one solid Text spanning DIALOG_W.
117
+ 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));
118
+ // Clip a string to at most `max` display columns (so a row never overruns the
119
+ // border on narrow terminals — wrap="truncate" would eat the closing │).
120
+ const clip = (s, max) => {
121
+ if (max <= 0)
122
+ return "";
123
+ if (displayWidth(s) <= max)
124
+ return s;
125
+ let out = "", w = 0;
126
+ for (const ch of s) {
127
+ const cw = displayWidth(ch);
128
+ if (w + cw > max - 1)
129
+ break; // leave a column for the ellipsis
130
+ out += ch;
131
+ w += cw;
132
+ }
133
+ return out + "…";
134
+ };
135
+ const avail = innerW - 1; // usable columns after the leading space
136
+ const title = menu.type === "model" ? "Select model" : menu.type === "llm" ? "Select LLM provider" : "Select search provider";
137
+ const hint = "↑↓ navigate · ⏎ apply · esc close";
138
+ const dialogLines = [];
139
+ // Title + hint, dropping/clipping the (secondary) hint when space is tight.
140
+ const titleC = clip(title, avail);
141
+ const hintRoom = avail - displayWidth(titleC) - 2;
142
+ const hintC = hintRoom >= 6 ? clip(hint, hintRoom) : "";
143
+ 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] }))));
144
+ if (!menu.loading) {
145
+ const filterC = clip(menu.query || "type to filter…", avail - 2);
146
+ 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 })] }))));
147
+ dialogLines.push(line("divider", innerW - 1, _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(4, innerW - 1)) })));
148
+ }
149
+ if (menu.loading) {
150
+ dialogLines.push(line("loading", displayWidth("loading models…"), _jsx(Text, { color: theme.textDim, children: "loading models\u2026" })));
151
+ }
152
+ else if (menuFiltered.length === 0) {
153
+ const msg = clip(`no matches for "${menu.query}"`, avail);
154
+ dialogLines.push(line("empty", displayWidth(msg), _jsx(Text, { color: theme.textDim, children: msg })));
155
+ }
156
+ else {
157
+ if (menuStart > 0)
158
+ dialogLines.push(line("up", displayWidth(`↑ ${menuStart} more`), _jsx(Text, { color: theme.textDim, children: `↑ ${menuStart} more` })));
159
+ menuFiltered.slice(menuStart, menuStart + DIALOG_ITEMS).forEach((item, i) => {
160
+ const active = menuStart + i === menu.index;
161
+ const marker = active ? "❯ " : " ";
162
+ const label = clip(displayName(item), avail - displayWidth(marker));
163
+ const suffixRoom = avail - displayWidth(marker + label);
164
+ const suffix = menu.type === "llm" && suffixRoom >= 4 ? clip(" " + item, suffixRoom) : "";
165
+ 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] }))));
166
+ });
167
+ const moreBelow = menuFiltered.length - (menuStart + DIALOG_ITEMS);
168
+ if (moreBelow > 0)
169
+ dialogLines.push(line("down", displayWidth(`↓ ${moreBelow} more`), _jsx(Text, { color: theme.textDim, children: `↓ ${moreBelow} more` })));
170
+ }
171
+ 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
172
  }
115
173
  export function buildMcpDialogRows(config) {
116
174
  const dt = config.mcp.chromeDevtools;
@@ -163,7 +221,7 @@ export function McpDialog({ open, config, cols, rows, selectedIdx, hoveredIdx, o
163
221
  });
164
222
  clickMapRef.current = clickMap;
165
223
  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) => {
224
+ 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
225
  const active = i === selectedIdx || hoveredIdx === i;
168
226
  const check = row.enabled ? "x" : " ";
169
227
  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,15 @@ 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
7
  /** Slash commands that take an argument; ⏎ from the menu appends a space instead of running. */
8
8
  export const COMMANDS_NEEDING_ARGS = new Set(["/theme", "/key", "/rename", "/why", "/links"]);
9
9
  export const COMMAND_DESCRIPTIONS = {
10
10
  "/help": "Show command and keyboard shortcuts.",
11
11
  "/home": "Go to the home screen (or show the welcome card on home).",
12
- "/new": "Go to the home screen to start a new research run.",
12
+ "/new": "Go to the home screen to start a new session.",
13
13
  "/plan": "Toggle plan mode (explore and plan before making changes).",
14
- "/rerun": "Run the research agent again for this run.",
14
+ "/rerun": "Run the research agent again for this session.",
15
15
  "/report": "Show the generated report.md in the timeline.",
16
16
  "/sources": "List the run's gathered sources with links.",
17
17
  "/claims": "List all claims with id, confidence, status, and text.",
@@ -21,8 +21,10 @@ export const COMMAND_DESCRIPTIONS = {
21
21
  "/usage": "Show token usage per model for this session.",
22
22
  "/rename": "Set a title for this session, e.g. /rename SpaceX IPO analysis",
23
23
  "/model": "Open the model selector dropup.",
24
- "/llm": "Switch the LLM provider (gateway, xai, workers-ai).",
24
+ "/llm": "Switch the LLM provider (gateway, xai, workers-ai, claude-code, codex).",
25
25
  "/provider": "Open the search provider selector.",
26
+ "/thinking": "Claude Code thinking: /thinking off · on · adaptive",
27
+ "/reasoning": "Codex reasoning effort: /reasoning low · medium · high",
26
28
  "/theme": "Set UI theme: /theme dark · /theme light · /theme auto",
27
29
  "/links": "Link opens: /links always · /links ask",
28
30
  "/key": "Save an API key, e.g. /key EXA_API_KEY ...",
@@ -40,6 +42,7 @@ export const TOOL_ICONS = {
40
42
  createClaim: "◎",
41
43
  verifyClaim: "✓",
42
44
  webSearch: "⌕",
45
+ multiWebSearch: "⌕",
43
46
  readUrl: "↗",
44
47
  listSkills: "★",
45
48
  readSkill: "★",
@@ -79,11 +82,11 @@ export const LOADING_PHRASES = [
79
82
  "Wrapping my head around it…",
80
83
  ];
81
84
  export const HOME_TIPS = [
82
- "Type a question and press ⏎ to start a new research run.",
85
+ "Type a question and press ⏎ to start a new session.",
83
86
  "Say \"deep research …\" or \"compare …\" to trigger the full research harness.",
84
- "↑↓ navigate · ⏎ open · type to start a new run.",
87
+ "↑↓ navigate · ⏎ open · type to start a new session.",
85
88
  "/model · /provider · /key NAME value to configure.",
86
- "Browse all sessions to find older runs.",
89
+ "Browse all sessions to find older ones.",
87
90
  "ready / draft badges show full-research runs and their report state."
88
91
  ];
89
92
  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++));
@@ -16,6 +16,9 @@ export function useKeyboard(o) {
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;
18
18
  const deleteArmedRef = useRef(null);
19
+ // Quit requires confirmation: first Ctrl+C arms, second within the window quits.
20
+ const quitArmedRef = useRef(false);
21
+ const quitTimerRef = useRef(null);
19
22
  const editInput = (char, key) => {
20
23
  const deleteWordBefore = () => {
21
24
  const match = inputText.slice(0, cursorPos).match(/\S+\s*$/u);
@@ -95,6 +98,30 @@ export function useKeyboard(o) {
95
98
  // OSC background-color query responses leak as stdin when terminals reply to theme probes.
96
99
  if (char && (/\]11;rgb:/u.test(char) || /^11;rgb:/u.test(char)))
97
100
  return;
101
+ // Quit handling (all screens): Ctrl+C twice (with warning) or Ctrl+D.
102
+ if (key.ctrl && char === "c") {
103
+ if (busy) {
104
+ stopTurn();
105
+ quitArmedRef.current = false;
106
+ return;
107
+ }
108
+ if (quitArmedRef.current) {
109
+ exit();
110
+ return;
111
+ }
112
+ quitArmedRef.current = true;
113
+ setNotice("Press Ctrl+C again to quit (or Ctrl+D).");
114
+ if (quitTimerRef.current)
115
+ clearTimeout(quitTimerRef.current);
116
+ quitTimerRef.current = setTimeout(() => { quitArmedRef.current = false; }, 3000);
117
+ // Don't let the disarm timer keep the process alive after a quit.
118
+ quitTimerRef.current.unref?.();
119
+ return;
120
+ }
121
+ if (key.ctrl && char === "d" && !inputText) {
122
+ exit();
123
+ return;
124
+ }
98
125
  if (approvalPending) {
99
126
  if (char === "y" || char === "Y" || key.return) {
100
127
  const p = approvalPending;
@@ -303,7 +330,7 @@ export function useKeyboard(o) {
303
330
  setSessionsModalIdx(0);
304
331
  }
305
332
  else if (selectedIdx === newIdx) {
306
- setNotice("Type a question below to start a new research run.");
333
+ setNotice("Type a question below to start a new session.");
307
334
  }
308
335
  else if (selectedIdx === quitIdx) {
309
336
  exit();
@@ -312,10 +339,6 @@ export function useKeyboard(o) {
312
339
  void submitHome("");
313
340
  }
314
341
  }
315
- else if (char === "q" && !inputText)
316
- exit();
317
- else if (key.ctrl && char === "d" && !inputText)
318
- exit();
319
342
  else
320
343
  editInput(char, key);
321
344
  }
@@ -1,10 +1,9 @@
1
1
  import { useCallback } from "react";
2
- import { readFile } from "node:fs/promises";
3
2
  import { join } from "node:path";
4
3
  import { listRuns, summarizeRun } from "../../../storage/run-store.js";
5
4
  import { getSession, attachSubscriber } from "../session-manager.js";
6
5
  export function useSession(o) {
7
- const { config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef, setSessions, setRunState, setCurrentRunPath, setInputText, setCursorPos, setFeed, setUsage, setScrollOffset, setScreen, setMode, setPlanMode, setBusy, setApprovalPending, getSubscriber, } = o;
6
+ const { config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef, pendingPlanModeRef, setSessions, setRunState, setCurrentRunPath, setInputText, setCursorPos, setFeed, setUsage, setScrollOffset, setScreen, setMode, setPlanMode, setPendingPlanMode, setBusy, setApprovalPending, getSubscriber, } = o;
8
7
  const refreshSessions = useCallback(async () => {
9
8
  const runs = await listRuns(config);
10
9
  setSessions(runs);
@@ -14,8 +13,11 @@ export function useSession(o) {
14
13
  setRunState(await summarizeRun(currentRunPath));
15
14
  }, [currentRunPath]);
16
15
  const openRun = useCallback(async (runPath, initialQuestion) => {
17
- if (runPath !== currentRunPath)
18
- setPlanMode(false);
16
+ if (runPath !== currentRunPath) {
17
+ // Honor a plan-mode preference armed from the home screen, then disarm it.
18
+ setPlanMode(pendingPlanModeRef.current);
19
+ setPendingPlanMode(false);
20
+ }
19
21
  setCurrentRunPath(runPath);
20
22
  setInputText("");
21
23
  setCursorPos(0);
@@ -39,7 +41,7 @@ export function useSession(o) {
39
41
  return undefined;
40
42
  }
41
43
  try {
42
- const raw = await readFile(join(runPath, "convo.json"), "utf8");
44
+ const raw = await Bun.file(join(runPath, "convo.json")).text();
43
45
  const saved = JSON.parse(raw);
44
46
  if (saved.feed && saved.feed.length > 0) {
45
47
  const filteredFeed = saved.feed.filter((item) => !(item.kind === "status" && item.text === "This will wipe the conversation history. Press /rerun again to confirm."));
@@ -146,6 +146,26 @@ export function useSettings({ config, setConfig, screen, pushFeed, setNotice })
146
146
  await setEnvKey(name, value);
147
147
  return `${name} saved to ~/.scira/.env and active for this session. Use .scira/.env in a project to scope keys to that repo.`;
148
148
  }
149
+ if (cmd === "/thinking") {
150
+ if (!arg)
151
+ return `Claude Code thinking: ${config.harness.thinking}\nOptions: off, on, adaptive`;
152
+ if (!["off", "on", "adaptive"].includes(arg))
153
+ return `Unknown thinking mode "${arg}". Options: off, on, adaptive`;
154
+ const next = { ...config, harness: { ...config.harness, thinking: arg } };
155
+ setConfig(next);
156
+ await saveGlobalConfig(next);
157
+ return `Claude Code thinking set to ${arg}.`;
158
+ }
159
+ if (cmd === "/reasoning") {
160
+ if (!arg)
161
+ return `Codex reasoning effort: ${config.harness.reasoningEffort}\nOptions: low, medium, high`;
162
+ if (!["low", "medium", "high"].includes(arg))
163
+ return `Unknown reasoning effort "${arg}". Options: low, medium, high`;
164
+ const next = { ...config, harness: { ...config.harness, reasoningEffort: arg } };
165
+ setConfig(next);
166
+ await saveGlobalConfig(next);
167
+ return `Codex reasoning effort set to ${arg}.`;
168
+ }
149
169
  if (cmd === "/theme") {
150
170
  if (!arg) {
151
171
  const terminal = detectTerminalTheme();
@@ -1,5 +1,4 @@
1
1
  import { useCallback, useRef } from "react";
2
- import { readFile } from "node:fs/promises";
3
2
  import { createRun, getRunPaths, setRunTitle } from "../../../storage/run-store.js";
4
3
  import { readJsonl } from "../../../storage/jsonl.js";
5
4
  import { fmtDuration, fmtTokens, copyToClipboard } from "../lib/utils.js";
@@ -7,8 +6,8 @@ import { detachSubscriber, abortSession } from "../session-manager.js";
7
6
  import { saveGlobalMcpConfig } from "../../../config/load-config.js";
8
7
  export function useSubmit(o) {
9
8
  const { config, currentRunPath, sessions, selectedIdx, busy, usage, pendingRerun } = o.state;
10
- const { queuedPromptRef, fullModeRef, planModeRef, conversationRef, feedRef } = o.refs;
11
- const { setApprovalPending, setInputText, setCursorPos, setInputHistory, setHistoryIndex, setHelpOpen, setNotice, setBusy, setScreen, setFeed, setRunState, setPendingRerun, setMode, setPlanMode, setConfig, setMcpOpen, setHeroHidden, } = o.setters;
9
+ const { queuedPromptRef, fullModeRef, planModeRef, pendingPlanModeRef, conversationRef, feedRef } = o.refs;
10
+ const { setApprovalPending, setInputText, setCursorPos, setInputHistory, setHistoryIndex, setHelpOpen, setNotice, setBusy, setScreen, setFeed, setRunState, setPendingRerun, setMode, setPlanMode, setPendingPlanMode, setConfig, setMcpOpen, setHeroHidden, } = o.setters;
12
11
  const { pushFeed, refreshSessions, openRun, openMenu, handleSettings, runTurn, exit } = o.actions;
13
12
  const rerunConfirmRef = useRef(false);
14
13
  const abortTurn = useCallback(() => {
@@ -27,7 +26,7 @@ export function useSubmit(o) {
27
26
  void openRun(selected.path);
28
27
  return;
29
28
  }
30
- if (text === "q" || text === "/quit" || text === "/q") {
29
+ if (text === "/quit" || text === "/q") {
31
30
  exit();
32
31
  return;
33
32
  }
@@ -60,7 +59,15 @@ export function useSubmit(o) {
60
59
  setMcpOpen(true);
61
60
  return;
62
61
  }
63
- setNotice("Open a research session first to use /mcp enable/disable/add.");
62
+ setNotice("Open a session first to use /mcp enable/disable/add.");
63
+ return;
64
+ }
65
+ if (text === "/plan") {
66
+ const next = !pendingPlanModeRef.current;
67
+ setPendingPlanMode(next);
68
+ setNotice(next
69
+ ? "Plan mode armed — the next run you start will open in plan mode."
70
+ : "Plan mode disarmed.");
64
71
  return;
65
72
  }
66
73
  if (text.startsWith("/")) {
@@ -136,7 +143,7 @@ export function useSubmit(o) {
136
143
  void openMenu("provider");
137
144
  return;
138
145
  }
139
- if (["/key", "/keys", "/llm", "/theme"].includes(text.split(/\s+/u)[0])) {
146
+ if (["/key", "/keys", "/llm", "/theme", "/thinking", "/reasoning"].includes(text.split(/\s+/u)[0])) {
140
147
  void (async () => {
141
148
  const result = await handleSettings(text);
142
149
  if (result)
@@ -147,7 +154,7 @@ export function useSubmit(o) {
147
154
  if (text === "/report") {
148
155
  void (async () => {
149
156
  try {
150
- const report = await readFile(getRunPaths(currentRunPath).report, "utf8");
157
+ const report = await Bun.file(getRunPaths(currentRunPath).report).text();
151
158
  pushFeed({ kind: "text", text: report });
152
159
  }
153
160
  catch {
@@ -290,7 +297,7 @@ export function useSubmit(o) {
290
297
  void (async () => {
291
298
  const currentSession = sessions.find(s => s.path === currentRunPath);
292
299
  const report = currentSession?.isFull
293
- ? await readFile(getRunPaths(currentRunPath).report, "utf8").catch(() => "")
300
+ ? await Bun.file(getRunPaths(currentRunPath).report).text().catch(() => "")
294
301
  : "";
295
302
  const lastText = [...feedRef.current].reverse().find((it) => it.kind === "text")?.text ?? "";
296
303
  const content = report.trim() || lastText;