@scira/cli 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/research-agent.js +4 -3
- package/dist/cli/commands/init.js +3 -1
- package/dist/cli/index.js +96 -103
- package/dist/types/index.js +2 -0
- package/dist/ui/ink/SciraApp.js +102 -13
- package/dist/ui/ink/components/home-screen.js +74 -17
- package/dist/ui/ink/components/overlays.js +85 -31
- package/dist/ui/ink/constants.js +5 -2
- package/dist/ui/ink/hooks/use-agent-turn.js +25 -8
- package/dist/ui/ink/hooks/use-feed-lines.js +65 -39
- package/dist/ui/ink/hooks/use-feed-lines.test.js +16 -0
- package/dist/ui/ink/hooks/use-feed.js +18 -18
- package/dist/ui/ink/hooks/use-keyboard.js +36 -4
- package/dist/ui/ink/hooks/use-mcp-actions.js +44 -0
- package/dist/ui/ink/hooks/use-mouse.js +1 -1
- package/dist/ui/ink/hooks/use-settings.js +16 -0
- package/dist/ui/ink/hooks/use-submit.js +2 -2
- package/dist/ui/ink/hooks/use-theme.js +1 -0
- package/dist/ui/ink/lib/markdown.js +14 -14
- package/dist/ui/ink/lib/tool-result.js +319 -0
- package/dist/ui/ink/lib/tool-result.test.js +60 -0
- package/dist/ui/ink/lib/utils.js +88 -6
- package/dist/ui/ink/lib/utils.test.js +31 -0
- package/dist/ui/ink/session-manager.js +41 -4
- package/dist/ui/ink/session-manager.test.js +31 -0
- package/dist/ui/ink/terminal-probe.js +53 -0
- package/dist/ui/ink/terminal-probe.test.js +12 -0
- package/dist/ui/ink/theme-context.js +33 -0
- package/dist/ui/ink/theme.js +183 -0
- package/dist/ui/ink/theme.test.js +41 -0
- package/package.json +5 -6
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useMemo } from "react";
|
|
3
3
|
import { Text } from "ink";
|
|
4
|
-
import { S_BAR, TOOL_ICONS,
|
|
4
|
+
import { S_BAR, TOOL_ICONS, SPINNER_FRAMES } from "../constants.js";
|
|
5
5
|
import { formatTime, fmtDuration, wrapText, hyperlink, displayWidth } from "../lib/utils.js";
|
|
6
|
+
import { formatToolResultLines, formatToolResultPreview, feedToolItemId, isCollapsibleToolName, isToolItemCollapsed, } from "../lib/tool-result.js";
|
|
6
7
|
import { markdownToSegLines } from "../lib/markdown.js";
|
|
8
|
+
import { useTheme } from "./use-theme.js";
|
|
7
9
|
export function computeGroups(feed) {
|
|
8
10
|
const groupOf = new Array(feed.length).fill(-1);
|
|
9
11
|
const groups = new Map();
|
|
@@ -13,7 +15,7 @@ export function computeGroups(feed) {
|
|
|
13
15
|
if (k === "tool" || k === "reasoning") {
|
|
14
16
|
if (gs === -1) {
|
|
15
17
|
gs = i;
|
|
16
|
-
groups.set(gs, {
|
|
18
|
+
groups.set(gs, { stepLabels: [], itemCount: 0, active: false, end: i });
|
|
17
19
|
}
|
|
18
20
|
const g = groups.get(gs);
|
|
19
21
|
g.end = i;
|
|
@@ -21,12 +23,12 @@ export function computeGroups(feed) {
|
|
|
21
23
|
groupOf[i] = gs;
|
|
22
24
|
if (k === "tool") {
|
|
23
25
|
const it = feed[i];
|
|
24
|
-
|
|
25
|
-
g.toolNames.push(it.name);
|
|
26
|
+
g.stepLabels.push(it.name);
|
|
26
27
|
if (it.status === "running")
|
|
27
28
|
g.active = true;
|
|
28
29
|
}
|
|
29
30
|
else {
|
|
31
|
+
g.stepLabels.push("thinking");
|
|
30
32
|
const it = feed[i];
|
|
31
33
|
if (it.durationMs === undefined)
|
|
32
34
|
g.active = true;
|
|
@@ -38,12 +40,16 @@ export function computeGroups(feed) {
|
|
|
38
40
|
}
|
|
39
41
|
return { groupOf, groups };
|
|
40
42
|
}
|
|
41
|
-
const isGH = (item) =>
|
|
43
|
+
const isGH = (item) => item._tag === "gh";
|
|
42
44
|
export function useFeedLines(feed, innerWidth,
|
|
43
45
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
44
|
-
reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey) {
|
|
46
|
+
reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState, hoveredLineIdx, config) {
|
|
47
|
+
const theme = useTheme();
|
|
45
48
|
return useMemo(() => {
|
|
49
|
+
const bandBg = theme.userBandBackground ? { backgroundColor: theme.userBandBackground } : {};
|
|
46
50
|
const lines = [];
|
|
51
|
+
const toggleAtLine = new Map();
|
|
52
|
+
const groupToggleAtLine = new Map();
|
|
47
53
|
let key = 0;
|
|
48
54
|
const { groupOf, groups } = computeGroups(feed);
|
|
49
55
|
const eff = [];
|
|
@@ -55,18 +61,18 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey) {
|
|
|
55
61
|
if (gs === i) {
|
|
56
62
|
eff.push({ _tag: "gh", info, key: gs, collapsed, focused: focusedGroupKey === gs });
|
|
57
63
|
if (!collapsed)
|
|
58
|
-
eff.push(feed[i]);
|
|
64
|
+
eff.push({ _tag: "fi", idx: i, item: feed[i] });
|
|
59
65
|
}
|
|
60
66
|
else if (!collapsed) {
|
|
61
|
-
eff.push(feed[i]);
|
|
67
|
+
eff.push({ _tag: "fi", idx: i, item: feed[i] });
|
|
62
68
|
}
|
|
63
69
|
}
|
|
64
70
|
else {
|
|
65
|
-
eff.push(feed[i]);
|
|
71
|
+
eff.push({ _tag: "fi", idx: i, item: feed[i] });
|
|
66
72
|
}
|
|
67
73
|
}
|
|
68
74
|
eff.forEach((item, ei) => {
|
|
69
|
-
const currKind = isGH(item) ? "gh" : item.kind;
|
|
75
|
+
const currKind = isGH(item) ? "gh" : item.item.kind;
|
|
70
76
|
if (ei === 0 && currKind === "user") {
|
|
71
77
|
lines.push(_jsx(Text, { children: " " }, key++));
|
|
72
78
|
}
|
|
@@ -74,13 +80,13 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey) {
|
|
|
74
80
|
const prev = eff[ei - 1];
|
|
75
81
|
const prevGH = isGH(prev);
|
|
76
82
|
const currGH = isGH(item);
|
|
77
|
-
const prevKind = prevGH ? "gh" : prev.kind;
|
|
78
|
-
const currKind = currGH ? "gh" : item.kind;
|
|
83
|
+
const prevKind = prevGH ? "gh" : prev.item.kind;
|
|
84
|
+
const currKind = currGH ? "gh" : item.item.kind;
|
|
79
85
|
const prevTool = prevKind === "tool" || prevKind === "reasoning";
|
|
80
86
|
const currTool = currKind === "tool" || currKind === "reasoning";
|
|
81
87
|
if (currKind === "gh") {
|
|
82
88
|
if (prevTool) {
|
|
83
|
-
lines.push(_jsx(Text, { color:
|
|
89
|
+
lines.push(_jsx(Text, { color: theme.textDim, children: S_BAR }, key++));
|
|
84
90
|
lines.push(_jsx(Text, { children: " " }, key++));
|
|
85
91
|
}
|
|
86
92
|
else if (prevKind !== "gh") {
|
|
@@ -95,7 +101,7 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey) {
|
|
|
95
101
|
}
|
|
96
102
|
else if (prevTool && currTool) {
|
|
97
103
|
if (!(prevKind === "reasoning" && currKind === "reasoning")) {
|
|
98
|
-
lines.push(_jsx(Text, { color:
|
|
104
|
+
lines.push(_jsx(Text, { color: theme.textDim, children: S_BAR }, key++));
|
|
99
105
|
}
|
|
100
106
|
}
|
|
101
107
|
else if (prevTool) {
|
|
@@ -118,25 +124,45 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey) {
|
|
|
118
124
|
}
|
|
119
125
|
}
|
|
120
126
|
if (isGH(item)) {
|
|
121
|
-
const { info, collapsed, focused } = item;
|
|
127
|
+
const { info, collapsed, focused, key: groupKey } = item;
|
|
128
|
+
const headerLineIdx = lines.length;
|
|
129
|
+
const hovered = hoveredLineIdx === headerLineIdx;
|
|
130
|
+
if (!info.active)
|
|
131
|
+
groupToggleAtLine.set(headerLineIdx, groupKey);
|
|
122
132
|
const icon = info.active ? "◎" : collapsed ? "▶" : "▼";
|
|
123
|
-
const hc = focused ?
|
|
124
|
-
const
|
|
125
|
-
lines.push(_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: info.active ?
|
|
133
|
+
const hc = focused || hovered ? theme.accent : theme.textDim;
|
|
134
|
+
const labels = info.stepLabels.slice(0, 6).join(", ") + (info.stepLabels.length > 6 ? ", …" : "");
|
|
135
|
+
lines.push(_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: info.active ? theme.accent : hc, bold: info.active || focused || hovered, children: [icon, " "] }), _jsxs(Text, { color: info.active ? theme.text : hc, bold: info.active || focused || hovered, children: [info.itemCount, " step", info.itemCount !== 1 ? "s" : ""] }), (collapsed || info.active) && labels ? (_jsxs(Text, { color: theme.textDim, children: [" ", labels] })) : null, focused && !collapsed && !info.active ? (_jsx(Text, { color: theme.textDim, children: " [c] collapse · [esc] unfocus" })) : null] }, key++));
|
|
126
136
|
return;
|
|
127
137
|
}
|
|
128
|
-
const fi = item;
|
|
138
|
+
const fi = item.item;
|
|
139
|
+
const feedIdx = item.idx;
|
|
129
140
|
if (fi.kind === "tool") {
|
|
130
|
-
const
|
|
141
|
+
const running = fi.status === "running";
|
|
142
|
+
const failed = fi.status === "error";
|
|
143
|
+
const itemId = feedToolItemId(feedIdx, fi.toolCallId);
|
|
144
|
+
const collapsible = isCollapsibleToolName(fi.name) && !running;
|
|
145
|
+
const collapsed = collapsible && isToolItemCollapsed(itemId, fi.name, fi.status, itemExpandState);
|
|
146
|
+
const headerLineIdx = lines.length;
|
|
147
|
+
const hovered = hoveredLineIdx === headerLineIdx;
|
|
148
|
+
const toolIcon = running
|
|
131
149
|
? SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length]
|
|
132
150
|
: TOOL_ICONS[fi.name] ?? "·";
|
|
133
|
-
const symColor =
|
|
134
|
-
const nameColor =
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
151
|
+
const symColor = failed ? theme.error : theme.accentDim;
|
|
152
|
+
const nameColor = running ? theme.text : failed ? theme.error : theme.textDim;
|
|
153
|
+
const panelWidth = innerWidth - 4;
|
|
154
|
+
const preview = formatToolResultPreview(fi.name, fi.summary, fi.result, fi.status);
|
|
155
|
+
const bodyLines = formatToolResultLines(fi.name, fi.summary, fi.result, fi.status, panelWidth, theme, !collapsed);
|
|
156
|
+
if (collapsible)
|
|
157
|
+
toggleAtLine.set(headerLineIdx, itemId);
|
|
158
|
+
lines.push(_jsxs(Text, { wrap: "truncate", children: [collapsible ? (_jsx(Text, { color: hovered ? theme.accent : theme.textDim, bold: hovered, children: collapsed ? "▶ " : "▼ " })) : null, _jsx(Text, { color: symColor, bold: running, children: toolIcon }), _jsxs(Text, { color: nameColor, bold: running || failed || hovered, children: [" ", fi.name] }), failed ? _jsx(Text, { color: theme.error, children: " failed" }) : null, running ? _jsx(Text, { color: theme.textDim, children: " \u2026" }) : null, !running && !failed && collapsed && preview ? (_jsxs(Text, { color: theme.textDim, children: [" ", preview] })) : null] }, key++));
|
|
159
|
+
for (const row of bodyLines) {
|
|
160
|
+
if (row.length === 0) {
|
|
161
|
+
lines.push(_jsx(Text, { color: theme.textDim, children: S_BAR }, key++));
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
lines.push(_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: theme.textDim, children: [S_BAR, " "] }), row.map((s, i) => (_jsx(Text, { color: s.color ?? theme.textDim, bold: s.bold, italic: s.italic, underline: s.underline, dimColor: s.dim, children: hyperlink(s.text, s.url) }, i)))] }, key++));
|
|
165
|
+
}
|
|
140
166
|
}
|
|
141
167
|
else if (fi.kind === "user") {
|
|
142
168
|
const bandW = innerWidth;
|
|
@@ -144,43 +170,43 @@ reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey) {
|
|
|
144
170
|
const rightPad = time ? time.length + 1 : 0;
|
|
145
171
|
const wrapped = wrapText(fi.text, Math.max(10, bandW - 4 - rightPad));
|
|
146
172
|
const blank = " ".repeat(bandW);
|
|
147
|
-
lines.push(_jsx(Text, {
|
|
173
|
+
lines.push(_jsx(Text, { ...bandBg, children: blank }, key++));
|
|
148
174
|
wrapped.forEach((l, idx) => {
|
|
149
175
|
const isFirst = idx === 0;
|
|
150
176
|
const prefix = isFirst ? " ❯ " : " ";
|
|
151
177
|
const left = prefix + l;
|
|
152
178
|
const pad = Math.max(1, bandW - displayWidth(left) - (isFirst ? rightPad : 0));
|
|
153
|
-
lines.push(_jsxs(Text, {
|
|
179
|
+
lines.push(_jsxs(Text, { ...bandBg, wrap: "truncate", children: [_jsx(Text, { color: isFirst ? theme.accent : theme.text, children: prefix }), _jsx(Text, { color: theme.text, children: l }), _jsx(Text, { children: " ".repeat(pad) }), isFirst && time ? _jsx(Text, { color: theme.textDim, children: time + " " }) : null] }, key++));
|
|
154
180
|
});
|
|
155
|
-
lines.push(_jsx(Text, {
|
|
181
|
+
lines.push(_jsx(Text, { ...bandBg, children: blank }, key++));
|
|
156
182
|
}
|
|
157
183
|
else if (fi.kind === "status") {
|
|
158
|
-
lines.push(_jsxs(Text, { color:
|
|
184
|
+
lines.push(_jsxs(Text, { color: theme.textDim, wrap: "truncate", children: [" · ", fi.text] }, key++));
|
|
159
185
|
}
|
|
160
186
|
else if (fi.kind === "reasoning") {
|
|
161
187
|
const isOpen = fi.durationMs === undefined;
|
|
162
188
|
const elapsedMs = fi.durationMs ?? (fi.startedAt ? Date.now() - fi.startedAt : 0);
|
|
163
189
|
const titleText = isOpen ? `Thinking… ${fmtDuration(elapsedMs)}` : `Thought for ${fmtDuration(elapsedMs)}`;
|
|
164
|
-
lines.push(_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color:
|
|
165
|
-
for (const segLine of markdownToSegLines(fi.text, innerWidth - 4)) {
|
|
190
|
+
lines.push(_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: theme.textDim, children: "\u25CC " }), _jsx(Text, { color: theme.textDim, bold: isOpen, children: titleText })] }, key++));
|
|
191
|
+
for (const segLine of markdownToSegLines(fi.text, innerWidth - 4, theme)) {
|
|
166
192
|
if (segLine.length === 0) {
|
|
167
|
-
lines.push(_jsx(Text, { color:
|
|
193
|
+
lines.push(_jsx(Text, { color: theme.textDim, children: S_BAR }, key++));
|
|
168
194
|
continue;
|
|
169
195
|
}
|
|
170
|
-
lines.push(_jsxs(Text, { color:
|
|
196
|
+
lines.push(_jsxs(Text, { color: theme.textDim, italic: true, wrap: "truncate-end", children: [_jsx(Text, { color: theme.textDim, children: "│ " }), segLine.map((s, i) => (_jsx(Text, { color: theme.textDim, bold: s.bold, italic: s.italic ?? true, underline: s.underline, children: hyperlink(s.text, s.url) }, i)))] }, key++));
|
|
171
197
|
}
|
|
172
198
|
}
|
|
173
199
|
else {
|
|
174
|
-
for (const segLine of markdownToSegLines(fi.text, innerWidth - 2)) {
|
|
200
|
+
for (const segLine of markdownToSegLines(fi.text, innerWidth - 2, theme)) {
|
|
175
201
|
if (segLine.length === 0) {
|
|
176
202
|
lines.push(_jsx(Text, { children: " " }, key++));
|
|
177
203
|
continue;
|
|
178
204
|
}
|
|
179
|
-
lines.push(_jsx(Text, { wrap: "truncate-end", children: segLine.map((s, i) => (_jsx(Text, { color: s.color ??
|
|
205
|
+
lines.push(_jsx(Text, { wrap: "truncate-end", children: segLine.map((s, i) => (_jsx(Text, { color: s.color ?? theme.text, bold: s.bold, italic: s.italic, underline: s.underline, dimColor: s.dim, children: hyperlink(s.text, s.url) }, i))) }, key++));
|
|
180
206
|
}
|
|
181
207
|
}
|
|
182
208
|
});
|
|
183
|
-
return lines;
|
|
209
|
+
return { lines, toggleAtLine, groupToggleAtLine };
|
|
184
210
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
185
|
-
}, [feed, innerWidth, reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey]);
|
|
211
|
+
}, [feed, innerWidth, reasoningTick, spinnerFrame, collapsedGroups, focusedGroupKey, itemExpandState, hoveredLineIdx, config, theme]);
|
|
186
212
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { computeGroups } from "./use-feed-lines.js";
|
|
3
|
+
describe("computeGroups", () => {
|
|
4
|
+
it("lists thinking and tools in step order", () => {
|
|
5
|
+
const feed = [
|
|
6
|
+
{ kind: "reasoning", text: "plan", durationMs: 100 },
|
|
7
|
+
{ kind: "tool", name: "webSearch", summary: "q", status: "done" },
|
|
8
|
+
{ kind: "reasoning", text: "read", durationMs: 50 },
|
|
9
|
+
{ kind: "tool", name: "readUrl", summary: "url", status: "done" },
|
|
10
|
+
];
|
|
11
|
+
const { groups } = computeGroups(feed);
|
|
12
|
+
const g = groups.get(0);
|
|
13
|
+
expect(g?.stepLabels).toEqual(["thinking", "webSearch", "thinking", "readUrl"]);
|
|
14
|
+
expect(g?.itemCount).toBe(4);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -2,11 +2,17 @@ import { useCallback, useRef, useState } from "react";
|
|
|
2
2
|
export function useFeed() {
|
|
3
3
|
const [feed, setFeed] = useState([]);
|
|
4
4
|
const feedRef = useRef([]);
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
/** Keep feedRef in sync immediately so convo.json saves never read stale React state. */
|
|
6
|
+
const applyFeed = useCallback((update) => {
|
|
7
|
+
const next = update(feedRef.current);
|
|
8
|
+
feedRef.current = next;
|
|
9
|
+
setFeed(next);
|
|
7
10
|
}, []);
|
|
11
|
+
const pushFeed = useCallback((item) => {
|
|
12
|
+
applyFeed((f) => [...f, item]);
|
|
13
|
+
}, [applyFeed]);
|
|
8
14
|
const appendText = useCallback((delta) => {
|
|
9
|
-
|
|
15
|
+
applyFeed((f) => {
|
|
10
16
|
const next = [...f];
|
|
11
17
|
const last = next.at(-1);
|
|
12
18
|
if (last?.kind === "text") {
|
|
@@ -15,12 +21,11 @@ export function useFeed() {
|
|
|
15
21
|
else {
|
|
16
22
|
next.push({ kind: "text", text: delta });
|
|
17
23
|
}
|
|
18
|
-
feedRef.current = next;
|
|
19
24
|
return next;
|
|
20
25
|
});
|
|
21
|
-
}, []);
|
|
26
|
+
}, [applyFeed]);
|
|
22
27
|
const appendReasoning = useCallback((delta) => {
|
|
23
|
-
|
|
28
|
+
applyFeed((f) => {
|
|
24
29
|
const next = [...f];
|
|
25
30
|
const last = next.at(-1);
|
|
26
31
|
if (last?.kind === "reasoning" && last.durationMs === undefined) {
|
|
@@ -29,12 +34,11 @@ export function useFeed() {
|
|
|
29
34
|
else {
|
|
30
35
|
next.push({ kind: "reasoning", text: delta, startedAt: Date.now() });
|
|
31
36
|
}
|
|
32
|
-
feedRef.current = next;
|
|
33
37
|
return next;
|
|
34
38
|
});
|
|
35
|
-
}, []);
|
|
39
|
+
}, [applyFeed]);
|
|
36
40
|
const finishReasoning = useCallback(() => {
|
|
37
|
-
|
|
41
|
+
applyFeed((f) => {
|
|
38
42
|
let changed = false;
|
|
39
43
|
const ended = Date.now();
|
|
40
44
|
const next = f.map((it) => {
|
|
@@ -45,25 +49,21 @@ export function useFeed() {
|
|
|
45
49
|
}
|
|
46
50
|
return it;
|
|
47
51
|
});
|
|
48
|
-
|
|
49
|
-
return f;
|
|
50
|
-
feedRef.current = next;
|
|
51
|
-
return next;
|
|
52
|
+
return changed ? next : f;
|
|
52
53
|
});
|
|
53
|
-
}, []);
|
|
54
|
+
}, [applyFeed]);
|
|
54
55
|
const markToolDone = useCallback((toolCallId, status, result) => {
|
|
55
|
-
|
|
56
|
+
applyFeed((f) => {
|
|
56
57
|
const next = [...f];
|
|
57
58
|
for (let i = next.length - 1; i >= 0; i--) {
|
|
58
59
|
const item = next[i];
|
|
59
60
|
if (item.kind === "tool" && item.status === "running" && (item.toolCallId === toolCallId || !toolCallId)) {
|
|
60
|
-
next[i] = { ...item, status, result };
|
|
61
|
+
next[i] = { ...item, status, result: result ?? item.result };
|
|
61
62
|
break;
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
|
-
feedRef.current = next;
|
|
65
65
|
return next;
|
|
66
66
|
});
|
|
67
|
-
}, []);
|
|
67
|
+
}, [applyFeed]);
|
|
68
68
|
return { feed, setFeed, feedRef, pushFeed, appendText, appendReasoning, finishReasoning, markToolDone };
|
|
69
69
|
}
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { useRef } from "react";
|
|
2
2
|
import { useInput } from "ink";
|
|
3
|
+
import { COMMANDS_NEEDING_ARGS } from "../constants.js";
|
|
4
|
+
function completeCommandWithArgSuffix(selected, inputText, acceptActiveSuggestion) {
|
|
5
|
+
if (COMMANDS_NEEDING_ARGS.has(selected) && inputText.trim() === selected) {
|
|
6
|
+
acceptActiveSuggestion(`${selected} `);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
3
11
|
export function useKeyboard(o) {
|
|
4
12
|
const { screen, setNotice, exit } = o;
|
|
5
13
|
const { text: inputText, setText: setInputText, cursorPos, setCursorPos, history: inputHistory, historyIndex, setHistoryIndex } = o.input;
|
|
6
|
-
const { approvalPending, setApprovalPending, menu, setMenu, applyMenuSelection, helpOpen, setHelpOpen, mcpOpen, setMcpOpen } = o.dialogs;
|
|
14
|
+
const { approvalPending, setApprovalPending, menu, setMenu, applyMenuSelection, helpOpen, setHelpOpen, mcpOpen, setMcpOpen, mcpRowIdx, setMcpRowIdx, mcpRowCount, toggleMcpRow, removeMcpRow, } = o.dialogs;
|
|
7
15
|
const { activeSuggestions, activeSuggestionKind, commandMenuIndex, setCommandMenuIndex, acceptActiveSuggestion } = o.suggestions;
|
|
8
16
|
const { setScrollOffset, contentRows, maxScrollOffset, pendingRerun, setPendingRerun, busy, stopTurn, submitChat, toggleAllGroups, toggleFocusedGroup, focusPrevGroup, focusNextGroup, unfocusGroup, hasFocusedGroup } = o.chat;
|
|
9
17
|
const { sessionsModalOpen, setSessionsModalOpen, sessionsModalIdx, setSessionsModalIdx, sessions, deleteSession, selectedIdx, setSelectedIdx, setHeroHidden, openRun, submitHome } = o.home;
|
|
@@ -139,8 +147,26 @@ export function useKeyboard(o) {
|
|
|
139
147
|
return;
|
|
140
148
|
}
|
|
141
149
|
if (mcpOpen) {
|
|
142
|
-
if (key.escape ||
|
|
150
|
+
if (key.escape || char === "q") {
|
|
143
151
|
setMcpOpen(false);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (key.upArrow) {
|
|
155
|
+
setMcpRowIdx((i) => Math.max(0, i - 1));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (key.downArrow) {
|
|
159
|
+
setMcpRowIdx((i) => Math.min(mcpRowCount - 1, i + 1));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (char === " " || key.return) {
|
|
163
|
+
toggleMcpRow(mcpRowIdx);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (char === "x" || char === "X") {
|
|
167
|
+
removeMcpRow(mcpRowIdx);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
144
170
|
return;
|
|
145
171
|
}
|
|
146
172
|
if (screen === "chat" && busy && key.escape) {
|
|
@@ -243,7 +269,10 @@ export function useKeyboard(o) {
|
|
|
243
269
|
setSelectedIdx((i) => Math.min(maxHomeIdx, i + 1));
|
|
244
270
|
else if (key.return) {
|
|
245
271
|
if (activeSuggestions.length > 0 && activeSuggestionKind === "command" && inputText.trim().startsWith("/")) {
|
|
246
|
-
|
|
272
|
+
const selected = activeSuggestions[Math.min(commandMenuIndex, activeSuggestions.length - 1)] ?? inputText;
|
|
273
|
+
if (completeCommandWithArgSuffix(selected, inputText, acceptActiveSuggestion))
|
|
274
|
+
return;
|
|
275
|
+
void submitHome(selected);
|
|
247
276
|
}
|
|
248
277
|
else if (activeSuggestions.length > 0 && activeSuggestionKind === "file") {
|
|
249
278
|
acceptActiveSuggestion(activeSuggestions[Math.min(commandMenuIndex, activeSuggestions.length - 1)] ?? inputText);
|
|
@@ -296,7 +325,10 @@ export function useKeyboard(o) {
|
|
|
296
325
|
}
|
|
297
326
|
if (key.return) {
|
|
298
327
|
if (activeSuggestions.length > 0 && activeSuggestionKind === "command" && inputText.trim().startsWith("/")) {
|
|
299
|
-
|
|
328
|
+
const selected = activeSuggestions[Math.min(commandMenuIndex, activeSuggestions.length - 1)] ?? inputText;
|
|
329
|
+
if (completeCommandWithArgSuffix(selected, inputText, acceptActiveSuggestion))
|
|
330
|
+
return;
|
|
331
|
+
submitChat(selected);
|
|
300
332
|
}
|
|
301
333
|
else if (activeSuggestions.length > 0 && activeSuggestionKind === "file") {
|
|
302
334
|
acceptActiveSuggestion(activeSuggestions[Math.min(commandMenuIndex, activeSuggestions.length - 1)] ?? inputText);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { saveGlobalMcpConfig } from "../../../config/load-config.js";
|
|
3
|
+
export function useMcpActions(config, setConfig, notify) {
|
|
4
|
+
const toggleMcp = useCallback(async (target) => {
|
|
5
|
+
if (target.kind === "devtools") {
|
|
6
|
+
const dt = config.mcp.chromeDevtools;
|
|
7
|
+
const enabled = !dt.enabled;
|
|
8
|
+
const next = {
|
|
9
|
+
...config,
|
|
10
|
+
mcp: { ...config.mcp, chromeDevtools: { ...dt, enabled } },
|
|
11
|
+
};
|
|
12
|
+
setConfig(next);
|
|
13
|
+
await saveGlobalMcpConfig(next.mcp);
|
|
14
|
+
notify(`chromeDevtools ${enabled ? "enabled" : "disabled"}.`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const idx = config.mcp.servers.findIndex((s) => s.name === target.name);
|
|
18
|
+
if (idx === -1)
|
|
19
|
+
return;
|
|
20
|
+
const enabled = !config.mcp.servers[idx].enabled;
|
|
21
|
+
const next = {
|
|
22
|
+
...config,
|
|
23
|
+
mcp: {
|
|
24
|
+
...config.mcp,
|
|
25
|
+
servers: config.mcp.servers.map((s, i) => (i === idx ? { ...s, enabled } : s)),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
setConfig(next);
|
|
29
|
+
await saveGlobalMcpConfig(next.mcp);
|
|
30
|
+
notify(`"${target.name}" ${enabled ? "enabled" : "disabled"}.`);
|
|
31
|
+
}, [config, notify, setConfig]);
|
|
32
|
+
const removeMcp = useCallback(async (name) => {
|
|
33
|
+
if (!config.mcp.servers.some((s) => s.name === name))
|
|
34
|
+
return;
|
|
35
|
+
const next = {
|
|
36
|
+
...config,
|
|
37
|
+
mcp: { ...config.mcp, servers: config.mcp.servers.filter((s) => s.name !== name) },
|
|
38
|
+
};
|
|
39
|
+
setConfig(next);
|
|
40
|
+
await saveGlobalMcpConfig(next.mcp);
|
|
41
|
+
notify(`Removed MCP server "${name}".`);
|
|
42
|
+
}, [config, notify, setConfig]);
|
|
43
|
+
return { toggleMcp, removeMcp };
|
|
44
|
+
}
|
|
@@ -25,7 +25,7 @@ export function useMouse(onWheel) {
|
|
|
25
25
|
return; // left button press only
|
|
26
26
|
const action = clickMapRef.current.get(y);
|
|
27
27
|
if (action)
|
|
28
|
-
action();
|
|
28
|
+
action(Number(m[2]));
|
|
29
29
|
}, []);
|
|
30
30
|
return { clickMapRef, hoverMapRef, hoveredIdx, setHoveredIdx, handleMouseData };
|
|
31
31
|
}
|
|
@@ -6,6 +6,7 @@ import { listModels } from "../../../providers/llm/models.js";
|
|
|
6
6
|
import { LLM_PROVIDERS, LLM_PROVIDER_LABELS, defaultModelFor } from "../../../providers/llm/registry.js";
|
|
7
7
|
import { PROVIDERS } from "../constants.js";
|
|
8
8
|
import { prettifyModelId } from "../lib/utils.js";
|
|
9
|
+
import { detectTerminalTheme } from "../theme.js";
|
|
9
10
|
import { useMountEffect } from "../components/effects.js";
|
|
10
11
|
export function useSettings({ config, setConfig, screen, pushFeed, setNotice }) {
|
|
11
12
|
const [menu, setMenu] = useState(null);
|
|
@@ -144,6 +145,21 @@ export function useSettings({ config, setConfig, screen, pushFeed, setNotice })
|
|
|
144
145
|
await setEnvKey(name, value);
|
|
145
146
|
return `${name} saved to ~/.scira/.env and active for this session.`;
|
|
146
147
|
}
|
|
148
|
+
if (cmd === "/theme") {
|
|
149
|
+
if (!arg) {
|
|
150
|
+
const resolved = config.theme === "auto" ? detectTerminalTheme() : config.theme;
|
|
151
|
+
const mode = config.theme === "auto"
|
|
152
|
+
? "follows terminal picker"
|
|
153
|
+
: "locked — run /theme auto to sync with picker";
|
|
154
|
+
return `Current theme: ${config.theme} (rendering ${resolved})\n${mode}\nOptions: dark, light, auto`;
|
|
155
|
+
}
|
|
156
|
+
if (!["dark", "light", "auto"].includes(arg))
|
|
157
|
+
return `Unknown theme "${arg}". Options: dark, light, auto`;
|
|
158
|
+
const next = { ...config, theme: arg };
|
|
159
|
+
setConfig(next);
|
|
160
|
+
await saveGlobalConfig(next);
|
|
161
|
+
return `Theme set to ${arg}.`;
|
|
162
|
+
}
|
|
147
163
|
if (cmd === "/keys") {
|
|
148
164
|
return detectEnv(config.search.provider, config.llmProvider)
|
|
149
165
|
.map((c) => `${c.present ? "set " : "missing"} ${c.name}${c.required ? " (required)" : ""}`)
|
|
@@ -117,7 +117,7 @@ export function useSubmit(o) {
|
|
|
117
117
|
stopTurn();
|
|
118
118
|
return;
|
|
119
119
|
}
|
|
120
|
-
if (text === "/back" || text === "/new") {
|
|
120
|
+
if (text === "/back" || text === "/new" || text === "/home") {
|
|
121
121
|
if (currentRunPath)
|
|
122
122
|
detachSubscriber(currentRunPath);
|
|
123
123
|
setScreen("home");
|
|
@@ -136,7 +136,7 @@ export function useSubmit(o) {
|
|
|
136
136
|
void openMenu("provider");
|
|
137
137
|
return;
|
|
138
138
|
}
|
|
139
|
-
if (["/key", "/keys", "/llm"].includes(text.split(/\s+/u)[0])) {
|
|
139
|
+
if (["/key", "/keys", "/llm", "/theme"].includes(text.split(/\s+/u)[0])) {
|
|
140
140
|
void (async () => {
|
|
141
141
|
const result = await handleSettings(text);
|
|
142
142
|
if (result)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ThemeProvider, useTheme } from "../theme-context.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { displayWidth } from "./utils.js";
|
|
2
|
-
export function parseInlineMarkdown(text) {
|
|
2
|
+
export function parseInlineMarkdown(text, theme) {
|
|
3
3
|
const segs = [];
|
|
4
4
|
const re = /(\[[^\]]+\]\([^)]+\))|(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*\s][^*]*\*)|(_[^_\s][^_]*_)/gu;
|
|
5
5
|
let last = 0;
|
|
@@ -9,12 +9,12 @@ export function parseInlineMarkdown(text) {
|
|
|
9
9
|
segs.push({ text: text.slice(last, m.index) });
|
|
10
10
|
const tok = m[0];
|
|
11
11
|
if (tok.startsWith("`"))
|
|
12
|
-
segs.push({ text: tok.slice(1, -1), color: "#FFE0C2" });
|
|
12
|
+
segs.push({ text: tok.slice(1, -1), color: theme?.accent ?? "#FFE0C2" });
|
|
13
13
|
else if (tok.startsWith("**") || tok.startsWith("__"))
|
|
14
14
|
segs.push({ text: tok.slice(2, -2), bold: true });
|
|
15
15
|
else if (tok.startsWith("[")) {
|
|
16
16
|
const link = /^\[([^\]]+)\]\(([^)]+)\)$/u.exec(tok);
|
|
17
|
-
segs.push({ text: link ? link[1] : tok, color: "#FFE0C2", underline: true, url: link ? link[2] : undefined });
|
|
17
|
+
segs.push({ text: link ? link[1] : tok, color: theme?.accent ?? "#FFE0C2", underline: true, url: link ? link[2] : undefined });
|
|
18
18
|
}
|
|
19
19
|
else
|
|
20
20
|
segs.push({ text: tok.slice(1, -1), italic: true });
|
|
@@ -180,7 +180,7 @@ export function tableToSegLines(rows, width) {
|
|
|
180
180
|
});
|
|
181
181
|
return out;
|
|
182
182
|
}
|
|
183
|
-
export function markdownToSegLines(text, width) {
|
|
183
|
+
export function markdownToSegLines(text, width, theme) {
|
|
184
184
|
const out = [];
|
|
185
185
|
let inFence = false;
|
|
186
186
|
const normalized = text
|
|
@@ -194,8 +194,8 @@ export function markdownToSegLines(text, width) {
|
|
|
194
194
|
continue;
|
|
195
195
|
}
|
|
196
196
|
if (inFence) {
|
|
197
|
-
const gutter = { text: " │ ", color: "gray", dim:
|
|
198
|
-
const wrapped = wrapSegments([{ text: raw || " ", color: "#FFE0C2", dim: true }], width - 4);
|
|
197
|
+
const gutter = { text: " │ ", color: theme?.textDim ?? "gray", dim: false };
|
|
198
|
+
const wrapped = wrapSegments([{ text: raw || " ", color: theme?.accent ?? "#FFE0C2", dim: true }], width - 4);
|
|
199
199
|
for (const ln of wrapped)
|
|
200
200
|
out.push([gutter, ...ln]);
|
|
201
201
|
continue;
|
|
@@ -211,34 +211,34 @@ export function markdownToSegLines(text, width) {
|
|
|
211
211
|
continue;
|
|
212
212
|
}
|
|
213
213
|
if (/^\s*([-*_])(\s*\1){2,}\s*$/u.test(raw)) {
|
|
214
|
-
out.push([{ text: "─".repeat(Math.max(3, width - 1)), color: "gray", dim:
|
|
214
|
+
out.push([{ text: "─".repeat(Math.max(3, width - 1)), color: theme?.textDim ?? "gray", dim: false }]);
|
|
215
215
|
continue;
|
|
216
216
|
}
|
|
217
217
|
const heading = /^(#{1,6})\s+(.*)$/u.exec(raw);
|
|
218
218
|
if (heading) {
|
|
219
|
-
const color = heading[1].length <= 2 ? "#FFE0C2" : "white";
|
|
220
|
-
const segs = parseInlineMarkdown(heading[2]).map((s) => ({ ...s, bold: true, color }));
|
|
219
|
+
const color = heading[1].length <= 2 ? theme?.accent ?? "#FFE0C2" : theme?.text ?? "white";
|
|
220
|
+
const segs = parseInlineMarkdown(heading[2], theme).map((s) => ({ ...s, bold: true, color }));
|
|
221
221
|
for (const ln of wrapSegments(segs, width))
|
|
222
222
|
out.push(ln);
|
|
223
223
|
continue;
|
|
224
224
|
}
|
|
225
225
|
const quote = /^\s*>\s?(.*)$/u.exec(raw);
|
|
226
226
|
if (quote) {
|
|
227
|
-
const segs = parseInlineMarkdown(quote[1]).map((s) => ({ ...s, dim: true }));
|
|
227
|
+
const segs = parseInlineMarkdown(quote[1], theme).map((s) => ({ ...s, dim: true }));
|
|
228
228
|
for (const ln of wrapSegments(segs, width - 2))
|
|
229
|
-
out.push([{ text: "│ ", color: "gray", dim:
|
|
229
|
+
out.push([{ text: "│ ", color: theme?.textDim ?? "gray", dim: false }, ...ln]);
|
|
230
230
|
continue;
|
|
231
231
|
}
|
|
232
232
|
const list = /^(\s*)(?:[-*+]|(\d+)[.)])\s+(.*)$/u.exec(raw);
|
|
233
233
|
if (list) {
|
|
234
234
|
const marker = list[2] ? `${list[2]}. ` : "• ";
|
|
235
235
|
const prefix = list[1] + marker;
|
|
236
|
-
const segs = parseInlineMarkdown(list[3]);
|
|
236
|
+
const segs = parseInlineMarkdown(list[3], theme);
|
|
237
237
|
const wrapped = wrapSegments(segs, Math.max(10, width - prefix.length));
|
|
238
|
-
wrapped.forEach((ln, i) => out.push([{ text: i === 0 ? prefix : " ".repeat(prefix.length), color: "#FFE0C2" }, ...ln]));
|
|
238
|
+
wrapped.forEach((ln, i) => out.push([{ text: i === 0 ? prefix : " ".repeat(prefix.length), color: theme?.accent ?? "#FFE0C2" }, ...ln]));
|
|
239
239
|
continue;
|
|
240
240
|
}
|
|
241
|
-
for (const ln of wrapSegments(parseInlineMarkdown(raw), width))
|
|
241
|
+
for (const ln of wrapSegments(parseInlineMarkdown(raw, theme), width))
|
|
242
242
|
out.push(ln);
|
|
243
243
|
}
|
|
244
244
|
return out;
|