@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.
@@ -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, USER_BAND_BG, SPINNER_FRAMES } from "../constants.js";
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, { toolNames: [], itemCount: 0, active: false, end: i });
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
- if (!g.toolNames.includes(it.name))
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) => "_tag" in 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: "gray", dimColor: true, children: S_BAR }, key++));
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: "gray", dimColor: true, children: S_BAR }, key++));
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 ? "#FFE0C2" : "gray";
124
- const names = info.toolNames.slice(0, 5).join(", ") + (info.toolNames.length > 5 ? ", …" : "");
125
- lines.push(_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: info.active ? "#FFE0C2" : hc, bold: info.active || focused, children: [icon, " "] }), _jsxs(Text, { color: info.active ? "white" : hc, bold: info.active || focused, dimColor: !info.active && !focused, children: [info.itemCount, " step", info.itemCount !== 1 ? "s" : ""] }), (collapsed || info.active) && names ? (_jsxs(Text, { color: "gray", dimColor: true, children: [" ", names] })) : null, focused && !collapsed && !info.active ? (_jsx(Text, { color: "gray", dimColor: true, children: " [c] collapse · [esc] unfocus" })) : null] }, key++));
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 toolIcon = fi.status === "running"
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 = "#CFB59D";
134
- const nameColor = fi.status === "running" ? "white" : "gray";
135
- const summaryLine = fi.summary.replace(/\s+/gu, " ").trim();
136
- const toolSummary = summaryLine.length > innerWidth - fi.name.length - 6
137
- ? summaryLine.slice(0, Math.max(0, innerWidth - fi.name.length - 7)) + "…"
138
- : summaryLine;
139
- lines.push(_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: symColor, bold: fi.status === "running", children: toolIcon }), _jsxs(Text, { color: nameColor, bold: fi.status === "running", dimColor: fi.status === "done", children: [" ", fi.name] }), _jsxs(Text, { color: "gray", dimColor: true, children: [" ", toolSummary] })] }, key++));
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, { backgroundColor: USER_BAND_BG, children: blank }, key++));
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, { backgroundColor: USER_BAND_BG, wrap: "truncate", children: [_jsx(Text, { color: isFirst ? "#FFE0C2" : "white", children: prefix }), _jsx(Text, { color: "white", children: l }), _jsx(Text, { children: " ".repeat(pad) }), isFirst && time ? _jsx(Text, { color: "gray", dimColor: true, children: time + " " }) : null] }, key++));
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, { backgroundColor: USER_BAND_BG, children: blank }, key++));
181
+ lines.push(_jsx(Text, { ...bandBg, children: blank }, key++));
156
182
  }
157
183
  else if (fi.kind === "status") {
158
- lines.push(_jsxs(Text, { color: "gray", dimColor: true, wrap: "truncate", children: [" · ", fi.text] }, key++));
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: "gray", dimColor: true, children: "\u25CC " }), _jsx(Text, { color: "gray", bold: isOpen, dimColor: !isOpen, children: titleText })] }, key++));
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: "gray", dimColor: true, children: S_BAR }, key++));
193
+ lines.push(_jsx(Text, { color: theme.textDim, children: S_BAR }, key++));
168
194
  continue;
169
195
  }
170
- lines.push(_jsxs(Text, { color: "gray", dimColor: true, italic: true, wrap: "truncate-end", children: [_jsx(Text, { color: "gray", dimColor: true, children: "│ " }), segLine.map((s, i) => (_jsx(Text, { color: "gray", bold: s.bold, italic: s.italic ?? true, underline: s.underline, dimColor: true, children: hyperlink(s.text, s.url) }, i)))] }, key++));
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 ?? "white", bold: s.bold, italic: s.italic, underline: s.underline, dimColor: s.dim, children: hyperlink(s.text, s.url) }, i))) }, key++));
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
- const pushFeed = useCallback((item) => {
6
- setFeed((f) => { const next = [...f, item]; feedRef.current = next; return next; });
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
- setFeed((f) => {
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
- setFeed((f) => {
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
- setFeed((f) => {
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
- if (!changed)
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
- setFeed((f) => {
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 || key.return || char === "q")
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
- void submitHome(activeSuggestions[Math.min(commandMenuIndex, activeSuggestions.length - 1)] ?? inputText);
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
- submitChat(activeSuggestions[Math.min(commandMenuIndex, activeSuggestions.length - 1)] ?? inputText);
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: true };
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: true }]);
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: true }, ...ln]);
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;