@scira/cli 0.1.4 → 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 +53 -12
  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 +37 -7
  27. package/dist/ui/ink/hooks/use-agent-turn.js +17 -6
  28. package/dist/ui/ink/hooks/use-feed-lines.js +34 -7
  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 +205 -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 +15 -13
  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
@@ -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
- import { Box, useApp, useStdout, useStdin } from "ink";
4
- import { CHAT_COMMANDS, MENU_VISIBLE } from "./constants.js";
3
+ import { Box, Text, useApp, useStdout, useStdin } from "ink";
4
+ import { CHAT_COMMANDS, 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";
@@ -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 },
@@ -346,12 +350,25 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
346
350
  const menuHeight = commandMenuHeight + helpHeight + approvalHeight + linkHeight;
347
351
  const feedRows = Math.max(3, rows - 6 - inputLines.length - menuHeight);
348
352
  const hasRunningTool = feed.some((it) => it.kind === "tool" && it.status === "running");
349
- const { lines: feedLines, toggleAtLine, groupToggleAtLine, linkAtLine } = useFeedLines(feed, innerWidth, reasoningTick, hasRunningTool ? frame : 0, collapsedGroups, focusedGroupKey, itemExpandState, hoveredIdx, config);
353
+ const { lines: feedLines, toggleAtLine, groupToggleAtLine, linkAtLine, lastUserLineStart } = useFeedLines(feed, innerWidth, reasoningTick, hasRunningTool ? frame : 0, collapsedGroups, focusedGroupKey, itemExpandState, hoveredIdx, config);
350
354
  const contentRows = Math.max(1, feedRows);
351
355
  const maxScrollOffset = Math.max(0, feedLines.length - contentRows);
352
356
  wheelStateRef.current = { screen, maxScrollOffset };
353
- const clampedOffset = Math.min(scrollOffset, maxScrollOffset);
354
- const startIdx = Math.max(0, feedLines.length - contentRows - clampedOffset);
357
+ // scrollOffset < 0 is a sentinel meaning "pin the most recent user message to
358
+ // the top of the viewport" (with empty space below for the incoming reply).
359
+ // Once the reply grows past the viewport we fall back to bottom-anchoring so
360
+ // the streaming output stays visible. Any manual scroll clears the sentinel.
361
+ const pinUserToTop = scrollOffset < 0 && lastUserLineStart >= 0;
362
+ let startIdx;
363
+ if (pinUserToTop) {
364
+ const fitsBelow = feedLines.length - lastUserLineStart <= contentRows;
365
+ startIdx = fitsBelow ? lastUserLineStart : Math.max(0, feedLines.length - contentRows);
366
+ }
367
+ else {
368
+ const off = Math.min(Math.max(0, scrollOffset), maxScrollOffset);
369
+ startIdx = Math.max(0, feedLines.length - contentRows - off);
370
+ }
371
+ const clampedOffset = Math.max(0, feedLines.length - contentRows - startIdx);
355
372
  const hasLinkHover = hoveredIdx !== null && (linkAtLine.get(hoveredIdx)?.length ?? 0) > 0;
356
373
  const feedStartRow = 3;
357
374
  if (screen === "chat") {
@@ -384,7 +401,31 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
384
401
  clickMapRef.current = clickMap;
385
402
  hoverMapRef.current = hoverMap;
386
403
  }
