@scira/cli 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/agent/harness-agent.js +216 -0
  2. package/dist/agent/{research-agent.js → main-agent.js} +30 -10
  3. package/dist/cli/commands/init.js +7 -5
  4. package/dist/cli/index.js +75 -14
  5. package/dist/cli/shell/shell.js +4 -5
  6. package/dist/cli/shell/tui.js +7 -4
  7. package/dist/config/env-guide.js +24 -0
  8. package/dist/config/env-store.js +5 -3
  9. package/dist/config/load-config.js +9 -14
  10. package/dist/providers/harness/local-sandbox.js +143 -0
  11. package/dist/providers/llm/gateway.js +5 -2
  12. package/dist/providers/llm/models.js +18 -4
  13. package/dist/providers/llm/readiness.js +5 -1
  14. package/dist/providers/llm/registry.js +24 -3
  15. package/dist/storage/jsonl.js +2 -2
  16. package/dist/storage/run-store.js +22 -15
  17. package/dist/tools/agent-tools.js +7 -7
  18. package/dist/tools/background-tasks.js +4 -5
  19. package/dist/tools/mcp-oauth.js +29 -25
  20. package/dist/tools/open-url.js +1 -2
  21. package/dist/tools/todos.js +3 -3
  22. package/dist/tools/workspace.js +15 -0
  23. package/dist/types/index.js +13 -1
  24. package/dist/ui/ink/SciraApp.js +14 -10
  25. package/dist/ui/ink/components/home-screen.js +2 -2
  26. package/dist/ui/ink/components/overlays.js +78 -17
  27. package/dist/ui/ink/constants.js +26 -7
  28. package/dist/ui/ink/hooks/use-agent-turn.js +14 -5
  29. package/dist/ui/ink/hooks/use-feed-lines.js +31 -6
  30. package/dist/ui/ink/hooks/use-keyboard.js +28 -5
  31. package/dist/ui/ink/hooks/use-session.js +7 -5
  32. package/dist/ui/ink/hooks/use-settings.js +20 -0
  33. package/dist/ui/ink/hooks/use-submit.js +15 -8
  34. package/dist/ui/ink/lib/file-mentions.js +1 -2
  35. package/dist/ui/ink/lib/tool-result.js +219 -4
  36. package/dist/ui/ink/lib/utils.js +54 -28
  37. package/dist/ui/ink/theme.js +5 -10
  38. package/dist/utils/update-check.js +63 -0
  39. package/dist/watch/runner.js +2 -2
  40. package/package.json +13 -11
  41. package/dist/agent/background-tasks.js +0 -173
  42. package/dist/agent/todos.js +0 -140
  43. package/dist/agent/tools.js +0 -432
  44. package/dist/agent/tools.test.js +0 -60
  45. package/dist/agent/workspace.js +0 -85
  46. package/dist/config/env-guide.test.js +0 -18
  47. package/dist/config/env-store.test.js +0 -60
  48. package/dist/storage/jsonl.test.js +0 -38
  49. package/dist/storage/run-store.test.js +0 -65
  50. package/dist/tools/bash-policy.test.js +0 -38
  51. package/dist/tools/search-web.test.js +0 -24
  52. package/dist/tools/workspace.test.js +0 -75
  53. package/dist/types/schema.test.js +0 -61
  54. package/dist/ui/ink/hooks/use-feed-lines.test.js +0 -16
  55. package/dist/ui/ink/lib/tool-result.test.js +0 -60
  56. package/dist/ui/ink/lib/utils.test.js +0 -48
  57. package/dist/ui/ink/session-manager.test.js +0 -31
  58. package/dist/ui/ink/terminal-probe.test.js +0 -12
  59. package/dist/ui/ink/theme.test.js +0 -68
