@scira/cli 0.1.7 → 0.1.9

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.
@@ -6,11 +6,6 @@ import { S_BAR, TOOL_ICONS, SPINNER_FRAMES } from "../constants.js";
6
6
  import { formatTime, fmtDuration, wrapText, computeLineLinks, displayWidth } from "../lib/utils.js";
7
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
9
  const toolBodyCache = new Map();
15
10
  function cachedToolBody(themeKey, name, callId, summary, result, status, width, theme, expanded, input) {
16
11
  const key = `${themeKey}|${callId}|${name}|${status}|${width}|${expanded}|${result?.length ?? 0}|${input?.length ?? 0}`;
@@ -62,17 +57,12 @@ const isGH = (item) => item._tag === "gh";
62
57
  function renderSegNodes(segs, theme, defaultColor) {
63
58
  return segs.map((s, i) => {
64
59
  const inner = (_jsx(Text, { color: s.url ? (s.color ?? theme.accent) : (s.color ?? defaultColor), bold: s.bold, italic: s.italic, underline: s.url ? true : s.underline, dimColor: s.dim, children: s.text }));
65
- // For URL segments, emit an OSC 8 terminal hyperlink so the terminal itself makes the
66
- // text clickable (Cmd/Ctrl-click). fallback={false} keeps the visible text unchanged so
67
- // the pre-computed line widths still hold on terminals without hyperlink support.
68
60
  return s.url
69
61
  ? _jsx(Link, { url: s.url, fallback: false, children: inner }, i)
70
62
  : React.cloneElement(inner, { key: i });
71
63
  });
72
64
  }
73
- export function useFeedLines(feed, innerWidth,
74
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
75
- reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState, hoveredLineIdx, config) {
65
+ export function useFeedLines(feed, innerWidth, _reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState, hoveredLineIdx, config) {
76
66
  const theme = useTheme();
77
67
  return useMemo(() => {
78
68
  const bandBg = theme.userBandBackground ? { backgroundColor: theme.userBandBackground } : {};
@@ -172,9 +162,6 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState,
172
162
  const running = fi.status === "running";
173
163
  const failed = fi.status === "error";
174
164
  const itemId = feedToolItemId(feedIdx, fi.toolCallId);
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
165
  const canon = canonicalToolName(fi.name);
179
166
  const shownName = displayToolName(fi.name);
180
167
  const collapsible = isCollapsibleToolName(canon) && !running;
@@ -187,9 +174,6 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState,
187
174
  const symColor = failed ? theme.error : theme.accentDim;
188
175
  const nameColor = running ? theme.text : failed ? theme.error : theme.textDim;
189
176
  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.
193
177
  const preview = formatToolResultPreview(fi.name, fi.summary, fi.result, fi.status);
194
178
  const bodyLines = cachedToolBody(theme.accent, fi.name, fi.toolCallId ?? String(feedIdx), fi.summary, fi.result, fi.status, panelWidth, theme, !collapsed, fi.input);
195
179
  if (collapsible)
@@ -261,6 +245,5 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState,
261
245
  }
262
246
  });
263
247
  return { lines, toggleAtLine, groupToggleAtLine, linkAtLine, lastUserLineStart };
264
- // eslint-disable-next-line react-hooks/exhaustive-deps
265
- }, [feed, innerWidth, reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState, hoveredLineIdx, config, theme]);
248
+ }, [feed, innerWidth, _reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState, hoveredLineIdx, config, theme]);
266
249
  }
