@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.
- package/README.md +56 -10
- package/dist/agent/background-tasks.js +173 -0
- package/dist/agent/research-agent.js +95 -38
- package/dist/agent/todos.js +140 -0
- package/dist/agent/tools.js +146 -143
- package/dist/agent/tools.test.js +33 -0
- package/dist/agent/workspace.js +85 -0
- package/dist/cli/commands/init.js +53 -39
- package/dist/cli/index.js +30 -14
- package/dist/config/env-guide.js +151 -0
- package/dist/config/env-guide.test.js +18 -0
- package/dist/config/env-store.js +53 -0
- package/dist/config/env-store.test.js +60 -0
- package/dist/tools/agent-tools.js +621 -0
- package/dist/tools/background-tasks.js +261 -0
- package/dist/tools/bash-policy.test.js +38 -0
- package/dist/tools/file-tools.js +6 -1
- package/dist/tools/search-web.js +24 -6
- package/dist/tools/search-web.test.js +24 -0
- package/dist/tools/todos.js +140 -0
- package/dist/tools/workspace.js +91 -0
- package/dist/tools/workspace.test.js +75 -0
- package/dist/tools/x-search.js +142 -0
- package/dist/types/index.js +1 -0
- package/dist/types/schema.test.js +1 -0
- package/dist/ui/ink/SciraApp.js +74 -21
- package/dist/ui/ink/components/overlays.js +15 -9
- package/dist/ui/ink/constants.js +13 -4
- package/dist/ui/ink/hooks/use-agent-turn.js +26 -7
- package/dist/ui/ink/hooks/use-feed-lines.js +33 -6
- package/dist/ui/ink/hooks/use-keyboard.js +16 -1
- package/dist/ui/ink/hooks/use-session.js +15 -14
- package/dist/ui/ink/hooks/use-settings.js +30 -8
- package/dist/ui/ink/hooks/use-submit.js +14 -3
- package/dist/ui/ink/hooks/use-theme.js +1 -1
- package/dist/ui/ink/lib/tool-result.js +73 -5
- package/dist/ui/ink/lib/tool-result.test.js +3 -3
- package/dist/ui/ink/lib/utils.js +104 -5
- package/dist/ui/ink/lib/utils.test.js +18 -1
- package/dist/ui/ink/theme-context.js +29 -26
- package/dist/ui/ink/theme.js +36 -9
- package/dist/ui/ink/theme.test.js +32 -5
- package/package.json +6 -2
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { harnessBasename, isRunArtifactPath, resolveInsideRun, resolveProjectRoot, resolveToolPath } from "./workspace.js";
|
|
3
|
+
const RUN = "/tmp/scira-test-run";
|
|
4
|
+
const PROJECT = "/Users/me/my-app";
|
|
5
|
+
const RUN_UNDER_SCIRA = `${PROJECT}/.scira/runs/2024-test-abc`;
|
|
6
|
+
describe("resolveInsideRun", () => {
|
|
7
|
+
it("resolves a relative path inside the run dir", () => {
|
|
8
|
+
expect(resolveInsideRun(RUN, "notes.md")).toBe(`${RUN}/notes.md`);
|
|
9
|
+
});
|
|
10
|
+
it("resolves a nested relative path inside the run dir", () => {
|
|
11
|
+
expect(resolveInsideRun(RUN, "artifacts/output.txt")).toBe(`${RUN}/artifacts/output.txt`);
|
|
12
|
+
});
|
|
13
|
+
it("resolves an absolute path that is inside the run dir", () => {
|
|
14
|
+
expect(resolveInsideRun(RUN, `${RUN}/plan.md`)).toBe(`${RUN}/plan.md`);
|
|
15
|
+
});
|
|
16
|
+
it("throws for a path that escapes with ../", () => {
|
|
17
|
+
expect(() => resolveInsideRun(RUN, "../outside.txt")).toThrow("outside the run directory");
|
|
18
|
+
});
|
|
19
|
+
it("throws for a deep escape path", () => {
|
|
20
|
+
expect(() => resolveInsideRun(RUN, "a/../../outside.txt")).toThrow("outside the run directory");
|
|
21
|
+
});
|
|
22
|
+
it("throws for an absolute path outside the run dir", () => {
|
|
23
|
+
expect(() => resolveInsideRun(RUN, "/etc/passwd")).toThrow("outside the run directory");
|
|
24
|
+
});
|
|
25
|
+
it("throws for a home-dir escape", () => {
|
|
26
|
+
const home = `${process.env.HOME ?? "/root"}/evil.sh`;
|
|
27
|
+
expect(() => resolveInsideRun(RUN, home)).toThrow("outside the run directory");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe("resolveProjectRoot", () => {
|
|
31
|
+
it("returns parent of .scira when run is under .scira/runs", () => {
|
|
32
|
+
expect(resolveProjectRoot(RUN_UNDER_SCIRA)).toBe(PROJECT);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe("harnessBasename", () => {
|
|
36
|
+
it("strips run: and ./ prefixes", () => {
|
|
37
|
+
expect(harnessBasename("run:report.md")).toBe("report.md");
|
|
38
|
+
expect(harnessBasename("./plan.md")).toBe("plan.md");
|
|
39
|
+
expect(harnessBasename("notes.md")).toBe("notes.md");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe("isRunArtifactPath", () => {
|
|
43
|
+
it("treats bare harness filenames as run artifacts", () => {
|
|
44
|
+
expect(isRunArtifactPath("plan.md")).toBe(true);
|
|
45
|
+
expect(isRunArtifactPath("notes.md")).toBe(true);
|
|
46
|
+
expect(isRunArtifactPath("src/foo.ts")).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
it("does not treat nested paths as run artifacts by basename", () => {
|
|
49
|
+
expect(isRunArtifactPath("docs/notes.md")).toBe(false);
|
|
50
|
+
expect(isRunArtifactPath("src/plan.md")).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
it("treats run: prefix as run artifact", () => {
|
|
53
|
+
expect(isRunArtifactPath("run:custom.md")).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe("resolveToolPath", () => {
|
|
57
|
+
it("routes source paths to workspace", () => {
|
|
58
|
+
const resolved = resolveToolPath(RUN_UNDER_SCIRA, PROJECT, "src/index.ts");
|
|
59
|
+
expect(resolved.scope).toBe("workspace");
|
|
60
|
+
expect(resolved.abs).toBe(`${PROJECT}/src/index.ts`);
|
|
61
|
+
});
|
|
62
|
+
it("routes plan.md to run directory", () => {
|
|
63
|
+
const resolved = resolveToolPath(RUN_UNDER_SCIRA, PROJECT, "plan.md");
|
|
64
|
+
expect(resolved.scope).toBe("run");
|
|
65
|
+
expect(resolved.abs).toBe(`${RUN_UNDER_SCIRA}/plan.md`);
|
|
66
|
+
});
|
|
67
|
+
it("routes nested notes.md to workspace not run", () => {
|
|
68
|
+
const resolved = resolveToolPath(RUN_UNDER_SCIRA, PROJECT, "docs/notes.md");
|
|
69
|
+
expect(resolved.scope).toBe("workspace");
|
|
70
|
+
expect(resolved.abs).toBe(`${PROJECT}/docs/notes.md`);
|
|
71
|
+
});
|
|
72
|
+
it("blocks writes into .scira from workspace paths", () => {
|
|
73
|
+
expect(() => resolveToolPath(RUN_UNDER_SCIRA, PROJECT, ".scira/config.json")).toThrow("inside .scira");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { generateText, tool, stepCountIs } from "ai";
|
|
2
|
+
import { xai } from "@ai-sdk/xai";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { getTweet } from "react-tweet/api";
|
|
5
|
+
import { logEvent } from "../storage/run-store.js";
|
|
6
|
+
const XSEARCH_MODEL = "grok-4.20-0309-non-reasoning";
|
|
7
|
+
function sanitizeHandle(h) {
|
|
8
|
+
return h.replace(/^@+/u, "").trim();
|
|
9
|
+
}
|
|
10
|
+
function toYMD(d) {
|
|
11
|
+
return d.toISOString().slice(0, 10);
|
|
12
|
+
}
|
|
13
|
+
function extractTweetId(url) {
|
|
14
|
+
return url.match(/status\/(\d+)/u)?.[1] ?? null;
|
|
15
|
+
}
|
|
16
|
+
function canonicalLink(id, fallback) {
|
|
17
|
+
return id ? `https://x.com/i/status/${id}` : fallback;
|
|
18
|
+
}
|
|
19
|
+
export function createXSearchTool(runPath) {
|
|
20
|
+
return tool({
|
|
21
|
+
description: "Search X (formerly Twitter) for recent posts. Best for current events, public reactions, announcements, breaking news, and real-time opinions. Searches the last 7 days by default. Use 1–3 targeted queries per call.",
|
|
22
|
+
inputSchema: z
|
|
23
|
+
.object({
|
|
24
|
+
queries: z
|
|
25
|
+
.array(z.string())
|
|
26
|
+
.min(1)
|
|
27
|
+
.max(5)
|
|
28
|
+
.describe("Search queries for X posts. 1–3 targeted queries recommended."),
|
|
29
|
+
startDate: z
|
|
30
|
+
.string()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Start date YYYY-MM-DD (default: 7 days ago)."),
|
|
33
|
+
endDate: z
|
|
34
|
+
.string()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe("End date YYYY-MM-DD (default: today)."),
|
|
37
|
+
includeXHandles: z
|
|
38
|
+
.array(z.string())
|
|
39
|
+
.max(10)
|
|
40
|
+
.optional()
|
|
41
|
+
.describe("Only include posts from these X handles (max 10). Cannot be combined with excludeXHandles."),
|
|
42
|
+
excludeXHandles: z
|
|
43
|
+
.array(z.string())
|
|
44
|
+
.max(10)
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("Exclude posts from these X handles (max 10). Cannot be combined with includeXHandles."),
|
|
47
|
+
})
|
|
48
|
+
.refine((data) => {
|
|
49
|
+
const hasInclude = data.includeXHandles && data.includeXHandles.length > 0;
|
|
50
|
+
const hasExclude = data.excludeXHandles && data.excludeXHandles.length > 0;
|
|
51
|
+
return !(hasInclude && hasExclude);
|
|
52
|
+
}, { message: "Cannot specify both includeXHandles and excludeXHandles", path: ["includeXHandles"] }),
|
|
53
|
+
execute: async ({ queries, startDate, endDate, includeXHandles, excludeXHandles }) => {
|
|
54
|
+
await logEvent(runPath, "tool.xSearch", { queries });
|
|
55
|
+
const today = new Date();
|
|
56
|
+
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
57
|
+
const effectiveStart = startDate?.trim() || toYMD(sevenDaysAgo);
|
|
58
|
+
const effectiveEnd = endDate?.trim() || toYMD(today);
|
|
59
|
+
const dateRange = `${effectiveStart} to ${effectiveEnd}`;
|
|
60
|
+
const normalizedInclude = includeXHandles?.map(sanitizeHandle).filter(Boolean);
|
|
61
|
+
const normalizedExclude = excludeXHandles?.map(sanitizeHandle).filter(Boolean);
|
|
62
|
+
const results = await Promise.all(queries.map(async (query) => {
|
|
63
|
+
try {
|
|
64
|
+
const searchConfig = {
|
|
65
|
+
fromDate: effectiveStart,
|
|
66
|
+
toDate: effectiveEnd,
|
|
67
|
+
};
|
|
68
|
+
if (normalizedInclude?.length)
|
|
69
|
+
searchConfig.allowedXHandles = normalizedInclude;
|
|
70
|
+
if (normalizedExclude?.length)
|
|
71
|
+
searchConfig.excludedXHandles = normalizedExclude;
|
|
72
|
+
const { sources } = await generateText({
|
|
73
|
+
model: xai.responses(XSEARCH_MODEL),
|
|
74
|
+
system: "Run the x_search tool for the given query and stop immediately. Do not output any text.",
|
|
75
|
+
messages: [{ role: "user", content: query }],
|
|
76
|
+
maxOutputTokens: 5,
|
|
77
|
+
stopWhen: stepCountIs(1),
|
|
78
|
+
tools: { x_search: xai.tools.xSearch(searchConfig) },
|
|
79
|
+
});
|
|
80
|
+
const citations = (Array.isArray(sources) ? sources : []);
|
|
81
|
+
// Deduplicate citation URLs within this query before fetching
|
|
82
|
+
const seenIds = new Set();
|
|
83
|
+
const uniqueCitations = citations.filter((c) => {
|
|
84
|
+
if (c.sourceType !== "url" || !c.url)
|
|
85
|
+
return false;
|
|
86
|
+
const id = extractTweetId(c.url) ?? c.url;
|
|
87
|
+
if (seenIds.has(id))
|
|
88
|
+
return false;
|
|
89
|
+
seenIds.add(id);
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
// Hydrate each citation URL with full tweet content
|
|
93
|
+
const posts = (await Promise.all(uniqueCitations.map(async (c) => {
|
|
94
|
+
const rawUrl = c.url;
|
|
95
|
+
const tweetId = extractTweetId(rawUrl);
|
|
96
|
+
try {
|
|
97
|
+
if (!tweetId)
|
|
98
|
+
return { url: rawUrl };
|
|
99
|
+
const data = await getTweet(tweetId);
|
|
100
|
+
if (!data)
|
|
101
|
+
return { url: canonicalLink(tweetId, rawUrl), id: tweetId };
|
|
102
|
+
const handle = data.user?.screen_name ?? undefined;
|
|
103
|
+
return {
|
|
104
|
+
url: handle
|
|
105
|
+
? `https://x.com/${handle}/status/${tweetId}`
|
|
106
|
+
: canonicalLink(tweetId, rawUrl),
|
|
107
|
+
id: tweetId,
|
|
108
|
+
handle,
|
|
109
|
+
text: data.text,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { url: canonicalLink(tweetId, rawUrl), id: tweetId ?? undefined };
|
|
114
|
+
}
|
|
115
|
+
}))).filter((p) => p !== null);
|
|
116
|
+
return { query, dateRange, posts };
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
return { query, dateRange, posts: [], error: String(error) };
|
|
120
|
+
}
|
|
121
|
+
}));
|
|
122
|
+
// Cross-query dedup by tweet ID or URL
|
|
123
|
+
const seenKeys = new Set();
|
|
124
|
+
const deduped = results.map((r) => ({
|
|
125
|
+
...r,
|
|
126
|
+
posts: r.posts.filter((p) => {
|
|
127
|
+
const key = p.id ?? p.url;
|
|
128
|
+
if (seenKeys.has(key))
|
|
129
|
+
return false;
|
|
130
|
+
seenKeys.add(key);
|
|
131
|
+
return true;
|
|
132
|
+
}),
|
|
133
|
+
}));
|
|
134
|
+
const allFailed = deduped.every((r) => r.posts.length === 0 && r.error);
|
|
135
|
+
if (allFailed) {
|
|
136
|
+
const errors = deduped.map((r) => r.error).filter(Boolean).join(" | ");
|
|
137
|
+
throw new Error(`X search failed: ${errors}`);
|
|
138
|
+
}
|
|
139
|
+
return JSON.stringify(deduped, null, 2);
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
package/dist/types/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export const SciraConfigSchema = z.object({
|
|
|
8
8
|
// last selected model per LLM provider, restored when switching back
|
|
9
9
|
lastModels: z.record(z.string(), z.string()).default({}),
|
|
10
10
|
approvalMode: ApprovalModeSchema.default("suggest"),
|
|
11
|
+
alwaysAllowLinks: z.boolean().default(false),
|
|
11
12
|
runDirectory: z.string().default(".scira/runs"),
|
|
12
13
|
maxSources: z.number().int().min(1).max(100).default(20),
|
|
13
14
|
citationPolicy: z.enum(["strict", "balanced"]).default("strict"),
|
|
@@ -51,6 +51,7 @@ describe("SciraConfigSchema", () => {
|
|
|
51
51
|
const config = SciraConfigSchema.parse({});
|
|
52
52
|
expect(config.llmProvider).toBe("gateway");
|
|
53
53
|
expect(config.approvalMode).toBe("suggest");
|
|
54
|
+
expect(config.alwaysAllowLinks).toBe(false);
|
|
54
55
|
expect(config.runDirectory).toBe(".scira/runs");
|
|
55
56
|
expect(config.maxSources).toBe(20);
|
|
56
57
|
});
|
package/dist/ui/ink/SciraApp.js
CHANGED
|
@@ -2,13 +2,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { Box, useApp, useStdout, useStdin } from "ink";
|
|
4
4
|
import { CHAT_COMMANDS, MENU_VISIBLE } from "./constants.js";
|
|
5
|
-
import { CWD_DISPLAY, wrapText, wrapInputWithCursor, loadInputHistory, saveInputHistory } from "./lib/utils.js";
|
|
5
|
+
import { CWD_DISPLAY, wrapText, wrapInputWithCursor, loadInputHistory, saveInputHistory, linkAtMouseColumn, openExternalUrl } from "./lib/utils.js";
|
|
6
6
|
import { deleteRun } from "../../storage/run-store.js";
|
|
7
|
+
import { saveGlobalConfig } from "../../config/load-config.js";
|
|
7
8
|
import { useMountEffect, TipCycler, AnimationTick, MouseTracker } from "./components/effects.js";
|
|
8
9
|
import { useFeedLines, computeGroups } from "./hooks/use-feed-lines.js";
|
|
9
10
|
import { feedToolItemId, isToolItemCollapsed } from "./lib/tool-result.js";
|
|
10
11
|
import { useAgentTurn } from "./hooks/use-agent-turn.js";
|
|
11
|
-
import { TopBar, InputBar, HintLine, CommandMenuBox, HelpBox, ApprovalBox, MenuDialog, McpDialog, buildMcpDialogRows } from "./components/overlays.js";
|
|
12
|
+
import { TopBar, InputBar, HintLine, CommandMenuBox, HelpBox, ApprovalBox, LinkOpenBox, MenuDialog, McpDialog, buildMcpDialogRows } from "./components/overlays.js";
|
|
12
13
|
import { useMcpActions } from "./hooks/use-mcp-actions.js";
|
|
13
14
|
import { useKeyboard } from "./hooks/use-keyboard.js";
|
|
14
15
|
import { HomeScreen } from "./components/home-screen.js";
|
|
@@ -52,6 +53,9 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
|
|
|
52
53
|
const fullModeRef = useRef(false);
|
|
53
54
|
const [fullMode, setFullModeState] = useState(false);
|
|
54
55
|
const setMode = useCallback((full) => { fullModeRef.current = full; setFullModeState(full); }, []);
|
|
56
|
+
const planModeRef = useRef(false);
|
|
57
|
+
const [planMode, setPlanModeState] = useState(false);
|
|
58
|
+
const setPlanMode = useCallback((active) => { planModeRef.current = active; setPlanModeState(active); }, []);
|
|
55
59
|
const [usage, setUsage] = useState({});
|
|
56
60
|
const turnsRef = useRef([]);
|
|
57
61
|
const recordUsage = useCallback((model, u) => {
|
|
@@ -66,6 +70,27 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
|
|
|
66
70
|
});
|
|
67
71
|
}, []);
|
|
68
72
|
const [approvalPending, setApprovalPending] = useState(null);
|
|
73
|
+
const [linkPending, setLinkPending] = useState(null);
|
|
74
|
+
const confirmLinkOpen = useCallback(() => {
|
|
75
|
+
setLinkPending((pending) => {
|
|
76
|
+
if (pending)
|
|
77
|
+
void openExternalUrl(pending.url);
|
|
78
|
+
return null;
|
|
79
|
+
});
|
|
80
|
+
}, []);
|
|
81
|
+
const enableAlwaysAllowLinks = useCallback(() => {
|
|
82
|
+
setLinkPending((pending) => {
|
|
83
|
+
if (pending) {
|
|
84
|
+
void (async () => {
|
|
85
|
+
const next = { ...config, alwaysAllowLinks: true };
|
|
86
|
+
setConfig(next);
|
|
87
|
+
await saveGlobalConfig(next);
|
|
88
|
+
void openExternalUrl(pending.url);
|
|
89
|
+
})();
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
});
|
|
93
|
+
}, [config, setConfig]);
|
|
69
94
|
const [inputText, setInputText] = useState("");
|
|
70
95
|
const [cursorPos, setCursorPos] = useState(0);
|
|
71
96
|
const [inputHistory, setInputHistory] = useState([]);
|
|
@@ -201,15 +226,22 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
|
|
|
201
226
|
}), [pushFeed, appendText, appendReasoning, finishReasoning, markToolDone, setBusy, setApprovalPending, setMode]);
|
|
202
227
|
const runTurnRef = useRef(async () => { });
|
|
203
228
|
const { refreshSessions, refreshRun, openRun: openRunBase } = useSession({
|
|
204
|
-
config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef,
|
|
229
|
+
config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef,
|
|
205
230
|
setSessions, setRunState, setCurrentRunPath, setInputText, setCursorPos,
|
|
206
|
-
setFeed, setUsage, setScrollOffset, setScreen, setMode,
|
|
231
|
+
setFeed, setUsage, setScrollOffset, setScreen, setMode, setPlanMode,
|
|
207
232
|
setBusy, setApprovalPending, getSubscriber,
|
|
208
233
|
});
|
|
234
|
+
const { runTurn } = useAgentTurn({
|
|
235
|
+
config, currentRunPath, queuedPromptRef, fullModeRef, planModeRef, conversationRef, turnsRef, feedRef,
|
|
236
|
+
setBusy, setScrollOffset, refreshRun, recordUsage, setMode, setPlanMode, getSubscriber,
|
|
237
|
+
});
|
|
238
|
+
runTurnRef.current = runTurn;
|
|
209
239
|
const openRun = useCallback(async (runPath, initialQuestion) => {
|
|
210
240
|
setPendingRerun(false);
|
|
211
|
-
await openRunBase(runPath, initialQuestion);
|
|
212
|
-
|
|
241
|
+
const start = await openRunBase(runPath, initialQuestion);
|
|
242
|
+
if (start)
|
|
243
|
+
await runTurn(start.startPrompt, runPath);
|
|
244
|
+
}, [openRunBase, runTurn, setPendingRerun]);
|
|
213
245
|
useMountEffect(() => {
|
|
214
246
|
if (!initialRunPath)
|
|
215
247
|
void refreshSessions();
|
|
@@ -276,17 +308,16 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
|
|
|
276
308
|
React.useEffect(() => {
|
|
277
309
|
setMcpRowIdx((i) => Math.min(i, Math.max(0, mcpRowCount - 1)));
|
|
278
310
|
}, [mcpRowCount]);
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
311
|
+
useMountEffect(() => {
|
|
312
|
+
if (initialRunPath)
|
|
313
|
+
void openRun(initialRunPath);
|
|
282
314
|
});
|
|
283
|
-
runTurnRef.current = runTurn;
|
|
284
315
|
const { submitHome, submitChat, stopTurn } = useSubmit({
|
|
285
316
|
state: { config, currentRunPath, sessions, selectedIdx, busy, usage, pendingRerun },
|
|
286
|
-
refs: { queuedPromptRef, conversationRef, feedRef },
|
|
317
|
+
refs: { queuedPromptRef, fullModeRef, planModeRef, conversationRef, feedRef },
|
|
287
318
|
setters: {
|
|
288
319
|
setApprovalPending, setInputText, setCursorPos, setInputHistory, setHistoryIndex, setHelpOpen,
|
|
289
|
-
setNotice, setBusy, setScreen, setFeed, setRunState, setPendingRerun, setMode, setConfig, setMcpOpen,
|
|
320
|
+
setNotice, setBusy, setScreen, setFeed, setRunState, setPendingRerun, setMode, setPlanMode, setConfig, setMcpOpen,
|
|
290
321
|
setHeroHidden,
|
|
291
322
|
},
|
|
292
323
|
actions: { pushFeed, refreshSessions, openRun, openMenu, handleSettings, runTurn, exit },
|
|
@@ -297,8 +328,9 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
|
|
|
297
328
|
const innerWidth = Math.max(20, cols - 4);
|
|
298
329
|
const boxWidth = Math.max(20, cols - 4);
|
|
299
330
|
const textWidth = Math.max(1, boxWidth - 6);
|
|
300
|
-
const
|
|
301
|
-
const
|
|
331
|
+
const inputBlocked = !!approvalPending || !!linkPending;
|
|
332
|
+
const rawInputText = approvalPending ? "waiting for approval\u2026" : linkPending ? "open link? a/y/n" : inputText;
|
|
333
|
+
const showCursor = !busy && !inputBlocked;
|
|
302
334
|
const caret = Math.max(0, Math.min(cursorPos, inputText.length));
|
|
303
335
|
const { lines: inputLines, cursorLine, cursorCol } = wrapInputWithCursor(rawInputText, textWidth, showCursor ? caret : -1);
|
|
304
336
|
const commandMenuHeight = activeSuggestions.length > 0 ? Math.min(MENU_VISIBLE, activeSuggestions.length) + 3 : 0;
|
|
@@ -307,15 +339,20 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
|
|
|
307
339
|
? Math.min(5, wrapText(approvalPending.description, Math.max(10, innerWidth - 4)).length)
|
|
308
340
|
: 0;
|
|
309
341
|
const approvalHeight = approvalPending ? approvalPreviewLines + 5 : 0;
|
|
310
|
-
const
|
|
342
|
+
const linkPreviewLines = linkPending
|
|
343
|
+
? Math.min(4, wrapText(linkPending.url, Math.max(10, innerWidth - 4)).length)
|
|
344
|
+
: 0;
|
|
345
|
+
const linkHeight = linkPending ? linkPreviewLines + 5 : 0;
|
|
346
|
+
const menuHeight = commandMenuHeight + helpHeight + approvalHeight + linkHeight;
|
|
311
347
|
const feedRows = Math.max(3, rows - 6 - inputLines.length - menuHeight);
|
|
312
348
|
const hasRunningTool = feed.some((it) => it.kind === "tool" && it.status === "running");
|
|
313
|
-
const { lines: feedLines, toggleAtLine, groupToggleAtLine } = useFeedLines(feed, innerWidth, reasoningTick, hasRunningTool ? frame : 0, collapsedGroups, focusedGroupKey, itemExpandState, hoveredIdx, config);
|
|
349
|
+
const { lines: feedLines, toggleAtLine, groupToggleAtLine, linkAtLine } = useFeedLines(feed, innerWidth, reasoningTick, hasRunningTool ? frame : 0, collapsedGroups, focusedGroupKey, itemExpandState, hoveredIdx, config);
|
|
314
350
|
const contentRows = Math.max(1, feedRows);
|
|
315
351
|
const maxScrollOffset = Math.max(0, feedLines.length - contentRows);
|
|
316
352
|
wheelStateRef.current = { screen, maxScrollOffset };
|
|
317
353
|
const clampedOffset = Math.min(scrollOffset, maxScrollOffset);
|
|
318
354
|
const startIdx = Math.max(0, feedLines.length - contentRows - clampedOffset);
|
|
355
|
+
const hasLinkHover = hoveredIdx !== null && (linkAtLine.get(hoveredIdx)?.length ?? 0) > 0;
|
|
319
356
|
const feedStartRow = 3;
|
|
320
357
|
if (screen === "chat") {
|
|
321
358
|
const clickMap = new Map();
|
|
@@ -325,11 +362,25 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
|
|
|
325
362
|
if (vis < 0 || vis >= contentRows)
|
|
326
363
|
return;
|
|
327
364
|
const row = feedStartRow + vis;
|
|
328
|
-
clickMap.
|
|
365
|
+
const prev = clickMap.get(row);
|
|
366
|
+
clickMap.set(row, prev ? (x) => { prev(x); onClick(x); } : onClick);
|
|
329
367
|
hoverMap.set(row, lineIdx);
|
|
330
368
|
};
|
|
331
369
|
toggleAtLine.forEach((id, lineIdx) => registerLine(lineIdx, () => toggleToolItem(id)));
|
|
332
370
|
groupToggleAtLine.forEach((groupKey, lineIdx) => registerLine(lineIdx, () => toggleGroup(groupKey)));
|
|
371
|
+
linkAtLine.forEach((links, lineIdx) => {
|
|
372
|
+
registerLine(lineIdx, (x) => {
|
|
373
|
+
if (approvalPending)
|
|
374
|
+
return;
|
|
375
|
+
const url = linkAtMouseColumn(links, x);
|
|
376
|
+
if (!url)
|
|
377
|
+
return;
|
|
378
|
+
if (config.alwaysAllowLinks)
|
|
379
|
+
void openExternalUrl(url);
|
|
380
|
+
else
|
|
381
|
+
setLinkPending({ url });
|
|
382
|
+
});
|
|
383
|
+
});
|
|
333
384
|
clickMapRef.current = clickMap;
|
|
334
385
|
hoverMapRef.current = hoverMap;
|
|
335
386
|
}
|
|
@@ -343,7 +394,9 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
|
|
|
343
394
|
exit,
|
|
344
395
|
input: { text: inputText, setText: setInputText, cursorPos, setCursorPos, history: inputHistory, historyIndex, setHistoryIndex },
|
|
345
396
|
dialogs: {
|
|
346
|
-
approvalPending, setApprovalPending,
|
|
397
|
+
approvalPending, setApprovalPending, linkPending, setLinkPending,
|
|
398
|
+
onConfirmLink: confirmLinkOpen, onAlwaysAllowLinks: enableAlwaysAllowLinks,
|
|
399
|
+
menu, setMenu, applyMenuSelection, helpOpen, setHelpOpen,
|
|
347
400
|
mcpOpen, setMcpOpen, mcpRowIdx, setMcpRowIdx, mcpRowCount, toggleMcpRow, removeMcpRow,
|
|
348
401
|
},
|
|
349
402
|
suggestions: { activeSuggestions, activeSuggestionKind, commandMenuIndex, setCommandMenuIndex, acceptActiveSuggestion },
|
|
@@ -351,11 +404,11 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
|
|
|
351
404
|
home: { sessionsModalOpen, setSessionsModalOpen, sessionsModalIdx, setSessionsModalIdx, sessions, deleteSession, selectedIdx, setSelectedIdx, setHeroHidden, openRun, submitHome },
|
|
352
405
|
});
|
|
353
406
|
const activeUsage = usage[config.model];
|
|
354
|
-
const themed = (node) => (_jsx(ThemeProvider, { config: config,
|
|
407
|
+
const themed = (node) => (_jsx(ThemeProvider, { config: config, children: node }));
|
|
355
408
|
if (screen === "home") {
|
|
356
|
-
return themed(_jsxs(Box, { flexDirection: "column", width: cols, height: rows, paddingX: 2, children: [_jsx(TipCycler, { setTipIndex: setTipIndex }), (!sessionsModalOpen || mcpOpen) && 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, config: config }), _jsx(HomeScreen, { cols: cols, rows: rows, sessions: sessions, selectedIdx: selectedIdx, hoveredIdx: hoveredIdx, heroHidden: heroHidden, notice: notice, tipIndex: tipIndex, commandMenuHeight: commandMenuHeight, mcpOpen: mcpOpen, sessionsModalOpen: sessionsModalOpen, sessionsModalIdx: sessionsModalIdx, inputText: inputText, config: config, modelName: modelName, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef, setSelectedIdx: setSelectedIdx, setSessionsModalOpen: setSessionsModalOpen, setSessionsModalIdx: setSessionsModalIdx, setNotice: setNotice, openRun: openRun, submitHome: submitHome, exit: exit }), _jsxs(Box, { flexDirection: "column", paddingBottom: 1, children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions, config: config }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending:
|
|
409
|
+
return themed(_jsxs(Box, { flexDirection: "column", width: cols, height: rows, paddingX: 2, children: [_jsx(TipCycler, { setTipIndex: setTipIndex }), (!sessionsModalOpen || mcpOpen) && 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, planMode: planMode, activeUsage: activeUsage, busy: busy, frame: frame, cwdDisplay: CWD_DISPLAY, config: config }), _jsx(HomeScreen, { cols: cols, rows: rows, sessions: sessions, selectedIdx: selectedIdx, hoveredIdx: hoveredIdx, heroHidden: heroHidden, notice: notice, tipIndex: tipIndex, commandMenuHeight: commandMenuHeight, mcpOpen: mcpOpen, sessionsModalOpen: sessionsModalOpen, sessionsModalIdx: sessionsModalIdx, inputText: inputText, config: config, modelName: modelName, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef, setSelectedIdx: setSelectedIdx, setSessionsModalOpen: setSessionsModalOpen, setSessionsModalIdx: setSessionsModalIdx, setNotice: setNotice, openRun: openRun, submitHome: submitHome, exit: exit }), _jsxs(Box, { flexDirection: "column", paddingBottom: 1, children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions, config: config }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: inputBlocked, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName, config: config }), _jsx(HintLine, { screen: screen, busy: busy, config: config })] }), _jsx(MenuDialog, { menu: menu, cols: cols, rows: rows, config: config }), _jsx(McpDialog, { open: mcpOpen, config: config, cols: cols, rows: rows, selectedIdx: mcpRowIdx, hoveredIdx: hoveredIdx, onToggle: handleMcpToggle, onRemove: handleMcpRemove, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef })] }));
|
|
357
410
|
}
|
|
358
|
-
return themed(_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, config: config }), _jsx(Box, { flexDirection: "column",
|
|
411
|
+
return themed(_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, planMode: planMode, activeUsage: activeUsage, busy: busy, frame: frame, cwdDisplay: CWD_DISPLAY, config: config }), _jsx(Box, { flexDirection: "column", height: contentRows, flexShrink: 0, justifyContent: "flex-end", paddingTop: 1, overflow: "hidden", children: visibleLines }), _jsxs(ChatInputChrome, { children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions, config: config }), _jsx(HelpBox, { open: helpOpen, innerWidth: innerWidth, config: config }), approvalPending && _jsx(ApprovalBox, { toolName: approvalPending.toolName, description: approvalPending.description, innerWidth: innerWidth, config: config }), linkPending && _jsx(LinkOpenBox, { url: linkPending.url, innerWidth: innerWidth, config: config }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: inputBlocked, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName, config: config }), _jsx(HintLine, { screen: screen, busy: busy, scrollLabel: scrollLabel, hasDoneGroups: doneGroupKeys.length > 0, hasFocusedGroup: focusedGroupKey !== null, hasLinkHover: hasLinkHover || !!linkPending, alwaysAllowLinks: config.alwaysAllowLinks, config: config })] }), _jsx(MenuDialog, { menu: menu, cols: cols, rows: rows, config: config }), _jsx(McpDialog, { open: mcpOpen, config: config, cols: cols, rows: rows, selectedIdx: mcpRowIdx, hoveredIdx: hoveredIdx, onToggle: handleMcpToggle, onRemove: handleMcpRemove, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef })] }));
|
|
359
412
|
}
|
|
360
413
|
function ChatInputChrome({ children }) {
|
|
361
414
|
const theme = useTheme();
|
|
@@ -4,9 +4,9 @@ import { SPINNER_FRAMES, CHAT_COMMANDS, COMMAND_DESCRIPTIONS, MENU_VISIBLE } fro
|
|
|
4
4
|
import { fmtTokens, wrapText } from "../lib/utils.js";
|
|
5
5
|
import { LLM_PROVIDER_LABELS } from "../../../providers/llm/registry.js";
|
|
6
6
|
import { useTheme } from "../hooks/use-theme.js";
|
|
7
|
-
export function TopBar({ screen, runState, fullMode, activeUsage, busy, frame, cwdDisplay, config }) {
|
|
7
|
+
export function TopBar({ screen, runState, fullMode, planMode, activeUsage, busy, frame, cwdDisplay, config }) {
|
|
8
8
|
const theme = useTheme();
|
|
9
|
-
return (_jsxs(Box, { paddingTop: 1, justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, minWidth: 0, marginRight: 2, children: _jsx(Text, { color: theme.textDim, wrap: "truncate-end", children: screen === "chat" ? (runState?.title || runState?.goal || cwdDisplay) : cwdDisplay }) }), screen === "chat" && (_jsxs(Box, { flexShrink: 0, gap: 1, children: [_jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: fullMode ? "magenta" : theme.accent, children: fullMode ? "full" : "quick" }), activeUsage && (_jsx(Text, { color: theme.textDim, children: `↑${fmtTokens(activeUsage.input)} ↓${fmtTokens(activeUsage.output)}` })), fullMode && (_jsxs(Text, { color: theme.textDim, children: [`src:${runState?.sourceCount ?? 0}`, (runState?.claimCount ?? 0) > 0 ? ` · claims:${runState?.claimCount}` : ""] })), fullMode && (_jsx(Text, { color: runState?.reportDirty ? theme.warning : theme.success, children: runState?.reportDirty ? "draft" : "ready" })), busy && _jsx(Text, { color: theme.accent, children: SPINNER_FRAMES[frame % SPINNER_FRAMES.length] }), _jsx(Text, { color: theme.textDim, children: "|" })] }))] }));
|
|
9
|
+
return (_jsxs(Box, { paddingTop: 1, justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, minWidth: 0, marginRight: 2, children: _jsx(Text, { color: theme.textDim, wrap: "truncate-end", children: screen === "chat" ? (runState?.title || runState?.goal || cwdDisplay) : cwdDisplay }) }), screen === "chat" && (_jsxs(Box, { flexShrink: 0, gap: 1, children: [_jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: fullMode ? "magenta" : theme.accent, children: fullMode ? "full" : "quick" }), planMode && _jsx(Text, { color: "cyan", children: "plan" }), activeUsage && (_jsx(Text, { color: theme.textDim, children: `↑${fmtTokens(activeUsage.input)} ↓${fmtTokens(activeUsage.output)}` })), fullMode && (_jsxs(Text, { color: theme.textDim, children: [`src:${runState?.sourceCount ?? 0}`, (runState?.claimCount ?? 0) > 0 ? ` · claims:${runState?.claimCount}` : ""] })), fullMode && (_jsx(Text, { color: runState?.reportDirty ? theme.warning : theme.success, children: runState?.reportDirty ? "draft" : "ready" })), busy && _jsx(Text, { color: theme.accent, children: SPINNER_FRAMES[frame % SPINNER_FRAMES.length] }), _jsx(Text, { color: theme.textDim, children: "|" })] }))] }));
|
|
10
10
|
}
|
|
11
11
|
export function InputBar({ inputLines, cursorLine, cursorCol, showCursor, approvalPending, busy, frame, boxWidth, modelName, config }) {
|
|
12
12
|
const theme = useTheme();
|
|
@@ -17,12 +17,14 @@ export function InputBar({ inputLines, cursorLine, cursorCol, showCursor, approv
|
|
|
17
17
|
const labelMax = Math.max(0, boxWidth - 6);
|
|
18
18
|
const label = borderLabel.length > labelMax ? borderLabel.slice(0, labelMax) : borderLabel;
|
|
19
19
|
const dashCount = Math.max(1, boxWidth - label.length - 5);
|
|
20
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: borderColor, children: "╭" + "─".repeat(Math.max(0, boxWidth - 2)) + "╮" }), inputLines.map((line, i) => (_jsxs(Box, { width: boxWidth, children: [_jsx(Text, { color: borderColor, children: "│ " }), _jsx(Text, { color: i === 0 ? promptColor : borderColor, children: i === 0 ? "❯ " : " " }), _jsx(
|
|
20
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: borderColor, children: "╭" + "─".repeat(Math.max(0, boxWidth - 2)) + "╮" }), inputLines.map((line, i) => (_jsxs(Box, { width: boxWidth, children: [_jsx(Text, { color: borderColor, children: "│ " }), _jsx(Text, { color: i === 0 ? promptColor : borderColor, children: i === 0 ? "❯ " : " " }), _jsx(Box, { flexGrow: 1, minWidth: 0, children: showCursor && i === cursorLine ? (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: inputColor, children: line.slice(0, cursorCol) }), _jsx(Text, { backgroundColor: theme.cursorBackground, color: theme.cursorForeground, children: line[cursorCol] ?? " " }), _jsx(Text, { color: inputColor, children: line.slice(cursorCol + 1) })] })) : (_jsx(Text, { color: inputColor, wrap: "truncate", children: line })) }), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: borderColor, children: " \u2502" })] }, i))), _jsxs(Box, { width: boxWidth, children: [_jsx(Text, { color: borderColor, children: "╰" + "─".repeat(dashCount) + " " }), _jsx(Text, { color: theme.accent, children: label }), _jsx(Text, { color: borderColor, children: " ─╯" })] })] }));
|
|
21
21
|
}
|
|
22
|
-
export function HintLine({ screen, busy, scrollLabel, hasDoneGroups, hasFocusedGroup, config }) {
|
|
22
|
+
export function HintLine({ screen, busy, scrollLabel, hasDoneGroups, hasFocusedGroup, hasLinkHover, alwaysAllowLinks, config }) {
|
|
23
23
|
const theme = useTheme();
|
|
24
24
|
if (screen === "chat") {
|
|
25
|
-
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: theme.textDim, children: _jsx(Text, { bold: true, color: theme.accent, children: "/HELP" }) }), _jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: theme.textDim, children: _jsx(Text, { bold: true, color: theme.accent, children: "/REPORT" }) }), _jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: theme.textDim, children: _jsx(Text, { bold: true, color: theme.accent, children: "/NEW" }) }),
|
|
25
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: theme.textDim, children: _jsx(Text, { bold: true, color: theme.accent, children: "/HELP" }) }), _jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: theme.textDim, children: _jsx(Text, { bold: true, color: theme.accent, children: "/REPORT" }) }), _jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: theme.textDim, children: _jsx(Text, { bold: true, color: theme.accent, children: "/NEW" }) }), hasLinkHover && !busy ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: theme.textDim, children: alwaysAllowLinks
|
|
26
|
+
? "click link to open"
|
|
27
|
+
: _jsxs(_Fragment, { children: ["click link \u00B7 ", _jsx(Text, { bold: true, color: theme.accent, children: "a" }), " always \u00B7 ", _jsx(Text, { bold: true, color: theme.accent, children: "y" }), " open \u00B7 ", _jsx(Text, { bold: true, color: theme.accent, children: "n" }), " cancel"] }) })] })) : null, busy && (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: theme.textDim, children: _jsx(Text, { bold: true, color: theme.accent, children: "/STOP" }) })] })), hasDoneGroups && !busy ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.textDim, children: "|" }), _jsx(Text, { color: theme.textDim, children: hasFocusedGroup
|
|
26
28
|
? _jsxs(_Fragment, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "C" }), " toggle \u00B7 ", _jsx(Text, { bold: true, color: theme.accent, children: "ESC" }), " unfocus"] })
|
|
27
29
|
: _jsxs(_Fragment, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "[ ]" }), " \u00B7 ", _jsx(Text, { bold: true, color: theme.accent, children: "C" }), " groups"] }) })] })) : null, scrollLabel ? (_jsxs(_Fragment, { children: [_jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: theme.textDim, children: scrollLabel })] })) : null] }));
|
|
28
30
|
}
|
|
@@ -56,7 +58,7 @@ export function CommandMenuBox({ activeSuggestions, activeSuggestionKind, comman
|
|
|
56
58
|
bits.push(`${s.claimCount} claims`);
|
|
57
59
|
return bits.join(" · ");
|
|
58
60
|
};
|
|
59
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1,
|
|
61
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, children: [_jsxs(Text, { color: theme.textDim, children: [header, windowStart > 0 ? " ↑" : "", windowStart + MENU_VISIBLE < total ? " ↓" : ""] }), visible.map((item, i) => {
|
|
60
62
|
const gi = windowStart + i;
|
|
61
63
|
const active = gi === clampedIdx;
|
|
62
64
|
const name = isSessionMenu && item.length > nameWidth ? item.slice(0, Math.max(0, nameWidth - 1)) + "…" : item;
|
|
@@ -73,11 +75,15 @@ export function HelpBox({ open, innerWidth, config }) {
|
|
|
73
75
|
const theme = useTheme();
|
|
74
76
|
if (!open)
|
|
75
77
|
return null;
|
|
76
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1,
|
|
78
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, children: [_jsxs(Text, { bold: true, color: theme.text, children: ["help ", _jsx(Text, { color: theme.textDim, children: "esc close" })] }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(10, innerWidth - 6)) }), _jsx(Text, { color: theme.textDim, children: "scroll \u2191/\u2193 k/j u/d pgup/pgdn" }), _jsx(Text, { color: theme.textDim, children: "autocomplete / commands \u00B7 @ files \u00B7 # sessions" }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(10, innerWidth - 6)) }), CHAT_COMMANDS.map((cmd) => (_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: theme.accent, children: cmd }), _jsx(Text, { color: theme.textDim, children: COMMAND_DESCRIPTIONS[cmd] })] }, cmd)))] }));
|
|
79
|
+
}
|
|
80
|
+
export function LinkOpenBox({ url, innerWidth, config }) {
|
|
81
|
+
const theme = useTheme();
|
|
82
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, children: [_jsxs(Text, { bold: true, color: theme.accent, children: ["\u2197 Open in browser?", _jsx(Text, { color: theme.textDim, children: " a always \u00B7 y open \u00B7 n cancel" })] }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(10, innerWidth - 6)) }), wrapText(url, Math.max(10, innerWidth - 4)).slice(0, 4).map((line, i) => (_jsx(Text, { color: theme.text, wrap: "truncate", children: line }, i)))] }));
|
|
77
83
|
}
|
|
78
84
|
export function ApprovalBox({ toolName, description, innerWidth, config }) {
|
|
79
85
|
const theme = useTheme();
|
|
80
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.warning, paddingX: 1,
|
|
86
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.warning, paddingX: 1, children: [_jsxs(Text, { bold: true, color: theme.warning, children: ["\u26A0 ", toolName, _jsx(Text, { color: theme.textDim, children: " y approve \u00B7 n reject" })] }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(10, innerWidth - 6)) }), wrapText(description, Math.max(10, innerWidth - 4)).slice(0, 6).map((line, i) => {
|
|
81
87
|
const isAdded = line.startsWith("+ ");
|
|
82
88
|
const isRemoved = line.startsWith("- ");
|
|
83
89
|
return (_jsx(Text, { color: isAdded ? theme.success : isRemoved ? theme.error : theme.textDim, wrap: "truncate", children: line }, i));
|
|
@@ -100,7 +106,7 @@ export function MenuDialog({ menu, cols, rows, config }) {
|
|
|
100
106
|
const dialogLeft = Math.max(0, Math.floor((cols - 4 - DIALOG_W) / 2));
|
|
101
107
|
const dialogH = 5 + (menu.loading ? 1 : Math.min(DIALOG_ITEMS, menuFiltered.length) + (menuStart > 0 ? 1 : 0) + (menuFiltered.length - (menuStart + DIALOG_ITEMS) > 0 ? 1 : 0));
|
|
102
108
|
const dialogTop = Math.max(1, Math.floor((rows - dialogH) / 2));
|
|
103
|
-
return (_jsxs(Box, { position: "absolute", marginLeft: dialogLeft, marginTop: dialogTop, width: DIALOG_W, flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, children: [_jsxs(Text, { bold: true, color: theme.text, children: [menu.type === "model" ? "Select model" : menu.type === "llm" ? "Select LLM provider" : "Select search provider", " ", _jsx(Text, { color: theme.textDim, children: "\u2191\u2193 navigate \u00B7 \u23CE apply \u00B7 esc close" })] }), !menu.loading && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: "⌕ " }), _jsx(Text, { color: theme.
|
|
109
|
+
return (_jsxs(Box, { position: "absolute", marginLeft: dialogLeft, marginTop: dialogTop, width: DIALOG_W, flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, children: [_jsxs(Text, { bold: true, color: theme.text, children: [menu.type === "model" ? "Select model" : menu.type === "llm" ? "Select LLM provider" : "Select search provider", " ", _jsx(Text, { color: theme.textDim, children: "\u2191\u2193 navigate \u00B7 \u23CE apply \u00B7 esc close" })] }), !menu.loading && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: "⌕ " }), _jsx(Text, { color: theme.inputText, children: menu.query }), !menu.query && _jsx(Text, { color: theme.textDim, children: "type to filter\u2026" })] }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(4, DIALOG_W - 4)) })] })), menu.loading ? (_jsx(Text, { color: theme.textDim, children: " loading models\u2026" })) : menuFiltered.length === 0 ? (_jsxs(Text, { color: theme.textDim, children: [" no matches for \"", menu.query, "\""] })) : (_jsxs(_Fragment, { children: [menuStart > 0 && _jsxs(Text, { color: theme.textDim, children: [" \u2191 ", menuStart, " more"] }), menuFiltered.slice(menuStart, menuStart + DIALOG_ITEMS).map((item, i) => {
|
|
104
110
|
const idx = menuStart + i;
|
|
105
111
|
const active = idx === menu.index;
|
|
106
112
|
return (_jsxs(Text, { color: active ? theme.accent : theme.textDim, bold: active, wrap: "truncate", children: [active ? "❯ " : " ", displayName(item), menu.type === "llm" ? _jsx(Text, { color: theme.textDim, children: " " + item }) : null] }, item));
|
package/dist/ui/ink/constants.js
CHANGED
|
@@ -3,13 +3,14 @@ export const MENU_VISIBLE = 8;
|
|
|
3
3
|
export const FILE_MENTION_MAX_CHARS = 20000;
|
|
4
4
|
export const FILE_MENTION_SKIP = new Set([".git", "node_modules", "dist", ".scira"]);
|
|
5
5
|
export const PROVIDERS = ["parallel", "exa", "firecrawl"];
|
|
6
|
-
export const CHAT_COMMANDS = ["/help", "/home", "/new", "/rerun", "/report", "/sources", "/claims", "/why", "/mcp", "/copy", "/usage", "/rename", "/model", "/llm", "/provider", "/theme", "/key", "/keys", "/stop", "/back", "/quit"];
|
|
6
|
+
export const CHAT_COMMANDS = ["/help", "/home", "/new", "/plan", "/rerun", "/report", "/sources", "/claims", "/why", "/mcp", "/copy", "/usage", "/rename", "/model", "/llm", "/provider", "/theme", "/links", "/key", "/keys", "/stop", "/back", "/quit"];
|
|
7
7
|
/** Slash commands that take an argument; ⏎ from the menu appends a space instead of running. */
|
|
8
|
-
export const COMMANDS_NEEDING_ARGS = new Set(["/theme", "/key", "/rename", "/why"]);
|
|
8
|
+
export const COMMANDS_NEEDING_ARGS = new Set(["/theme", "/key", "/rename", "/why", "/links"]);
|
|
9
9
|
export const COMMAND_DESCRIPTIONS = {
|
|
10
10
|
"/help": "Show command and keyboard shortcuts.",
|
|
11
11
|
"/home": "Go to the home screen (or show the welcome card on home).",
|
|
12
12
|
"/new": "Go to the home screen to start a new research run.",
|
|
13
|
+
"/plan": "Toggle plan mode (explore and plan before making changes).",
|
|
13
14
|
"/rerun": "Run the research agent again for this run.",
|
|
14
15
|
"/report": "Show the generated report.md in the timeline.",
|
|
15
16
|
"/sources": "List the run's gathered sources with links.",
|
|
@@ -23,14 +24,16 @@ export const COMMAND_DESCRIPTIONS = {
|
|
|
23
24
|
"/llm": "Switch the LLM provider (gateway, xai, workers-ai).",
|
|
24
25
|
"/provider": "Open the search provider selector.",
|
|
25
26
|
"/theme": "Set UI theme: /theme dark · /theme light · /theme auto",
|
|
27
|
+
"/links": "Link opens: /links always · /links ask",
|
|
26
28
|
"/key": "Save an API key, e.g. /key EXA_API_KEY ...",
|
|
27
|
-
"/keys": "Show
|
|
29
|
+
"/keys": "Show API key status and where to get missing keys.",
|
|
28
30
|
"/stop": "Abort the currently running agent turn.",
|
|
29
31
|
"/back": "Return to the sessions list.",
|
|
30
32
|
"/quit": "Quit the TUI."
|
|
31
33
|
};
|
|
32
34
|
export const TOOL_ICONS = {
|
|
33
35
|
bash: "$",
|
|
36
|
+
runBash: "$",
|
|
34
37
|
writeFile: "✎",
|
|
35
38
|
editFile: "✎",
|
|
36
39
|
readFile: "▤",
|
|
@@ -39,7 +42,13 @@ export const TOOL_ICONS = {
|
|
|
39
42
|
webSearch: "⌕",
|
|
40
43
|
readUrl: "↗",
|
|
41
44
|
listSkills: "★",
|
|
42
|
-
readSkill: "★"
|
|
45
|
+
readSkill: "★",
|
|
46
|
+
todo: "☐",
|
|
47
|
+
readWorkspaceFile: "▤",
|
|
48
|
+
writeWorkspaceFile: "✎",
|
|
49
|
+
editWorkspaceFile: "✎",
|
|
50
|
+
listWorkspaceDir: "▤",
|
|
51
|
+
grepWorkspace: "⌕"
|
|
43
52
|
};
|
|
44
53
|
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
45
54
|
export const HOME_TIPS = [
|