387
- const visibleLines = feedLines.slice(startIdx, startIdx + contentRows);
404
+ const slicedLines = feedLines.slice(startIdx, startIdx + contentRows);
405
+ // The feed box is bottom-aligned (justifyContent="flex-end"). When pinning the
406
+ // user message to the top, pad blank lines below it so short content is pushed
407
+ // up — the user message sits at the top with empty room below for the reply.
408
+ const blankLine = (k) => _jsx(Text, { children: " " }, k);
409
+ // Show an animated loading line whenever the agent is still doing non-text
410
+ // work (reasoning, tool calls, status) — i.e. the latest feed item isn't the
411
+ // streamed answer text. It stays pinned below the latest content for the whole
412
+ // turn, and the phrase rotates slowly so it doesn't feel frozen. The feed box
413
+ // is bottom-aligned with overflow hidden, so when the timeline is long the
414
+ // loader stays visible and the oldest lines scroll off the top instead.
415
+ const lastItem = feed[feed.length - 1];
416
+ const showLoader = busy && lastItem !== undefined && lastItem.kind !== "text";
417
+ const phrase = LOADING_PHRASES[Math.floor(frame / 24) % LOADING_PHRASES.length];
418
+ const loadingLine = (_jsx(Text, { dimColor: true, children: `${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ${phrase}` }, "loading"));
419
+ const contentWithLoader = showLoader
420
+ ? (slicedLines.length > 0 ? [...slicedLines, blankLine("loading-gap"), loadingLine] : [loadingLine])
421
+ : slicedLines;
422
+ const visibleLines = pinUserToTop && contentWithLoader.length < contentRows
423
+ ? [
424
+ blankLine("pad-top"),
425
+ ...contentWithLoader,
426
+ ...Array.from({ length: Math.max(0, contentRows - contentWithLoader.length - 1) }, (_, i) => blankLine(`pad-${i}`)),
427
+ ]
428
+ : contentWithLoader;
388
429
  const scrollLabel = clampedOffset > 0
389
430
  ? (startIdx > 0 ? `↑ ${startIdx} · ↓ ${clampedOffset} · wheel/⇞⇟` : `top · ↓ ${clampedOffset} · wheel/⇞⇟`)
390
431
  : "";
@@ -406,9 +447,9 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
406
447
  const activeUsage = usage[config.model];
407
448
  const themed = (node) => (_jsx(ThemeProvider, { config: config, children: node }));
408
449
  if (screen === "home") {
409
- return themed(_jsxs(Box, { flexDirection: "column", width: cols, height: rows, paddingX: 2, children: [_jsx(TipCycler, { setTipIndex: setTipIndex }), (!sessionsModalOpen || mcpOpen) && stdout !== undefined && stdin !== undefined && (_jsx(MouseTracker, { stdout: stdout, stdin: stdin, onData: handleMouseData, onUnmount: () => setHoveredIdx(null) })), busy && _jsx(AnimationTick, { setBlink: setBlink, setFrame: setFrame, setReasoningTick: setReasoningTick }), _jsx(TopBar, { screen: screen, runState: runState, fullMode: fullMode, planMode: planMode, activeUsage: activeUsage, busy: busy, frame: frame, cwdDisplay: CWD_DISPLAY, config: config }), _jsx(HomeScreen, { cols: cols, rows: rows, sessions: sessions, selectedIdx: selectedIdx, hoveredIdx: hoveredIdx, heroHidden: heroHidden, notice: notice, tipIndex: tipIndex, commandMenuHeight: commandMenuHeight, mcpOpen: mcpOpen, sessionsModalOpen: sessionsModalOpen, sessionsModalIdx: sessionsModalIdx, inputText: inputText, config: config, modelName: modelName, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef, setSelectedIdx: setSelectedIdx, setSessionsModalOpen: setSessionsModalOpen, setSessionsModalIdx: setSessionsModalIdx, setNotice: setNotice, openRun: openRun, submitHome: submitHome, exit: exit }), _jsxs(Box, { flexDirection: "column", paddingBottom: 1, children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions, config: config }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: inputBlocked, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName, config: config }), _jsx(HintLine, { screen: screen, busy: busy, config: config })] }), _jsx(MenuDialog, { menu: menu, cols: cols, rows: rows, config: config }), _jsx(McpDialog, { open: mcpOpen, config: config, cols: cols, rows: rows, selectedIdx: mcpRowIdx, hoveredIdx: hoveredIdx, onToggle: handleMcpToggle, onRemove: handleMcpRemove, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef })] }));
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 })] }));
410
451
  }