@@ -2,7 +2,6 @@ import { useCallback, useRef, useState } from "react";
2
2
  export function useFeed() {
3
3
  const [feed, setFeed] = useState([]);
4
4
  const feedRef = useRef([]);
5
- /** Keep feedRef in sync immediately so convo.json saves never read stale React state. */
6
5
  const applyFeed = useCallback((update) => {
7
6
  const next = update(feedRef.current);
8
7
  feedRef.current = next;
@@ -16,7 +16,6 @@ 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
19
  const quitArmedRef = useRef(false);
21
20
  const quitTimerRef = useRef(null);
22
21
  const editInput = (char, key) => {
@@ -95,10 +94,8 @@ export function useKeyboard(o) {
95
94
  useInput((char, key) => {
96
95
  if (char && (char.includes("[<") || /^\d+;\d+;\d+[Mm]$/u.test(char)))
97
96
  return;
98
- // OSC background-color query responses leak as stdin when terminals reply to theme probes.
99
97
  if (char && (/\]11;rgb:/u.test(char) || /^11;rgb:/u.test(char)))
100
98
  return;
101
- // Quit handling (all screens): Ctrl+C twice (with warning) or Ctrl+D.
102
99
  if (key.ctrl && char === "c") {
103
100
  if (busy) {
104
101
  stopTurn();
@@ -114,7 +111,6 @@ export function useKeyboard(o) {
114
111
  if (quitTimerRef.current)
115
112
  clearTimeout(quitTimerRef.current);
116
113
  quitTimerRef.current = setTimeout(() => { quitArmedRef.current = false; }, 3000);
117
- // Don't let the disarm timer keep the process alive after a quit.
118
114
  quitTimerRef.current.unref?.();
119
115
  return;
120
116
  }
@@ -238,7 +234,6 @@ export function useKeyboard(o) {
238
234
  });
239
235
  return;
240
236
  }
241
- // Cancel pendingRerun if user types something other than /rerun
242
237
  if (pendingRerun && char && !(inputText + char).trim().startsWith("/rerun")) {
243
238
  setPendingRerun(false);
244
239
  }
@@ -14,14 +14,12 @@ export function useSession(o) {
14
14
  }, [currentRunPath]);
15
15
  const openRun = useCallback(async (runPath, initialQuestion) => {
16
16
  if (runPath !== currentRunPath) {
17
- // Honor a plan-mode preference armed from the home screen, then disarm it.
18
17
  setPlanMode(pendingPlanModeRef.current);
19
18
  setPendingPlanMode(false);
20
19
  }
21
20
  setCurrentRunPath(runPath);
22
21
  setInputText("");
23
22
  setCursorPos(0);
24
- // If a background session is already running for this path, reattach to it.
25
23
  const live = getSession(runPath);
26
24
  if (live) {
27
25
  startedRef.current = runPath;
@@ -37,7 +37,6 @@ export function useSuggestions({ inputText, setInputText, setCursorPos, sessions
37
37
  sessionMatchesRef.current = matches;
38
38
  return matches.map(sessionLabel);
39
39
  }, [inputText, sessions]);
40
- // Refresh sessions when typing # if the list is empty
41
40
  React.useEffect(() => {
42
41
  if (inputText.startsWith("#") && sessions.length === 0) {
43
42
  void refreshSessions();
@@ -2,11 +2,8 @@ import { diffLines } from "diff";
2
2
  import { markdownToSegLines } from "./markdown.js";
3
3
  import { wrapText } from "./utils.js";
4
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
5
  const CANONICAL_TOOL = {
7
- // Scira host tools exposed to the CLI
8
6
  multiWebSearch: "webSearch",
9
- // Claude Code built-ins
10
7
  Read: "readFile",
11
8
  Write: "writeFile",
12
9
  Edit: "editFile",
@@ -20,23 +17,15 @@ const CANONICAL_TOOL = {
20
17
  TodoWrite: "todo",
21
18
  WebFetch: "readUrl",
22
19
  WebSearch: "webSearch",
23
- // Codex built-ins
24
20
  shell: "bash",
25
21
  };
26
- /** Strip the harness host-tool MCP prefix so `mcp__harness-tools__readUrl` reads as `readUrl`. */
27
22
  export function displayToolName(name) {
28
23
  return name.startsWith(HARNESS_TOOL_PREFIX) ? name.slice(HARNESS_TOOL_PREFIX.length) : name;
29
24
  }
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
25
  export function canonicalToolName(name) {
36
26
  const stripped = displayToolName(name);
37
27
  return CANONICAL_TOOL[stripped] ?? stripped;
38
28
  }
39
- /** Tools that start collapsed in the timeline (long output). */
40
29
  export const DEFAULT_COLLAPSED_TOOLS = new Set([
41
30
  "webSearch",
42
31
  "multiWebSearch",
@@ -57,8 +46,6 @@ export function isCollapsibleToolName(name) {
57
46
  return name.length > 0;
58
47
  }
59
48
  export function defaultCollapsedToolName(name) {
60
- // Chrome DevTools MCP tools (prefixed `devtools_`) produce long browser
61
- // snapshots/output, so collapse them by default like the built-in tools.
62
49
  if (name.startsWith("devtools_"))
63
50
  return true;
64
51
  return DEFAULT_COLLAPSED_TOOLS.has(name);
@@ -222,7 +209,6 @@ function formatListSkills(result, width, theme) {
222
209
  });
223
210
  }
224
211
  function formatShellOutput(result, width, theme) {
225
- // Codex returns `{ exitCode, output }`; Claude returns the output string directly.
226
212
  let text = result;
227
213
  const obj = parseObj(result);
228
214
  if (obj && typeof obj.output === "string") {
@@ -333,7 +319,6 @@ function formatBody(name, result, width, theme) {
333
319
  }
334
320
  }
335
321
  }
336
- /** One-line preview for a collapsed tool header. */
337
322
  export function formatToolResultPreview(rawName, inputSummary, result, status) {
338
323
  const name = canonicalToolName(rawName);
339
324
  const input = inputSummary.replace(/\s+/gu, " ").trim();
@@ -364,7 +349,7 @@ export function formatToolResultPreview(rawName, inputSummary, result, status) {
364
349
  return q ? `${q} · ${total} sources` : `${total} sources`;
365
350
  }
366
351
  }
367
- catch { /* fall through */ }
352
+ catch { }
368
353
  }
369
354
  if (name === "xSearch") {
370
355
  try {
@@ -376,7 +361,7 @@ export function formatToolResultPreview(rawName, inputSummary, result, status) {
376
361
  return q ? `${q} · ${total} posts` : `${total} posts`;
377
362
  }
378
363
  }
379
- catch { /* fall through */ }
364
+ catch { }
380
365
  }
381
366
  if (name === "readFile" || name === "readWorkspaceFile") {
382
367
  const lines = result.split("\n").length;
@@ -389,7 +374,6 @@ export function formatToolResultPreview(rawName, inputSummary, result, status) {
389
374
  const first = result.replace(/\s+/gu, " ").trim();
390
375
  return first.length > 140 ? `${first.slice(0, 137)}…` : first;
391
376
  }
392
- // --- Dedicated renderers for Claude Code / Codex built-in tools ---
393
377
  function parseObj(s) {
394
378
  if (!s)
395
379
  return null;
@@ -401,7 +385,6 @@ function parseObj(s) {
401
385
  return null;
402
386
  }
403
387
  }
404
- /** Unified-ish diff between two strings: removed lines red, added green, a little context dim. */
405
388
  function diffSegLines(oldStr, newStr, width, theme) {
406
389
  const parts = diffLines(oldStr ?? "", newStr ?? "");
407
390
  const out = [];
@@ -427,7 +410,6 @@ function diffSegLines(oldStr, newStr, width, theme) {
427
410
  function pathHeader(p, theme) {
428
411
  return [seg("path ", { dim: true, color: theme.textDim }), seg(String(p ?? ""), { color: theme.text })];
429
412
  }
430
- /** Edit / MultiEdit → file path + colored diff(s). */
431
413
  function formatEditBody(input, width, theme) {
432
414
  const lines = [pathHeader(input.file_path ?? input.notebook_path, theme)];
433
415
  const edits = Array.isArray(input.edits)
@@ -440,7 +422,6 @@ function formatEditBody(input, width, theme) {
440
422
  });
441
423
  return lines;
442
424
  }
443
- /** TodoWrite → checklist with status glyphs. */
444
425
  function formatTodoBody(input, width, theme) {
445
426
  const todos = Array.isArray(input.todos) ? input.todos : [];
446
427
  if (todos.length === 0)
@@ -454,7 +435,6 @@ function formatTodoBody(input, width, theme) {
454
435
  return wrapped.map((w, i) => [seg(i === 0 ? `${glyph} ` : " ", { color }), seg(w, { color: status === "completed" ? theme.textDim : theme.text })]);
455
436
  });
456
437
  }
457
- /** Write → file path + content preview. */
458
438
  function formatWriteBody(input, width, theme) {
459
439
  const lines = [pathHeader(input.file_path, theme), blank()];
460
440
  const allLines = String(input.content ?? "").split("\n");
@@ -465,7 +445,6 @@ function formatWriteBody(input, width, theme) {
465
445
  lines.push([seg(`… +${allLines.length - shown.length} more lines`, { dim: true, color: theme.textDim })]);
466
446
  return lines;
467
447
  }
468
- /** WebFetch → url + fetched/answer text. */
469
448
  function formatWebFetchBody(input, result, width, theme) {
470
449
  const lines = [];
471
450
  const url = input?.url;
@@ -478,7 +457,6 @@ function formatWebFetchBody(input, result, width, theme) {
478
457
  }
479
458
  return lines;
480
459
  }
481
- /** Task / Agent (subagent) → description + output. */
482
460
  function formatSubagentBody(input, result, width, theme) {
483
461
  const lines = [];
484
462
  const desc = input?.description ?? input?.subagent_type;
@@ -491,7 +469,6 @@ function formatSubagentBody(input, result, width, theme) {
491
469
  }
492
470
  return lines;
493
471
  }
494
- /** ToolSearch → query + which tool reference it loaded. */
495
472
  function formatToolSearchBody(input, result, width, theme) {
496
473
  const lines = [];
497
474
  if (input?.query)
@@ -503,10 +480,6 @@ function formatToolSearchBody(input, result, width, theme) {
503
480
  lines.push(...plainLines(result, width, { color: theme.textDim }));
504
481
  return lines;
505
482
  }
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
483
  function formatBuiltinBody(real, rawInput, result, width, theme) {
511
484
  const input = parseObj(rawInput);
512
485
  switch (real) {
@@ -533,7 +506,6 @@ function formatBuiltinBody(real, rawInput, result, width, theme) {
533
506
  return null;
534
507
  }
535
508
  }
536
- /** Codex/Claude file mutation event → a single colored "<event> <path>" line. */
537
509
  function formatFileChangeBody(fc, theme) {
538
510
  if (!fc)
539
511
  return null;
@@ -541,21 +513,15 @@ function formatFileChangeBody(fc, theme) {
541
513
  const color = event === "delete" ? theme.error : event === "create" ? theme.success : theme.accent;
542
514
  return [[seg(`${event} `, { color }), seg(String(fc.path ?? ""), { color: theme.text })]];
543
515
  }
544
- /** Multi-line formatted tool output for the feed panel. */
545
516
  export function formatToolResultLines(rawName, inputSummary, rawResult, status, contentWidth, theme, expanded = true, rawInput) {
546
517
  const name = canonicalToolName(rawName);
547
518
  const real = displayToolName(rawName);
548
519
  if (!expanded)
549
520
  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
521
  const MAX_RENDER = 60_000;
554
522
  const result = rawResult && rawResult.length > MAX_RENDER
555
523
  ? `${rawResult.slice(0, MAX_RENDER)}\n\n… [${rawResult.length - MAX_RENDER} more chars not shown]`
556
524
  : 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
525
  if (status !== "error") {
560
526
  const builtin = formatBuiltinBody(real, rawInput, result ?? "", Math.max(16, contentWidth), theme);
561
527
  if (builtin && builtin.length > 0) {
@@ -6,7 +6,6 @@ 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
- /** Pipe text to the OS clipboard (pbcopy / clip / xclip). Resolves false when unavailable. */
10
9
  export async function copyToClipboard(text) {
11
10
  const cmd = process.platform === "darwin" ? "pbcopy" : process.platform === "win32" ? "clip" : "xclip";
12
11
  const args = cmd === "xclip" ? ["-selection", "clipboard"] : [];
@@ -38,7 +37,7 @@ export async function saveInputHistory(runDirectory, history) {
38
37
  await mkdir(dirname(file), { recursive: true });
39
38
  await Bun.write(file, JSON.stringify(history.slice(-50), null, 2));
40
39
  }
41
- catch { /* non-fatal */ }
40
+ catch { }
42
41
  }
43
42
  export const CWD_DISPLAY = (() => {
44
43
  const home = homedir();
@@ -52,11 +51,9 @@ export function prettifyModelId(id) {
52
51
  .map((w) => (/^[0-9]/u.test(w) ? w : w.charAt(0).toUpperCase() + w.slice(1)))
53
52
  .join(" ");
54
53
  }
55
- /** Terminal-cell width of a string (CJK/emoji aware, strips ANSI). */
56
54
  export function displayWidth(text) {
57
55
  return Bun.stringWidth(text);
58
56
  }
59
- /** Longest prefix of `text` whose terminal-cell width fits in `width`; returns its char length. */
60
57
  function fitChars(text, width) {
61
58
  if (Bun.stringWidth(text) === text.length)
62
59
  return Math.min(text.length, width);
@@ -123,27 +120,7 @@ export function fmtTokens(n) {
123
120
  return `${(n / 1000).toFixed(1)}k`;
124
121
  return String(n);
125
122
  }
126
- /** Compact relative time, e.g. "now", "5m", "3h", "2d", "3w". */
127
- export function relativeTime(ms) {
128
- if (!ms)
129
- return "";
130
- const diff = Date.now() - ms;
131
- if (diff < 60_000)
132
- return "now";
133
- const mins = Math.floor(diff / 60_000);
134
- if (mins < 60)
135
- return `${mins}m ago`;
136
- const hours = Math.floor(mins / 60);
137
- if (hours < 24)
138
- return `${hours}h ago`;
139
- const days = Math.floor(hours / 24);
140
- if (days < 7)
141
- return `${days}d ago`;
142
- const weeks = Math.floor(days / 7);
143
- if (weeks < 5)
144
- return `${weeks}w ago`;
145
- return new Date(ms).toLocaleDateString();
146
- }
123
+ export { relativeTime } from "../../../utils/time.js";
147
124
  function colorToAnsi(color) {
148
125
  if (!color)
149
126
  return [];
@@ -162,7 +139,6 @@ function colorToAnsi(color) {
162
139
  const code = named[color.toLowerCase()];
163
140
  return code ? [code] : [];
164
141
  }
165
- /** OSC 8 link with inline ANSI styling — avoids Ink Text props breaking the escape sequence. */
166
142
  export function ansiHyperlink(text, url, style) {
167
143
  const params = [];
168
144
  if (style?.bold)
@@ -193,7 +169,6 @@ export function computeLineLinks(segs, prefixCols = 0) {
193
169
  }
194
170
  return links;
195
171
  }
196
- /** Match an SGR mouse column (1-based) against link regions from computeLineLinks. */
197
172
  export function linkAtMouseColumn(links, x) {
198
173
  for (const l of links) {
199
174
  if (x >= l.start + 1 && x <= l.end + 1)
@@ -201,7 +176,6 @@ export function linkAtMouseColumn(links, x) {
201
176
  }
202
177
  return undefined;
203
178
  }
204
- /** Open a URL in the system browser. */
205
179
  export async function openExternalUrl(url) {
206
180
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
207
181
  const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
@@ -214,12 +188,10 @@ export async function openExternalUrl(url) {
214
188
  return false;
215
189
  }
216
190
  }
217
- /** True if the prompt clearly asks for full, report-grade research. */
218
191
  export function wantsFullResearch(prompt) {
219
192
  const p = prompt.toLowerCase();
220
193
  return FULL_MODE_TRIGGERS.some((kw) => p.includes(kw));
221
194
  }
222
- /** Word-wrap input text and locate the caret in the wrapped output (no cursor char injection). */
223
195
  export function wrapInputWithCursor(text, width, caretPos) {
224
196
  const w = Math.max(1, width);
225
197
  const lines = [];
@@ -283,8 +255,6 @@ function toolOutputText(output) {
283
255
  return String(output);
284
256
  }
285
257
  }
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
258
  const HARNESS_TOOL_PREFIX = "mcp__harness-tools__";
289
259
  const SUMMARY_CANONICAL = {
290
260
  multiWebSearch: "webSearch",
@@ -337,7 +307,6 @@ export function summarizeToolInput(rawName, input) {
337
307
  return "";
338
308
  }
339
309
  }
340
- /** Short one-line summary of a completed tool's output for the feed. */
341
310
  export function summarizeToolOutput(name, output) {
342
311
  const text = toolOutputText(output);
343
312
  if (name === "webSearch") {
@@ -352,7 +321,7 @@ export function summarizeToolOutput(name, output) {
352
321
  return titles.length > 0 ? `${head} · ${titles.join(" · ")}` : head;
353
322
  }
354
323
  }
355
- catch { /* fall through */ }
324
+ catch { }
356
325
  }
357
326
  if (name === "xSearch") {
358
327
  try {
@@ -371,7 +340,7 @@ export function summarizeToolOutput(name, output) {
371
340
  return handles.length > 0 ? `${head} · ${handles.join(", ")}` : head;
372
341
  }
373
342
  }
374
- catch { /* fall through */ }
343
+ catch { }
375
344
  }
376
345
  if (name === "readUrl") {
377
346
  const titleMatch = text.match(/^#\s+(.+)/m);
@@ -28,7 +28,6 @@ export function detachSubscriber(runPath) {
28
28
  const session = sessions.get(runPath);
29
29
  if (session)
30
30
  session.subscriber = null;
31
- // deliberately NOT aborting — stream keeps running in the background
32
31
  }
33
32
  export function abortSession(runPath) {
34
33
  const session = sessions.get(runPath);
@@ -42,7 +41,6 @@ export function abortSession(runPath) {
42
41
  export function removeSession(runPath) {
43
42
  sessions.delete(runPath);
44
43
  }
45
- /** Merge full tool results from the session buffer into a feed snapshot before persisting. */
46
44
  export function mergeFeedToolResults(feed, buffer) {
47
45
  const toolById = new Map();
48
46
  for (const item of buffer) {
@@ -74,17 +72,14 @@ export function getSessionFeedBuffer(runPath) {
74
72
  const session = sessions.get(runPath);
75
73
  return session ? [...session.feedBuffer] : [];
76
74
  }
77
- /** Route a feed item to the correct subscriber method and buffer it. */
78
75
  export function sessionPushFeed(runPath, item) {
79
76
  const session = sessions.get(runPath);
80
77
  if (!session)
81
78
  return;
82
79
  const sub = session.subscriber;
83
80
  if (sub) {
84
- // Route to fine-grained helpers for smooth streaming rendering
85
81
  if (item.kind === "text") {
86
82
  sub.appendText(item.text);
87
- // Keep buffer in sync: merge into last text item or push new
88
83
  const last = session.feedBuffer.at(-1);
89
84
  if (last?.kind === "text") {
90
85
  session.feedBuffer[session.feedBuffer.length - 1] = { ...last, text: last.text + item.text };
@@ -121,7 +116,6 @@ export function sessionPushFeed(runPath, item) {
121
116
  }
122
117
  sub.pushFeed(item);
123
118
  }
124
- // No subscriber — always buffer
125
119
  if (item.kind === "tool" && (item.status === "done" || item.status === "error") && item.toolCallId) {
126
120
  const resultText = item.result;
127
121
  if (!resultText)
@@ -178,8 +172,6 @@ export function sessionSetApproval(runPath, pending) {
178
172
  if (!session)
179
173
  return;
180
174
  session.approvalPending = pending;
181
- // NOTE: The resolve closure in pending is captured per-promise, so it correctly
182
- // resolves even if the subscriber changed (user navigated away and back).
183
175
  if (pending) {
184
176
  session.subscriber?.onApprovalRequired(pending);
185
177
  }
@@ -70,7 +70,6 @@ function readTerminalProfile() {
70
70
  return "dark";
71
71
  return undefined;
72
72
  }
73
- /** Common standalone terminals that default to dark profiles when unset. */
74
73
  function readTermProgram() {
75
74
  const program = (process.env.TERM_PROGRAM ?? "").toLowerCase();
76
75
  if (!program)
@@ -115,7 +114,6 @@ function readEditorColorTheme() {
115
114
  return inferred;
116
115
  }
117
116
  catch {
118
- // try next settings file
119
117
  }
120
118
  }
121
119
  return undefined;
@@ -124,7 +122,6 @@ function readSystemAppearance() {
124
122
  if (process.platform === "darwin") {
125
123
  try {
126
124
  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
125
  return r.stdout.toString().trim() === "Dark" ? "dark" : "light";
129
126
  }
130
127
  catch {
@@ -141,7 +138,6 @@ function readSystemAppearance() {
141
138
  return "light";
142
139
  }
143
140
  catch {
144
- // fall through
145
141
  }
146
142
  const gtk = process.env.GTK_THEME;
147
143
  if (gtk && /dark/i.test(gtk))
@@ -157,14 +153,9 @@ export function detectTerminalTheme() {
157
153
  ?? readSystemAppearance()
158
154
  ?? "dark";
159
155
  }
160
- /** Input foreground matched to terminal appearance. */
161
156
  export function inputForegroundForAppearance(appearance) {
162
157
  return appearance === "dark" ? "ansi256(15)" : "ansi256(0)";
163
158
  }
164
- /**
165
- * Pick colors for rendering. When a locked theme disagrees with the terminal
166
- * background, follow the terminal so text stays readable.
167
- */
168
159
  export function resolveRenderingAppearance(configTheme, terminalAppearance) {
169
160
  if (configTheme === "auto")
170
161
  return terminalAppearance;
@@ -179,7 +170,6 @@ export function getTheme(theme) {
179
170
  const resolved = theme === "auto" ? detectTerminalTheme() : theme;
180
171
  return getThemeFromResolved(resolved);
181
172
  }
182
- /** Poll editor settings + system appearance while theme mode is auto. */
183
173
  export function watchAutoThemeChanges(onChange) {
184
174
  const cleanups = [];
185
175
  onChange();
@@ -191,11 +181,10 @@ export function watchAutoThemeChanges(onChange) {
191
181
  try {
192
182
  unwatchFile(path);
193
183
  }
194
- catch { /* file may be gone */ }
184
+ catch { }
195
185
  });
196
186
  }
197
187
  catch {
198
- // settings file not present yet
199
188
  }
200
189
  }
201
190
  }
@@ -0,0 +1,26 @@
1
+ /** Best-effort OS desktop notification. Never throws — notifications are a nicety. */
2
+ export async function tryDesktopNotify(title, body) {
3
+ try {
4
+ if (process.platform === "darwin") {
5
+ const script = `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`;
6
+ await Bun.spawn(["osascript", "-e", script]).exited;
7
+ }
8
+ else if (process.platform === "linux" && Bun.which("notify-send")) {
9
+ await Bun.spawn(["notify-send", title, body]).exited;
10
+ }
11
+ else if (process.platform === "win32") {
12
+ const ps = [
13
+ `[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime]`,
14
+ `$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent("ToastBasicText02")`,
15
+ `$xml.GetElementsByTagName("text")[0].InnerText = ${JSON.stringify(title)}`,
16
+ `$xml.GetElementsByTagName("text")[1].InnerText = ${JSON.stringify(body)}`,
17
+ `$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)`,
18
+ `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Scira").Show($toast)`
19
+ ].join("; ");
20
+ await Bun.spawn(["powershell", "-NoProfile", "-Command", ps]).exited;
21
+ }
22
+ }
23
+ catch {
24
+ /* best-effort — never propagate notification failures */
25
+ }
26
+ }