botholomew 0.22.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/package.json +3 -2
- package/src/approvals/decide.ts +36 -0
- package/src/approvals/errors.ts +22 -0
- package/src/approvals/schema.ts +48 -0
- package/src/approvals/store.ts +276 -0
- package/src/chat/approval.ts +62 -0
- package/src/chat/dream-prompt.ts +20 -0
- package/src/chat/session.ts +32 -3
- package/src/cli.ts +4 -0
- package/src/commands/approval.ts +130 -0
- package/src/commands/chat.ts +48 -34
- package/src/commands/dream.ts +194 -0
- package/src/commands/nuke.ts +12 -2
- package/src/commands/status.ts +0 -4
- package/src/commands/thread.ts +64 -0
- package/src/commands/worker.ts +31 -12
- package/src/config/loader.ts +27 -0
- package/src/config/schemas.ts +31 -0
- package/src/constants.ts +6 -0
- package/src/init/index.ts +5 -0
- package/src/mcpx/client.ts +83 -1
- package/src/skills/commands.ts +14 -0
- package/src/tasks/store.ts +4 -4
- package/src/threads/store.ts +102 -0
- package/src/tools/mcp/exec.ts +32 -0
- package/src/tools/skill/write.ts +3 -3
- package/src/tools/thread/search.ts +21 -73
- package/src/tools/tool.ts +7 -0
- package/src/tui/App.tsx +25 -2
- package/src/tui/components/ApprovalPanel.tsx +222 -0
- package/src/tui/components/ApprovalPrompt.tsx +68 -0
- package/src/tui/components/HelpPanel.tsx +3 -0
- package/src/tui/components/TabBar.tsx +13 -6
- package/src/tui/components/TabPanels.tsx +9 -0
- package/src/tui/hooks/useAppKeybindings.ts +9 -0
- package/src/tui/hooks/useApprovalCount.ts +32 -0
- package/src/tui/hooks/useApprovalPrompt.ts +49 -0
- package/src/tui/hooks/useCaptureTabCycle.ts +1 -1
- package/src/tui/hooks/useChatSession.ts +5 -3
- package/src/tui/keys.ts +1 -0
- package/src/worker/approval.ts +60 -0
- package/src/worker/index.ts +37 -4
- package/src/worker/llm.ts +18 -0
- package/src/worker/run.ts +3 -1
- package/src/worker/spawn.ts +3 -0
- package/src/worker/tick.ts +25 -2
- package/src/workers/store.ts +4 -4
package/src/tui/App.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
BUILTIN_SLASH_COMMANDS,
|
|
5
5
|
type SlashCommand,
|
|
6
6
|
} from "../skills/commands.ts";
|
|
7
|
+
import { ApprovalPrompt } from "./components/ApprovalPrompt.tsx";
|
|
7
8
|
import { InputBar } from "./components/InputBar.tsx";
|
|
8
9
|
import { AnimatedLogo } from "./components/Logo.tsx";
|
|
9
10
|
import {
|
|
@@ -17,6 +18,8 @@ import { TabBar, type TabId } from "./components/TabBar.tsx";
|
|
|
17
18
|
import { TabPanels } from "./components/TabPanels.tsx";
|
|
18
19
|
import { useChatSubmit } from "./handleSubmit.ts";
|
|
19
20
|
import { useAppKeybindings } from "./hooks/useAppKeybindings.ts";
|
|
21
|
+
import { useApprovalCount } from "./hooks/useApprovalCount.ts";
|
|
22
|
+
import { useApprovalPrompt } from "./hooks/useApprovalPrompt.ts";
|
|
20
23
|
import { useCaptureTabCycle } from "./hooks/useCaptureTabCycle.ts";
|
|
21
24
|
import { useChatSession } from "./hooks/useChatSession.ts";
|
|
22
25
|
import { useChatTitlePolling } from "./hooks/useChatTitlePolling.ts";
|
|
@@ -31,6 +34,7 @@ interface AppProps {
|
|
|
31
34
|
threadId?: string;
|
|
32
35
|
initialPrompt?: string;
|
|
33
36
|
idleTimeoutMs: number;
|
|
37
|
+
unsafe?: boolean;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
export function App({
|
|
@@ -38,6 +42,7 @@ export function App({
|
|
|
38
42
|
threadId: resumeThreadId,
|
|
39
43
|
initialPrompt,
|
|
40
44
|
idleTimeoutMs,
|
|
45
|
+
unsafe,
|
|
41
46
|
}: AppProps) {
|
|
42
47
|
return (
|
|
43
48
|
<IdleProvider timeoutMs={idleTimeoutMs}>
|
|
@@ -45,6 +50,7 @@ export function App({
|
|
|
45
50
|
projectDir={projectDir}
|
|
46
51
|
threadId={resumeThreadId}
|
|
47
52
|
initialPrompt={initialPrompt}
|
|
53
|
+
unsafe={unsafe}
|
|
48
54
|
/>
|
|
49
55
|
</IdleProvider>
|
|
50
56
|
);
|
|
@@ -54,12 +60,14 @@ interface AppInnerProps {
|
|
|
54
60
|
projectDir: string;
|
|
55
61
|
threadId?: string;
|
|
56
62
|
initialPrompt?: string;
|
|
63
|
+
unsafe?: boolean;
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
function AppInner({
|
|
60
67
|
projectDir,
|
|
61
68
|
threadId: resumeThreadId,
|
|
62
69
|
initialPrompt,
|
|
70
|
+
unsafe,
|
|
63
71
|
}: AppInnerProps) {
|
|
64
72
|
const { markActivity } = useIdle();
|
|
65
73
|
const rows = useTerminalRows();
|
|
@@ -85,6 +93,7 @@ function AppInner({
|
|
|
85
93
|
projectDir,
|
|
86
94
|
resumeThreadId,
|
|
87
95
|
initialPrompt,
|
|
96
|
+
unsafe,
|
|
88
97
|
setMessages,
|
|
89
98
|
setError,
|
|
90
99
|
});
|
|
@@ -114,6 +123,12 @@ function AppInner({
|
|
|
114
123
|
|
|
115
124
|
const { chatTitle, setChatTitle } = useChatTitlePolling(ready, sessionRef);
|
|
116
125
|
|
|
126
|
+
const { pending: approvalPending, decide: decideApproval } =
|
|
127
|
+
useApprovalPrompt(sessionRef);
|
|
128
|
+
const approvalActiveRef = useRef(false);
|
|
129
|
+
approvalActiveRef.current = approvalPending != null;
|
|
130
|
+
const pendingApprovals = useApprovalCount(projectDir, ready);
|
|
131
|
+
|
|
117
132
|
useCaptureTabCycle(setActiveTab);
|
|
118
133
|
|
|
119
134
|
// Auto-submit initial prompt once session is ready
|
|
@@ -166,6 +181,7 @@ function AppInner({
|
|
|
166
181
|
slashCommandsRef,
|
|
167
182
|
inputValueRef,
|
|
168
183
|
markActivityRef,
|
|
184
|
+
approvalActiveRef,
|
|
169
185
|
});
|
|
170
186
|
|
|
171
187
|
const handleSubmit = useChatSubmit({
|
|
@@ -293,17 +309,24 @@ function AppInner({
|
|
|
293
309
|
/>
|
|
294
310
|
)}
|
|
295
311
|
|
|
312
|
+
{/* Inline approval prompt (gates input while a gated tool call waits) */}
|
|
313
|
+
<ApprovalPrompt request={approvalPending} onDecide={decideApproval} />
|
|
314
|
+
|
|
296
315
|
{/* Bottom bar: StatusBar + InputBar (input only on Chat tab) + TabBar */}
|
|
297
316
|
<InputBar
|
|
298
317
|
value={inputValue}
|
|
299
318
|
onChange={setInputValue}
|
|
300
319
|
onSubmit={handleSubmit}
|
|
301
|
-
disabled={activeTab !== 1 || clearing}
|
|
320
|
+
disabled={activeTab !== 1 || clearing || approvalPending != null}
|
|
302
321
|
history={inputHistory}
|
|
303
322
|
header={inputBarHeader}
|
|
304
323
|
slashCommands={slashCommands}
|
|
305
324
|
/>
|
|
306
|
-
<TabBar
|
|
325
|
+
<TabBar
|
|
326
|
+
activeTab={activeTab}
|
|
327
|
+
usage={usage}
|
|
328
|
+
pendingApprovals={pendingApprovals}
|
|
329
|
+
/>
|
|
307
330
|
</Box>
|
|
308
331
|
);
|
|
309
332
|
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { Box, Text, useInput } from "ink";
|
|
2
|
+
import { memo, useCallback, useEffect, useState } from "react";
|
|
3
|
+
import { decideAndRequeue } from "../../approvals/decide.ts";
|
|
4
|
+
import type { Approval, ApprovalStatus } from "../../approvals/schema.ts";
|
|
5
|
+
import { listApprovals } from "../../approvals/store.ts";
|
|
6
|
+
import { theme } from "../theme.ts";
|
|
7
|
+
import { useLatestRef } from "../useLatestRef.ts";
|
|
8
|
+
import { useTerminalSize } from "../useTerminalSize.ts";
|
|
9
|
+
|
|
10
|
+
interface ApprovalPanelProps {
|
|
11
|
+
projectDir: string;
|
|
12
|
+
isActive: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SIDEBAR_WIDTH = 44;
|
|
16
|
+
|
|
17
|
+
const STATUS_ICONS: Record<ApprovalStatus, string> = {
|
|
18
|
+
pending: "◌",
|
|
19
|
+
approved: "✔",
|
|
20
|
+
denied: "✖",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const STATUS_COLORS: Record<ApprovalStatus, string> = {
|
|
24
|
+
pending: theme.accent,
|
|
25
|
+
approved: theme.success,
|
|
26
|
+
denied: theme.error,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function prettyArgs(args: string): string {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.stringify(JSON.parse(args), null, 2);
|
|
32
|
+
} catch {
|
|
33
|
+
return args;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const ApprovalPanel = memo(function ApprovalPanel({
|
|
38
|
+
projectDir,
|
|
39
|
+
isActive,
|
|
40
|
+
}: ApprovalPanelProps) {
|
|
41
|
+
const { rows: termRows } = useTerminalSize();
|
|
42
|
+
const [approvals, setApprovals] = useState<Approval[]>([]);
|
|
43
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
44
|
+
const [refreshTick, setRefreshTick] = useState(0);
|
|
45
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
46
|
+
|
|
47
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshTick triggers manual refresh
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
let mounted = true;
|
|
50
|
+
const refresh = async () => {
|
|
51
|
+
try {
|
|
52
|
+
const result = await listApprovals(projectDir);
|
|
53
|
+
if (mounted) {
|
|
54
|
+
setApprovals(result);
|
|
55
|
+
setSelectedIndex((prev) =>
|
|
56
|
+
Math.min(prev, Math.max(0, result.length - 1)),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// ignore — next tick retries
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
refresh();
|
|
64
|
+
const interval = setInterval(refresh, 5000);
|
|
65
|
+
return () => {
|
|
66
|
+
mounted = false;
|
|
67
|
+
clearInterval(interval);
|
|
68
|
+
};
|
|
69
|
+
}, [projectDir, refreshTick]);
|
|
70
|
+
|
|
71
|
+
const forceRefresh = useCallback(() => setRefreshTick((t) => t + 1), []);
|
|
72
|
+
|
|
73
|
+
const selected = approvals[selectedIndex];
|
|
74
|
+
const approvalsRef = useLatestRef(approvals);
|
|
75
|
+
const selectedRef = useLatestRef(selected);
|
|
76
|
+
|
|
77
|
+
const decide = useCallback(
|
|
78
|
+
(decision: "approved" | "denied") => {
|
|
79
|
+
const a = selectedRef.current;
|
|
80
|
+
if (!a || a.status !== "pending") return;
|
|
81
|
+
void decideAndRequeue(projectDir, a.id, decision, "tui").then(() => {
|
|
82
|
+
setNotice(
|
|
83
|
+
`${decision === "approved" ? "Approved" : "Denied"} ${a.server}/${a.tool}`,
|
|
84
|
+
);
|
|
85
|
+
forceRefresh();
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
[projectDir, forceRefresh, selectedRef],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
useInput(
|
|
92
|
+
(input, key) => {
|
|
93
|
+
if (key.upArrow || input === "k") {
|
|
94
|
+
setSelectedIndex((i) => Math.max(0, i - 1));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (key.downArrow || input === "j") {
|
|
98
|
+
setSelectedIndex((i) =>
|
|
99
|
+
Math.min(approvalsRef.current.length - 1, i + 1),
|
|
100
|
+
);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (input === "a") decide("approved");
|
|
104
|
+
else if (input === "d") decide("denied");
|
|
105
|
+
else if (key.ctrl && (input === "r" || input === "R")) forceRefresh();
|
|
106
|
+
},
|
|
107
|
+
{ isActive },
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const visibleRows = Math.max(1, termRows - 6);
|
|
111
|
+
const pendingCount = approvals.filter((a) => a.status === "pending").length;
|
|
112
|
+
|
|
113
|
+
if (approvals.length === 0) {
|
|
114
|
+
return (
|
|
115
|
+
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
|
116
|
+
<Text dimColor>
|
|
117
|
+
No approval requests. When a worker hits a gated mcpx tool, the
|
|
118
|
+
request appears here for you to approve or deny.
|
|
119
|
+
</Text>
|
|
120
|
+
</Box>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const sidebarOffset = Math.max(
|
|
125
|
+
0,
|
|
126
|
+
Math.min(
|
|
127
|
+
selectedIndex - Math.floor(visibleRows / 2),
|
|
128
|
+
approvals.length - visibleRows,
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
const sidebarVisible = approvals.slice(
|
|
132
|
+
sidebarOffset,
|
|
133
|
+
sidebarOffset + visibleRows,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<Box flexGrow={1} height={visibleRows + 1} overflow="hidden">
|
|
138
|
+
<Box
|
|
139
|
+
flexDirection="column"
|
|
140
|
+
width={SIDEBAR_WIDTH}
|
|
141
|
+
height={visibleRows + 1}
|
|
142
|
+
borderStyle="single"
|
|
143
|
+
borderColor={theme.muted}
|
|
144
|
+
borderRight
|
|
145
|
+
borderTop={false}
|
|
146
|
+
borderBottom={false}
|
|
147
|
+
borderLeft={false}
|
|
148
|
+
overflow="hidden"
|
|
149
|
+
>
|
|
150
|
+
<Box paddingX={1}>
|
|
151
|
+
<Text bold dimColor>
|
|
152
|
+
Approvals ({approvals.length}
|
|
153
|
+
{pendingCount > 0 ? `, ${pendingCount} pending` : ""})
|
|
154
|
+
</Text>
|
|
155
|
+
</Box>
|
|
156
|
+
{sidebarVisible.map((a, vi) => {
|
|
157
|
+
const i = vi + sidebarOffset;
|
|
158
|
+
const isSelected = i === selectedIndex;
|
|
159
|
+
const label = `${a.server}/${a.tool}`;
|
|
160
|
+
const maxName = SIDEBAR_WIDTH - 8;
|
|
161
|
+
const nameDisplay =
|
|
162
|
+
label.length > maxName ? `${label.slice(0, maxName - 1)}…` : label;
|
|
163
|
+
return (
|
|
164
|
+
<Box key={a.id} paddingX={1}>
|
|
165
|
+
<Text
|
|
166
|
+
backgroundColor={isSelected ? theme.selectionBg : undefined}
|
|
167
|
+
bold={isSelected}
|
|
168
|
+
color={isSelected ? theme.info : undefined}
|
|
169
|
+
wrap="truncate-end"
|
|
170
|
+
>
|
|
171
|
+
{isSelected ? "▸" : " "}{" "}
|
|
172
|
+
<Text color={STATUS_COLORS[a.status]}>
|
|
173
|
+
{STATUS_ICONS[a.status]}
|
|
174
|
+
</Text>{" "}
|
|
175
|
+
{nameDisplay}
|
|
176
|
+
</Text>
|
|
177
|
+
</Box>
|
|
178
|
+
);
|
|
179
|
+
})}
|
|
180
|
+
</Box>
|
|
181
|
+
|
|
182
|
+
<Box
|
|
183
|
+
flexDirection="column"
|
|
184
|
+
flexGrow={1}
|
|
185
|
+
height={visibleRows + 1}
|
|
186
|
+
paddingX={1}
|
|
187
|
+
overflow="hidden"
|
|
188
|
+
>
|
|
189
|
+
{selected && (
|
|
190
|
+
<>
|
|
191
|
+
<Text bold color={theme.toolName} wrap="truncate-end">
|
|
192
|
+
{selected.server}/{selected.tool}
|
|
193
|
+
</Text>
|
|
194
|
+
<Text>
|
|
195
|
+
<Text color={STATUS_COLORS[selected.status]}>
|
|
196
|
+
{STATUS_ICONS[selected.status]} {selected.status}
|
|
197
|
+
</Text>
|
|
198
|
+
{selected.reason ? (
|
|
199
|
+
<Text dimColor> · {selected.reason}</Text>
|
|
200
|
+
) : null}
|
|
201
|
+
</Text>
|
|
202
|
+
{selected.task_id && <Text dimColor>task: {selected.task_id}</Text>}
|
|
203
|
+
<Box marginTop={1} flexDirection="column">
|
|
204
|
+
<Text bold color={theme.primary}>
|
|
205
|
+
Arguments
|
|
206
|
+
</Text>
|
|
207
|
+
<Text dimColor wrap="truncate-end">
|
|
208
|
+
{prettyArgs(selected.args)}
|
|
209
|
+
</Text>
|
|
210
|
+
</Box>
|
|
211
|
+
</>
|
|
212
|
+
)}
|
|
213
|
+
<Box flexGrow={1} />
|
|
214
|
+
{notice && <Text color={theme.success}>{notice}</Text>}
|
|
215
|
+
<Text dimColor>
|
|
216
|
+
↑↓ select · <Text color={theme.success}>a</Text> approve ·{" "}
|
|
217
|
+
<Text color={theme.error}>d</Text> deny · ^R refresh
|
|
218
|
+
</Text>
|
|
219
|
+
</Box>
|
|
220
|
+
</Box>
|
|
221
|
+
);
|
|
222
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Box, Text, useInput } from "ink";
|
|
2
|
+
import type { ChatApprovalRequest } from "../../chat/approval.ts";
|
|
3
|
+
import type { ApprovalDecision } from "../hooks/useApprovalPrompt.ts";
|
|
4
|
+
import { theme } from "../theme.ts";
|
|
5
|
+
|
|
6
|
+
interface ApprovalPromptProps {
|
|
7
|
+
request: ChatApprovalRequest | null;
|
|
8
|
+
onDecide: (decision: ApprovalDecision) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Compact one-line preview of the tool arguments. */
|
|
12
|
+
function previewArgs(args: Record<string, unknown>): string {
|
|
13
|
+
const json = JSON.stringify(args);
|
|
14
|
+
if (json === undefined) return "{}";
|
|
15
|
+
return json.length > 120 ? `${json.slice(0, 117)}…` : json;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Inline modal asking the user to approve a gated mcpx tool call. While a
|
|
20
|
+
* request is pending it owns keyboard input (y/a/n/Esc); App disables the
|
|
21
|
+
* input bar and global keybindings so the keystrokes land here.
|
|
22
|
+
*/
|
|
23
|
+
export function ApprovalPrompt({ request, onDecide }: ApprovalPromptProps) {
|
|
24
|
+
useInput(
|
|
25
|
+
(input, key) => {
|
|
26
|
+
if (key.escape || input === "n") onDecide("deny");
|
|
27
|
+
else if (input === "y") onDecide("approve");
|
|
28
|
+
else if (input === "a") onDecide("always");
|
|
29
|
+
},
|
|
30
|
+
{ isActive: !!request },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (!request) return null;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Box
|
|
37
|
+
flexDirection="column"
|
|
38
|
+
borderStyle="round"
|
|
39
|
+
borderColor={theme.accentBorder}
|
|
40
|
+
paddingX={1}
|
|
41
|
+
>
|
|
42
|
+
<Text color={theme.accent} bold>
|
|
43
|
+
⚠ Approve tool call?
|
|
44
|
+
</Text>
|
|
45
|
+
<Text>
|
|
46
|
+
<Text color={theme.toolName} bold>
|
|
47
|
+
{request.server}/{request.tool}
|
|
48
|
+
</Text>
|
|
49
|
+
<Text color={theme.muted}> ({request.reason})</Text>
|
|
50
|
+
</Text>
|
|
51
|
+
<Text color={theme.muted}>{previewArgs(request.args)}</Text>
|
|
52
|
+
<Text>
|
|
53
|
+
<Text color={theme.success} bold>
|
|
54
|
+
y
|
|
55
|
+
</Text>{" "}
|
|
56
|
+
approve ·{" "}
|
|
57
|
+
<Text color={theme.info} bold>
|
|
58
|
+
a
|
|
59
|
+
</Text>{" "}
|
|
60
|
+
always allow this tool ·{" "}
|
|
61
|
+
<Text color={theme.error} bold>
|
|
62
|
+
n
|
|
63
|
+
</Text>
|
|
64
|
+
/Esc deny
|
|
65
|
+
</Text>
|
|
66
|
+
</Box>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
|
|
3
|
-
export type TabId = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
|
3
|
+
export type TabId = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
|
4
4
|
|
|
5
5
|
// Help uses Ctrl+G rather than Ctrl+H because most terminals deliver Ctrl+H
|
|
6
6
|
// as backspace. Ctrl+G also catches the Ctrl+/ keystroke on terminals that
|
|
@@ -13,19 +13,22 @@ const TABS: { id: TabId; label: string; key: string }[] = [
|
|
|
13
13
|
{ id: 5, label: "Threads", key: "^e" },
|
|
14
14
|
{ id: 6, label: "Schedules", key: "^s" },
|
|
15
15
|
{ id: 7, label: "Workers", key: "^w" },
|
|
16
|
+
{ id: 9, label: "Approvals", key: "^p" },
|
|
16
17
|
{ id: 8, label: "Help", key: "^g" },
|
|
17
18
|
];
|
|
18
19
|
|
|
19
20
|
interface TabBarProps {
|
|
20
21
|
activeTab: TabId;
|
|
21
22
|
usage?: { used: number; max: number } | null;
|
|
23
|
+
/** Pending-approval count rendered as a badge on the Approvals tab. */
|
|
24
|
+
pendingApprovals?: number;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
function formatK(n: number): string {
|
|
25
28
|
return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
export function TabBar({ activeTab, usage }: TabBarProps) {
|
|
31
|
+
export function TabBar({ activeTab, usage, pendingApprovals }: TabBarProps) {
|
|
29
32
|
const pct =
|
|
30
33
|
usage && usage.max > 0 ? Math.round((usage.used / usage.max) * 100) : null;
|
|
31
34
|
const usageColor =
|
|
@@ -41,15 +44,19 @@ export function TabBar({ activeTab, usage }: TabBarProps) {
|
|
|
41
44
|
<Box paddingX={1} gap={1}>
|
|
42
45
|
{TABS.map(({ id, label, key: shortcut }) => {
|
|
43
46
|
const active = id === activeTab;
|
|
47
|
+
const badge =
|
|
48
|
+
id === 9 && pendingApprovals && pendingApprovals > 0
|
|
49
|
+
? `(${pendingApprovals})`
|
|
50
|
+
: "";
|
|
44
51
|
return (
|
|
45
52
|
<Box key={id}>
|
|
46
53
|
<Text
|
|
47
|
-
bold={active}
|
|
48
|
-
color={active ? "cyan" : undefined}
|
|
49
|
-
dimColor={!active}
|
|
54
|
+
bold={active || badge !== ""}
|
|
55
|
+
color={active ? "cyan" : badge !== "" ? "yellow" : undefined}
|
|
56
|
+
dimColor={!active && badge === ""}
|
|
50
57
|
backgroundColor={active ? "#1a3a5c" : undefined}
|
|
51
58
|
>
|
|
52
|
-
{` ${shortcut} ${label} `}
|
|
59
|
+
{` ${shortcut} ${label}${badge ? ` ${badge}` : ""} `}
|
|
53
60
|
</Text>
|
|
54
61
|
</Box>
|
|
55
62
|
);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Box } from "ink";
|
|
2
2
|
import type { ContextUsage } from "../../chat/usage.ts";
|
|
3
|
+
import { ApprovalPanel } from "./ApprovalPanel.tsx";
|
|
3
4
|
import { ContextPanel } from "./ContextPanel.tsx";
|
|
4
5
|
import { HelpPanel } from "./HelpPanel.tsx";
|
|
5
6
|
import { SchedulePanel } from "./SchedulePanel.tsx";
|
|
@@ -90,6 +91,14 @@ export function TabPanels({
|
|
|
90
91
|
>
|
|
91
92
|
<WorkerPanel projectDir={projectDir} isActive={activeTab === 7} />
|
|
92
93
|
</Box>
|
|
94
|
+
<Box
|
|
95
|
+
display={activeTab === 9 ? "flex" : "none"}
|
|
96
|
+
flexDirection="column"
|
|
97
|
+
flexGrow={1}
|
|
98
|
+
overflow="hidden"
|
|
99
|
+
>
|
|
100
|
+
<ApprovalPanel projectDir={projectDir} isActive={activeTab === 9} />
|
|
101
|
+
</Box>
|
|
93
102
|
<Box
|
|
94
103
|
display={activeTab === 8 ? "flex" : "none"}
|
|
95
104
|
flexDirection="column"
|
|
@@ -28,6 +28,8 @@ interface UseAppKeybindingsParams {
|
|
|
28
28
|
slashCommandsRef: MutableRefObject<SlashCommand[]>;
|
|
29
29
|
inputValueRef: MutableRefObject<string>;
|
|
30
30
|
markActivityRef: MutableRefObject<() => void>;
|
|
31
|
+
/** True while the approval prompt owns input; gates all bindings but Ctrl+C. */
|
|
32
|
+
approvalActiveRef: MutableRefObject<boolean>;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export function useAppKeybindings({
|
|
@@ -45,6 +47,7 @@ export function useAppKeybindings({
|
|
|
45
47
|
slashCommandsRef,
|
|
46
48
|
inputValueRef,
|
|
47
49
|
markActivityRef,
|
|
50
|
+
approvalActiveRef,
|
|
48
51
|
}: UseAppKeybindingsParams): void {
|
|
49
52
|
// Stable refs for the input handler — same pattern as InputBar to prevent
|
|
50
53
|
// Ink's useInput from re-registering stdin listeners on every render.
|
|
@@ -69,6 +72,11 @@ export function useAppKeybindings({
|
|
|
69
72
|
return;
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
// While the approval prompt is up it owns the keyboard (y/a/n/Esc handled
|
|
76
|
+
// by its own useInput). Swallow everything else so a tab-jump or Esc-abort
|
|
77
|
+
// doesn't fire mid-prompt.
|
|
78
|
+
if (approvalActiveRef.current) return;
|
|
79
|
+
|
|
72
80
|
// Ctrl+<letter> jumps directly to a tab from any tab. On Chat, only
|
|
73
81
|
// suppress these if the slash-autocomplete popup needs the keystroke
|
|
74
82
|
// (Ctrl combos don't drive the popup, but keep the guard symmetric
|
|
@@ -159,6 +167,7 @@ export function useAppKeybindings({
|
|
|
159
167
|
slashCommandsRef,
|
|
160
168
|
inputValueRef,
|
|
161
169
|
markActivityRef,
|
|
170
|
+
approvalActiveRef,
|
|
162
171
|
],
|
|
163
172
|
);
|
|
164
173
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { listApprovals } from "../../approvals/store.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Poll the count of pending approvals so the TabBar can render a badge on the
|
|
6
|
+
* Approvals tab regardless of which tab is active. Lightweight: a directory
|
|
7
|
+
* scan every few seconds, mirroring the panel refresh cadence.
|
|
8
|
+
*/
|
|
9
|
+
export function useApprovalCount(projectDir: string, ready: boolean): number {
|
|
10
|
+
const [count, setCount] = useState(0);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!ready) return;
|
|
14
|
+
let mounted = true;
|
|
15
|
+
const refresh = async () => {
|
|
16
|
+
try {
|
|
17
|
+
const pending = await listApprovals(projectDir, { status: "pending" });
|
|
18
|
+
if (mounted) setCount(pending.length);
|
|
19
|
+
} catch {
|
|
20
|
+
// ignore — next tick retries
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
refresh();
|
|
24
|
+
const interval = setInterval(refresh, 5000);
|
|
25
|
+
return () => {
|
|
26
|
+
mounted = false;
|
|
27
|
+
clearInterval(interval);
|
|
28
|
+
};
|
|
29
|
+
}, [projectDir, ready]);
|
|
30
|
+
|
|
31
|
+
return count;
|
|
32
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type MutableRefObject, useEffect, useState } from "react";
|
|
2
|
+
import type { ChatApprovalRequest } from "../../chat/approval.ts";
|
|
3
|
+
import type { ChatSession } from "../../chat/session.ts";
|
|
4
|
+
import { addAllowedTool } from "../../config/loader.ts";
|
|
5
|
+
|
|
6
|
+
export type ApprovalDecision = "approve" | "deny" | "always";
|
|
7
|
+
|
|
8
|
+
interface UseApprovalPromptResult {
|
|
9
|
+
/** The pending approval request to render, or null. */
|
|
10
|
+
pending: ChatApprovalRequest | null;
|
|
11
|
+
/** Resolve the pending request. `always` also allowlists the tool. */
|
|
12
|
+
decide: (decision: ApprovalDecision) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Subscribe to the chat session's approval bridge and expose the pending
|
|
17
|
+
* request plus a `decide` callback for the inline TUI prompt. `always` appends
|
|
18
|
+
* `<server>/<tool>` to `approvals.allowed_tools` (in-memory so the live session
|
|
19
|
+
* stops prompting, and on disk so it persists) and approves.
|
|
20
|
+
*/
|
|
21
|
+
export function useApprovalPrompt(
|
|
22
|
+
sessionRef: MutableRefObject<ChatSession | null>,
|
|
23
|
+
): UseApprovalPromptResult {
|
|
24
|
+
const [, setTick] = useState(0);
|
|
25
|
+
const bridge = sessionRef.current?.approvalBridge ?? null;
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!bridge) return;
|
|
29
|
+
return bridge.subscribe(() => setTick((t) => t + 1));
|
|
30
|
+
}, [bridge]);
|
|
31
|
+
|
|
32
|
+
const pending = bridge?.current() ?? null;
|
|
33
|
+
|
|
34
|
+
const decide = (decision: ApprovalDecision) => {
|
|
35
|
+
const session = sessionRef.current;
|
|
36
|
+
if (!session || !pending) return;
|
|
37
|
+
if (decision === "always") {
|
|
38
|
+
const pattern = `${pending.server}/${pending.tool}`;
|
|
39
|
+
const allowed = session.config.approvals.allowed_tools;
|
|
40
|
+
if (!allowed.includes(pattern)) allowed.push(pattern);
|
|
41
|
+
void addAllowedTool(session.projectDir, pattern);
|
|
42
|
+
session.approvalBridge.resolve(true);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
session.approvalBridge.resolve(decision === "approve");
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return { pending, decide };
|
|
49
|
+
}
|
|
@@ -17,7 +17,7 @@ export function useCaptureTabCycle(
|
|
|
17
17
|
const [dwellRaw, delayRaw] = spec.split(":");
|
|
18
18
|
const dwellMs = Number.parseInt(dwellRaw ?? "", 10) || 2500;
|
|
19
19
|
const startDelayMs = Number.parseInt(delayRaw ?? "", 10) || 0;
|
|
20
|
-
const sequence: TabId[] = [2, 3, 4, 5, 6, 7, 8, 1];
|
|
20
|
+
const sequence: TabId[] = [2, 3, 4, 5, 6, 7, 9, 8, 1];
|
|
21
21
|
const timers = sequence.map((tab, i) =>
|
|
22
22
|
setTimeout(() => setActiveTab(tab), startDelayMs + dwellMs * (i + 1)),
|
|
23
23
|
);
|
|
@@ -24,6 +24,7 @@ interface UseChatSessionParams {
|
|
|
24
24
|
projectDir: string;
|
|
25
25
|
resumeThreadId: string | undefined;
|
|
26
26
|
initialPrompt: string | undefined;
|
|
27
|
+
unsafe: boolean | undefined;
|
|
27
28
|
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
|
28
29
|
setError: Dispatch<SetStateAction<string | null>>;
|
|
29
30
|
}
|
|
@@ -39,6 +40,7 @@ export function useChatSession({
|
|
|
39
40
|
projectDir,
|
|
40
41
|
resumeThreadId,
|
|
41
42
|
initialPrompt,
|
|
43
|
+
unsafe,
|
|
42
44
|
setMessages,
|
|
43
45
|
setError,
|
|
44
46
|
}: UseChatSessionParams): UseChatSessionResult {
|
|
@@ -53,7 +55,7 @@ export function useChatSession({
|
|
|
53
55
|
useEffect(() => {
|
|
54
56
|
let cancelled = false;
|
|
55
57
|
|
|
56
|
-
startChatSession(projectDir, resumeThreadId)
|
|
58
|
+
startChatSession(projectDir, resumeThreadId, { unsafe })
|
|
57
59
|
.then(async (session) => {
|
|
58
60
|
if (cancelled) {
|
|
59
61
|
endChatSession(session);
|
|
@@ -82,7 +84,7 @@ export function useChatSession({
|
|
|
82
84
|
id: msgId(),
|
|
83
85
|
role: "system" as const,
|
|
84
86
|
content:
|
|
85
|
-
"Switch panels with Ctrl+<letter> (^a chat · ^o tools · ^n context · ^t tasks · ^r threads · ^s schedules · ^w workers) — `?` for help. Type /help for commands.",
|
|
87
|
+
"Switch panels with Ctrl+<letter> (^a chat · ^o tools · ^n context · ^t tasks · ^r threads · ^s schedules · ^w workers · ^p approvals) — `?` for help. Type /help for commands.",
|
|
86
88
|
timestamp: new Date(),
|
|
87
89
|
},
|
|
88
90
|
]);
|
|
@@ -109,7 +111,7 @@ export function useChatSession({
|
|
|
109
111
|
);
|
|
110
112
|
}
|
|
111
113
|
};
|
|
112
|
-
}, [projectDir, resumeThreadId, setMessages, setError]);
|
|
114
|
+
}, [projectDir, resumeThreadId, unsafe, setMessages, setError]);
|
|
113
115
|
|
|
114
116
|
const performShutdown = useCallback(async () => {
|
|
115
117
|
if (shuttingDownRef.current) {
|
package/src/tui/keys.ts
CHANGED
|
@@ -18,6 +18,7 @@ export const TAB_BY_CTRL_KEY: Record<string, TabId> = {
|
|
|
18
18
|
e: 5, // thr[e]ads
|
|
19
19
|
s: 6, // [s]chedules
|
|
20
20
|
w: 7, // [w]orkers
|
|
21
|
+
p: 9, // a[p]provals
|
|
21
22
|
g: 8, // help (also catches Ctrl+/ on terminals that map it to BEL)
|
|
22
23
|
"/": 8, // help (Kitty keyboard protocol)
|
|
23
24
|
_: 8, // help (terminals that send Ctrl+/ as 0x1F)
|