@scira/cli 0.1.8 → 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.
@@ -0,0 +1,196 @@
1
+ import { readFile, rm } from "node:fs/promises";
2
+ import { spawn } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+ import { loadConfig } from "../../config/load-config.js";
6
+ import { requireLlmKeys } from "../../providers/llm/registry.js";
7
+ import { watchLoop } from "../../watch/runner.js";
8
+ import { loadWatches, addWatch, removeWatch, findWatch, nextWatchId } from "../../watch/store.js";
9
+ import { isDue, nextRunAt } from "../../watch/scheduler.js";
10
+ import { DAEMON_LOG_FILE, DAEMON_PID_FILE, isProcessRunning, readLock } from "../../watch/daemon-lock.js";
11
+ const FREQUENCIES = ["day", "weekday", "week"];
12
+ const INTERVALS = { hourly: 60 * 60 * 1000, daily: 24 * 60 * 60 * 1000, weekly: 7 * 24 * 60 * 60 * 1000 };
13
+ /** Entry point for `scira watch <goal>`: foreground loop, or persisted schedule with -b. */
14
+ export async function watchCommand(goal, opts) {
15
+ if (opts.background || opts.b) {
16
+ await watchBackground(goal, opts);
17
+ }
18
+ else {
19
+ await watchForeground(goal, opts);
20
+ }
21
+ }
22
+ // --- Foreground: interval loop tied to this terminal ---------------------
23
+ async function watchForeground(goal, opts) {
24
+ const config = await loadConfig();
25
+ requireLlmKeys(config);
26
+ const intervalMs = opts.interval
27
+ ? Number.parseInt(String(opts.interval), 10)
28
+ : opts.hourly ? INTERVALS.hourly
29
+ : opts.weekly ? INTERVALS.weekly
30
+ : INTERVALS.daily;
31
+ if (Number.isNaN(intervalMs) || intervalMs < 1000) {
32
+ throw new Error("Interval must be at least 1000 ms.");
33
+ }
34
+ const maxRuns = opts.runs != null ? Number.parseInt(String(opts.runs), 10) : undefined;
35
+ const controller = new AbortController();
36
+ process.on("SIGINT", () => { console.log("\nStopping watch…"); controller.abort(); });
37
+ process.on("SIGTERM", () => controller.abort());
38
+ console.log(`Watching: "${goal}"`);
39
+ console.log(`Interval: ${intervalMs / 1000}s${maxRuns ? ` · max ${maxRuns} runs` : ""}`);
40
+ console.log("Press Ctrl-C to stop. (Tip: -b runs this in the background instead.)\n");
41
+ await watchLoop({
42
+ goal, intervalMs, maxRuns, config,
43
+ onRunStart: (runPath, tick) => { console.log(`\n[run ${tick + 1}] Starting → ${runPath}`); },
44
+ onRunComplete: (_runPath, diff, tick) => { console.log(`[run ${tick + 1}] Done.\n${diff.text}`); },
45
+ onError: (err, tick) => { console.error(`[run ${tick + 1}] Error: ${err.message}`); }
46
+ }, controller.signal);
47
+ console.log("Watch finished.");
48
+ }
49
+ // --- Background: persist a scheduled watch, run by the daemon -------------
50
+ async function watchBackground(goal, opts) {
51
+ const at = opts.at ?? "09:00";
52
+ const every = opts.every ?? "day";
53
+ // Reject impossible times: a bare \d{2}:\d{2} would accept 25:61, which
54
+ // Date.setHours silently rolls over into the wrong day.
55
+ if (!/^([01]\d|2[0-3]):[0-5]\d$/u.test(at)) {
56
+ throw new Error(`--at must be a valid HH:MM 24h time, 00:00–23:59 (got: ${at})`);
57
+ }
58
+ if (!FREQUENCIES.includes(every)) {
59
+ throw new Error(`--every must be one of: ${FREQUENCIES.join(", ")} (got: ${every})`);
60
+ }
61
+ const config = await loadConfig();
62
+ requireLlmKeys(config); // fail now, not on the first silent background run
63
+ const projectRoot = process.cwd();
64
+ const existing = await loadWatches();
65
+ // Don't create duplicates: an identical goal+schedule+project already covers it.
66
+ const duplicate = existing.find((w) => w.goal === goal && w.projectRoot === projectRoot && w.frequency === every && w.atHHMM === at);
67
+ if (duplicate) {
68
+ console.log(`Already scheduled as "${duplicate.name}" (${duplicate.id}) — runs ${every} at ${at}.`);
69
+ await printSchedulerHint();
70
+ return;
71
+ }
72
+ const id = nextWatchId(existing);
73
+ const name = opts.name ?? id;
74
+ const watch = {
75
+ id,
76
+ name,
77
+ goal,
78
+ projectRoot,
79
+ frequency: every,
80
+ atHHMM: at,
81
+ createdAt: new Date().toISOString(),
82
+ lastRunAt: null,
83
+ enabled: true
84
+ };
85
+ await addWatch(watch);
86
+ console.log(`Scheduled watch "${name}" (${id}) — runs ${every} at ${at}`);
87
+ console.log(`Goal: ${goal}`);
88
+ console.log("");
89
+ await printSchedulerHint();
90
+ }
91
+ /** Tell the user whether the scheduler is up (without starting it — that's an explicit action). */
92
+ async function printSchedulerHint() {
93
+ const lock = await readLock();
94
+ if (lock && isProcessRunning(lock.pid)) {
95
+ console.log(`Scheduler is running (pid ${lock.pid}); it will pick this up on the next tick.`);
96
+ }
97
+ else {
98
+ console.log(`Start the scheduler to run it: scira watch start`);
99
+ }
100
+ }
101
+ // --- Management ----------------------------------------------------------
102
+ export async function watchList() {
103
+ const watches = await loadWatches();
104
+ if (watches.length === 0) {
105
+ console.log("No scheduled watches. Create one with: scira watch <goal> --background");
106
+ return;
107
+ }
108
+ const now = new Date();
109
+ for (const w of watches) {
110
+ const when = isDue(w, now) ? "due now" : `next: ${nextRunAt(w, now).toLocaleString()}`;
111
+ const status = w.enabled ? "enabled" : "disabled";
112
+ console.log(`${w.id} "${w.name}" ${w.frequency}@${w.atHHMM} [${status}] ${when}`);
113
+ console.log(` goal: ${w.goal.slice(0, 80)}`);
114
+ }
115
+ }
116
+ export async function watchRemove(idOrName) {
117
+ const w = await findWatch(idOrName);
118
+ if (!w)
119
+ throw new Error(`Watch not found: ${idOrName}`);
120
+ await removeWatch(w.id);
121
+ console.log(`Removed watch "${w.name}" (${w.id})`);
122
+ }
123
+ // --- Daemon control ------------------------------------------------------
124
+ export async function daemonStart() {
125
+ const lock = await readLock();
126
+ if (lock && isProcessRunning(lock.pid)) {
127
+ console.log(`Scheduler already running (pid ${lock.pid})`);
128
+ return;
129
+ }
130
+ // Resolve the daemon script next to this command's compiled output. tsc emits
131
+ // per-file, so dist/cli/commands/watch.js → dist/watch/daemon.js; in dev
132
+ // (bun src/...) the .ts mirror resolves the same way.
133
+ const thisFile = fileURLToPath(import.meta.url);
134
+ const ext = thisFile.endsWith(".ts") ? ".ts" : ".js";
135
+ const daemonScript = join(dirname(thisFile), "..", "..", "watch", `daemon${ext}`);
136
+ const bunExe = Bun.which("bun") ?? "bun";
137
+ const child = spawn(bunExe, [daemonScript], { detached: true, stdio: "ignore" });
138
+ child.unref();
139
+ // Give the daemon a beat to claim its PID lock before we report status.
140
+ await Bun.sleep(600);
141
+ const fresh = await readLock();
142
+ if (fresh && isProcessRunning(fresh.pid)) {
143
+ console.log(`Scheduler started (pid ${fresh.pid})`);
144
+ console.log(`Log: ${DAEMON_LOG_FILE}`);
145
+ }
146
+ else {
147
+ console.log(`Scheduler may have failed to start. Check: ${DAEMON_LOG_FILE}`);
148
+ }
149
+ }
150
+ export async function daemonStop() {
151
+ const lock = await readLock();
152
+ if (!lock) {
153
+ console.log("Scheduler not running.");
154
+ return;
155
+ }
156
+ if (!isProcessRunning(lock.pid)) {
157
+ await rm(DAEMON_PID_FILE, { force: true });
158
+ console.log("Scheduler not running (cleared stale pid file).");
159
+ return;
160
+ }
161
+ process.kill(lock.pid, "SIGTERM");
162
+ console.log(`Sent SIGTERM to scheduler (pid ${lock.pid})`);
163
+ }
164
+ export async function daemonStatus() {
165
+ const lock = await readLock();
166
+ if (!lock || !isProcessRunning(lock.pid)) {
167
+ console.log("Scheduler: not running");
168
+ console.log("Start with: scira watch start");
169
+ return;
170
+ }
171
+ const upSecs = Math.floor((Date.now() - new Date(lock.startedAt).getTime()) / 1000);
172
+ console.log(`Scheduler: running (pid ${lock.pid}, uptime ${upSecs}s)`);
173
+ console.log(`Log: ${DAEMON_LOG_FILE}`);
174
+ try {
175
+ const tail = (await readFile(DAEMON_LOG_FILE, "utf8")).trim().split("\n").filter(Boolean).slice(-10);
176
+ if (tail.length > 0) {
177
+ console.log("\nLast log lines:");
178
+ for (const line of tail)
179
+ console.log(` ${line}`);
180
+ }
181
+ }
182
+ catch {
183
+ /* no log yet */
184
+ }
185
+ const enabled = (await loadWatches()).filter((w) => w.enabled);
186
+ if (enabled.length === 0) {
187
+ console.log("\nNo scheduled watches. Use: scira watch <goal> --background");
188
+ return;
189
+ }
190
+ console.log("\nScheduled watches:");
191
+ const now = new Date();
192
+ for (const w of enabled) {
193
+ const when = isDue(w, now) ? "due now" : nextRunAt(w, now).toLocaleString();
194
+ console.log(` ${w.name} ${w.frequency}@${w.atHHMM} next: ${when}`);
195
+ }
196
+ }
package/dist/cli/index.js CHANGED
@@ -30,6 +30,7 @@ import { saveGlobalMcpConfig } from "../config/load-config.js";
30
30
  import { runOAuthFlow } from "../tools/mcp-oauth.js";
