@scira/cli 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +56 -10
  2. package/dist/agent/background-tasks.js +173 -0
  3. package/dist/agent/research-agent.js +95 -38
  4. package/dist/agent/todos.js +140 -0
  5. package/dist/agent/tools.js +146 -143
  6. package/dist/agent/tools.test.js +33 -0
  7. package/dist/agent/workspace.js +85 -0
  8. package/dist/cli/commands/init.js +53 -39
  9. package/dist/cli/index.js +30 -14
  10. package/dist/config/env-guide.js +151 -0
  11. package/dist/config/env-guide.test.js +18 -0
  12. package/dist/config/env-store.js +53 -0
  13. package/dist/config/env-store.test.js +60 -0
  14. package/dist/tools/agent-tools.js +621 -0
  15. package/dist/tools/background-tasks.js +261 -0
  16. package/dist/tools/bash-policy.test.js +38 -0
  17. package/dist/tools/file-tools.js +6 -1
  18. package/dist/tools/search-web.js +24 -6
  19. package/dist/tools/search-web.test.js +24 -0
  20. package/dist/tools/todos.js +140 -0
  21. package/dist/tools/workspace.js +91 -0
  22. package/dist/tools/workspace.test.js +75 -0
  23. package/dist/tools/x-search.js +142 -0
  24. package/dist/types/index.js +1 -0
  25. package/dist/types/schema.test.js +1 -0
  26. package/dist/ui/ink/SciraApp.js +74 -21
  27. package/dist/ui/ink/components/overlays.js +15 -9
  28. package/dist/ui/ink/constants.js +13 -4
  29. package/dist/ui/ink/hooks/use-agent-turn.js +26 -7
  30. package/dist/ui/ink/hooks/use-feed-lines.js +33 -6
  31. package/dist/ui/ink/hooks/use-keyboard.js +16 -1
  32. package/dist/ui/ink/hooks/use-session.js +15 -14
  33. package/dist/ui/ink/hooks/use-settings.js +30 -8
  34. package/dist/ui/ink/hooks/use-submit.js +14 -3
  35. package/dist/ui/ink/hooks/use-theme.js +1 -1
  36. package/dist/ui/ink/lib/tool-result.js +73 -5
  37. package/dist/ui/ink/lib/tool-result.test.js +3 -3
  38. package/dist/ui/ink/lib/utils.js +104 -5
  39. package/dist/ui/ink/lib/utils.test.js +18 -1
  40. package/dist/ui/ink/theme-context.js +29 -26
  41. package/dist/ui/ink/theme.js +36 -9
  42. package/dist/ui/ink/theme.test.js +32 -5
  43. package/package.json +6 -2
@@ -142,11 +142,73 @@ export function relativeTime(ms) {
142
142
  return `${weeks}w ago`;
143
143
  return new Date(ms).toLocaleDateString();
144
144
  }
