@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.
- package/dist/cli/commands/watch.js +196 -0
- package/dist/cli/index.js +60 -35
- package/dist/tools/background-tasks.js +1 -12
- package/dist/types/index.js +30 -0
- package/dist/ui/ink/SciraApp.js +0 -16
- package/dist/ui/ink/components/effects.js +0 -8
- package/dist/ui/ink/components/home-screen.js +0 -2
- package/dist/ui/ink/components/overlays.js +3 -10
- package/dist/ui/ink/constants.js +0 -3
- package/dist/ui/ink/hooks/use-agent-turn.js +3 -8
- package/dist/ui/ink/hooks/use-feed-lines.js +2 -19
- package/dist/ui/ink/hooks/use-feed.js +0 -1
- package/dist/ui/ink/hooks/use-keyboard.js +0 -5
- package/dist/ui/ink/hooks/use-session.js +0 -2
- package/dist/ui/ink/hooks/use-suggestions.js +0 -1
- package/dist/ui/ink/lib/tool-result.js +2 -36
- package/dist/ui/ink/lib/utils.js +4 -35
- package/dist/ui/ink/session-manager.js +0 -8
- package/dist/ui/ink/theme.js +1 -12
- package/dist/utils/desktop-notify.js +26 -0
- package/dist/utils/markdown-joiner.js +0 -30
- package/dist/utils/process.js +12 -0
- package/dist/utils/time.js +21 -0
- package/dist/utils/watch-notice.js +40 -0
- package/dist/watch/daemon-lock.js +13 -0
- package/dist/watch/daemon.js +59 -0
- package/dist/watch/executor.js +43 -0
- package/dist/watch/format.js +9 -0
- package/dist/watch/runner.js +44 -27
- package/dist/watch/scheduler.js +58 -0
- package/dist/watch/store.js +110 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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 {
|
|
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 {
|
|
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) {
|
package/dist/ui/ink/lib/utils.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
}
|
package/dist/ui/ink/theme.js
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|