31
31
  import { initCommand } from "./commands/init.js";
32
32
  import { checkForUpdate, formatUpdateNotice } from "../utils/update-check.js";
33
+ import { checkWatchNotices, commitWatchNotices, formatWatchNotice } from "../utils/watch-notice.js";
33
34
  // Once per invocation (throttled to a real npm check at most daily): surface an
34
35
  // available update. The TUI shows it as an in-app notice; CLI commands print it.
35
36
  // Skip for --version/--help so those stay instant (the daily check can spend up
@@ -38,6 +39,12 @@ const argv = process.argv.slice(2);
38
39
  const wantsUpdateCheck = !argv.some((a) => ["-v", "--version", "-h", "--help"].includes(a));
39
40
  const update = wantsUpdateCheck ? await checkForUpdate(pkgVersion) : null;
40
41
  const updateNotice = update ? formatUpdateNotice(update) : undefined;
42
+ // Unread results from background (--background) watches, surfaced as a banner
43
+ // after the command finishes (mirrors the update-notice pattern above). We only
44
+ // advance the read cursor once the banner has actually been printed (in the
45
+ // finally block), so a crash mid-command can't silently mark results read.
46
+ const watch = wantsUpdateCheck ? await checkWatchNotices() : { results: [], nextOffset: 0 };
47
+ const watchNotice = watch.results.length > 0 ? formatWatchNotice(watch.results) : undefined;
41
48
  // The TUI renders the notice in-app, so the finally banner would double it up.
