@scira/cli 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/harness-agent.js +216 -0
- package/dist/agent/{research-agent.js → main-agent.js} +30 -10
- package/dist/cli/commands/init.js +7 -5
- package/dist/cli/index.js +75 -14
- package/dist/cli/shell/shell.js +4 -5
- package/dist/cli/shell/tui.js +7 -4
- package/dist/config/env-guide.js +24 -0
- package/dist/config/env-store.js +5 -3
- package/dist/config/load-config.js +9 -14
- package/dist/providers/harness/local-sandbox.js +143 -0
- package/dist/providers/llm/gateway.js +5 -2
- package/dist/providers/llm/models.js +18 -4
- package/dist/providers/llm/readiness.js +5 -1
- package/dist/providers/llm/registry.js +24 -3
- package/dist/storage/jsonl.js +2 -2
- package/dist/storage/run-store.js +22 -15
- package/dist/tools/agent-tools.js +7 -7
- package/dist/tools/background-tasks.js +4 -5
- package/dist/tools/mcp-oauth.js +29 -25
- package/dist/tools/open-url.js +1 -2
- package/dist/tools/todos.js +3 -3
- package/dist/tools/workspace.js +15 -0
- package/dist/types/index.js +13 -1
- package/dist/ui/ink/SciraApp.js +14 -10
- package/dist/ui/ink/components/home-screen.js +2 -2
- package/dist/ui/ink/components/overlays.js +78 -17
- package/dist/ui/ink/constants.js +26 -7
- package/dist/ui/ink/hooks/use-agent-turn.js +14 -5
- package/dist/ui/ink/hooks/use-feed-lines.js +31 -6
- package/dist/ui/ink/hooks/use-keyboard.js +28 -5
- package/dist/ui/ink/hooks/use-session.js +7 -5
- package/dist/ui/ink/hooks/use-settings.js +20 -0
- package/dist/ui/ink/hooks/use-submit.js +15 -8
- package/dist/ui/ink/lib/file-mentions.js +1 -2
- package/dist/ui/ink/lib/tool-result.js +219 -4
- package/dist/ui/ink/lib/utils.js +54 -28
- package/dist/ui/ink/theme.js +5 -10
- package/dist/utils/update-check.js +63 -0
- package/dist/watch/runner.js +2 -2
- package/package.json +13 -11
- package/dist/agent/background-tasks.js +0 -173
- package/dist/agent/todos.js +0 -140
- package/dist/agent/tools.js +0 -432
- package/dist/agent/tools.test.js +0 -60
- package/dist/agent/workspace.js +0 -85
- package/dist/config/env-guide.test.js +0 -18
- package/dist/config/env-store.test.js +0 -60
- package/dist/storage/jsonl.test.js +0 -38
- package/dist/storage/run-store.test.js +0 -65
- package/dist/tools/bash-policy.test.js +0 -38
- package/dist/tools/search-web.test.js +0 -24
- package/dist/tools/workspace.test.js +0 -75
- package/dist/types/schema.test.js +0 -61
- package/dist/ui/ink/hooks/use-feed-lines.test.js +0 -16
- package/dist/ui/ink/lib/tool-result.test.js +0 -60
- package/dist/ui/ink/lib/utils.test.js +0 -48
- package/dist/ui/ink/session-manager.test.js +0 -31
- package/dist/ui/ink/terminal-probe.test.js +0 -12
- package/dist/ui/ink/theme.test.js +0 -68
|
@@ -16,6 +16,9 @@ export function useKeyboard(o) {
|
|
|
16
16
|
const { setScrollOffset, contentRows, maxScrollOffset, pendingRerun, setPendingRerun, busy, stopTurn, submitChat, toggleAllGroups, toggleFocusedGroup, focusPrevGroup, focusNextGroup, unfocusGroup, hasFocusedGroup } = o.chat;
|
|
17
17
|
const { sessionsModalOpen, setSessionsModalOpen, sessionsModalIdx, setSessionsModalIdx, sessions, deleteSession, selectedIdx, setSelectedIdx, setHeroHidden, openRun, submitHome } = o.home;
|
|
18
18
|
const deleteArmedRef = useRef(null);
|
|
19
|
+
// Quit requires confirmation: first Ctrl+C arms, second within the window quits.
|
|
20
|
+
const quitArmedRef = useRef(false);
|
|
21
|
+
const quitTimerRef = useRef(null);
|
|
19
22
|
const editInput = (char, key) => {
|
|
20
23
|
const deleteWordBefore = () => {
|
|
21
24
|
const match = inputText.slice(0, cursorPos).match(/\S+\s*$/u);
|
|
@@ -95,6 +98,30 @@ export function useKeyboard(o) {
|
|
|
95
98
|
// OSC background-color query responses leak as stdin when terminals reply to theme probes.
|
|
96
99
|
if (char && (/\]11;rgb:/u.test(char) || /^11;rgb:/u.test(char)))
|
|
97
100
|
return;
|
|
101
|
+
// Quit handling (all screens): Ctrl+C twice (with warning) or Ctrl+D.
|
|
102
|
+
if (key.ctrl && char === "c") {
|
|
103
|
+
if (busy) {
|
|
104
|
+
stopTurn();
|
|
105
|
+
quitArmedRef.current = false;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (quitArmedRef.current) {
|
|
109
|
+
exit();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
quitArmedRef.current = true;
|
|
113
|
+
setNotice("Press Ctrl+C again to quit (or Ctrl+D).");
|
|
114
|
+
if (quitTimerRef.current)
|
|
115
|
+
clearTimeout(quitTimerRef.current);
|
|
116
|
+
quitTimerRef.current = setTimeout(() => { quitArmedRef.current = false; }, 3000);
|
|
117
|
+
// Don't let the disarm timer keep the process alive after a quit.
|
|
118
|
+
quitTimerRef.current.unref?.();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (key.ctrl && char === "d" && !inputText) {
|
|
122
|
+
exit();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
98
125
|
if (approvalPending) {
|
|
99
126
|
if (char === "y" || char === "Y" || key.return) {
|
|
100
127
|
const p = approvalPending;
|
|
@@ -303,7 +330,7 @@ export function useKeyboard(o) {
|
|
|
303
330
|
setSessionsModalIdx(0);
|
|
304
331
|
}
|
|
305
332
|
else if (selectedIdx === newIdx) {
|
|
306
|
-
setNotice("Type a question below to start a new
|
|
333
|
+
setNotice("Type a question below to start a new session.");
|
|
307
334
|
}
|
|
308
335
|
else if (selectedIdx === quitIdx) {
|
|
309
336
|
exit();
|
|
@@ -312,10 +339,6 @@ export function useKeyboard(o) {
|
|
|
312
339
|
void submitHome("");
|
|
313
340
|
}
|
|
314
341
|
}
|
|
315
|
-
else if (char === "q" && !inputText)
|
|
316
|
-
exit();
|
|
317
|
-
else if (key.ctrl && char === "d" && !inputText)
|
|
318
|
-
exit();
|
|
319
342
|
else
|
|
320
343
|
editInput(char, key);
|
|
321
344
|
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
import { listRuns, summarizeRun } from "../../../storage/run-store.js";
|
|
5
4
|
import { getSession, attachSubscriber } from "../session-manager.js";
|
|
6
5
|
export function useSession(o) {
|
|
7
|
-
const { config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef, setSessions, setRunState, setCurrentRunPath, setInputText, setCursorPos, setFeed, setUsage, setScrollOffset, setScreen, setMode, setPlanMode, setBusy, setApprovalPending, getSubscriber, } = o;
|
|
6
|
+
const { config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef, pendingPlanModeRef, setSessions, setRunState, setCurrentRunPath, setInputText, setCursorPos, setFeed, setUsage, setScrollOffset, setScreen, setMode, setPlanMode, setPendingPlanMode, setBusy, setApprovalPending, getSubscriber, } = o;
|
|
8
7
|
const refreshSessions = useCallback(async () => {
|
|
9
8
|
const runs = await listRuns(config);
|
|
10
9
|
setSessions(runs);
|
|
@@ -14,8 +13,11 @@ export function useSession(o) {
|
|
|
14
13
|
setRunState(await summarizeRun(currentRunPath));
|
|
15
14
|
}, [currentRunPath]);
|
|
16
15
|
const openRun = useCallback(async (runPath, initialQuestion) => {
|
|
17
|
-
if (runPath !== currentRunPath)
|
|
18
|
-
|
|
16
|
+
if (runPath !== currentRunPath) {
|
|
17
|
+
// Honor a plan-mode preference armed from the home screen, then disarm it.
|
|
18
|
+
setPlanMode(pendingPlanModeRef.current);
|
|
19
|
+
setPendingPlanMode(false);
|
|
20
|
+
}
|
|
19
21
|
setCurrentRunPath(runPath);
|
|
20
22
|
setInputText("");
|
|
21
23
|
setCursorPos(0);
|
|
@@ -39,7 +41,7 @@ export function useSession(o) {
|
|
|
39
41
|
return undefined;
|
|
40
42
|
}
|
|
41
43
|
try {
|
|
42
|
-
const raw = await
|
|
44
|
+
const raw = await Bun.file(join(runPath, "convo.json")).text();
|
|
43
45
|
const saved = JSON.parse(raw);
|
|
44
46
|
if (saved.feed && saved.feed.length > 0) {
|
|
45
47
|
const filteredFeed = saved.feed.filter((item) => !(item.kind === "status" && item.text === "This will wipe the conversation history. Press /rerun again to confirm."));
|
|
@@ -146,6 +146,26 @@ export function useSettings({ config, setConfig, screen, pushFeed, setNotice })
|
|
|
146
146
|
await setEnvKey(name, value);
|
|
147
147
|
return `${name} saved to ~/.scira/.env and active for this session. Use .scira/.env in a project to scope keys to that repo.`;
|
|
148
148
|
}
|
|
149
|
+
if (cmd === "/thinking") {
|
|
150
|
+
if (!arg)
|
|
151
|
+
return `Claude Code thinking: ${config.harness.thinking}\nOptions: off, on, adaptive`;
|
|
152
|
+
if (!["off", "on", "adaptive"].includes(arg))
|
|
153
|
+
return `Unknown thinking mode "${arg}". Options: off, on, adaptive`;
|
|
154
|
+
const next = { ...config, harness: { ...config.harness, thinking: arg } };
|
|
155
|
+
setConfig(next);
|
|
156
|
+
await saveGlobalConfig(next);
|
|
157
|
+
return `Claude Code thinking set to ${arg}.`;
|
|
158
|
+
}
|
|
159
|
+
if (cmd === "/reasoning") {
|
|
160
|
+
if (!arg)
|
|
161
|
+
return `Codex reasoning effort: ${config.harness.reasoningEffort}\nOptions: low, medium, high`;
|
|
162
|
+
if (!["low", "medium", "high"].includes(arg))
|
|
163
|
+
return `Unknown reasoning effort "${arg}". Options: low, medium, high`;
|
|
164
|
+
const next = { ...config, harness: { ...config.harness, reasoningEffort: arg } };
|
|
165
|
+
setConfig(next);
|
|
166
|
+
await saveGlobalConfig(next);
|
|
167
|
+
return `Codex reasoning effort set to ${arg}.`;
|
|
168
|
+
}
|
|
149
169
|
if (cmd === "/theme") {
|
|
150
170
|
if (!arg) {
|
|
151
171
|
const terminal = detectTerminalTheme();
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
3
2
|
import { createRun, getRunPaths, setRunTitle } from "../../../storage/run-store.js";
|
|
4
3
|
import { readJsonl } from "../../../storage/jsonl.js";
|
|
5
4
|
import { fmtDuration, fmtTokens, copyToClipboard } from "../lib/utils.js";
|
|
@@ -7,8 +6,8 @@ import { detachSubscriber, abortSession } from "../session-manager.js";
|
|
|
7
6
|
import { saveGlobalMcpConfig } from "../../../config/load-config.js";
|
|
8
7
|
export function useSubmit(o) {
|
|
9
8
|
const { config, currentRunPath, sessions, selectedIdx, busy, usage, pendingRerun } = o.state;
|
|
10
|
-
const { queuedPromptRef, fullModeRef, planModeRef, conversationRef, feedRef } = o.refs;
|
|
11
|
-
const { setApprovalPending, setInputText, setCursorPos, setInputHistory, setHistoryIndex, setHelpOpen, setNotice, setBusy, setScreen, setFeed, setRunState, setPendingRerun, setMode, setPlanMode, setConfig, setMcpOpen, setHeroHidden, } = o.setters;
|
|
9
|
+
const { queuedPromptRef, fullModeRef, planModeRef, pendingPlanModeRef, conversationRef, feedRef } = o.refs;
|
|
10
|
+
const { setApprovalPending, setInputText, setCursorPos, setInputHistory, setHistoryIndex, setHelpOpen, setNotice, setBusy, setScreen, setFeed, setRunState, setPendingRerun, setMode, setPlanMode, setPendingPlanMode, setConfig, setMcpOpen, setHeroHidden, } = o.setters;
|
|
12
11
|
const { pushFeed, refreshSessions, openRun, openMenu, handleSettings, runTurn, exit } = o.actions;
|
|
13
12
|
const rerunConfirmRef = useRef(false);
|
|
14
13
|
const abortTurn = useCallback(() => {
|
|
@@ -27,7 +26,7 @@ export function useSubmit(o) {
|
|
|
27
26
|
void openRun(selected.path);
|
|
28
27
|
return;
|
|
29
28
|
}
|
|
30
|
-
if (text === "
|
|
29
|
+
if (text === "/quit" || text === "/q") {
|
|
31
30
|
exit();
|
|
32
31
|
return;
|
|
33
32
|
}
|
|
@@ -60,7 +59,15 @@ export function useSubmit(o) {
|
|
|
60
59
|
setMcpOpen(true);
|
|
61
60
|
return;
|
|
62
61
|
}
|
|
63
|
-
setNotice("Open a
|
|
62
|
+
setNotice("Open a session first to use /mcp enable/disable/add.");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (text === "/plan") {
|
|
66
|
+
const next = !pendingPlanModeRef.current;
|
|
67
|
+
setPendingPlanMode(next);
|
|
68
|
+
setNotice(next
|
|
69
|
+
? "Plan mode armed — the next run you start will open in plan mode."
|
|
70
|
+
: "Plan mode disarmed.");
|
|
64
71
|
return;
|
|
65
72
|
}
|
|
66
73
|
if (text.startsWith("/")) {
|
|
@@ -136,7 +143,7 @@ export function useSubmit(o) {
|
|
|
136
143
|
void openMenu("provider");
|
|
137
144
|
return;
|
|
138
145
|
}
|
|
139
|
-
if (["/key", "/keys", "/llm", "/theme"].includes(text.split(/\s+/u)[0])) {
|
|
146
|
+
if (["/key", "/keys", "/llm", "/theme", "/thinking", "/reasoning"].includes(text.split(/\s+/u)[0])) {
|
|
140
147
|
void (async () => {
|
|
141
148
|
const result = await handleSettings(text);
|
|
142
149
|
if (result)
|
|
@@ -147,7 +154,7 @@ export function useSubmit(o) {
|
|
|
147
154
|
if (text === "/report") {
|
|
148
155
|
void (async () => {
|
|
149
156
|
try {
|
|
150
|
-
const report = await
|
|
157
|
+
const report = await Bun.file(getRunPaths(currentRunPath).report).text();
|
|
151
158
|
pushFeed({ kind: "text", text: report });
|
|
152
159
|
}
|
|
153
160
|
catch {
|
|
@@ -290,7 +297,7 @@ export function useSubmit(o) {
|
|
|
290
297
|
void (async () => {
|
|
291
298
|
const currentSession = sessions.find(s => s.path === currentRunPath);
|
|
292
299
|
const report = currentSession?.isFull
|
|
293
|
-
? await
|
|
300
|
+
? await Bun.file(getRunPaths(currentRunPath).report).text().catch(() => "")
|
|
294
301
|
: "";
|
|
295
302
|
const lastText = [...feedRef.current].reverse().find((it) => it.kind === "text")?.text ?? "";
|
|
296
303
|
const content = report.trim() || lastText;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { readdirSync, statSync } from "node:fs";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
import { FILE_MENTION_SKIP, FILE_MENTION_MAX_CHARS } from "../constants.js";
|
|
5
4
|
export function listMentionableFiles(root = process.cwd(), max = 300) {
|
|
@@ -52,7 +51,7 @@ export async function promptWithFileMentions(prompt) {
|
|
|
52
51
|
for (const file of files) {
|
|
53
52
|
const abs = join(process.cwd(), file);
|
|
54
53
|
try {
|
|
55
|
-
const content = await
|
|
54
|
+
const content = await Bun.file(abs).text();
|
|
56
55
|
const body = content.length > FILE_MENTION_MAX_CHARS
|
|
57
56
|
? `${content.slice(0, FILE_MENTION_MAX_CHARS)}\n...[truncated ${content.length - FILE_MENTION_MAX_CHARS} chars]`
|
|
58
57
|
: content;
|
|
@@ -1,8 +1,45 @@
|
|
|
1
|
+
import { diffLines } from "diff";
|
|
1
2
|
import { markdownToSegLines } from "./markdown.js";
|
|
2
3
|
import { wrapText } from "./utils.js";
|
|
4
|
+
const HARNESS_TOOL_PREFIX = "mcp__harness-tools__";
|
|
5
|
+
/** Map Claude Code / Codex built-in (and harness host) tool names onto Scira's renderers. */
|
|
6
|
+
const CANONICAL_TOOL = {
|
|
7
|
+
// Scira host tools exposed to the CLI
|
|
8
|
+
multiWebSearch: "webSearch",
|
|
9
|
+
// Claude Code built-ins
|
|
10
|
+
Read: "readFile",
|
|
11
|
+
Write: "writeFile",
|
|
12
|
+
Edit: "editFile",
|
|
13
|
+
MultiEdit: "editFile",
|
|
14
|
+
NotebookEdit: "editFile",
|
|
15
|
+
Bash: "bash",
|
|
16
|
+
BashOutput: "bash",
|
|
17
|
+
Grep: "grepWorkspace",
|
|
18
|
+
Glob: "listWorkspaceDir",
|
|
19
|
+
LS: "listWorkspaceDir",
|
|
20
|
+
TodoWrite: "todo",
|
|
21
|
+
WebFetch: "readUrl",
|
|
22
|
+
WebSearch: "webSearch",
|
|
23
|
+
// Codex built-ins
|
|
24
|
+
shell: "bash",
|
|
25
|
+
};
|
|
26
|
+
/** Strip the harness host-tool MCP prefix so `mcp__harness-tools__readUrl` reads as `readUrl`. */
|
|
27
|
+
export function displayToolName(name) {
|
|
28
|
+
return name.startsWith(HARNESS_TOOL_PREFIX) ? name.slice(HARNESS_TOOL_PREFIX.length) : name;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a harness/CLI tool name to the Scira renderer key. The harness exposes
|
|
32
|
+
* our host tools as `mcp__harness-tools__*` and the CLIs have their own builtin
|
|
33
|
+
* names (Read, Bash, Grep, …); both should render like Scira's equivalents.
|
|
34
|
+
*/
|
|
35
|
+
export function canonicalToolName(name) {
|
|
36
|
+
const stripped = displayToolName(name);
|
|
37
|
+
return CANONICAL_TOOL[stripped] ?? stripped;
|
|
38
|
+
}
|
|
3
39
|
/** Tools that start collapsed in the timeline (long output). */
|
|
4
40
|
export const DEFAULT_COLLAPSED_TOOLS = new Set([
|
|
5
41
|
"webSearch",
|
|
42
|
+
"multiWebSearch",
|
|
6
43
|
"readUrl",
|
|
7
44
|
"readFile",
|
|
8
45
|
"readWorkspaceFile",
|
|
@@ -185,9 +222,15 @@ function formatListSkills(result, width, theme) {
|
|
|
185
222
|
});
|
|
186
223
|
}
|
|
187
224
|
function formatShellOutput(result, width, theme) {
|
|
188
|
-
|
|
225
|
+
// Codex returns `{ exitCode, output }`; Claude returns the output string directly.
|
|
226
|
+
let text = result;
|
|
227
|
+
const obj = parseObj(result);
|
|
228
|
+
if (obj && typeof obj.output === "string") {
|
|
229
|
+
text = typeof obj.exitCode === "number" && obj.exitCode !== 0 ? `[exit ${obj.exitCode}]\n${obj.output}` : obj.output;
|
|
230
|
+
}
|
|
231
|
+
if (!text.trim())
|
|
189
232
|
return [[seg("(no output)", { dim: true, color: theme.textDim })]];
|
|
190
|
-
return
|
|
233
|
+
return text.split("\n").flatMap((line) => plainLines(line, width, { color: theme.textDim }));
|
|
191
234
|
}
|
|
192
235
|
function formatFileContent(result, width, theme) {
|
|
193
236
|
const rows = result.split("\n");
|
|
@@ -291,7 +334,8 @@ function formatBody(name, result, width, theme) {
|
|
|
291
334
|
}
|
|
292
335
|
}
|
|
293
336
|
/** One-line preview for a collapsed tool header. */
|
|
294
|
-
export function formatToolResultPreview(
|
|
337
|
+
export function formatToolResultPreview(rawName, inputSummary, result, status) {
|
|
338
|
+
const name = canonicalToolName(rawName);
|
|
295
339
|
const input = inputSummary.replace(/\s+/gu, " ").trim();
|
|
296
340
|
if (status === "running")
|
|
297
341
|
return input ? `${input} · running…` : "running…";
|
|
@@ -345,10 +389,181 @@ export function formatToolResultPreview(name, inputSummary, result, status) {
|
|
|
345
389
|
const first = result.replace(/\s+/gu, " ").trim();
|
|
346
390
|
return first.length > 140 ? `${first.slice(0, 137)}…` : first;
|
|
347
391
|
}
|
|
392
|
+
// --- Dedicated renderers for Claude Code / Codex built-in tools ---
|
|
393
|
+
function parseObj(s) {
|
|
394
|
+
if (!s)
|
|
395
|
+
return null;
|
|
396
|
+
try {
|
|
397
|
+
const v = JSON.parse(s);
|
|
398
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/** Unified-ish diff between two strings: removed lines red, added green, a little context dim. */
|
|
405
|
+
function diffSegLines(oldStr, newStr, width, theme) {
|
|
406
|
+
const parts = diffLines(oldStr ?? "", newStr ?? "");
|
|
407
|
+
const out = [];
|
|
408
|
+
const MAX = 60;
|
|
409
|
+
let count = 0;
|
|
410
|
+
for (const part of parts) {
|
|
411
|
+
const sign = part.added ? "+" : part.removed ? "-" : " ";
|
|
412
|
+
const color = part.added ? theme.success : part.removed ? theme.error : theme.textDim;
|
|
413
|
+
const linesIn = part.value.replace(/\n$/u, "").split("\n");
|
|
414
|
+
for (const ln of linesIn) {
|
|
415
|
+
if (count >= MAX) {
|
|
416
|
+
out.push([seg("… diff truncated", { dim: true, color: theme.textDim })]);
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
for (const wrapped of wrapText(`${sign} ${ln}`, width)) {
|
|
420
|
+
out.push([seg(wrapped, { color, dim: !part.added && !part.removed })]);
|
|
421
|
+
}
|
|
422
|
+
count++;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
427
|
+
function pathHeader(p, theme) {
|
|
428
|
+
return [seg("path ", { dim: true, color: theme.textDim }), seg(String(p ?? ""), { color: theme.text })];
|
|
429
|
+
}
|
|
430
|
+
/** Edit / MultiEdit → file path + colored diff(s). */
|
|
431
|
+
function formatEditBody(input, width, theme) {
|
|
432
|
+
const lines = [pathHeader(input.file_path ?? input.notebook_path, theme)];
|
|
433
|
+
const edits = Array.isArray(input.edits)
|
|
434
|
+
? input.edits
|
|
435
|
+
: [{ old_string: input.old_string, new_string: input.new_string }];
|
|
436
|
+
edits.forEach((e, i) => {
|
|
437
|
+
if (edits.length > 1)
|
|
438
|
+
lines.push([seg(`edit ${i + 1}`, { dim: true, color: theme.textDim })]);
|
|
439
|
+
lines.push(...diffSegLines(String(e.old_string ?? ""), String(e.new_string ?? input.new_source ?? ""), width, theme));
|
|
440
|
+
});
|
|
441
|
+
return lines;
|
|
442
|
+
}
|
|
443
|
+
/** TodoWrite → checklist with status glyphs. */
|
|
444
|
+
function formatTodoBody(input, width, theme) {
|
|
445
|
+
const todos = Array.isArray(input.todos) ? input.todos : [];
|
|
446
|
+
if (todos.length === 0)
|
|
447
|
+
return [[seg("(no todos)", { dim: true, color: theme.textDim })]];
|
|
448
|
+
return todos.flatMap((t) => {
|
|
449
|
+
const status = String(t.status ?? "pending");
|
|
450
|
+
const glyph = status === "completed" ? "☑" : status === "in_progress" ? "◐" : "☐";
|
|
451
|
+
const color = status === "completed" ? theme.success : status === "in_progress" ? theme.warning : theme.textDim;
|
|
452
|
+
const text = String(t.content ?? t.activeForm ?? "");
|
|
453
|
+
const wrapped = wrapText(text, Math.max(8, width - 2));
|
|
454
|
+
return wrapped.map((w, i) => [seg(i === 0 ? `${glyph} ` : " ", { color }), seg(w, { color: status === "completed" ? theme.textDim : theme.text })]);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
/** Write → file path + content preview. */
|
|
458
|
+
function formatWriteBody(input, width, theme) {
|
|
459
|
+
const lines = [pathHeader(input.file_path, theme), blank()];
|
|
460
|
+
const allLines = String(input.content ?? "").split("\n");
|
|
461
|
+
const shown = allLines.slice(0, 40);
|
|
462
|
+
for (const ln of shown)
|
|
463
|
+
lines.push(...plainLines(ln, width, { color: theme.text }));
|
|
464
|
+
if (allLines.length > shown.length)
|
|
465
|
+
lines.push([seg(`… +${allLines.length - shown.length} more lines`, { dim: true, color: theme.textDim })]);
|
|
466
|
+
return lines;
|
|
467
|
+
}
|
|
468
|
+
/** WebFetch → url + fetched/answer text. */
|
|
469
|
+
function formatWebFetchBody(input, result, width, theme) {
|
|
470
|
+
const lines = [];
|
|
471
|
+
const url = input?.url;
|
|
472
|
+
if (url)
|
|
473
|
+
lines.push([seg("url ", { dim: true, color: theme.textDim }), seg(String(url), { color: theme.accent, underline: true, url: String(url) })]);
|
|
474
|
+
if (result.trim()) {
|
|
475
|
+
if (lines.length > 0)
|
|
476
|
+
lines.push(blank());
|
|
477
|
+
lines.push(...plainLines(result, width, { color: theme.text }));
|
|
478
|
+
}
|
|
479
|
+
return lines;
|
|
480
|
+
}
|
|
481
|
+
/** Task / Agent (subagent) → description + output. */
|
|
482
|
+
function formatSubagentBody(input, result, width, theme) {
|
|
483
|
+
const lines = [];
|
|
484
|
+
const desc = input?.description ?? input?.subagent_type;
|
|
485
|
+
if (desc)
|
|
486
|
+
lines.push([seg("task ", { dim: true, color: theme.textDim }), seg(String(desc), { color: theme.text })]);
|
|
487
|
+
if (result.trim()) {
|
|
488
|
+
if (lines.length > 0)
|
|
489
|
+
lines.push(blank());
|
|
490
|
+
lines.push(...markdownToSegLines(result, width, theme));
|
|
491
|
+
}
|
|
492
|
+
return lines;
|
|
493
|
+
}
|
|
494
|
+
/** ToolSearch → query + which tool reference it loaded. */
|
|
495
|
+
function formatToolSearchBody(input, result, width, theme) {
|
|
496
|
+
const lines = [];
|
|
497
|
+
if (input?.query)
|
|
498
|
+
lines.push([seg("query ", { dim: true, color: theme.textDim }), seg(String(input.query), { color: theme.text })]);
|
|
499
|
+
const ref = parseObj(result);
|
|
500
|
+
if (ref?.tool_name)
|
|
501
|
+
lines.push([seg("loaded ", { dim: true, color: theme.textDim }), seg(String(ref.tool_name), { color: theme.accent })]);
|
|
502
|
+
else if (result.trim())
|
|
503
|
+
lines.push(...plainLines(result, width, { color: theme.textDim }));
|
|
504
|
+
return lines;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Dedicated body for a Claude Code / Codex built-in tool, keyed by its real
|
|
508
|
+
* (un-prefixed) name. Returns null to fall through to the generic renderer.
|
|
509
|
+
*/
|
|
510
|
+
function formatBuiltinBody(real, rawInput, result, width, theme) {
|
|
511
|
+
const input = parseObj(rawInput);
|
|
512
|
+
switch (real) {
|
|
513
|
+
case "Edit":
|
|
514
|
+
case "edit":
|
|
515
|
+
case "MultiEdit":
|
|
516
|
+
case "NotebookEdit":
|
|
517
|
+
return input ? formatEditBody(input, width, theme) : null;
|
|
518
|
+
case "TodoWrite":
|
|
519
|
+
return input ? formatTodoBody(input, width, theme) : null;
|
|
520
|
+
case "Write":
|
|
521
|
+
case "write":
|
|
522
|
+
return input ? formatWriteBody(input, width, theme) : null;
|
|
523
|
+
case "WebFetch":
|
|
524
|
+
return formatWebFetchBody(input, result, width, theme);
|
|
525
|
+
case "Task":
|
|
526
|
+
case "Agent":
|
|
527
|
+
return formatSubagentBody(input, result, width, theme);
|
|
528
|
+
case "ToolSearch":
|
|
529
|
+
return formatToolSearchBody(input, result, width, theme);
|
|
530
|
+
case "fileChange":
|
|
531
|
+
return formatFileChangeBody(input ?? parseObj(result), theme);
|
|
532
|
+
default:
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
/** Codex/Claude file mutation event → a single colored "<event> <path>" line. */
|
|
537
|
+
function formatFileChangeBody(fc, theme) {
|
|
538
|
+
if (!fc)
|
|
539
|
+
return null;
|
|
540
|
+
const event = String(fc.event ?? "change");
|
|
541
|
+
const color = event === "delete" ? theme.error : event === "create" ? theme.success : theme.accent;
|
|
542
|
+
return [[seg(`${event} `, { color }), seg(String(fc.path ?? ""), { color: theme.text })]];
|
|
543
|
+
}
|
|
348
544
|
/** Multi-line formatted tool output for the feed panel. */
|
|
349
|
-
export function formatToolResultLines(
|
|
545
|
+
export function formatToolResultLines(rawName, inputSummary, rawResult, status, contentWidth, theme, expanded = true, rawInput) {
|
|
546
|
+
const name = canonicalToolName(rawName);
|
|
547
|
+
const real = displayToolName(rawName);
|
|
350
548
|
if (!expanded)
|
|
351
549
|
return [];
|
|
550
|
+
// Bound the text we lay out per render — a terminal can't show a 1MB result,
|
|
551
|
+
// and wrapping/parsing that much on every frame is what stalls the renderer.
|
|
552
|
+
// The full result stays in the stored feed; only what we format is capped.
|
|
553
|
+
const MAX_RENDER = 60_000;
|
|
554
|
+
const result = rawResult && rawResult.length > MAX_RENDER
|
|
555
|
+
? `${rawResult.slice(0, MAX_RENDER)}\n\n… [${rawResult.length - MAX_RENDER} more chars not shown]`
|
|
556
|
+
: rawResult;
|
|
557
|
+
// Dedicated built-in tool rendering (diffs, checklists, …). Input-driven ones
|
|
558
|
+
// (Edit, Write, TodoWrite) render even while the tool is still running.
|
|
559
|
+
if (status !== "error") {
|
|
560
|
+
const builtin = formatBuiltinBody(real, rawInput, result ?? "", Math.max(16, contentWidth), theme);
|
|
561
|
+
if (builtin && builtin.length > 0) {
|
|
562
|
+
if (status === "running" && !result?.trim())
|
|
563
|
+
builtin.push([seg("running…", { dim: true, color: theme.textDim })]);
|
|
564
|
+
return builtin;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
352
567
|
const width = Math.max(16, contentWidth);
|
|
353
568
|
const lines = [];
|
|
354
569
|
const input = inputSummary.replace(/\s+/gu, " ").trim();
|
package/dist/ui/ink/lib/utils.js
CHANGED
|
@@ -1,29 +1,31 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import * as Bun from "bun";
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { dirname, join, resolve } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { FULL_MODE_TRIGGERS } from "../constants.js";
|
|
8
8
|
export const pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../../../package.json"), "utf8")).version;
|
|
9
9
|
/** Pipe text to the OS clipboard (pbcopy / clip / xclip). Resolves false when unavailable. */
|
|
10
|
-
export function copyToClipboard(text) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
export async function copyToClipboard(text) {
|
|
11
|
+
const cmd = process.platform === "darwin" ? "pbcopy" : process.platform === "win32" ? "clip" : "xclip";
|
|
12
|
+
const args = cmd === "xclip" ? ["-selection", "clipboard"] : [];
|
|
13
|
+
try {
|
|
14
|
+
const proc = Bun.spawn([cmd, ...args], { stdin: "pipe", stdout: "ignore", stderr: "ignore" });
|
|
15
|
+
proc.stdin.write(text);
|
|
16
|
+
await proc.stdin.end();
|
|
17
|
+
return (await proc.exited) === 0;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
20
22
|
}
|
|
21
23
|
function historyFile(runDirectory) {
|
|
22
24
|
return resolve(process.cwd(), runDirectory, "..", "input-history.json");
|
|
23
25
|
}
|
|
24
26
|
export async function loadInputHistory(runDirectory) {
|
|
25
27
|
try {
|
|
26
|
-
const parsed =
|
|
28
|
+
const parsed = await Bun.file(historyFile(runDirectory)).json();
|
|
27
29
|
return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string").slice(-50) : [];
|
|
28
30
|
}
|
|
29
31
|
catch {
|
|
@@ -34,7 +36,7 @@ export async function saveInputHistory(runDirectory, history) {
|
|
|
34
36
|
try {
|
|
35
37
|
const file = historyFile(runDirectory);
|
|
36
38
|
await mkdir(dirname(file), { recursive: true });
|
|
37
|
-
await
|
|
39
|
+
await Bun.write(file, JSON.stringify(history.slice(-50), null, 2));
|
|
38
40
|
}
|
|
39
41
|
catch { /* non-fatal */ }
|
|
40
42
|
}
|
|
@@ -200,15 +202,17 @@ export function linkAtMouseColumn(links, x) {
|
|
|
200
202
|
return undefined;
|
|
201
203
|
}
|
|
202
204
|
/** Open a URL in the system browser. */
|
|
203
|
-
export function openExternalUrl(url) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
205
|
+
export async function openExternalUrl(url) {
|
|
206
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
207
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
208
|
+
try {
|
|
209
|
+
const proc = Bun.spawn([cmd, ...args], { stdout: "ignore", stderr: "ignore" });
|
|
210
|
+
proc.unref();
|
|
211
|
+
return (await proc.exited) === 0;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
212
216
|
}
|
|
213
217
|
/** True if the prompt clearly asks for full, report-grade research. */
|
|
214
218
|
export function wantsFullResearch(prompt) {
|
|
@@ -279,16 +283,32 @@ function toolOutputText(output) {
|
|
|
279
283
|
return String(output);
|
|
280
284
|
}
|
|
281
285
|
}
|
|
282
|
-
|
|
286
|
+
// Harness/CLI tool names mapped to Scira renderer keys. Kept local to avoid a
|
|
287
|
+
// circular import with tool-result.ts (which imports from this file).
|
|
288
|
+
const HARNESS_TOOL_PREFIX = "mcp__harness-tools__";
|
|
289
|
+
const SUMMARY_CANONICAL = {
|
|
290
|
+
multiWebSearch: "webSearch",
|
|
291
|
+
Read: "readFile", Write: "writeFile", Edit: "editFile", MultiEdit: "editFile", NotebookEdit: "editFile",
|
|
292
|
+
Bash: "bash", BashOutput: "bash", shell: "bash",
|
|
293
|
+
Grep: "grepWorkspace", Glob: "listWorkspaceDir", LS: "listWorkspaceDir",
|
|
294
|
+
TodoWrite: "todo", WebFetch: "readUrl", WebSearch: "webSearch",
|
|
295
|
+
};
|
|
296
|
+
export function summarizeToolInput(rawName, input) {
|
|
297
|
+
const stripped = rawName.startsWith(HARNESS_TOOL_PREFIX) ? rawName.slice(HARNESS_TOOL_PREFIX.length) : rawName;
|
|
298
|
+
const name = SUMMARY_CANONICAL[stripped] ?? stripped;
|
|
283
299
|
const obj = (input ?? {});
|
|
300
|
+
const path = obj.path ?? obj.file_path ?? obj.notebook_path;
|
|
284
301
|
if (name === "bash" || name === "runBash" || name === "runWorkspaceCommand") {
|
|
285
302
|
const action = obj.action;
|
|
286
303
|
if (action && action !== "run")
|
|
287
304
|
return `${action}${obj.taskId ? ` ${obj.taskId}` : ""}`;
|
|
288
305
|
return String(obj.command ?? "");
|
|
289
306
|
}
|
|
290
|
-
if (name === "todo")
|
|
307
|
+
if (name === "todo") {
|
|
308
|
+
if (Array.isArray(obj.todos))
|
|
309
|
+
return `${obj.todos.length} item(s)`;
|
|
291
310
|
return `${String(obj.action ?? "list")}${obj.id ? ` ${obj.id}` : ""}`;
|
|
311
|
+
}
|
|
292
312
|
if (name === "webSearch" || name === "xSearch") {
|
|
293
313
|
const queries = Array.isArray(obj.queries) ? obj.queries : [];
|
|
294
314
|
return queries.length > 0 ? queries.slice(0, 2).join(" · ") + (queries.length > 2 ? ` +${queries.length - 2}` : "") : String(obj.query ?? "");
|
|
@@ -296,14 +316,20 @@ export function summarizeToolInput(name, input) {
|
|
|
296
316
|
if (name === "readUrl")
|
|
297
317
|
return String(obj.url ?? "");
|
|
298
318
|
if (name === "writeFile" || name === "editFile" || name === "readFile" || name === "readWorkspaceFile" || name === "writeWorkspaceFile" || name === "editWorkspaceFile") {
|
|
299
|
-
return String(
|
|
319
|
+
return String(path ?? "");
|
|
300
320
|
}
|
|
301
321
|
if (name === "listWorkspaceDir" || name === "grepWorkspace")
|
|
302
|
-
return String(obj.
|
|
322
|
+
return String(obj.pattern ?? path ?? "");
|
|
303
323
|
if (name === "readSkill" || name === "listSkills")
|
|
304
|
-
return String(obj.name ?? "");
|
|
324
|
+
return String(obj.name ?? obj.skill ?? "");
|
|
305
325
|
if (name === "createClaim" || name === "verifyClaim")
|
|
306
326
|
return String(obj.id ?? "");
|
|
327
|
+
if (name === "fileChange")
|
|
328
|
+
return `${String(obj.event ?? "change")} ${String(obj.path ?? "")}`.trim();
|
|
329
|
+
if (stripped === "ToolSearch")
|
|
330
|
+
return String(obj.query ?? "");
|
|
331
|
+
if (stripped === "Task" || stripped === "Agent")
|
|
332
|
+
return String(obj.description ?? obj.subagent_type ?? "");
|
|
307
333
|
try {
|
|
308
334
|
return JSON.stringify(obj).slice(0, 80);
|
|
309
335
|
}
|
package/dist/ui/ink/theme.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { readFileSync, unwatchFile, watchFile } from "node:fs";
|
|
2
|
-
import { execSync } from "node:child_process";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
3
|
import { join } from "node:path";
|
|
5
4
|
export const DARK_THEME = {
|
|
@@ -124,11 +123,9 @@ function readEditorColorTheme() {
|
|
|
124
123
|
function readSystemAppearance() {
|
|
125
124
|
if (process.platform === "darwin") {
|
|
126
125
|
try {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}).trim();
|
|
131
|
-
return style === "Dark" ? "dark" : "light";
|
|
126
|
+
const r = Bun.spawnSync(["defaults", "read", "-g", "AppleInterfaceStyle"], { stdout: "pipe", stderr: "ignore" });
|
|
127
|
+
// The key is absent (and `defaults` exits non-zero) in light mode.
|
|
128
|
+
return r.stdout.toString().trim() === "Dark" ? "dark" : "light";
|
|
132
129
|
}
|
|
133
130
|
catch {
|
|
134
131
|
return "light";
|
|
@@ -136,10 +133,8 @@ function readSystemAppearance() {
|
|
|
136
133
|
}
|
|
137
134
|
if (process.platform === "linux") {
|
|
138
135
|
try {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
142
|
-
}).trim();
|
|
136
|
+
const r = Bun.spawnSync(["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"], { stdout: "pipe", stderr: "ignore" });
|
|
137
|
+
const scheme = r.stdout.toString().trim();
|
|
143
138
|
if (/dark/i.test(scheme))
|
|
144
139
|
return "dark";
|
|
145
140
|
if (/light/i.test(scheme))
|