145
- /** Wrap text in an OSC 8 terminal hyperlink (clickable in supported terminals). */
145
+ function colorToAnsi(color) {
146
+ if (!color)
147
+ return [];
148
+ const hex = /^#([0-9a-f]{6})$/i.exec(color);
149
+ if (hex) {
150
+ const n = hex[1];
151
+ return [38, 2, parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
152
+ }
153
+ const ansi256 = /^ansi256\((\d+)\)$/i.exec(color);
154
+ if (ansi256)
155
+ return [38, 5, parseInt(ansi256[1], 10)];
156
+ const named = {
157
+ red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36,
158
+ gray: 90, white: 97, black: 30,
159
+ };
160
+ const code = named[color.toLowerCase()];
161
+ return code ? [code] : [];
162
+ }
163
+ /** OSC 8 link with inline ANSI styling — avoids Ink Text props breaking the escape sequence. */
164
+ export function ansiHyperlink(text, url, style) {
165
+ const params = [];
166
+ if (style?.bold)
167
+ params.push(1);
168
+ if (style?.dim)
169
+ params.push(2);
170
+ if (style?.italic)
171
+ params.push(3);
172
+ if (style?.underline !== false)
173
+ params.push(4);
174
+ params.push(...colorToAnsi(style?.color));
175
+ const styled = params.length > 0 ? `\x1b[${params.join(";")}m${text}\x1b[0m` : text;
176
+ return `\x1b]8;;${url}\x1b\\${styled}\x1b]8;;\x1b\\`;
177
+ }
146
178
  export function hyperlink(text, url) {
147
179
  if (!url)
148
180
  return text;
149
- return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
181
+ return ansiHyperlink(text, url, { underline: true });
182
+ }
183
+ export function computeLineLinks(segs, prefixCols = 0) {
184
+ const links = [];
185
+ let col = prefixCols;
186
+ for (const s of segs) {
187
+ const w = displayWidth(s.text);
188
+ if (s.url && w > 0)
189
+ links.push({ start: col, end: col + w - 1, url: s.url });
190
+ col += w;
191
+ }
192
+ return links;
193
+ }
194
+ /** Match an SGR mouse column (1-based) against link regions from computeLineLinks. */
195
+ export function linkAtMouseColumn(links, x) {
196
+ for (const l of links) {
197
+ if (x >= l.start + 1 && x <= l.end + 1)
198
+ return l.url;
199
+ }
200
+ return undefined;
201
+ }
202
+ /** Open a URL in the system browser. */
203
+ export function openExternalUrl(url) {
204
+ return new Promise((res) => {
205
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
206
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
207
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
208
+ child.on("error", () => res(false));
209
+ child.on("close", (code) => res(code === 0));
210
+ child.unref();
211
+ });
150
212
  }
151
213
  /** True if the prompt clearly asks for full, report-grade research. */
152
214
  export function wantsFullResearch(prompt) {
@@ -219,9 +281,15 @@ function toolOutputText(output) {
219
281
  }
220
282
  export function summarizeToolInput(name, input) {
221
283
  const obj = (input ?? {});
222
- if (name === "bash" || name === "runWorkspaceCommand")
284
+ if (name === "bash" || name === "runBash" || name === "runWorkspaceCommand") {
285
+ const action = obj.action;
286
+ if (action && action !== "run")
287
+ return `${action}${obj.taskId ? ` ${obj.taskId}` : ""}`;
223
288
  return String(obj.command ?? "");
224
- if (name === "webSearch") {
289
+ }
290
+ if (name === "todo")
291
+ return `${String(obj.action ?? "list")}${obj.id ? ` ${obj.id}` : ""}`;
292
+ if (name === "webSearch" || name === "xSearch") {
225
293
  const queries = Array.isArray(obj.queries) ? obj.queries : [];
226
294
  return queries.length > 0 ? queries.slice(0, 2).join(" · ") + (queries.length > 2 ? ` +${queries.length - 2}` : "") : String(obj.query ?? "");
227
295
  }
@@ -260,6 +328,25 @@ export function summarizeToolOutput(name, output) {
260
328
  }
261
329
  catch { /* fall through */ }
262
330
  }
331
+ if (name === "xSearch") {
332
+ try {
333
+ const parsed = JSON.parse(text);
334
+ if (Array.isArray(parsed)) {
335
+ const posts = parsed.flatMap((s) => s.posts ?? []);
336
+ const total = posts.length;
337
+ if (total === 0)
338
+ return "no posts";
339
+ const handles = posts
340
+ .map((p) => p.handle)
341
+ .filter((h) => Boolean(h))
342
+ .slice(0, 3)
343
+ .map((h) => `@${h}`);
344
+ const head = `${total} post${total === 1 ? "" : "s"}`;
345
+ return handles.length > 0 ? `${head} · ${handles.join(", ")}` : head;
346
+ }
347
+ }
348
+ catch { /* fall through */ }
349
+ }
263
350
  if (name === "readUrl") {
264
351
  const titleMatch = text.match(/^#\s+(.+)/m);
265
352
  if (titleMatch?.[1])
@@ -284,12 +371,24 @@ export function summarizeToolOutput(name, output) {
284
371
  if (name === "writeFile" || name === "writeWorkspaceFile" || name === "editFile" || name === "editWorkspaceFile") {
285
372
  return oneLine(text, 120) || "ok";
286
373
  }
287
- if (name === "bash" || name === "runWorkspaceCommand") {
374
+ if (name === "bash" || name === "runBash" || name === "runWorkspaceCommand") {
375
+ if (text.startsWith("Started background task"))
376
+ return oneLine(text, 120);
377
+ if (text.startsWith("No background tasks") || text.includes("[running]") || text.includes("[exited]")) {
378
+ return oneLine(text.split("\n")[0] ?? text, 120);
379
+ }
288
380
  const lines = text.split("\n").filter((l) => l.trim());
289
381
  if (lines.length === 0)
290
382
  return "done";
291
383
  return lines.slice(-3).map((l) => oneLine(l, 80)).join(" · ").slice(0, 200);
292
384
  }
385
+ if (name === "todo") {
386
+ const lines = text.split("\n").filter((l) => l.trim());
387
+ if (lines.length === 0)
388
+ return "no todos";
389
+ const active = lines.filter((l) => l.includes("[ ]") || l.includes("[~]")).length;
390
+ return `${lines.length} todo${lines.length === 1 ? "" : "s"}${active > 0 ? ` · ${active} open` : ""}`;
391
+ }
293
392
  if (name === "listWorkspaceDir") {
294
393
  const lines = text.split("\n").filter((l) => l.trim());
295
394
  const preview = lines.slice(0, 3).map((l) => oneLine(l, 40)).join(", ");
@@ -1,5 +1,22 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { summarizeToolInput, summarizeToolOutput } from "./utils.js";
2
+ import { summarizeToolInput, summarizeToolOutput, ansiHyperlink, computeLineLinks, linkAtMouseColumn } from "./utils.js";
3
+ describe("hyperlink helpers", () => {
4
+ it("wraps OSC 8 around styled link text", () => {
5
+ const out = ansiHyperlink("docs", "https://example.com", { color: "#FFE0C2", underline: true });
6
+ expect(out).toContain("\x1b]8;;https://example.com\x1b\\");
7
+ expect(out).toContain("docs");
8
+ expect(out).toContain("\x1b]8;;\x1b\\");
9
+ });
10
+ it("maps mouse column to link url", () => {
11
+ const links = computeLineLinks([
12
+ { text: "see " },
13
+ { text: "docs", url: "https://example.com" },
14
+ ], 2);
15
+ expect(links).toEqual([{ start: 6, end: 9, url: "https://example.com" }]);
16
+ expect(linkAtMouseColumn(links, 7)).toBe("https://example.com");
17
+ expect(linkAtMouseColumn(links, 3)).toBeUndefined();
18
+ });
19
+ });
3
20
  describe("summarizeToolInput", () => {
4
21
  it("formats webSearch queries", () => {
5
22
  expect(summarizeToolInput("webSearch", { queries: ["a", "b", "c"] })).toBe("a · b +1");
@@ -1,33 +1,36 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React, { createContext, useContext, useMemo, useState } from "react";
3
- import { probeTerminalTheme } from "./terminal-probe.js";
4
- import { detectTerminalTheme, getTheme, getThemeFromResolved, watchAutoThemeChanges, } from "./theme.js";
5
- const ThemeCtx = createContext(getTheme("auto"));
6
- export function ThemeProvider({ config, stdin, stdout, children }) {
7
- const [autoResolved, setAutoResolved] = useState(detectTerminalTheme);
8
- const [probed, setProbed] = useState(undefined);
3
+ import { detectTerminalTheme, getThemeFromResolved, resolveRenderingAppearance, watchAutoThemeChanges, } from "./theme.js";
4
+ const initialTerminal = detectTerminalTheme();
5
+ const ThemeCtx = createContext({
6
+ colors: getThemeFromResolved(initialTerminal),
7
+ terminalAppearance: initialTerminal,
8
+ renderingAppearance: initialTerminal,
9
+ });
10
+ export function ThemeProvider({ config, children }) {
11
+ const [terminalAppearance, setTerminalAppearance] = useState(detectTerminalTheme);
9
12
  React.useEffect(() => {
10
- if (config.theme !== "auto") {
11
- setProbed(undefined);
12
- return;
13
- }
14
- const sync = () => {
15
- void (async () => {
16
- const live = stdin && stdout ? await probeTerminalTheme(stdin, stdout) : undefined;
17
- setProbed((prev) => (prev === live ? prev : live));
18
- const next = live ?? detectTerminalTheme();
19
- setAutoResolved((prev) => (prev === next ? prev : next));
20
- })();
13
+ return watchAutoThemeChanges(() => {
14
+ const next = detectTerminalTheme();
15
+ setTerminalAppearance((prev) => (prev === next ? prev : next));
16
+ });
17
+ }, []);
18
+ const value = useMemo(() => {
19
+ const renderingAppearance = resolveRenderingAppearance(config.theme, terminalAppearance);
20
+ return {
21
+ colors: getThemeFromResolved(renderingAppearance),
22
+ terminalAppearance,
23
+ renderingAppearance,
21
24
  };
22
- return watchAutoThemeChanges(sync);
23
- }, [config.theme, stdin, stdout]);
24
- const colors = useMemo(() => {
25
- if (config.theme !== "auto")
26
- return getTheme(config.theme);
27
- return getThemeFromResolved(probed ?? autoResolved);
28
- }, [config.theme, autoResolved, probed]);
29
- return _jsx(ThemeCtx.Provider, { value: colors, children: children });
25
+ }, [config.theme, terminalAppearance]);
26
+ return _jsx(ThemeCtx.Provider, { value: value, children: children });
30
27
  }
31
28
  export function useTheme() {
32
- return useContext(ThemeCtx);
29
+ return useContext(ThemeCtx).colors;
30
+ }
31
+ export function useTerminalAppearance() {
32
+ return useContext(ThemeCtx).terminalAppearance;
33
+ }
34
+ export function useRenderingAppearance() {
35
+ return useContext(ThemeCtx).renderingAppearance;
33
36
  }
@@ -7,10 +7,10 @@ export const DARK_THEME = {
7
7
  accentDim: "#CFB59D",
8
8
  background: "",
9
9
  border: "#FFE0C2",
10
- text: "white",
10
+ text: "ansi256(15)",
11
11
  textDim: "ansi256(245)",
12
- textInverse: "black",
13
- inputText: "#ffffff",
12
+ textInverse: "ansi256(0)",
13
+ inputText: "ansi256(15)",
14
14
  cursorBackground: "#FFE0C2",
15
15
  cursorForeground: "#000000",
16
16
  success: "green",
@@ -24,10 +24,10 @@ export const LIGHT_THEME = {
24
24
  accentDim: "#CFB59D",
25
25
  background: "",
26
26
  border: "#FFE0C2",
27
- text: "black",
28
- textDim: "gray",
29
- textInverse: "white",
30
- inputText: "#000000",
27
+ text: "ansi256(0)",
28
+ textDim: "ansi256(242)",
29
+ textInverse: "ansi256(15)",
30
+ inputText: "ansi256(0)",
31
31
  cursorBackground: "#CFB59D",
32
32
  cursorForeground: "#000000",
33
33
  success: "green",
@@ -62,7 +62,7 @@ function readColorFgbg() {
62
62
  return undefined;
63
63
  }
64
64
  function readTerminalProfile() {
65
- const profile = process.env.TERM_PROFILE || process.env.ITERM_PROFILE || "";
65
+ const profile = process.env.TERM_PROFILE || process.env.ITERM_PROFILE || process.env.WARP_BOOTSTRAPPED || "";
66
66
  if (!profile)
67
67
  return undefined;
68
68
  if (/light|day|solar/i.test(profile))
@@ -71,6 +71,17 @@ function readTerminalProfile() {
71
71
  return "dark";
72
72
  return undefined;
73
73
  }
74
+ /** Common standalone terminals that default to dark profiles when unset. */
75
+ function readTermProgram() {
76
+ const program = (process.env.TERM_PROGRAM ?? "").toLowerCase();
77
+ if (!program)
78
+ return undefined;
79
+ if (/warp|ghostty|alacritty|kitty|hyper|wezterm|tabby/.test(program))
80
+ return "dark";
81
+ if (program === "apple_terminal")
82
+ return "dark";
83
+ return undefined;
84
+ }
74
85
  function editorSettingsPaths() {
75
86
  const home = homedir();
76
87
  if (process.platform === "darwin") {
@@ -146,9 +157,25 @@ function readSystemAppearance() {
146
157
  export function detectTerminalTheme() {
147
158
  return readColorFgbg()
148
159
  ?? readTerminalProfile()
160
+ ?? readTermProgram()
149
161
  ?? readEditorColorTheme()
150
162
  ?? readSystemAppearance()
151
- ?? "light";
163
+ ?? "dark";
164
+ }
165
+ /** Input foreground matched to terminal appearance. */
166
+ export function inputForegroundForAppearance(appearance) {
167
+ return appearance === "dark" ? "ansi256(15)" : "ansi256(0)";
168
+ }
169
+ /**
170
+ * Pick colors for rendering. When a locked theme disagrees with the terminal
171
+ * background, follow the terminal so text stays readable.
172
+ */
173
+ export function resolveRenderingAppearance(configTheme, terminalAppearance) {
174
+ if (configTheme === "auto")
175
+ return terminalAppearance;
176
+ if (configTheme !== terminalAppearance)
177
+ return terminalAppearance;
178
+ return configTheme;
152
179
  }
153
180
  export function getThemeFromResolved(resolved) {
154
181
  return resolved === "light" ? LIGHT_THEME : DARK_THEME;
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
- import { detectTerminalTheme, getTheme, watchAutoThemeChanges } from "./theme.js";
2
+ import { detectTerminalTheme, getTheme, inputForegroundForAppearance, resolveRenderingAppearance, watchAutoThemeChanges, } from "./theme.js";
3
3
  const env = process.env;
4
4
  afterEach(() => {
5
5
  process.env = { ...env };
@@ -18,14 +18,41 @@ describe("detectTerminalTheme", () => {
18
18
  });
19
19
  it("resolves auto theme colors from detected appearance", () => {
20
20
  process.env.COLORFGBG = "0;15";
21
- expect(getTheme("auto").text).toBe("black");
22
- expect(getTheme("auto").inputText).toBe("#000000");
21
+ expect(getTheme("auto").text).toBe("ansi256(0)");
22
+ expect(getTheme("auto").inputText).toBe("ansi256(0)");
23
23
  expect(getTheme("auto").userBandBackground).toBe("#f0f0f0");
24
24
  process.env.COLORFGBG = "15;0";
25
- expect(getTheme("auto").text).toBe("white");
26
- expect(getTheme("auto").inputText).toBe("#ffffff");
25
+ expect(getTheme("auto").text).toBe("ansi256(15)");
26
+ expect(getTheme("auto").inputText).toBe("ansi256(15)");
27
27
  expect(getTheme("auto").userBandBackground).toBe("ansi256(238)");
28
28
  });
29
+ it("detects Warp and Apple Terminal as dark when unset", () => {
30
+ delete process.env.COLORFGBG;
31
+ delete process.env.TERM_PROFILE;
32
+ delete process.env.ITERM_PROFILE;
33
+ process.env.TERM_PROGRAM = "WarpTerminal";
34
+ expect(detectTerminalTheme()).toBe("dark");
35
+ process.env.TERM_PROGRAM = "Apple_Terminal";
36
+ expect(detectTerminalTheme()).toBe("dark");
37
+ });
38
+ it("defaults to dark when no signals are present", () => {
39
+ delete process.env.COLORFGBG;
40
+ delete process.env.TERM_PROFILE;
41
+ delete process.env.ITERM_PROFILE;
42
+ delete process.env.TERM_PROGRAM;
43
+ delete process.env.TERM_PROGRAM;
44
+ expect(detectTerminalTheme()).toBe("dark");
45
+ });
46
+ it("maps terminal appearance to ansi256 input foreground", () => {
47
+ expect(inputForegroundForAppearance("dark")).toBe("ansi256(15)");
48
+ expect(inputForegroundForAppearance("light")).toBe("ansi256(0)");
49
+ });
50
+ it("overrides a mismatched locked theme to match the terminal", () => {
51
+ expect(resolveRenderingAppearance("light", "dark")).toBe("dark");
52
+ expect(resolveRenderingAppearance("dark", "light")).toBe("light");
53
+ expect(resolveRenderingAppearance("dark", "dark")).toBe("dark");
54
+ expect(resolveRenderingAppearance("auto", "dark")).toBe("dark");
55
+ });
29
56
  it("watchAutoThemeChanges fires immediately and on interval", () => {
30
57
  vi.useFakeTimers();
31
58
  const onChange = vi.fn();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scira/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Scira — terminal-native AI research agent with grounded sources, verified claims, and local run storage.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,6 +33,8 @@
33
33
  "typecheck": "tsc -p tsconfig.json --noEmit",
34
34
  "prepublishOnly": "bun run build",
35
35
  "dev": "bun src/cli/index.ts",
36
+ "docs:dev": "bun run --cwd docs dev",
37
+ "docs:build": "NODE_ENV=production bun run --cwd docs build",
36
38
  "test": "vitest run",
37
39
  "test:watch": "vitest"
38
40
  },
@@ -49,16 +51,18 @@
49
51
  "exa-js": "^2.13.0",
50
52
  "files-sdk": "^1.8.0",
51
53
  "ink": "^7.0.5",
54
+ "ink-link": "^5.0.0",
52
55
  "jsdom": "^29.1.1",
53
56
  "parallel-web": "^1.1.0",
54
57
  "picospinner": "^3.0.0",
55
58
  "react": "^19.2.7",
59
+ "react-tweet": "^3.3.1",
56
60
  "sade": "^1.8.1",
57
61
  "workers-ai-provider": "^3.1.14",
58
62
  "zod": "^4.4.3"
59
63
  },
60
64
  "devDependencies": {
61
- "bun-types": "^1.2.23",
65
+ "bun-types": "^1.3.14",
62
66
  "@types/jsdom": "^28.0.3",
63
67
  "@types/node": "^25.9.3",
64
68
  "@types/react": "^19.2.17",