42
49
  let noticeShownInApp = false;
43
50
  const prog = sade("scira");
@@ -284,43 +291,57 @@ prog
284
291
  await saveGlobalMcpConfig(nextMcp);
285
292
  console.log(`Removed MCP server "${name}" from ~/.scira/config.json`);
286
293
  });
294
+ // `watch` has two modes from one command:
295
+ // foreground (default) — interval loop tied to this terminal, prints diffs.
296
+ // --background — persist the watch; the daemon re-runs it on a
297
+ // day/weekday/week schedule at --at, even after the
298
+ // terminal closes, with a desktop notification and a
299
+ // banner on the next CLI invocation.
300
+ // The management subcommands (list/remove/start/stop/status) drive the daemon.
287
301
  prog
288
- .command("watch <goal>", "monitor a topic by running research on a schedule and diffing reports")
289
- .option("--daily", "run once per day (default)")
290
- .option("--hourly", "run once per hour")
291
- .option("--weekly", "run once per week")
292
- .option("--interval", "custom interval in milliseconds")
293
- .option("--runs", "stop after N runs (default: run forever)")
302
+ .command("watch <goal>", "re-run research on a schedule and diff each report (add -b to run in the background)")
303
+ .option("--hourly", "foreground: run once per hour")
304
+ .option("--daily", "foreground: run once per day (default)")
305
+ .option("--weekly", "foreground: run once per week")
306
+ .option("--interval", "foreground: custom interval in milliseconds")
307
+ .option("--runs", "foreground: stop after N runs (default: run forever)")
308
+ .option("-b, --background", "persist as a scheduled watch run by the daemon")
309
+ .option("--every", "background: frequency — day | weekday | week", "day")
310
+ .option("--at", "background: time of day to run, HH:MM 24h", "09:00")
311
+ .option("--name", "background: human-readable label")
294
312
  .action(async (goal, opts) => {
295
- const config = await loadConfig();
296
- const INTERVALS = {
297
- hourly: 60 * 60 * 1000,
298
- daily: 24 * 60 * 60 * 1000,
299
- weekly: 7 * 24 * 60 * 60 * 1000,
300
- };
301
- const intervalMs = opts.interval
302
- ? parseInt(String(opts.interval), 10)
303
- : opts.hourly ? INTERVALS.hourly
304
- : opts.weekly ? INTERVALS.weekly
305
- : INTERVALS.daily;
306
- if (Number.isNaN(intervalMs) || intervalMs < 1000) {
307
- throw new Error("Interval must be at least 1000 ms.");
308
- }
309
- const maxRuns = opts.runs != null ? parseInt(String(opts.runs), 10) : undefined;
310
- const { watchLoop } = await import("../watch/runner.js");
311
- const controller = new AbortController();
312
- process.on("SIGINT", () => { console.log("\nStopping watch…"); controller.abort(); });
313
- process.on("SIGTERM", () => { controller.abort(); });
314
- console.log(`Watching: "${goal}"`);
315
- console.log(`Interval: ${intervalMs / 1000}s${maxRuns ? ` · max ${maxRuns} runs` : ""}`);
316
- console.log("Press Ctrl-C to stop.\n");
317
- await watchLoop({
318
- goal, intervalMs, maxRuns, config,
319
- onRunStart: (runPath, tick) => { console.log(`\n[tick ${tick + 1}] Starting run → ${runPath}`); },
320
- onRunComplete: (runPath, diffText, tick) => { console.log(`[tick ${tick + 1}] Done. Diff:\n${diffText}`); },
321
- onError: (err, tick) => { console.error(`[tick ${tick + 1}] Error: ${err.message}`); },
322
- }, controller.signal);
323
- console.log("Watch finished.");
313
+ const { watchCommand } = await import("./commands/watch.js");
314
+ await watchCommand(goal, opts);
315
+ });
316
+ prog
317
+ .command("watch list", "list scheduled (background) watches and their next run times")
318
+ .action(async () => {
319
+ const { watchList } = await import("./commands/watch.js");
320
+ await watchList();
321
+ });
322
+ prog
323
+ .command("watch remove <id>", "remove a scheduled watch by id or name")
324
+ .action(async (id) => {
325
+ const { watchRemove } = await import("./commands/watch.js");
326
+ await watchRemove(id);
327
+ });
328
+ prog
329
+ .command("watch start", "start the background watch scheduler")
330
+ .action(async () => {
331
+ const { daemonStart } = await import("./commands/watch.js");
332
+ await daemonStart();
333
+ });
334
+ prog
335
+ .command("watch stop", "stop the background watch scheduler")
336
+ .action(async () => {
337
+ const { daemonStop } = await import("./commands/watch.js");
338
+ await daemonStop();
339
+ });
340
+ prog
341
+ .command("watch status", "show scheduler status and upcoming scheduled watches")
342
+ .action(async () => {
343
+ const { daemonStatus } = await import("./commands/watch.js");
344
+ await daemonStatus();
324
345
  });
