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.
Files changed (48) hide show
  1. package/README.md +11 -3
  2. package/package.json +3 -2
  3. package/src/approvals/decide.ts +36 -0
  4. package/src/approvals/errors.ts +22 -0
  5. package/src/approvals/schema.ts +48 -0
  6. package/src/approvals/store.ts +276 -0
  7. package/src/chat/approval.ts +62 -0
  8. package/src/chat/dream-prompt.ts +20 -0
  9. package/src/chat/session.ts +32 -3
  10. package/src/cli.ts +4 -0
  11. package/src/commands/approval.ts +130 -0
  12. package/src/commands/chat.ts +48 -34
  13. package/src/commands/dream.ts +194 -0
  14. package/src/commands/nuke.ts +12 -2
  15. package/src/commands/status.ts +0 -4
  16. package/src/commands/thread.ts +64 -0
  17. package/src/commands/worker.ts +31 -12
  18. package/src/config/loader.ts +27 -0
  19. package/src/config/schemas.ts +31 -0
  20. package/src/constants.ts +6 -0
  21. package/src/init/index.ts +5 -0
  22. package/src/mcpx/client.ts +83 -1
  23. package/src/skills/commands.ts +14 -0
  24. package/src/tasks/store.ts +4 -4
  25. package/src/threads/store.ts +102 -0
  26. package/src/tools/mcp/exec.ts +32 -0
  27. package/src/tools/skill/write.ts +3 -3
  28. package/src/tools/thread/search.ts +21 -73
  29. package/src/tools/tool.ts +7 -0
  30. package/src/tui/App.tsx +25 -2
  31. package/src/tui/components/ApprovalPanel.tsx +222 -0
  32. package/src/tui/components/ApprovalPrompt.tsx +68 -0
  33. package/src/tui/components/HelpPanel.tsx +3 -0
  34. package/src/tui/components/TabBar.tsx +13 -6
  35. package/src/tui/components/TabPanels.tsx +9 -0
  36. package/src/tui/hooks/useAppKeybindings.ts +9 -0
  37. package/src/tui/hooks/useApprovalCount.ts +32 -0
  38. package/src/tui/hooks/useApprovalPrompt.ts +49 -0
  39. package/src/tui/hooks/useCaptureTabCycle.ts +1 -1
  40. package/src/tui/hooks/useChatSession.ts +5 -3
  41. package/src/tui/keys.ts +1 -0
  42. package/src/worker/approval.ts +60 -0
  43. package/src/worker/index.ts +37 -4
  44. package/src/worker/llm.ts +18 -0
  45. package/src/worker/run.ts +3 -1
  46. package/src/worker/spawn.ts +3 -0
  47. package/src/worker/tick.ts +25 -2
  48. 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 activeTab={activeTab} usage={usage} />
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
+ }
@@ -64,6 +64,9 @@ export const HelpPanel = memo(function HelpPanel({
64
64
  <Text>
65
65
  {" "}Ctrl+w{" "}Workers
66
66
  </Text>
67
+ <Text>
68
+ {" "}Ctrl+p{" "}Approvals
69
+ </Text>
67
70
  <Text>
68
71
  {" "}Ctrl+g{" "}Help (Ctrl+/ also works in most terminals)
69
72
  </Text>
@@ -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)