411
- return themed(_jsxs(Box, { flexDirection: "column", width: cols, height: rows, paddingX: 2, children: [stdout !== undefined && stdin !== undefined && (_jsx(MouseTracker, { stdout: stdout, stdin: stdin, onData: handleMouseData, onUnmount: () => setHoveredIdx(null) })), busy && _jsx(AnimationTick, { setBlink: setBlink, setFrame: setFrame, setReasoningTick: setReasoningTick }), _jsx(TopBar, { screen: screen, runState: runState, fullMode: fullMode, planMode: planMode, activeUsage: activeUsage, busy: busy, frame: frame, cwdDisplay: CWD_DISPLAY, config: config }), _jsx(Box, { flexDirection: "column", height: contentRows, flexShrink: 0, justifyContent: "flex-end", paddingTop: 1, overflow: "hidden", children: visibleLines }), _jsxs(ChatInputChrome, { children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions, config: config }), _jsx(HelpBox, { open: helpOpen, innerWidth: innerWidth, config: config }), approvalPending && _jsx(ApprovalBox, { toolName: approvalPending.toolName, description: approvalPending.description, innerWidth: innerWidth, config: config }), linkPending && _jsx(LinkOpenBox, { url: linkPending.url, innerWidth: innerWidth, config: config }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: inputBlocked, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName, config: config }), _jsx(HintLine, { screen: screen, busy: busy, scrollLabel: scrollLabel, hasDoneGroups: doneGroupKeys.length > 0, hasFocusedGroup: focusedGroupKey !== null, hasLinkHover: hasLinkHover || !!linkPending, alwaysAllowLinks: config.alwaysAllowLinks, config: config })] }), _jsx(MenuDialog, { menu: menu, cols: cols, rows: rows, config: config }), _jsx(McpDialog, { open: mcpOpen, config: config, cols: cols, rows: rows, selectedIdx: mcpRowIdx, hoveredIdx: hoveredIdx, onToggle: handleMcpToggle, onRemove: handleMcpRemove, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef })] }));
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 })] }));
412
453
  }
413
454
  function ChatInputChrome({ children }) {
414
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: "★",
@@ -51,12 +54,39 @@ export const TOOL_ICONS = {
51
54
  grepWorkspace: "⌕"
52
55
  };
53
56
  export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
57
+ export const LOADING_PHRASES = [
58
+ "Thinking it through…",
59
+ "Digging into it…",
60
+ "Connecting the dots…",
61
+ "Gathering context…",
62
+ "Working through it…",
63
+ "Sifting the details…",
64
+ "Putting it together…",
65
+ "Chasing down answers…",
66
+ "Mulling it over…",
67
+ "Lining things up…",
68
+ "Reading the room…",
69
+ "Scanning the sources…",
70
+ "Cross-checking facts…",
71
+ "Tracing the threads…",
72
+ "Weighing the options…",
73
+ "Following the trail…",
74
+ "Piecing it together…",
75
+ "Untangling the details…",
76
+ "Skimming the fine print…",
77
+ "Joining the dots…",
78
+ "Hunting for specifics…",
79
+ "Sorting signal from noise…",
80
+ "Drafting the answer…",
81
+ "Double-checking the work…",
82
+ "Wrapping my head around it…",
83
+ ];
54
84
  export const HOME_TIPS = [
55
- "Type a question and press ⏎ to start a new research run.",
85
+ "Type a question and press ⏎ to start a new session.",
56
86
  "Say \"deep research …\" or \"compare …\" to trigger the full research harness.",
57
- "↑↓ navigate · ⏎ open · type to start a new run.",
87
+ "↑↓ navigate · ⏎ open · type to start a new session.",
58
88
  "/model · /provider · /key NAME value to configure.",
59
- "Browse all sessions to find older runs.",
89
+ "Browse all sessions to find older ones.",
60
90
  "ready / draft badges show full-research runs and their report state."
61
91
  ];
62
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";
@@ -26,7 +25,9 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
26
25
  const controller = new AbortController();
27
26
  session.abort = controller;
28
27
  setBusy(true);
29
- setScrollOffset(0);
28
+ // Pin the just-sent user message to the top of the viewport, leaving room
29
+ // below for the incoming assistant reply (-1 sentinel; see SciraApp).
30
+ setScrollOffset(-1);
30
31
  sessionSetBusy(runPath, true);
31
32
  const modelId = config.model;
32
33
  const turnStartedAt = Date.now();
@@ -60,9 +61,19 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
60
61
  case "reasoning-end":
61
62
  sessionFinishReasoning(runPath);
62
63
  break;
63
- case "tool-call":
64
- 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 });
65
75
  break;