325
346
  prog
326
347
  .command("models", "list models for the configured LLM provider")
@@ -512,4 +533,8 @@ finally {
512
533
  // rendered the notice in-app, so we don't show it twice.
513
534
  if (updateNotice && !noticeShownInApp)
514
535
  process.stderr.write(`\n\x1b[2m${updateNotice}\x1b[0m\n`);
536
+ if (watchNotice) {
537
+ process.stderr.write(`\n\x1b[36m${watchNotice}\x1b[0m\n`);
538
+ await commitWatchNotices(watch.nextOffset);
539
+ }
515
540
  }
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { mkdir } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
+ import { isProcessRunning } from "../utils/process.js";
4
5
  const MAX_OUTPUT_LINES = 500;
5
6
  const MAX_TAIL_CHARS = 4000;
6
7
  function nextTaskId(existing) {
@@ -17,18 +18,6 @@ function tailText(lines, maxChars = MAX_TAIL_CHARS) {
17
18
  return joined;
18
19
  return `…[truncated]\n${joined.slice(-maxChars)}`;
19
20
  }
20
- /** Returns true if a process with this pid is still running. */
21
- function isProcessRunning(pid) {
22
- if (pid <= 0)
23
- return false;
24
- try {
25
- process.kill(pid, 0);
26
- return true;
27
- }
28
- catch {
29
- return false;
30
- }
31
- }
32
21
  function isValidRecord(t) {
33
22
  if (typeof t !== "object" || t === null)
34
23
  return false;
@@ -104,3 +104,33 @@ export const ClaimSchema = z.object({
104
104
  reason: z.string().default(""),
105
105
  createdAt: z.string()
106
106
  });
107
+ // A scheduled (background) watch: a research goal the daemon re-runs on a
108
+ // schedule. `frequency` selects which days fire — weekly fires on the weekday
109
+ // the watch was created (derived from createdAt) — and `atHHMM` is the
110
+ // local-time trigger. Foreground watches are ephemeral and never persisted here.
111
+ export const SavedWatchSchema = z.object({
112
+ id: z.string(),
113
+ name: z.string(),
114
+ goal: z.string(),
115
+ projectRoot: z.string(), // run context — config + prior reports resolve from here
116
+ frequency: z.enum(["day", "weekday", "week"]),
117
+ atHHMM: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/u),
118
+ createdAt: z.string(),
119
+ lastRunAt: z.string().nullable().default(null),
120
+ enabled: z.boolean().default(true)
121
+ });
122
+ // One execution record per background run, appended to results.jsonl. We store
123
+ // the diff as section counts (not the full text) — the full report lives at
124
+ // runPath — so the unread-results banner needs no string re-parsing.
125
+ export const WatchResultSchema = z.object({
126
+ id: z.string(),
127
+ watchId: z.string(),
128
+ watchName: z.string(),
129
+ runPath: z.string(),
130
+ goal: z.string(),
131
+ added: z.number().int().default(0),
132
+ removed: z.number().int().default(0),
133
+ ranAt: z.string(),
134
+ status: z.enum(["ok", "error"]),
135
+ errorMessage: z.string().optional()
136
+ });
@@ -56,7 +56,6 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig, updat
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
59
  const pendingPlanModeRef = useRef(false);
61
60
  const [pendingPlanMode, setPendingPlanModeState] = useState(false);
62
61
  const setPendingPlanMode = useCallback((active) => { pendingPlanModeRef.current = active; setPendingPlanModeState(active); }, []);
@@ -136,7 +135,6 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig, updat
136
135
  setPendingCollapse(new Set());
137
136
  return new Set();
138
137
  }
