@scira/cli 0.1.0
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/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/agent/research-agent.js +253 -0
- package/dist/agent/skills.js +265 -0
- package/dist/agent/tools.js +429 -0
- package/dist/agent/tools.test.js +27 -0
- package/dist/cli/commands/init.js +370 -0
- package/dist/cli/index.js +445 -0
- package/dist/cli/shell/shell.js +76 -0
- package/dist/cli/shell/tui.js +11 -0
- package/dist/config/env-store.js +47 -0
- package/dist/config/load-config.js +58 -0
- package/dist/export/formatters.js +37 -0
- package/dist/providers/llm/gateway.js +64 -0
- package/dist/providers/llm/huggingface.js +33 -0
- package/dist/providers/llm/models.js +97 -0
- package/dist/providers/llm/readiness.js +50 -0
- package/dist/providers/llm/registry.js +56 -0
- package/dist/storage/jsonl.js +29 -0
- package/dist/storage/jsonl.test.js +38 -0
- package/dist/storage/run-store.js +134 -0
- package/dist/storage/run-store.test.js +65 -0
- package/dist/tools/chrome-devtools-mcp.js +61 -0
- package/dist/tools/file-tools.js +128 -0
- package/dist/tools/mcp-bridge.js +118 -0
- package/dist/tools/mcp-oauth.js +276 -0
- package/dist/tools/open-url.js +99 -0
- package/dist/tools/search-web.js +153 -0
- package/dist/types/index.js +91 -0
- package/dist/types/schema.test.js +60 -0
- package/dist/ui/ink/SciraApp.js +274 -0
- package/dist/ui/ink/components/effects.js +44 -0
- package/dist/ui/ink/components/home-screen.js +69 -0
- package/dist/ui/ink/components/overlays.js +111 -0
- package/dist/ui/ink/constants.js +56 -0
- package/dist/ui/ink/hooks/use-agent-turn.js +186 -0
- package/dist/ui/ink/hooks/use-feed-lines.js +186 -0
- package/dist/ui/ink/hooks/use-feed.js +69 -0
- package/dist/ui/ink/hooks/use-keyboard.js +315 -0
- package/dist/ui/ink/hooks/use-mouse.js +31 -0
- package/dist/ui/ink/hooks/use-session.js +103 -0
- package/dist/ui/ink/hooks/use-settings.js +155 -0
- package/dist/ui/ink/hooks/use-submit.js +366 -0
- package/dist/ui/ink/hooks/use-suggestions.js +91 -0
- package/dist/ui/ink/lib/file-mentions.js +71 -0
- package/dist/ui/ink/lib/markdown.js +245 -0
- package/dist/ui/ink/lib/utils.js +224 -0
- package/dist/ui/ink/session-manager.js +160 -0
- package/dist/ui/ink/types.js +1 -0
- package/dist/utils/ids.js +15 -0
- package/dist/utils/markdown-joiner.js +249 -0
- package/dist/watch/runner.js +65 -0
- package/package.json +74 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { Exa } from "exa-js";
|
|
3
|
+
import Parallel from "parallel-web";
|
|
4
|
+
import { Firecrawl } from "@mendable/firecrawl-js";
|
|
5
|
+
import { hasEnv, requireSearchProvider } from "../providers/llm/readiness.js";
|
|
6
|
+
// ── lazy singleton clients ──
|
|
7
|
+
let _exa;
|
|
8
|
+
let _parallel;
|
|
9
|
+
let _firecrawl;
|
|
10
|
+
function getExa() {
|
|
11
|
+
requireSearchProvider("exa");
|
|
12
|
+
if (!_exa)
|
|
13
|
+
_exa = new Exa(process.env.EXA_API_KEY);
|
|
14
|
+
return _exa;
|
|
15
|
+
}
|
|
16
|
+
function getParallel() {
|
|
17
|
+
requireSearchProvider("parallel");
|
|
18
|
+
if (!_parallel)
|
|
19
|
+
_parallel = new Parallel({ apiKey: process.env.PARALLEL_API_KEY });
|
|
20
|
+
return _parallel;
|
|
21
|
+
}
|
|
22
|
+
function getFirecrawl() {
|
|
23
|
+
requireSearchProvider("firecrawl");
|
|
24
|
+
if (!_firecrawl)
|
|
25
|
+
_firecrawl = new Firecrawl({ apiKey: process.env.FIRECRAWL_API_KEY });
|
|
26
|
+
return _firecrawl;
|
|
27
|
+
}
|
|
28
|
+
// ── helpers (mirrored from scira) ──
|
|
29
|
+
function extractDomain(url) {
|
|
30
|
+
if (!url || typeof url !== "string")
|
|
31
|
+
return "";
|
|
32
|
+
const match = url.match(/^https?:\/\/([^/?#]+)(?:[/?#]|$)/iu);
|
|
33
|
+
return match?.[1] || url;
|
|
34
|
+
}
|
|
35
|
+
function cleanTitle(title) {
|
|
36
|
+
return title
|
|
37
|
+
.replace(/\[.*?\]/gu, "")
|
|
38
|
+
.replace(/\(.*?\)/gu, "")
|
|
39
|
+
.replace(/\s+/gu, " ")
|
|
40
|
+
.trim();
|
|
41
|
+
}
|
|
42
|
+
function deduplicateByDomainAndUrl(items) {
|
|
43
|
+
const seenDomains = new Set();
|
|
44
|
+
const seenUrls = new Set();
|
|
45
|
+
return items.filter((item) => {
|
|
46
|
+
const domain = extractDomain(item.url);
|
|
47
|
+
if (!seenUrls.has(item.url) && !seenDomains.has(domain)) {
|
|
48
|
+
seenUrls.add(item.url);
|
|
49
|
+
seenDomains.add(domain);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// ── provider strategies ──
|
|
56
|
+
async function parallelSearch(query, config, opts = {}) {
|
|
57
|
+
const parallel = getParallel();
|
|
58
|
+
const maxResults = opts.maxResults ?? config.search.maxResults;
|
|
59
|
+
const startDate = opts.startDate ?? config.search.afterDate;
|
|
60
|
+
const response = await parallel.search({
|
|
61
|
+
objective: query,
|
|
62
|
+
search_queries: [query],
|
|
63
|
+
mode: opts.quality === "best" ? "advanced" : "basic",
|
|
64
|
+
max_chars_total: config.search.maxCharsTotal ?? 5000,
|
|
65
|
+
advanced_settings: {
|
|
66
|
+
max_results: Math.max(maxResults, 10),
|
|
67
|
+
fetch_policy: { max_age_seconds: config.search.maxAgeSeconds ?? 3600, timeout_seconds: 120 },
|
|
68
|
+
...(startDate ? { source_policy: { after_date: startDate } } : {})
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
const results = (response.results ?? []).map((r) => ({
|
|
72
|
+
url: r.url,
|
|
73
|
+
title: cleanTitle(r.title ?? ""),
|
|
74
|
+
snippet: (r.excerpts ?? []).join(" ").slice(0, 1000),
|
|
75
|
+
publishedDate: r.publish_date ?? undefined
|
|
76
|
+
}));
|
|
77
|
+
return deduplicateByDomainAndUrl(results);
|
|
78
|
+
}
|
|
79
|
+
async function exaSearch(query, config, opts = {}) {
|
|
80
|
+
const exa = getExa();
|
|
81
|
+
const maxResults = opts.maxResults ?? config.search.maxResults;
|
|
82
|
+
const startDate = opts.startDate ?? config.search.afterDate;
|
|
83
|
+
const startPublishedDate = startDate ? new Date(startDate).toISOString() : undefined;
|
|
84
|
+
const response = await exa.search(query, {
|
|
85
|
+
type: opts.quality === "best" ? "deep" : "auto",
|
|
86
|
+
numResults: Math.max(maxResults, 15),
|
|
87
|
+
...(startPublishedDate ? { startPublishedDate, endPublishedDate: new Date().toISOString() } : {}),
|
|
88
|
+
contents: { highlights: true }
|
|
89
|
+
});
|
|
90
|
+
const results = (response.results ?? []).map((r) => {
|
|
91
|
+
const highlights = r.highlights ?? [];
|
|
92
|
+
return {
|
|
93
|
+
url: r.url,
|
|
94
|
+
title: cleanTitle(r.title ?? ""),
|
|
95
|
+
snippet: highlights.join(" ").slice(0, 1000),
|
|
96
|
+
publishedDate: r.publishedDate ?? undefined
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
return deduplicateByDomainAndUrl(results);
|
|
100
|
+
}
|
|
101
|
+
async function firecrawlSearch(query, config, opts = {}) {
|
|
102
|
+
const firecrawl = getFirecrawl();
|
|
103
|
+
const maxResults = opts.maxResults ?? config.search.maxResults;
|
|
104
|
+
const startDate = opts.startDate ?? config.search.afterDate;
|
|
105
|
+
const topic = opts.topic ?? "general";
|
|
106
|
+
const formatTbs = (d) => {
|
|
107
|
+
const dt = new Date(d);
|
|
108
|
+
const end = new Date();
|
|
109
|
+
return `cdr:1,cd_min:${dt.getMonth() + 1}/${dt.getDate()}/${dt.getFullYear()},cd_max:${end.getMonth() + 1}/${end.getDate()}/${end.getFullYear()}`;
|
|
110
|
+
};
|
|
111
|
+
const sources = topic === "news" ? ["news", "web"] : ["web"];
|
|
112
|
+
const data = await firecrawl.search(query, {
|
|
113
|
+
sources,
|
|
114
|
+
limit: maxResults,
|
|
115
|
+
...(startDate ? { tbs: formatTbs(startDate) } : {}),
|
|
116
|
+
...(config.search.includeDomains.length ? { includeDomains: config.search.includeDomains } : {}),
|
|
117
|
+
...(config.search.excludeDomains.length ? { excludeDomains: config.search.excludeDomains } : {})
|
|
118
|
+
});
|
|
119
|
+
const web = (data.web ?? []).filter((item) => typeof item.url === "string");
|
|
120
|
+
const news = topic === "news"
|
|
121
|
+
? (data.news ?? []).filter((item) => typeof item.url === "string")
|
|
122
|
+
: [];
|
|
123
|
+
const results = [
|
|
124
|
+
...news.map((r) => ({ url: r.url, title: cleanTitle(r.title ?? ""), snippet: (r.snippet ?? "").slice(0, 1000), publishedDate: r.date })),
|
|
125
|
+
...web.map((r) => ({ url: r.url, title: cleanTitle(r.title ?? ""), snippet: (r.description ?? "").slice(0, 1000) }))
|
|
126
|
+
];
|
|
127
|
+
return deduplicateByDomainAndUrl(results);
|
|
128
|
+
}
|
|
129
|
+
const STRATEGIES = {
|
|
130
|
+
parallel: parallelSearch,
|
|
131
|
+
exa: exaSearch,
|
|
132
|
+
firecrawl: firecrawlSearch
|
|
133
|
+
};
|
|
134
|
+
export async function multiSearchWeb(queries, perQuery, config) {
|
|
135
|
+
const provider = config.search.provider;
|
|
136
|
+
const strategy = STRATEGIES[provider];
|
|
137
|
+
const settled = await Promise.allSettled(queries.map((q, i) => strategy(q, config, perQuery[i] ?? {})));
|
|
138
|
+
const searches = await Promise.all(settled.map(async (res, i) => {
|
|
139
|
+
if (res.status === "fulfilled" && res.value.length > 0) {
|
|
140
|
+
return { query: queries[i], results: res.value };
|
|
141
|
+
}
|
|
142
|
+
// per-query fallback to Firecrawl
|
|
143
|
+
if (provider !== "firecrawl" && hasEnv("FIRECRAWL_API_KEY")) {
|
|
144
|
+
try {
|
|
145
|
+
const fallback = await firecrawlSearch(queries[i], config, perQuery[i] ?? {});
|
|
146
|
+
return { query: queries[i], results: fallback };
|
|
147
|
+
}
|
|
148
|
+
catch { /* ignore */ }
|
|
149
|
+
}
|
|
150
|
+
return { query: queries[i], results: [] };
|
|
151
|
+
}));
|
|
152
|
+
return searches;
|
|
153
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const ApprovalModeSchema = z.enum(["manual", "suggest", "auto"]);
|
|
3
|
+
export const SciraConfigSchema = z.object({
|
|
4
|
+
llmProvider: z.enum(["gateway", "xai", "workers-ai", "huggingface"]).default("gateway"),
|
|
5
|
+
model: z.string().default("deepseek/deepseek-v4-flash"),
|
|
6
|
+
// last selected model per LLM provider, restored when switching back
|
|
7
|
+
lastModels: z.record(z.string(), z.string()).default({}),
|
|
8
|
+
approvalMode: ApprovalModeSchema.default("suggest"),
|
|
9
|
+
runDirectory: z.string().default(".scira/runs"),
|
|
10
|
+
maxSources: z.number().int().min(1).max(100).default(20),
|
|
11
|
+
citationPolicy: z.enum(["strict", "balanced"]).default("strict"),
|
|
12
|
+
search: z.object({
|
|
13
|
+
provider: z.enum(["parallel", "exa", "firecrawl"]).default("exa"),
|
|
14
|
+
maxResults: z.number().int().min(1).max(20).default(8),
|
|
15
|
+
includeDomains: z.array(z.string()).default([]),
|
|
16
|
+
excludeDomains: z.array(z.string()).default([]),
|
|
17
|
+
afterDate: z.string().optional(),
|
|
18
|
+
maxAgeSeconds: z.number().int().positive().optional(),
|
|
19
|
+
maxCharsTotal: z.number().int().positive().optional()
|
|
20
|
+
}).default({
|
|
21
|
+
provider: "exa",
|
|
22
|
+
maxResults: 20,
|
|
23
|
+
includeDomains: [],
|
|
24
|
+
excludeDomains: []
|
|
25
|
+
}),
|
|
26
|
+
files: z.object({
|
|
27
|
+
dir: z.string().describe("Absolute or relative path to the local files directory.")
|
|
28
|
+
}).optional(),
|
|
29
|
+
mcp: z.object({
|
|
30
|
+
chromeDevtools: z.object({
|
|
31
|
+
enabled: z.boolean().default(false),
|
|
32
|
+
command: z.string().default("npx"),
|
|
33
|
+
args: z.array(z.string()).default(["-y", "chrome-devtools-mcp@latest"]),
|
|
34
|
+
toolPrefix: z.string().default("devtools_")
|
|
35
|
+
}).default({
|
|
36
|
+
enabled: false,
|
|
37
|
+
command: "npx",
|
|
38
|
+
args: ["-y", "chrome-devtools-mcp@latest"],
|
|
39
|
+
toolPrefix: "devtools_"
|
|
40
|
+
}),
|
|
41
|
+
servers: z.array(z.object({
|
|
42
|
+
name: z.string(),
|
|
43
|
+
transport: z.enum(["stdio", "sse", "http"]).default("stdio"),
|
|
44
|
+
command: z.string().optional(),
|
|
45
|
+
args: z.array(z.string()).default([]),
|
|
46
|
+
url: z.string().optional(),
|
|
47
|
+
toolPrefix: z.string().default(""),
|
|
48
|
+
env: z.record(z.string(), z.string()).default({}),
|
|
49
|
+
enabled: z.boolean().default(true),
|
|
50
|
+
authType: z.enum(["none", "bearer", "header", "oauth"]).default("none"),
|
|
51
|
+
bearerToken: z.string().optional(),
|
|
52
|
+
headerName: z.string().optional(),
|
|
53
|
+
headerValue: z.string().optional(),
|
|
54
|
+
oauthClientId: z.string().optional(),
|
|
55
|
+
oauthClientSecret: z.string().optional(),
|
|
56
|
+
oauthIssuerUrl: z.string().optional(),
|
|
57
|
+
oauthAuthorizationUrl: z.string().optional(),
|
|
58
|
+
oauthTokenUrl: z.string().optional(),
|
|
59
|
+
oauthScopes: z.string().optional(),
|
|
60
|
+
oauthAccessToken: z.string().optional(),
|
|
61
|
+
oauthRefreshToken: z.string().optional(),
|
|
62
|
+
oauthTokenExpiresAt: z.number().optional()
|
|
63
|
+
})).default([])
|
|
64
|
+
}).default({
|
|
65
|
+
chromeDevtools: {
|
|
66
|
+
enabled: false,
|
|
67
|
+
command: "npx",
|
|
68
|
+
args: ["-y", "chrome-devtools-mcp@latest"],
|
|
69
|
+
toolPrefix: "devtools_"
|
|
70
|
+
},
|
|
71
|
+
servers: []
|
|
72
|
+
})
|
|
73
|
+
});
|
|
74
|
+
export const SourceSchema = z.object({
|
|
75
|
+
id: z.string(),
|
|
76
|
+
title: z.string(),
|
|
77
|
+
url: z.string(),
|
|
78
|
+
kind: z.enum(["primary", "secondary", "vendor", "weak", "unknown"]).default("unknown"),
|
|
79
|
+
summary: z.string().default(""),
|
|
80
|
+
snapshotPath: z.string().optional(),
|
|
81
|
+
createdAt: z.string()
|
|
82
|
+
});
|
|
83
|
+
export const ClaimSchema = z.object({
|
|
84
|
+
id: z.string(),
|
|
85
|
+
text: z.string(),
|
|
86
|
+
confidence: z.enum(["low", "medium", "high"]).default("medium"),
|
|
87
|
+
status: z.enum(["draft", "verified", "weak", "contradicted", "needs_review"]).default("draft"),
|
|
88
|
+
sourceIds: z.array(z.string()).default([]),
|
|
89
|
+
reason: z.string().default(""),
|
|
90
|
+
createdAt: z.string()
|
|
91
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { ClaimSchema, SourceSchema, SciraConfigSchema } from "./index.js";
|
|
3
|
+
describe("ClaimSchema", () => {
|
|
4
|
+
it("parses a valid claim with defaults", () => {
|
|
5
|
+
const result = ClaimSchema.parse({
|
|
6
|
+
id: "c1",
|
|
7
|
+
text: "The sky is blue.",
|
|
8
|
+
createdAt: new Date().toISOString(),
|
|
9
|
+
});
|
|
10
|
+
expect(result.confidence).toBe("medium");
|
|
11
|
+
expect(result.status).toBe("draft");
|
|
12
|
+
expect(result.sourceIds).toEqual([]);
|
|
13
|
+
expect(result.reason).toBe("");
|
|
14
|
+
});
|
|
15
|
+
it("rejects an invalid confidence value", () => {
|
|
16
|
+
expect(() => ClaimSchema.parse({ id: "c1", text: "x", confidence: "very_high", createdAt: "" })).toThrow();
|
|
17
|
+
});
|
|
18
|
+
it("rejects an invalid status value", () => {
|
|
19
|
+
expect(() => ClaimSchema.parse({ id: "c1", text: "x", status: "maybe", createdAt: "" })).toThrow();
|
|
20
|
+
});
|
|
21
|
+
it("preserves all fields when fully specified", () => {
|
|
22
|
+
const input = {
|
|
23
|
+
id: "c2",
|
|
24
|
+
text: "Claim text.",
|
|
25
|
+
confidence: "high",
|
|
26
|
+
status: "verified",
|
|
27
|
+
sourceIds: ["s1", "s2"],
|
|
28
|
+
reason: "Verified by primary source.",
|
|
29
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
30
|
+
};
|
|
31
|
+
expect(ClaimSchema.parse(input)).toEqual(input);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe("SourceSchema", () => {
|
|
35
|
+
it("parses a valid source with defaults", () => {
|
|
36
|
+
const result = SourceSchema.parse({
|
|
37
|
+
id: "s1",
|
|
38
|
+
title: "Example",
|
|
39
|
+
url: "https://example.com",
|
|
40
|
+
createdAt: new Date().toISOString(),
|
|
41
|
+
});
|
|
42
|
+
expect(result.kind).toBe("unknown");
|
|
43
|
+
expect(result.summary).toBe("");
|
|
44
|
+
});
|
|
45
|
+
it("rejects an invalid kind value", () => {
|
|
46
|
+
expect(() => SourceSchema.parse({ id: "s1", title: "t", url: "u", kind: "blog", createdAt: "" })).toThrow();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe("SciraConfigSchema", () => {
|
|
50
|
+
it("parses an empty object using all defaults", () => {
|
|
51
|
+
const config = SciraConfigSchema.parse({});
|
|
52
|
+
expect(config.llmProvider).toBe("gateway");
|
|
53
|
+
expect(config.approvalMode).toBe("suggest");
|
|
54
|
+
expect(config.runDirectory).toBe(".scira/runs");
|
|
55
|
+
expect(config.maxSources).toBe(20);
|
|
56
|
+
});
|
|
57
|
+
it("rejects an invalid approvalMode", () => {
|
|
58
|
+
expect(() => SciraConfigSchema.parse({ approvalMode: "yolo" })).toThrow();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { Box, useApp, useStdout, useStdin } from "ink";
|
|
4
|
+
import { CHAT_COMMANDS, MENU_VISIBLE } from "./constants.js";
|
|
5
|
+
import { CWD_DISPLAY, wrapText, wrapInputWithCursor, loadInputHistory, saveInputHistory } from "./lib/utils.js";
|
|
6
|
+
import { deleteRun } from "../../storage/run-store.js";
|
|
7
|
+
import { useMountEffect, TipCycler, AnimationTick, MouseTracker } from "./components/effects.js";
|
|
8
|
+
import { useFeedLines, computeGroups } from "./hooks/use-feed-lines.js";
|
|
9
|
+
import { useAgentTurn } from "./hooks/use-agent-turn.js";
|
|
10
|
+
import { TopBar, InputBar, HintLine, CommandMenuBox, HelpBox, ApprovalBox, MenuDialog, McpDialog } from "./components/overlays.js";
|
|
11
|
+
import { useKeyboard } from "./hooks/use-keyboard.js";
|
|
12
|
+
import { HomeScreen } from "./components/home-screen.js";
|
|
13
|
+
import { useFeed } from "./hooks/use-feed.js";
|
|
14
|
+
import { useSettings } from "./hooks/use-settings.js";
|
|
15
|
+
import { useSuggestions } from "./hooks/use-suggestions.js";
|
|
16
|
+
import { useSubmit } from "./hooks/use-submit.js";
|
|
17
|
+
import { useSession } from "./hooks/use-session.js";
|
|
18
|
+
import { useMouse } from "./hooks/use-mouse.js";
|
|
19
|
+
export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
|
|
20
|
+
const { exit } = useApp();
|
|
21
|
+
const { stdout } = useStdout();
|
|
22
|
+
const { stdin } = useStdin();
|
|
23
|
+
const [size, setSize] = useState({ cols: stdout?.columns ?? 120, rows: stdout?.rows ?? 30 });
|
|
24
|
+
const cols = size.cols;
|
|
25
|
+
const rows = size.rows;
|
|
26
|
+
useMountEffect(() => {
|
|
27
|
+
if (!stdout)
|
|
28
|
+
return;
|
|
29
|
+
const onResize = () => setSize({ cols: stdout.columns, rows: stdout.rows });
|
|
30
|
+
stdout.on("resize", onResize);
|
|
31
|
+
return () => { stdout.off("resize", onResize); };
|
|
32
|
+
});
|
|
33
|
+
const [screen, setScreen] = useState(initialRunPath ? "chat" : "home");
|
|
34
|
+
const [currentRunPath, setCurrentRunPath] = useState(initialRunPath);
|
|
35
|
+
const [config, setConfig] = useState(initialConfig);
|
|
36
|
+
const [notice, setNotice] = useState("");
|
|
37
|
+
const [pendingRerun, setPendingRerun] = useState(false);
|
|
38
|
+
const [mcpOpen, setMcpOpen] = useState(false);
|
|
39
|
+
const [sessions, setSessions] = useState([]);
|
|
40
|
+
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
41
|
+
const [sessionsModalOpen, setSessionsModalOpen] = useState(false);
|
|
42
|
+
const [sessionsModalIdx, setSessionsModalIdx] = useState(0);
|
|
43
|
+
const [runState, setRunState] = useState(null);
|
|
44
|
+
const { feed, setFeed, feedRef, pushFeed, appendText, appendReasoning, finishReasoning, markToolDone } = useFeed();
|
|
45
|
+
const conversationRef = useRef([]);
|
|
46
|
+
const queuedPromptRef = useRef(null);
|
|
47
|
+
const startedRef = useRef(undefined);
|
|
48
|
+
const fullModeRef = useRef(false);
|
|
49
|
+
const [fullMode, setFullModeState] = useState(false);
|
|
50
|
+
const setMode = useCallback((full) => { fullModeRef.current = full; setFullModeState(full); }, []);
|
|
51
|
+
const [usage, setUsage] = useState({});
|
|
52
|
+
const turnsRef = useRef([]);
|
|
53
|
+
const recordUsage = useCallback((model, u) => {
|
|
54
|
+
const input = u.inputTokens ?? 0;
|
|
55
|
+
const output = u.outputTokens ?? 0;
|
|
56
|
+
const total = u.totalTokens ?? input + output;
|
|
57
|
+
if (input + output + total === 0)
|
|
58
|
+
return;
|
|
59
|
+
setUsage((prev) => {
|
|
60
|
+
const cur = prev[model] ?? { input: 0, output: 0, total: 0, turns: 0 };
|
|
61
|
+
return { ...prev, [model]: { input: cur.input + input, output: cur.output + output, total: cur.total + total, turns: cur.turns + 1 } };
|
|
62
|
+
});
|
|
63
|
+
}, []);
|
|
64
|
+
const [approvalPending, setApprovalPending] = useState(null);
|
|
65
|
+
const [inputText, setInputText] = useState("");
|
|
66
|
+
const [cursorPos, setCursorPos] = useState(0);
|
|
67
|
+
const [inputHistory, setInputHistory] = useState([]);
|
|
68
|
+
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
69
|
+
const [heroHidden, setHeroHidden] = useState(false);
|
|
70
|
+
const [busy, setBusy] = useState(false);
|
|
71
|
+
const [blink, setBlink] = useState(true);
|
|
72
|
+
const [frame, setFrame] = useState(0);
|
|
73
|
+
const [reasoningTick, setReasoningTick] = useState(0);
|
|
74
|
+
const [commandMenuIndex, setCommandMenuIndex] = useState(0);
|
|
75
|
+
const [helpOpen, setHelpOpen] = useState(false);
|
|
76
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
77
|
+
const [collapsedGroups, setCollapsedGroups] = useState(new Set());
|
|
78
|
+
const [focusedGroupKey, setFocusedGroupKey] = useState(null);
|
|
79
|
+
const [pendingCollapse, setPendingCollapse] = useState(new Set());
|
|
80
|
+
const doneGroupKeys = useMemo(() => {
|
|
81
|
+
const { groups } = computeGroups(feed);
|
|
82
|
+
return [...groups.entries()].filter(([, g]) => !g.active).map(([k]) => k).sort((a, b) => a - b);
|
|
83
|
+
}, [feed]);
|
|
84
|
+
const toggleAllGroups = useCallback(() => {
|
|
85
|
+
setCollapsedGroups((prev) => {
|
|
86
|
+
const allCollapsed = doneGroupKeys.length > 0 && doneGroupKeys.every((k) => prev.has(k));
|
|
87
|
+
if (allCollapsed) {
|
|
88
|
+
setPendingCollapse(new Set());
|
|
89
|
+
return new Set();
|
|
90
|
+
}
|
|
91
|
+
// Mark done groups for pending collapse, but don't collapse active ones
|
|
92
|
+
setPendingCollapse(new Set(doneGroupKeys));
|
|
93
|
+
return new Set(doneGroupKeys.filter((k) => {
|
|
94
|
+
const group = computeGroups(feed).groups.get(k);
|
|
95
|
+
return group && !group.active;
|
|
96
|
+
}));
|
|
97
|
+
});
|
|
98
|
+
}, [doneGroupKeys, feed]);
|
|
99
|
+
const toggleFocusedGroup = useCallback(() => {
|
|
100
|
+
if (focusedGroupKey === null)
|
|
101
|
+
return;
|
|
102
|
+
setCollapsedGroups((prev) => {
|
|
103
|
+
const next = new Set(prev);
|
|
104
|
+
if (next.has(focusedGroupKey))
|
|
105
|
+
next.delete(focusedGroupKey);
|
|
106
|
+
else
|
|
107
|
+
next.add(focusedGroupKey);
|
|
108
|
+
return next;
|
|
109
|
+
});
|
|
110
|
+
}, [focusedGroupKey]);
|
|
111
|
+
// Auto-collapse groups when they become inactive if they're in pendingCollapse
|
|
112
|
+
React.useEffect(() => {
|
|
113
|
+
if (pendingCollapse.size === 0)
|
|
114
|
+
return;
|
|
115
|
+
const { groups } = computeGroups(feed);
|
|
116
|
+
const nowInactive = [...pendingCollapse].filter((k) => {
|
|
117
|
+
const group = groups.get(k);
|
|
118
|
+
return group && !group.active;
|
|
119
|
+
});
|
|
120
|
+
if (nowInactive.length > 0) {
|
|
121
|
+
setCollapsedGroups((prev) => {
|
|
122
|
+
const next = new Set(prev);
|
|
123
|
+
nowInactive.forEach((k) => next.add(k));
|
|
124
|
+
return next;
|
|
125
|
+
});
|
|
126
|
+
setPendingCollapse((prev) => {
|
|
127
|
+
const next = new Set(prev);
|
|
128
|
+
nowInactive.forEach((k) => next.delete(k));
|
|
129
|
+
return next;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}, [feed, pendingCollapse]);
|
|
133
|
+
const focusPrevGroup = useCallback(() => {
|
|
134
|
+
setFocusedGroupKey((prev) => {
|
|
135
|
+
if (doneGroupKeys.length === 0)
|
|
136
|
+
return null;
|
|
137
|
+
if (prev === null)
|
|
138
|
+
return doneGroupKeys[doneGroupKeys.length - 1] ?? null;
|
|
139
|
+
const idx = doneGroupKeys.indexOf(prev);
|
|
140
|
+
return idx <= 0 ? prev : (doneGroupKeys[idx - 1] ?? prev);
|
|
141
|
+
});
|
|
142
|
+
}, [doneGroupKeys]);
|
|
143
|
+
const focusNextGroup = useCallback(() => {
|
|
144
|
+
setFocusedGroupKey((prev) => {
|
|
145
|
+
if (doneGroupKeys.length === 0)
|
|
146
|
+
return null;
|
|
147
|
+
if (prev === null)
|
|
148
|
+
return doneGroupKeys[0] ?? null;
|
|
149
|
+
const idx = doneGroupKeys.indexOf(prev);
|
|
150
|
+
return idx < 0 || idx >= doneGroupKeys.length - 1 ? prev : (doneGroupKeys[idx + 1] ?? prev);
|
|
151
|
+
});
|
|
152
|
+
}, [doneGroupKeys]);
|
|
153
|
+
const unfocusGroup = useCallback(() => setFocusedGroupKey(null), []);
|
|
154
|
+
const [tipIndex, setTipIndex] = useState(0);
|
|
155
|
+
React.useEffect(() => {
|
|
156
|
+
if (!notice)
|
|
157
|
+
return;
|
|
158
|
+
const id = setTimeout(() => setNotice(""), 4000);
|
|
159
|
+
return () => clearTimeout(id);
|
|
160
|
+
}, [notice]);
|
|
161
|
+
const wheelStateRef = useRef({ screen, maxScrollOffset: 0 });
|
|
162
|
+
const handleWheel = useCallback((dir) => {
|
|
163
|
+
if (wheelStateRef.current.screen !== "chat")
|
|
164
|
+
return;
|
|
165
|
+
setScrollOffset((off) => Math.max(0, Math.min(off + dir * 3, wheelStateRef.current.maxScrollOffset)));
|
|
166
|
+
}, []);
|
|
167
|
+
const { clickMapRef, hoverMapRef, hoveredIdx, setHoveredIdx, handleMouseData } = useMouse(handleWheel);
|
|
168
|
+
const getSubscriber = useCallback(() => ({
|
|
169
|
+
pushFeed,
|
|
170
|
+
appendText,
|
|
171
|
+
appendReasoning,
|
|
172
|
+
finishReasoning,
|
|
173
|
+
markToolDone,
|
|
174
|
+
onBusyChange: setBusy,
|
|
175
|
+
onApprovalRequired: (p) => setApprovalPending(p),
|
|
176
|
+
onApprovalCleared: () => setApprovalPending(null),
|
|
177
|
+
onEscalate: () => setMode(true),
|
|
178
|
+
onModeChange: setMode,
|
|
179
|
+
}), [pushFeed, appendText, appendReasoning, finishReasoning, markToolDone, setBusy, setApprovalPending, setMode]);
|
|
180
|
+
const runTurnRef = useRef(async () => { });
|
|
181
|
+
const { refreshSessions, refreshRun, openRun: openRunBase } = useSession({
|
|
182
|
+
config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef, runTurnRef,
|
|
183
|
+
setSessions, setRunState, setCurrentRunPath, setInputText, setCursorPos,
|
|
184
|
+
setFeed, setUsage, setScrollOffset, setScreen, setMode,
|
|
185
|
+
setBusy, setApprovalPending, getSubscriber,
|
|
186
|
+
});
|
|
187
|
+
const openRun = useCallback(async (runPath, initialQuestion) => {
|
|
188
|
+
setPendingRerun(false);
|
|
189
|
+
await openRunBase(runPath, initialQuestion);
|
|
190
|
+
}, [openRunBase, setPendingRerun]);
|
|
191
|
+
useMountEffect(() => {
|
|
192
|
+
if (!initialRunPath)
|
|
193
|
+
void refreshSessions();
|
|
194
|
+
void loadInputHistory(config.runDirectory).then((h) => { if (h.length > 0)
|
|
195
|
+
setInputHistory(h); });
|
|
196
|
+
});
|
|
197
|
+
React.useEffect(() => {
|
|
198
|
+
if (inputHistory.length > 0)
|
|
199
|
+
void saveInputHistory(config.runDirectory, inputHistory);
|
|
200
|
+
}, [inputHistory, config.runDirectory]);
|
|
201
|
+
const deleteSession = useCallback((idx) => {
|
|
202
|
+
const s = sessions[idx];
|
|
203
|
+
if (!s)
|
|
204
|
+
return;
|
|
205
|
+
void (async () => {
|
|
206
|
+
await deleteRun(s.path).catch(() => { });
|
|
207
|
+
await refreshSessions();
|
|
208
|
+
setSessionsModalIdx((i) => Math.max(0, Math.min(i, sessions.length - 2)));
|
|
209
|
+
setNotice("Session deleted.");
|
|
210
|
+
})();
|
|
211
|
+
}, [sessions, refreshSessions]);
|
|
212
|
+
const { menu, setMenu, modelName, openMenu, applyMenuSelection, handleSettings } = useSettings({
|
|
213
|
+
config, setConfig, screen, pushFeed, setNotice,
|
|
214
|
+
});
|
|
215
|
+
const { runTurn } = useAgentTurn({
|
|
216
|
+
config, currentRunPath, queuedPromptRef, fullModeRef, conversationRef, turnsRef, feedRef,
|
|
217
|
+
setBusy, setScrollOffset, refreshRun, recordUsage, setMode, getSubscriber,
|
|
218
|
+
});
|
|
219
|
+
runTurnRef.current = runTurn;
|
|
220
|
+
const { submitHome, submitChat, stopTurn } = useSubmit({
|
|
221
|
+
state: { config, currentRunPath, sessions, selectedIdx, busy, usage, pendingRerun },
|
|
222
|
+
refs: { queuedPromptRef, conversationRef, feedRef },
|
|
223
|
+
setters: {
|
|
224
|
+
setApprovalPending, setInputText, setCursorPos, setInputHistory, setHistoryIndex, setHelpOpen,
|
|
225
|
+
setNotice, setBusy, setScreen, setFeed, setRunState, setPendingRerun, setMode, setConfig, setMcpOpen,
|
|
226
|
+
setHeroHidden,
|
|
227
|
+
},
|
|
228
|
+
actions: { pushFeed, refreshSessions, openRun, openMenu, handleSettings, runTurn, exit },
|
|
229
|
+
});
|
|
230
|
+
const { activeSuggestions, activeSuggestionKind, acceptActiveSuggestion } = useSuggestions({
|
|
231
|
+
inputText, setInputText, setCursorPos, sessions, openRun, refreshSessions,
|
|
232
|
+
});
|
|
233
|
+
const innerWidth = Math.max(20, cols - 4);
|
|
234
|
+
const boxWidth = Math.max(20, cols - 4);
|
|
235
|
+
const textWidth = Math.max(1, boxWidth - 6);
|
|
236
|
+
const rawInputText = approvalPending ? "waiting for approval\u2026" : inputText;
|
|
237
|
+
const showCursor = !busy && !approvalPending;
|
|
238
|
+
const caret = Math.max(0, Math.min(cursorPos, inputText.length));
|
|
239
|
+
const { lines: inputLines, cursorLine, cursorCol } = wrapInputWithCursor(rawInputText, textWidth, showCursor ? caret : -1);
|
|
240
|
+
const commandMenuHeight = activeSuggestions.length > 0 ? Math.min(MENU_VISIBLE, activeSuggestions.length) + 3 : 0;
|
|
241
|
+
const helpHeight = helpOpen ? Math.min(14, CHAT_COMMANDS.length + 4) : 0;
|
|
242
|
+
const approvalPreviewLines = approvalPending
|
|
243
|
+
? Math.min(5, wrapText(approvalPending.description, Math.max(10, innerWidth - 4)).length)
|
|
244
|
+
: 0;
|
|
245
|
+
const approvalHeight = approvalPending ? approvalPreviewLines + 5 : 0;
|
|
246
|
+
const menuHeight = commandMenuHeight + helpHeight + approvalHeight;
|
|
247
|
+
const feedRows = Math.max(3, rows - 6 - inputLines.length - menuHeight);
|
|
248
|
+
const hasRunningTool = feed.some((it) => it.kind === "tool" && it.status === "running");
|
|
249
|
+
const feedLines = useFeedLines(feed, innerWidth, reasoningTick, hasRunningTool ? frame : 0, collapsedGroups, focusedGroupKey);
|
|
250
|
+
const contentRows = Math.max(1, feedRows);
|
|
251
|
+
const maxScrollOffset = Math.max(0, feedLines.length - contentRows);
|
|
252
|
+
wheelStateRef.current = { screen, maxScrollOffset };
|
|
253
|
+
const clampedOffset = Math.min(scrollOffset, maxScrollOffset);
|
|
254
|
+
const startIdx = Math.max(0, feedLines.length - contentRows - clampedOffset);
|
|
255
|
+
const visibleLines = feedLines.slice(startIdx, startIdx + contentRows);
|
|
256
|
+
const scrollLabel = clampedOffset > 0
|
|
257
|
+
? (startIdx > 0 ? `↑ ${startIdx} · ↓ ${clampedOffset} · wheel/⇞⇟` : `top · ↓ ${clampedOffset} · wheel/⇞⇟`)
|
|
258
|
+
: "";
|
|
259
|
+
useKeyboard({
|
|
260
|
+
screen,
|
|
261
|
+
setNotice,
|
|
262
|
+
exit,
|
|
263
|
+
input: { text: inputText, setText: setInputText, cursorPos, setCursorPos, history: inputHistory, historyIndex, setHistoryIndex },
|
|
264
|
+
dialogs: { approvalPending, setApprovalPending, menu, setMenu, applyMenuSelection, helpOpen, setHelpOpen, mcpOpen, setMcpOpen },
|
|
265
|
+
suggestions: { activeSuggestions, activeSuggestionKind, commandMenuIndex, setCommandMenuIndex, acceptActiveSuggestion },
|
|
266
|
+
chat: { setScrollOffset, contentRows, maxScrollOffset, pendingRerun, setPendingRerun, busy, stopTurn, submitChat, toggleAllGroups, toggleFocusedGroup, focusPrevGroup, focusNextGroup, unfocusGroup, hasFocusedGroup: focusedGroupKey !== null },
|
|
267
|
+
home: { sessionsModalOpen, setSessionsModalOpen, sessionsModalIdx, setSessionsModalIdx, sessions, deleteSession, selectedIdx, setSelectedIdx, setHeroHidden, openRun, submitHome },
|
|
268
|
+
});
|
|
269
|
+
const activeUsage = usage[config.model];
|
|
270
|
+
if (screen === "home") {
|
|
271
|
+
return (_jsxs(Box, { flexDirection: "column", width: cols, height: rows, paddingX: 2, children: [_jsx(TipCycler, { setTipIndex: setTipIndex }), !sessionsModalOpen && stdout !== undefined && stdin !== undefined && (_jsx(MouseTracker, { stdout: stdout, stdin: stdin, onData: handleMouseData, onUnmount: () => setHoveredIdx(null) })), busy && _jsx(AnimationTick, { setBlink: setBlink, setFrame: setFrame, setReasoningTick: setReasoningTick }), _jsx(TopBar, { screen: screen, runState: runState, fullMode: fullMode, activeUsage: activeUsage, busy: busy, frame: frame, cwdDisplay: CWD_DISPLAY }), _jsx(HomeScreen, { cols: cols, rows: rows, sessions: sessions, selectedIdx: selectedIdx, hoveredIdx: hoveredIdx, heroHidden: heroHidden, notice: notice, tipIndex: tipIndex, commandMenuHeight: commandMenuHeight, sessionsModalOpen: sessionsModalOpen, sessionsModalIdx: sessionsModalIdx, inputText: inputText, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef, setSelectedIdx: setSelectedIdx, setSessionsModalOpen: setSessionsModalOpen, setSessionsModalIdx: setSessionsModalIdx, setNotice: setNotice, openRun: openRun, submitHome: submitHome, exit: exit }), _jsxs(Box, { flexDirection: "column", backgroundColor: "#141414", paddingBottom: 1, children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: !!approvalPending, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName }), _jsx(HintLine, { screen: screen, busy: busy })] }), _jsx(MenuDialog, { menu: menu, cols: cols, rows: rows }), _jsx(McpDialog, { open: mcpOpen, config: config, cols: cols, rows: rows })] }));
|
|
272
|
+
}
|
|
273
|
+
return (_jsxs(Box, { flexDirection: "column", width: cols, height: rows, paddingX: 2, children: [stdout !== undefined && stdin !== undefined && (_jsx(MouseTracker, { stdout: stdout, stdin: stdin, onData: handleMouseData, onUnmount: () => setHoveredIdx(null) })), busy && _jsx(AnimationTick, { setBlink: setBlink, setFrame: setFrame, setReasoningTick: setReasoningTick }), _jsx(TopBar, { screen: screen, runState: runState, fullMode: fullMode, activeUsage: activeUsage, busy: busy, frame: frame, cwdDisplay: CWD_DISPLAY }), _jsx(Box, { flexDirection: "column", flexGrow: 1, paddingTop: 1, overflow: "hidden", children: visibleLines }), _jsxs(Box, { flexDirection: "column", backgroundColor: "#141414", paddingBottom: 1, children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions }), _jsx(HelpBox, { open: helpOpen, innerWidth: innerWidth }), approvalPending && _jsx(ApprovalBox, { toolName: approvalPending.toolName, description: approvalPending.description, innerWidth: innerWidth }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: !!approvalPending, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName }), _jsx(HintLine, { screen: screen, busy: busy, scrollLabel: scrollLabel, hasDoneGroups: doneGroupKeys.length > 0, hasFocusedGroup: focusedGroupKey !== null })] }), _jsx(MenuDialog, { menu: menu, cols: cols, rows: rows }), _jsx(McpDialog, { open: mcpOpen, config: config, cols: cols, rows: rows })] }));
|
|
274
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { HOME_TIPS, SPINNER_FRAMES } from "../constants.js";
|
|
3
|
+
/** Stable mount-only effect: makes intent explicit, prevents accidental dep-driven re-runs. */
|
|
4
|
+
export function useMountEffect(effect) {
|
|
5
|
+
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
|
6
|
+
React.useEffect(effect, []);
|
|
7
|
+
}
|
|
8
|
+
/** Cycles the tip index every 6 s while mounted (rendered only on the home screen). */
|
|
9
|
+
export function TipCycler({ setTipIndex }) {
|
|
10
|
+
useMountEffect(() => {
|
|
11
|
+
const id = setInterval(() => setTipIndex((i) => (i + 1) % HOME_TIPS.length), 6000);
|
|
12
|
+
return () => clearInterval(id);
|
|
13
|
+
});
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
/** Drives the spinner, caret-blink, and reasoning-clock animations while mounted (rendered only when busy). */
|
|
17
|
+
export function AnimationTick({ setBlink, setFrame, setReasoningTick, }) {
|
|
18
|
+
useMountEffect(() => {
|
|
19
|
+
const blinkId = setInterval(() => setBlink((v) => !v), 400);
|
|
20
|
+
const frameId = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80);
|
|
21
|
+
const tickId = setInterval(() => setReasoningTick((t) => t + 1), 500);
|
|
22
|
+
return () => {
|
|
23
|
+
clearInterval(blinkId);
|
|
24
|
+
clearInterval(frameId);
|
|
25
|
+
clearInterval(tickId);
|
|
26
|
+
setBlink(true);
|
|
27
|
+
setFrame(0);
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
/** Enables SGR mouse-click/hover tracking while mounted (rendered only on the home screen). */
|
|
33
|
+
export function MouseTracker({ stdout, stdin, onData, onUnmount, }) {
|
|
34
|
+
useMountEffect(() => {
|
|
35
|
+
stdout.write("\x1b[?1003h\x1b[?1006h");
|
|
36
|
+
stdin.on("data", onData);
|
|
37
|
+
return () => {
|
|
38
|
+
stdin.off("data", onData);
|
|
39
|
+
onUnmount();
|
|
40
|
+
stdout.write("\x1b[?1003l\x1b[?1006l");
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
return null;
|
|
44
|
+
}
|