76
+ }
66
77
  case "tool-result": {
67
78
  if (part.preliminary)
68
79
  break;
@@ -204,7 +215,7 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
204
215
  .filter((item) => !(item.kind === "status" && item.text === "This will wipe the conversation history. Press /rerun again to confirm."))
205
216
  .map((item) => item.kind === "tool" && item.status === "running" ? { ...item, status: "error" } : item);
206
217
  try {
207
- 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));
208
219
  }
209
220
  catch { /* non-fatal */ }
210
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);
@@ -63,6 +80,7 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState,
63
80
  const toggleAtLine = new Map();
64
81
  const groupToggleAtLine = new Map();
65
82
  const linkAtLine = new Map();
83
+ let lastUserLineStart = -1;
66
84
  let key = 0;
67
85
  const { groupOf, groups } = computeGroups(feed);
68
86
  const eff = [];
@@ -154,21 +172,29 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState,
154
172
  const running = fi.status === "running";
155
173
  const failed = fi.status === "error";
156
174
  const itemId = feedToolItemId(feedIdx, fi.toolCallId);
157
- const collapsible = isCollapsibleToolName(fi.name) && !running;
158
- 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);
159
182
  const headerLineIdx = lines.length;
160
183
  const hovered = hoveredLineIdx === headerLineIdx;
161
184
  const toolIcon = running
162
185
  ? SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length]
163
- : TOOL_ICONS[fi.name] ?? "·";
186
+ : TOOL_ICONS[canon] ?? "·";
164
187
  const symColor = failed ? theme.error : theme.accentDim;
165
188
  const nameColor = running ? theme.text : failed ? theme.error : theme.textDim;
166
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.
167
193
  const preview = formatToolResultPreview(fi.name, fi.summary, fi.result, fi.status);
168
- 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);
169
195
  if (collapsible)
170
196
  toggleAtLine.set(headerLineIdx, itemId);
171
- 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++));
172
198
  for (const row of bodyLines) {
173
199
  if (row.length === 0) {
174
200
  lines.push(_jsx(Text, { color: theme.textDim, children: S_BAR }, key++));
@@ -188,6 +214,7 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState,
188
214
  const rightPad = time ? time.length + 1 : 0;
189
215
  const wrapped = wrapText(fi.text, Math.max(10, bandW - 4 - rightPad));
190
216
  const blank = " ".repeat(bandW);
217
+ lastUserLineStart = lines.length;
191
218
  lines.push(_jsx(Text, { ...bandBg, children: blank }, key++));
192
219
  wrapped.forEach((l, idx) => {
193
220
  const isFirst = idx === 0;
@@ -233,7 +260,7 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState,
233
260
  }
234
261
  }
235
262
  });
236
- return { lines, toggleAtLine, groupToggleAtLine, linkAtLine };
263
+ return { lines, toggleAtLine, groupToggleAtLine, linkAtLine, lastUserLineStart };
237
264
  // eslint-disable-next-line react-hooks/exhaustive-deps
238
265
  }, [feed, innerWidth, reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState, hoveredLineIdx, config, theme]);
239
266
  }
@@ -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
  }