@@ -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;
@@ -1,5 +1,4 @@
1
1
  import { readdirSync, statSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
3
2
  import { join } from "node:path";
4
3
  import { FILE_MENTION_SKIP, FILE_MENTION_MAX_CHARS } from "../constants.js";
5
4
  export function listMentionableFiles(root = process.cwd(), max = 300) {
@@ -52,7 +51,7 @@ export async function promptWithFileMentions(prompt) {
52
51
  for (const file of files) {
53
52
  const abs = join(process.cwd(), file);
54
53
  try {
55
- const content = await readFile(abs, "utf8");
54
+ const content = await Bun.file(abs).text();
56
55
  const body = content.length > FILE_MENTION_MAX_CHARS
57
56
  ? `${content.slice(0, FILE_MENTION_MAX_CHARS)}\n...[truncated ${content.length - FILE_MENTION_MAX_CHARS} chars]`
58
57
  : content;
@@ -1,8 +1,45 @@
1
+ import { diffLines } from "diff";
1
2
  import { markdownToSegLines } from "./markdown.js";
2
3
  import { wrapText } from "./utils.js";
4
+ const HARNESS_TOOL_PREFIX = "mcp__harness-tools__";
5
+ /** Map Claude Code / Codex built-in (and harness host) tool names onto Scira's renderers. */
6
+ const CANONICAL_TOOL = {
7
+ // Scira host tools exposed to the CLI
8
+ multiWebSearch: "webSearch",
9
+ // Claude Code built-ins
10
+ Read: "readFile",
11
+ Write: "writeFile",
12
+ Edit: "editFile",
13
+ MultiEdit: "editFile",
14
+ NotebookEdit: "editFile",
15
+ Bash: "bash",
16
+ BashOutput: "bash",
17
+ Grep: "grepWorkspace",
18
+ Glob: "listWorkspaceDir",
19
+ LS: "listWorkspaceDir",
20
+ TodoWrite: "todo",
21
+ WebFetch: "readUrl",
22
+ WebSearch: "webSearch",
23
+ // Codex built-ins
24
+ shell: "bash",
25
+ };
26
+ /** Strip the harness host-tool MCP prefix so `mcp__harness-tools__readUrl` reads as `readUrl`. */
27
+ export function displayToolName(name) {
28
+ return name.startsWith(HARNESS_TOOL_PREFIX) ? name.slice(HARNESS_TOOL_PREFIX.length) : name;
29
+ }
30
+ /**
31
+ * Resolve a harness/CLI tool name to the Scira renderer key. The harness exposes
32
+ * our host tools as `mcp__harness-tools__*` and the CLIs have their own builtin
33
+ * names (Read, Bash, Grep, …); both should render like Scira's equivalents.
34
+ */
35
+ export function canonicalToolName(name) {
36
+ const stripped = displayToolName(name);
37
+ return CANONICAL_TOOL[stripped] ?? stripped;
38
+ }
3
39
  /** Tools that start collapsed in the timeline (long output). */
4
40
  export const DEFAULT_COLLAPSED_TOOLS = new Set([
5
41
  "webSearch",
42
+ "multiWebSearch",
6
43
  "readUrl",
7
44
  "readFile",
8
45
  "readWorkspaceFile",
@@ -185,9 +222,15 @@ function formatListSkills(result, width, theme) {
185
222
  });
186
223
  }
187
224
  function formatShellOutput(result, width, theme) {
188
- if (!result.trim())
225
+ // Codex returns `{ exitCode, output }`; Claude returns the output string directly.
226
+ let text = result;
227
+ const obj = parseObj(result);
228
+ if (obj && typeof obj.output === "string") {
229
+ text = typeof obj.exitCode === "number" && obj.exitCode !== 0 ? `[exit ${obj.exitCode}]\n${obj.output}` : obj.output;
230
+ }
231
+ if (!text.trim())
189
232
  return [[seg("(no output)", { dim: true, color: theme.textDim })]];
190
- return result.split("\n").flatMap((line) => plainLines(line, width, { color: theme.textDim }));
233
+ return text.split("\n").flatMap((line) => plainLines(line, width, { color: theme.textDim }));
191
234
  }
192
235
  function formatFileContent(result, width, theme) {
193
236
  const rows = result.split("\n");
@@ -291,7 +334,8 @@ function formatBody(name, result, width, theme) {
291
334
  }
292
335
  }
293
336
  /** One-line preview for a collapsed tool header. */
294
- export function formatToolResultPreview(name, inputSummary, result, status) {
337
+ export function formatToolResultPreview(rawName, inputSummary, result, status) {
338
+ const name = canonicalToolName(rawName);
295
339
  const input = inputSummary.replace(/\s+/gu, " ").trim();
296
340
  if (status === "running")
297
341
  return input ? `${input} · running…` : "running…";
@@ -345,10 +389,181 @@ export function formatToolResultPreview(name, inputSummary, result, status) {
345
389
  const first = result.replace(/\s+/gu, " ").trim();
346
390
  return first.length > 140 ? `${first.slice(0, 137)}…` : first;
347
391
  }
392
+ // --- Dedicated renderers for Claude Code / Codex built-in tools ---
393
+ function parseObj(s) {
394
+ if (!s)
395
+ return null;
396
+ try {
397
+ const v = JSON.parse(s);
398
+ return v && typeof v === "object" && !Array.isArray(v) ? v : null;
399
+ }
400
+ catch {
401
+ return null;
402
+ }
403
+ }
404
+ /** Unified-ish diff between two strings: removed lines red, added green, a little context dim. */
405
+ function diffSegLines(oldStr, newStr, width, theme) {
406
+ const parts = diffLines(oldStr ?? "", newStr ?? "");
407
+ const out = [];
408
+ const MAX = 60;
409
+ let count = 0;
410
+ for (const part of parts) {
411
+ const sign = part.added ? "+" : part.removed ? "-" : " ";
412
+ const color = part.added ? theme.success : part.removed ? theme.error : theme.textDim;
413
+ const linesIn = part.value.replace(/\n$/u, "").split("\n");
414
+ for (const ln of linesIn) {
415
+ if (count >= MAX) {
416
+ out.push([seg("… diff truncated", { dim: true, color: theme.textDim })]);
417
+ return out;
418
+ }
419
+ for (const wrapped of wrapText(`${sign} ${ln}`, width)) {
420
+ out.push([seg(wrapped, { color, dim: !part.added && !part.removed })]);
421
+ }
422
+ count++;
423
+ }
424
+ }
425
+ return out;
426
+ }
427
+ function pathHeader(p, theme) {
428
+ return [seg("path ", { dim: true, color: theme.textDim }), seg(String(p ?? ""), { color: theme.text })];
429
+ }
430
+ /** Edit / MultiEdit → file path + colored diff(s). */
431
+ function formatEditBody(input, width, theme) {
432
+ const lines = [pathHeader(input.file_path ?? input.notebook_path, theme)];
433
+ const edits = Array.isArray(input.edits)
434
+ ? input.edits
435
+ : [{ old_string: input.old_string, new_string: input.new_string }];
436
+ edits.forEach((e, i) => {
437
+ if (edits.length > 1)
438
+ lines.push([seg(`edit ${i + 1}`, { dim: true, color: theme.textDim })]);
439
+ lines.push(...diffSegLines(String(e.old_string ?? ""), String(e.new_string ?? input.new_source ?? ""), width, theme));
440
+ });
441
+ return lines;
442
+ }
443
+ /** TodoWrite → checklist with status glyphs. */
444
+ function formatTodoBody(input, width, theme) {
445
+ const todos = Array.isArray(input.todos) ? input.todos : [];
446
+ if (todos.length === 0)
447
+ return [[seg("(no todos)", { dim: true, color: theme.textDim })]];
448
+ return todos.flatMap((t) => {
449
+ const status = String(t.status ?? "pending");
450
+ const glyph = status === "completed" ? "☑" : status === "in_progress" ? "◐" : "☐";
451
+ const color = status === "completed" ? theme.success : status === "in_progress" ? theme.warning : theme.textDim;
452
+ const text = String(t.content ?? t.activeForm ?? "");
453
+ const wrapped = wrapText(text, Math.max(8, width - 2));
454
+ return wrapped.map((w, i) => [seg(i === 0 ? `${glyph} ` : " ", { color }), seg(w, { color: status === "completed" ? theme.textDim : theme.text })]);
455
+ });
456
+ }
457
+ /** Write → file path + content preview. */
458
+ function formatWriteBody(input, width, theme) {
459
+ const lines = [pathHeader(input.file_path, theme), blank()];
460
+ const allLines = String(input.content ?? "").split("\n");
461
+ const shown = allLines.slice(0, 40);
462
+ for (const ln of shown)
463
+ lines.push(...plainLines(ln, width, { color: theme.text }));
464
+ if (allLines.length > shown.length)
465
+ lines.push([seg(`… +${allLines.length - shown.length} more lines`, { dim: true, color: theme.textDim })]);
466
+ return lines;
467
+ }
468
+ /** WebFetch → url + fetched/answer text. */
469
+ function formatWebFetchBody(input, result, width, theme) {
470
+ const lines = [];
471
+ const url = input?.url;
472
+ if (url)
473
+ lines.push([seg("url ", { dim: true, color: theme.textDim }), seg(String(url), { color: theme.accent, underline: true, url: String(url) })]);
474
+ if (result.trim()) {
475
+ if (lines.length > 0)
476
+ lines.push(blank());
477
+ lines.push(...plainLines(result, width, { color: theme.text }));
478
+ }
479
+ return lines;
480
+ }
481
+ /** Task / Agent (subagent) → description + output. */
482
+ function formatSubagentBody(input, result, width, theme) {
483
+ const lines = [];
484
+ const desc = input?.description ?? input?.subagent_type;
485
+ if (desc)
486
+ lines.push([seg("task ", { dim: true, color: theme.textDim }), seg(String(desc), { color: theme.text })]);
487
+ if (result.trim()) {
488
+ if (lines.length > 0)
489
+ lines.push(blank());
490
+ lines.push(...markdownToSegLines(result, width, theme));
491
+ }
492
+ return lines;
493
+ }
494
+ /** ToolSearch → query + which tool reference it loaded. */
495
+ function formatToolSearchBody(input, result, width, theme) {
496
+ const lines = [];
497
+ if (input?.query)
498
+ lines.push([seg("query ", { dim: true, color: theme.textDim }), seg(String(input.query), { color: theme.text })]);
499
+ const ref = parseObj(result);
500
+ if (ref?.tool_name)
501
+ lines.push([seg("loaded ", { dim: true, color: theme.textDim }), seg(String(ref.tool_name), { color: theme.accent })]);
502
+ else if (result.trim())
503
+ lines.push(...plainLines(result, width, { color: theme.textDim }));
504
+ return lines;
505
+ }
506
+ /**
507
+ * Dedicated body for a Claude Code / Codex built-in tool, keyed by its real
508
+ * (un-prefixed) name. Returns null to fall through to the generic renderer.
509
+ */
510
+ function formatBuiltinBody(real, rawInput, result, width, theme) {
511
+ const input = parseObj(rawInput);
512
+ switch (real) {
513
+ case "Edit":
514
+ case "edit":
515
+ case "MultiEdit":
516
+ case "NotebookEdit":
517
+ return input ? formatEditBody(input, width, theme) : null;
518
+ case "TodoWrite":
519
+ return input ? formatTodoBody(input, width, theme) : null;
520
+ case "Write":
521
+ case "write":
522
+ return input ? formatWriteBody(input, width, theme) : null;
523
+ case "WebFetch":
524
+ return formatWebFetchBody(input, result, width, theme);
525
+ case "Task":
526
+ case "Agent":
527
+ return formatSubagentBody(input, result, width, theme);
528
+ case "ToolSearch":
529
+ return formatToolSearchBody(input, result, width, theme);
530
+ case "fileChange":
531
+ return formatFileChangeBody(input ?? parseObj(result), theme);
532
+ default:
533
+ return null;
534
+ }
535
+ }
536
+ /** Codex/Claude file mutation event → a single colored "<event> <path>" line. */
537
+ function formatFileChangeBody(fc, theme) {
538
+ if (!fc)
539
+ return null;
540
+ const event = String(fc.event ?? "change");
541
+ const color = event === "delete" ? theme.error : event === "create" ? theme.success : theme.accent;
542
+ return [[seg(`${event} `, { color }), seg(String(fc.path ?? ""), { color: theme.text })]];
543
+ }
348
544
  /** Multi-line formatted tool output for the feed panel. */
349
- export function formatToolResultLines(name, inputSummary, result, status, contentWidth, theme, expanded = true) {
545
+ export function formatToolResultLines(rawName, inputSummary, rawResult, status, contentWidth, theme, expanded = true, rawInput) {
546
+ const name = canonicalToolName(rawName);
547
+ const real = displayToolName(rawName);
350
548
  if (!expanded)
351
549
  return [];
550
+ // Bound the text we lay out per render — a terminal can't show a 1MB result,
551
+ // and wrapping/parsing that much on every frame is what stalls the renderer.
552
+ // The full result stays in the stored feed; only what we format is capped.
553
+ const MAX_RENDER = 60_000;
554
+ const result = rawResult && rawResult.length > MAX_RENDER
555
+ ? `${rawResult.slice(0, MAX_RENDER)}\n\n… [${rawResult.length - MAX_RENDER} more chars not shown]`
556
+ : rawResult;
557
+ // Dedicated built-in tool rendering (diffs, checklists, …). Input-driven ones
558
+ // (Edit, Write, TodoWrite) render even while the tool is still running.
559
+ if (status !== "error") {
560
+ const builtin = formatBuiltinBody(real, rawInput, result ?? "", Math.max(16, contentWidth), theme);
561
+ if (builtin && builtin.length > 0) {
562
+ if (status === "running" && !result?.trim())
563
+ builtin.push([seg("running…", { dim: true, color: theme.textDim })]);
564
+ return builtin;
565
+ }
566
+ }
352
567
  const width = Math.max(16, contentWidth);
353
568
  const lines = [];
354
569
  const input = inputSummary.replace(/\s+/gu, " ").trim();
@@ -1,29 +1,31 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { readFile, writeFile, mkdir } from "node:fs/promises";
3
- import { spawn } from "node:child_process";
2
+ import * as Bun from "bun";
3
+ import { mkdir } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { FULL_MODE_TRIGGERS } from "../constants.js";
8
8
  export const pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../../../package.json"), "utf8")).version;
9
9
  /** Pipe text to the OS clipboard (pbcopy / clip / xclip). Resolves false when unavailable. */
10
- export function copyToClipboard(text) {
11
- return new Promise((res) => {
12
- const cmd = process.platform === "darwin" ? "pbcopy" : process.platform === "win32" ? "clip" : "xclip";
13
- const args = cmd === "xclip" ? ["-selection", "clipboard"] : [];
14
- const child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
15
- child.on("error", () => res(false));
16
- child.on("close", (code) => res(code === 0));
17
- child.stdin.write(text);
18
- child.stdin.end();
19
- });
10
+ export async function copyToClipboard(text) {
11
+ const cmd = process.platform === "darwin" ? "pbcopy" : process.platform === "win32" ? "clip" : "xclip";
12
+ const args = cmd === "xclip" ? ["-selection", "clipboard"] : [];
13
+ try {
14
+ const proc = Bun.spawn([cmd, ...args], { stdin: "pipe", stdout: "ignore", stderr: "ignore" });
15
+ proc.stdin.write(text);
16
+ await proc.stdin.end();
17
+ return (await proc.exited) === 0;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
20
22
  }
21
23
  function historyFile(runDirectory) {
22
24
  return resolve(process.cwd(), runDirectory, "..", "input-history.json");
23
25
  }
24
26
  export async function loadInputHistory(runDirectory) {
25
27
  try {
26
- const parsed = JSON.parse(await readFile(historyFile(runDirectory), "utf8"));
28
+ const parsed = await Bun.file(historyFile(runDirectory)).json();
27
29
  return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string").slice(-50) : [];
28
30
  }
29
31
  catch {
@@ -34,7 +36,7 @@ export async function saveInputHistory(runDirectory, history) {
34
36
  try {
35
37
  const file = historyFile(runDirectory);
36
38
  await mkdir(dirname(file), { recursive: true });
37
- await writeFile(file, JSON.stringify(history.slice(-50), null, 2));
39
+ await Bun.write(file, JSON.stringify(history.slice(-50), null, 2));
38
40
  }
39
41
  catch { /* non-fatal */ }
40
42
  }
@@ -200,15 +202,17 @@ export function linkAtMouseColumn(links, x) {
200
202
  return undefined;
201
203
  }
202
204
  /** Open a URL in the system browser. */
203
- export function openExternalUrl(url) {
204
- return new Promise((res) => {
205
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
206
- const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
207
- const child = spawn(cmd, args, { stdio: "ignore", detached: true });
208
- child.on("error", () => res(false));
209
- child.on("close", (code) => res(code === 0));
210
- child.unref();
211
- });
205
+ export async function openExternalUrl(url) {
206
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
207
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
208
+ try {
209
+ const proc = Bun.spawn([cmd, ...args], { stdout: "ignore", stderr: "ignore" });
210
+ proc.unref();
211
+ return (await proc.exited) === 0;
212
+ }
213
+ catch {
214
+ return false;
215
+ }
212
216
  }
213
217
  /** True if the prompt clearly asks for full, report-grade research. */
214
218
  export function wantsFullResearch(prompt) {
@@ -279,16 +283,32 @@ function toolOutputText(output) {
279
283
  return String(output);
280
284
  }
281
285
  }
282
- export function summarizeToolInput(name, input) {
286
+ // Harness/CLI tool names mapped to Scira renderer keys. Kept local to avoid a
287
+ // circular import with tool-result.ts (which imports from this file).
288
+ const HARNESS_TOOL_PREFIX = "mcp__harness-tools__";
289
+ const SUMMARY_CANONICAL = {
290
+ multiWebSearch: "webSearch",
291
+ Read: "readFile", Write: "writeFile", Edit: "editFile", MultiEdit: "editFile", NotebookEdit: "editFile",
292
+ Bash: "bash", BashOutput: "bash", shell: "bash",
293
+ Grep: "grepWorkspace", Glob: "listWorkspaceDir", LS: "listWorkspaceDir",
294
+ TodoWrite: "todo", WebFetch: "readUrl", WebSearch: "webSearch",
295
+ };
296
+ export function summarizeToolInput(rawName, input) {
297
+ const stripped = rawName.startsWith(HARNESS_TOOL_PREFIX) ? rawName.slice(HARNESS_TOOL_PREFIX.length) : rawName;
298
+ const name = SUMMARY_CANONICAL[stripped] ?? stripped;
283
299
  const obj = (input ?? {});
300
+ const path = obj.path ?? obj.file_path ?? obj.notebook_path;
284
301
  if (name === "bash" || name === "runBash" || name === "runWorkspaceCommand") {
285
302
  const action = obj.action;
286
303
  if (action && action !== "run")
287
304
  return `${action}${obj.taskId ? ` ${obj.taskId}` : ""}`;
288
305
  return String(obj.command ?? "");
289
306
  }
290
- if (name === "todo")
307
+ if (name === "todo") {
308
+ if (Array.isArray(obj.todos))
309
+ return `${obj.todos.length} item(s)`;
291
310
  return `${String(obj.action ?? "list")}${obj.id ? ` ${obj.id}` : ""}`;
311
+ }
292
312
  if (name === "webSearch" || name === "xSearch") {
293
313
  const queries = Array.isArray(obj.queries) ? obj.queries : [];
294
314
  return queries.length > 0 ? queries.slice(0, 2).join(" · ") + (queries.length > 2 ? ` +${queries.length - 2}` : "") : String(obj.query ?? "");
@@ -296,14 +316,20 @@ export function summarizeToolInput(name, input) {
296
316
  if (name === "readUrl")
297
317
  return String(obj.url ?? "");
298
318
  if (name === "writeFile" || name === "editFile" || name === "readFile" || name === "readWorkspaceFile" || name === "writeWorkspaceFile" || name === "editWorkspaceFile") {
299
- return String(obj.path ?? "");
319
+ return String(path ?? "");
300
320
  }
301
321
  if (name === "listWorkspaceDir" || name === "grepWorkspace")
302
- return String(obj.path ?? obj.pattern ?? "");
322
+ return String(obj.pattern ?? path ?? "");
303
323
  if (name === "readSkill" || name === "listSkills")
304
- return String(obj.name ?? "");
324
+ return String(obj.name ?? obj.skill ?? "");
305
325
  if (name === "createClaim" || name === "verifyClaim")
306
326
  return String(obj.id ?? "");
327
+ if (name === "fileChange")
328
+ return `${String(obj.event ?? "change")} ${String(obj.path ?? "")}`.trim();
329
+ if (stripped === "ToolSearch")
330
+ return String(obj.query ?? "");
331
+ if (stripped === "Task" || stripped === "Agent")
332
+ return String(obj.description ?? obj.subagent_type ?? "");
307
333
  try {
308
334
  return JSON.stringify(obj).slice(0, 80);
309
335
  }
@@ -1,5 +1,4 @@
1
1
  import { readFileSync, unwatchFile, watchFile } from "node:fs";
2
- import { execSync } from "node:child_process";
3
2
  import { homedir } from "node:os";
4
3
  import { join } from "node:path";
5
4
  export const DARK_THEME = {
@@ -124,11 +123,9 @@ function readEditorColorTheme() {
124
123
  function readSystemAppearance() {
125
124
  if (process.platform === "darwin") {
126
125
  try {
127
- const style = execSync("defaults read -g AppleInterfaceStyle 2>/dev/null", {
128
- encoding: "utf8",
129
- stdio: ["ignore", "pipe", "ignore"],
130
- }).trim();
131
- return style === "Dark" ? "dark" : "light";
126
+ const r = Bun.spawnSync(["defaults", "read", "-g", "AppleInterfaceStyle"], { stdout: "pipe", stderr: "ignore" });
127
+ // The key is absent (and `defaults` exits non-zero) in light mode.
128
+ return r.stdout.toString().trim() === "Dark" ? "dark" : "light";
132
129
  }
133
130
  catch {
134
131
  return "light";
@@ -136,10 +133,8 @@ function readSystemAppearance() {
136
133
  }
137
134
  if (process.platform === "linux") {
138
135
  try {
139
- const scheme = execSync("gsettings get org.gnome.desktop.interface color-scheme 2>/dev/null", {
140
- encoding: "utf8",
141
- stdio: ["ignore", "pipe", "ignore"],
142
- }).trim();
136
+ const r = Bun.spawnSync(["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"], { stdout: "pipe", stderr: "ignore" });
137
+ const scheme = r.stdout.toString().trim();
143
138
  if (/dark/i.test(scheme))
144
139
  return "dark";
145
140
  if (/light/i.test(scheme))