139
- // Mark done groups for pending collapse, but don't collapse active ones
140
138
  setPendingCollapse(new Set(doneGroupKeys));
141
139
  return new Set(doneGroupKeys.filter((k) => {
142
140
  const group = computeGroups(feed).groups.get(k);
@@ -159,7 +157,6 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig, updat
159
157
  return;
160
158
  toggleGroup(focusedGroupKey);
161
159
  }, [focusedGroupKey, toggleGroup]);
162
- // Auto-collapse groups when they become inactive if they're in pendingCollapse
163
160
  React.useEffect(() => {
164
161
  if (pendingCollapse.size === 0)
165
162
  return;
@@ -354,10 +351,6 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig, updat
354
351
  const contentRows = Math.max(1, feedRows);
355
352
  const maxScrollOffset = Math.max(0, feedLines.length - contentRows);
356
353
  wheelStateRef.current = { screen, maxScrollOffset };
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
354
  const pinUserToTop = scrollOffset < 0 && lastUserLineStart >= 0;
362
355
  let startIdx;
363
356
  if (pinUserToTop) {
@@ -402,16 +395,7 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig, updat
402
395
  hoverMapRef.current = hoverMap;
403
396
  }
404
397
  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
398
  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
399
  const lastItem = feed[feed.length - 1];
416
400
  const showLoader = busy && lastItem !== undefined && lastItem.kind !== "text";
417
401
  const phrase = LOADING_PHRASES[Math.floor(frame / 24) % LOADING_PHRASES.length];
@@ -1,11 +1,8 @@
1
1
  import React from "react";
2
2
  import { HOME_TIPS } from "../constants.js";
3
- /** Stable mount-only effect: makes intent explicit, prevents accidental dep-driven re-runs. */
4
3
  export function useMountEffect(effect) {
5
- /* eslint-disable-next-line react-hooks/exhaustive-deps */
6
4
  React.useEffect(effect, []);
7
5
  }
8
- /** Cycles the tip index every 6 s while mounted (rendered only on the home screen). */
9
6
  export function TipCycler({ setTipIndex }) {
10
7
  useMountEffect(() => {
11
8
  const id = setInterval(() => setTipIndex((i) => (i + 1) % HOME_TIPS.length), 6000);
@@ -13,13 +10,9 @@ export function TipCycler({ setTipIndex }) {
13
10
  });
14
11
  return null;
15
12
  }
16
- /** Drives the spinner, caret-blink, and reasoning-clock animations while mounted (rendered only when busy). */
17
13
  export function AnimationTick({ setBlink, setFrame, setReasoningTick, }) {
18
14
  useMountEffect(() => {
19
15
  const blinkId = setInterval(() => setBlink((v) => !v), 400);
20
- // frame increments monotonically; consumers take `% SPINNER_FRAMES.length` for the
21
- // spinner glyph and `Math.floor(frame / 24)` for the slow phrase rotation. Wrapping it
22
- // here would pin the phrase to index 0 forever (frame would never exceed the frame count).
23
16
  const frameId = setInterval(() => setFrame((f) => f + 1), 80);
24
17
  const tickId = setInterval(() => setReasoningTick((t) => t + 1), 500);
25
18
  return () => {
@@ -32,7 +25,6 @@ export function AnimationTick({ setBlink, setFrame, setReasoningTick, }) {
32
25
  });
33
26
  return null;
34
27
  }
35
- /** Enables SGR mouse-click/hover tracking while mounted (rendered only on the home screen). */
36
28
  export function MouseTracker({ stdout, stdin, onData, onUnmount, }) {
37
29
  useMountEffect(() => {
38
30
  stdout.write("\x1b[?1003h\x1b[?1006h");
@@ -48,8 +48,6 @@ function computeHeroLayout(bodyRows, bodyCols, hasNotice) {
48
48
  newResearchRowOffset,
49
49
  };
50
50
  }
51
- /** Home screen body: branding card, browse modal, notice, and tip line.
52
- * Also (re)builds the mouse click/hover row maps as a render side-effect. */
53
51
  export function HomeScreen({ cols, rows, sessions, selectedIdx, hoveredIdx, heroHidden, notice, tipIndex, commandMenuHeight, mcpOpen, sessionsModalOpen, sessionsModalIdx, inputText, config, modelName, clickMapRef, hoverMapRef, setSelectedIdx, setSessionsModalOpen, setSessionsModalIdx, setNotice, openRun, submitHome, exit, }) {
54
52
  const theme = useTheme();
55
53
  const bodyCols = Math.max(32, cols - 4);
@@ -10,7 +10,6 @@ export function TopBar({ screen, runState, fullMode, planMode, activeUsage, busy
10
10
  }
11
11
  export function InputBar({ inputLines, cursorLine, cursorCol, showCursor, approvalPending, busy, frame, boxWidth, modelName, planMode, config }) {
12
12
  const theme = useTheme();
13
- // Plan mode tints the whole input box (unless an approval/busy state takes precedence).
14
13
  const borderColor = approvalPending ? theme.warning : busy ? theme.accentDim : planMode ? "cyan" : theme.textDim;
15
14
  const promptColor = approvalPending ? theme.warning : busy ? theme.accentDim : planMode ? "cyan" : theme.accent;
16
15
  const inputColor = approvalPending ? theme.textDim : theme.inputText;
@@ -113,13 +112,8 @@ export function MenuDialog({ menu, cols, rows, config }) {
113
112
  const dialogH = 5 + (menu.loading ? 1 : Math.min(DIALOG_ITEMS, menuFiltered.length) + (menuStart > 0 ? 1 : 0) + (menuFiltered.length - (menuStart + DIALOG_ITEMS) > 0 ? 1 : 0));
114
113
  const dialogTop = Math.max(1, Math.floor((rows - dialogH) / 2));
115
114
  const bg = theme.userBandBackground ? { backgroundColor: theme.userBandBackground } : {};
116
- const innerW = DIALOG_W - 2; // cells between the two border columns
117
- // Draw the border as characters inside full-width background lines (like
118
- // InputBar). Ink's box border + backgroundColor leaves unfilled gaps, so we
119
- // compose each line ourselves: every line is one solid Text spanning DIALOG_W.
115
+ const innerW = DIALOG_W - 2;
120
116
  const line = (key, visibleLen, content) => (_jsxs(Text, { ...bg, wrap: "truncate", children: [_jsx(Text, { color: theme.accent, children: "\u2502" }), _jsx(Text, { children: " " }), content, _jsx(Text, { children: " ".repeat(Math.max(0, innerW - 1 - visibleLen)) }), _jsx(Text, { color: theme.accent, children: "\u2502" })] }, key));
121
- // Clip a string to at most `max` display columns (so a row never overruns the
122
- // border on narrow terminals — wrap="truncate" would eat the closing │).
123
117
  const clip = (s, max) => {
124
118
  if (max <= 0)
125
119
  return "";
@@ -129,17 +123,16 @@ export function MenuDialog({ menu, cols, rows, config }) {
129
123
  for (const ch of s) {
130
124
  const cw = displayWidth(ch);
131
125
  if (w + cw > max - 1)
132
- break; // leave a column for the ellipsis
126
+ break;
133
127
  out += ch;
134
128
  w += cw;
135
129
  }
136
130
  return out + "…";
137
131
  };
138
- const avail = innerW - 1; // usable columns after the leading space
132
+ const avail = innerW - 1;
139
133
  const title = menu.type === "model" ? "Select model" : menu.type === "llm" ? "Select LLM provider" : "Select search provider";
140
134
  const hint = "↑↓ navigate · ⏎ apply · esc close";
141
135
  const dialogLines = [];
142
- // Title + hint, dropping/clipping the (secondary) hint when space is tight.
143
136
  const titleC = clip(title, avail);
144
137
  const hintRoom = avail - displayWidth(titleC) - 2;
145
138
  const hintC = hintRoom >= 6 ? clip(hint, hintRoom) : "";
@@ -4,14 +4,12 @@ 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
6
  export const CHAT_COMMANDS = ["/help", "/home", "/new", "/plan", "/rerun", "/report", "/sources", "/claims", "/why", "/mcp", "/copy", "/usage", "/rename", "/model", "/llm", "/provider", "/thinking", "/reasoning", "/theme", "/links", "/key", "/keys", "/stop", "/back", "/quit"];
7
- /** Commands grouped by purpose, for the /help reference. */
8
7
  export const COMMAND_GROUPS = [
9
8
  { label: "Model", commands: ["/llm", "/model", "/provider", "/plan", "/thinking", "/reasoning"] },
10
9
  { label: "Session", commands: ["/new", "/rerun", "/rename", "/report", "/sources", "/claims", "/why", "/copy", "/usage"] },
11
10
  { label: "Setup", commands: ["/key", "/keys", "/mcp", "/theme", "/links"] },
12
11
  { label: "Go", commands: ["/home", "/back", "/stop", "/quit"] },
13
12
  ];
14
- /** Keyboard shortcuts shown in /help (not discoverable via the `/` autocomplete). */
15
13
  export const KEY_HINTS = [
16
14
  { keys: "↑↓ jk u/d pgup/dn", action: "scroll" },
17
15
  { keys: "^C ^C / ^D", action: "quit" },
@@ -19,7 +17,6 @@ export const KEY_HINTS = [
19
17
  { keys: "/ @ #", action: "commands · files · sessions" },
20
18
  { keys: "[ ] C", action: "navigate / toggle tool groups" },
21
19
  ];
22
- /** Slash commands that take an argument; ⏎ from the menu appends a space instead of running. */
23
20
  export const COMMANDS_NEEDING_ARGS = new Set(["/theme", "/key", "/rename", "/why", "/links"]);
24
21
  export const COMMAND_DESCRIPTIONS = {
25
22
  "/help": "Show command and keyboard shortcuts.",
@@ -20,13 +20,10 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
20
20
  if (existing?.busy)
21
21
  return;
22
22
  const session = createSession(runPath);
23
- // Always re-attach subscriber so follow-up turns have a live listener.
24
23
  attachSubscriber(runPath, getSubscriber());
25
24
  const controller = new AbortController();
26
25
  session.abort = controller;
27
26
  setBusy(true);
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
27
  setScrollOffset(-1);
31
28
  sessionSetBusy(runPath, true);
32
29
  const modelId = config.model;
@@ -43,7 +40,7 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
43
40
  if (cleanTitle)
44
41
  await setRunTitle(runPath, cleanTitle);
45
42
  }
46
- catch { /* non-fatal */ }
43
+ catch { }
47
44
  })();
48
45
  }
49
46
  const onApprovalRequired = (toolName, description) => new Promise((resolve) => sessionSetApproval(runPath, { toolName, description, resolve }));
@@ -62,8 +59,6 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
62
59
  sessionFinishReasoning(runPath);
63
60
  break;
64
61
  case "tool-call": {
65
- // Stash the raw input (capped) so dedicated tool renderers (diffs,
66
- // checklists, …) can reconstruct it from the feed.
67
62
  let inputJson;
68
63
  try {
69
64
  inputJson = JSON.stringify(part.input)?.slice(0, 32_000);
@@ -117,7 +112,7 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
117
112
  turnUsage.output += u.outputTokens ?? 0;
118
113
  turnUsage.total += u.totalTokens ?? (u.inputTokens ?? 0) + (u.outputTokens ?? 0);
119
114
  }
120
- catch { /* usage is best-effort */ }
115
+ catch { }
121
116
  return result.text;
122
117
  };
123
118
  const mentioned = await promptWithFileMentions(prompt);
@@ -217,7 +212,7 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
217
212
  try {
218
213
  await Bun.write(join(runPath, "convo.json"), JSON.stringify({ feed: snapshot, messages: conversationRef.current, usage: aggregateTurns(turnsRef.current) }, null, 2));
219
214
  }
220
- catch { /* non-fatal */ }
215
+ catch { }
221
216
  removeSession(runPath);
222
217
  const queued = queuedPromptRef.current;
223
218
  if (queued && !controller.signal.aborted) {