botholomew 0.15.0 → 0.15.2

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,17 +1,42 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { memo } from "react";
3
+ import type { ContextUsage } from "../../chat/usage.ts";
3
4
 
4
5
  interface HelpPanelProps {
5
6
  projectDir: string;
6
7
  threadId: string;
7
8
  workerRunning: boolean;
9
+ usage?: ContextUsage | null;
10
+ }
11
+
12
+ function formatK(n: number): string {
13
+ return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
14
+ }
15
+
16
+ function usageColorFor(pct: number): "red" | "yellow" | "green" {
17
+ if (pct >= 90) return "red";
18
+ if (pct >= 70) return "yellow";
19
+ return "green";
8
20
  }
9
21
 
10
22
  export const HelpPanel = memo(function HelpPanel({
11
23
  projectDir,
12
24
  threadId,
13
25
  workerRunning,
26
+ usage,
14
27
  }: HelpPanelProps) {
28
+ const pct =
29
+ usage && usage.max > 0 ? Math.round((usage.used / usage.max) * 100) : null;
30
+ const breakdownRows: { label: string; tokens: number }[] = usage
31
+ ? [
32
+ { label: "Prompts (files)", tokens: usage.breakdown.prompts },
33
+ { label: "Instructions ", tokens: usage.breakdown.instructions },
34
+ { label: "Tools ", tokens: usage.breakdown.tools },
35
+ { label: "Messages ", tokens: usage.breakdown.messages },
36
+ { label: "Tool I/O ", tokens: usage.breakdown.toolIo },
37
+ ]
38
+ : [];
39
+ const breakdownTotal = breakdownRows.reduce((s, r) => s + r.tokens, 0);
15
40
  return (
16
41
  <Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
17
42
  <Box marginTop={1} flexDirection="column">
@@ -40,7 +65,7 @@ export const HelpPanel = memo(function HelpPanel({
40
65
  {" "}Ctrl+w{" "}Workers
41
66
  </Text>
42
67
  <Text>
43
- {" "}?{" "}Help (from any non-chat tab)
68
+ {" "}Ctrl+g{" "}Help (Ctrl+/ also works in most terminals)
44
69
  </Text>
45
70
  <Text>
46
71
  {" "}Escape{" "}Return to Chat
@@ -112,18 +137,28 @@ export const HelpPanel = memo(function HelpPanel({
112
137
  <Text bold color="cyan">
113
138
  Per-panel actions
114
139
  </Text>
140
+ <Text dimColor>
141
+ {" "}d delete needs two presses — arms first, confirms second
142
+ (cancels on any other key or after 3s)
143
+ </Text>
115
144
  <Text>
116
- {" "}Tasks{" "}f filter · p priority · d delete · r refresh
145
+ {" "}Tasks{" "}f filter · p priority · d delete (×2) · r
146
+ refresh
117
147
  </Text>
118
148
  <Text>
119
- {" "}Threads{" "}f filter · s/ search · w follow · d delete ·
120
- r refresh
149
+ {" "}Threads{" "}f filter · s/ search · w follow · d delete
150
+ (×2) · r refresh
121
151
  </Text>
122
152
  <Text>
123
- {" "}Schedules{" "}f filter · e toggle · d delete · r refresh
153
+ {" "}Schedules{" "}f filter · e toggle · d delete (×2) · r
154
+ refresh
124
155
  </Text>
125
156
  <Text>
126
- {" "}Workers{" "}f filter · l toggle log/detail
157
+ {" "}Context{" "}d delete (×2) · r refresh
158
+ </Text>
159
+ <Text>
160
+ {" "}Workers{" "}f filter · l toggle log/detail · d delete log
161
+ (×2, log view)
127
162
  </Text>
128
163
  </Box>
129
164
 
@@ -139,6 +174,38 @@ export const HelpPanel = memo(function HelpPanel({
139
174
  </Text>
140
175
  </Box>
141
176
 
177
+ <Box marginTop={1} flexDirection="column">
178
+ <Text bold color="cyan">
179
+ Context usage
180
+ </Text>
181
+ {usage && pct !== null ? (
182
+ <>
183
+ <Text>
184
+ {" "}Total{" "}
185
+ <Text color={usageColorFor(pct)}>
186
+ {formatK(usage.used)}/{formatK(usage.max)} ({pct}%)
187
+ </Text>
188
+ </Text>
189
+ <Text dimColor>
190
+ {" "}Estimate (~4 chars/token, sums to ~{formatK(breakdownTotal)}
191
+ ):
192
+ </Text>
193
+ {breakdownRows.map((row) => (
194
+ <Text key={row.label}>
195
+ {" "}
196
+ {row.label}
197
+ {" "}
198
+ {formatK(row.tokens)}
199
+ </Text>
200
+ ))}
201
+ </>
202
+ ) : (
203
+ <Text dimColor>
204
+ {" "}Send a message to see token usage for the next turn.
205
+ </Text>
206
+ )}
207
+ </Box>
208
+
142
209
  <Box marginTop={1} flexDirection="column">
143
210
  <Text bold color="cyan">
144
211
  System Info
@@ -9,6 +9,7 @@ import {
9
9
  useState,
10
10
  } from "react";
11
11
  import type { SlashCommand } from "../../skills/commands.ts";
12
+ import { useIdle } from "../idle.tsx";
12
13
  import { getSlashMatches, shouldSubmitOnEnter } from "../slashCompletion.ts";
13
14
  import { SlashCommandPopup } from "./SlashCommandPopup.tsx";
14
15
 
@@ -38,6 +39,7 @@ export const InputBar = memo(function InputBar({
38
39
  const [popupDismissed, setPopupDismissed] = useState(false);
39
40
  const savedInput = useRef("");
40
41
  const lastActivity = useRef(Date.now());
42
+ const { isIdle } = useIdle();
41
43
 
42
44
  // Refs for values read inside the input handler — eagerly updated so rapid
43
45
  // keystrokes that arrive before React re-renders always see fresh state.
@@ -94,7 +96,7 @@ export const InputBar = memo(function InputBar({
94
96
  // Blink cursor when input is active — skip ticks while typing so the
95
97
  // cursor stays solid and we avoid unnecessary renders during rapid input.
96
98
  useEffect(() => {
97
- if (disabled) {
99
+ if (disabled || isIdle) {
98
100
  setCursorVisible(true);
99
101
  return;
100
102
  }
@@ -105,7 +107,7 @@ export const InputBar = memo(function InputBar({
105
107
  setCursorVisible((prev) => (prev === phase ? prev : phase));
106
108
  }, 530);
107
109
  return () => clearInterval(id);
108
- }, [disabled]);
110
+ }, [disabled, isIdle]);
109
111
 
110
112
  // Stable input handler — the callback reference never changes, which
111
113
  // prevents Ink's useInput from removing/re-adding the stdin listener on
@@ -337,14 +339,14 @@ export const InputBar = memo(function InputBar({
337
339
  <Box
338
340
  flexDirection="column"
339
341
  borderStyle="single"
340
- borderColor={disabled ? "gray" : "green"}
342
+ borderColor={disabled || isIdle ? "gray" : "green"}
341
343
  paddingX={1}
342
344
  >
343
345
  {header}
344
346
  {!disabled && (
345
347
  <Box flexDirection="column">
346
348
  <Box>
347
- <Text color="green">{"› "}</Text>
349
+ <Text color={isIdle ? "gray" : "green"}>{"› "}</Text>
348
350
  {placeholder ? (
349
351
  <Text dimColor>Type a message...</Text>
350
352
  ) : (
@@ -1,5 +1,6 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { useEffect, useState } from "react";
3
+ import { useIdle } from "../idle.tsx";
3
4
  import { theme } from "../theme.ts";
4
5
 
5
6
  const STARTUP_FRAMES = [
@@ -23,8 +24,10 @@ const IDLE_MS = 2000;
23
24
  export function AnimatedLogo() {
24
25
  const [frameIndex, setFrameIndex] = useState(0);
25
26
  const [startupDone, setStartupDone] = useState(false);
27
+ const { isIdle } = useIdle();
26
28
 
27
29
  useEffect(() => {
30
+ if (isIdle) return;
28
31
  const interval = setInterval(
29
32
  () => {
30
33
  setFrameIndex((prev) => {
@@ -42,20 +45,21 @@ export function AnimatedLogo() {
42
45
  startupDone ? IDLE_MS : STARTUP_MS,
43
46
  );
44
47
  return () => clearInterval(interval);
45
- }, [startupDone]);
48
+ }, [startupDone, isIdle]);
46
49
 
47
50
  const frames = startupDone ? IDLE_FRAMES : STARTUP_FRAMES;
48
51
  // biome-ignore lint: frameIndex is always in bounds
49
52
  const frame = frames[frameIndex]!;
53
+ const color = isIdle ? "gray" : theme.accent;
50
54
 
51
55
  return (
52
56
  <Box flexDirection="column" alignItems="center" justifyContent="center">
53
57
  {frame.map((line) => (
54
- <Text key={line} color={theme.accent}>
58
+ <Text key={line} color={color}>
55
59
  {line}
56
60
  </Text>
57
61
  ))}
58
- <Text bold color={theme.accent}>
62
+ <Text bold color={color}>
59
63
  Botholomew
60
64
  </Text>
61
65
  <Text dimColor>Starting chat session...</Text>
@@ -67,13 +71,19 @@ const CHAR_FRAMES = ["{o,o}", "{o,o}", "{-,-}", "{o,o}"];
67
71
 
68
72
  export function LogoChar() {
69
73
  const [frameIndex, setFrameIndex] = useState(0);
74
+ const { isIdle } = useIdle();
70
75
 
71
76
  useEffect(() => {
77
+ if (isIdle) return;
72
78
  const interval = setInterval(() => {
73
79
  setFrameIndex((prev) => (prev + 1) % CHAR_FRAMES.length);
74
80
  }, IDLE_MS);
75
81
  return () => clearInterval(interval);
76
- }, []);
82
+ }, [isIdle]);
77
83
 
78
- return <Text color={theme.accent}>{CHAR_FRAMES[frameIndex]} </Text>;
84
+ return (
85
+ <Text color={isIdle ? "gray" : theme.accent}>
86
+ {CHAR_FRAMES[frameIndex]}{" "}
87
+ </Text>
88
+ );
79
89
  }
@@ -12,7 +12,9 @@ import {
12
12
  handleListDetailKey,
13
13
  } from "../listDetailKeys.ts";
14
14
  import { ansi, theme } from "../theme.ts";
15
+ import { useDeleteConfirm } from "../useDeleteConfirm.ts";
15
16
  import { useLatestRef } from "../useLatestRef.ts";
17
+ import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
16
18
  import { Scrollbar } from "./Scrollbar.tsx";
17
19
 
18
20
  interface SchedulePanelProps {
@@ -94,7 +96,6 @@ export const SchedulePanel = memo(function SchedulePanel({
94
96
  const [focus, setFocus] = useState<FocusState>("list");
95
97
  const [enabledFilter, setEnabledFilter] = useState<boolean | null>(null);
96
98
  const [refreshTick, setRefreshTick] = useState(0);
97
- const [confirmDelete, setConfirmDelete] = useState(false);
98
99
 
99
100
  // biome-ignore lint/correctness/useExhaustiveDependencies: refreshTick triggers manual refresh
100
101
  useEffect(() => {
@@ -159,25 +160,19 @@ export const SchedulePanel = memo(function SchedulePanel({
159
160
  const itemCountRef = useLatestRef(schedules.length);
160
161
  const maxDetailScrollRef = useLatestRef(maxDetailScroll);
161
162
  const selectedScheduleRef = useLatestRef(selectedSchedule);
162
- const confirmDeleteRef = useLatestRef(confirmDelete);
163
163
  const focusRef = useLatestRef(focus);
164
164
 
165
+ const deleteConfirm = useDeleteConfirm(() => {
166
+ const s = selectedScheduleRef.current;
167
+ if (!s) return;
168
+ deleteSchedule(projectDir, s.id).then(() => {
169
+ forceRefresh();
170
+ });
171
+ });
172
+
165
173
  useInput(
166
174
  (input, key) => {
167
- if (confirmDeleteRef.current) {
168
- if (input === "y" || input === "d") {
169
- const s = selectedScheduleRef.current;
170
- if (s) {
171
- deleteSchedule(projectDir, s.id).then(() => {
172
- forceRefresh();
173
- });
174
- }
175
- setConfirmDelete(false);
176
- } else {
177
- setConfirmDelete(false);
178
- }
179
- return;
180
- }
175
+ if (input !== "d") deleteConfirm.cancel();
181
176
 
182
177
  if (
183
178
  handleListDetailKey(input, key, {
@@ -208,7 +203,8 @@ export const SchedulePanel = memo(function SchedulePanel({
208
203
  return;
209
204
  }
210
205
  if (input === "d" && selectedScheduleRef.current) {
211
- setConfirmDelete(true);
206
+ const s = selectedScheduleRef.current;
207
+ deleteConfirm.pressDelete(s.name);
212
208
  return;
213
209
  }
214
210
  if (input === "r") {
@@ -273,13 +269,6 @@ export const SchedulePanel = memo(function SchedulePanel({
273
269
  </Text>
274
270
  )}
275
271
  </Box>
276
- {confirmDelete && selectedSchedule && (
277
- <Box paddingX={1}>
278
- <Text color="red" bold>
279
- Delete schedule? (y/n)
280
- </Text>
281
- </Box>
282
- )}
283
272
  {sidebarVisible.map((schedule, vi) => {
284
273
  const i = vi + sidebarScrollOffset;
285
274
  const isSelected = i === selectedIndex;
@@ -342,10 +331,14 @@ export const SchedulePanel = memo(function SchedulePanel({
342
331
  focused={focus === "detail"}
343
332
  />
344
333
  </Box>
334
+ <DeleteArmedBanner
335
+ armed={deleteConfirm.armed}
336
+ label={deleteConfirm.armedLabel}
337
+ />
345
338
  <Text dimColor>
346
339
  {focus === "detail"
347
340
  ? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
348
- : "↑↓ select · → enter detail · f filter · e toggle · d delete · r refresh"}
341
+ : "↑↓ select · → enter detail · f filter · e toggle · d delete (×2) · r refresh"}
349
342
  </Text>
350
343
  </Box>
351
344
  </Box>
@@ -1,5 +1,6 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { useEffect, useState } from "react";
3
+ import { useIdle } from "../idle.tsx";
3
4
  import { theme } from "../theme.ts";
4
5
 
5
6
  interface SleepProgressProps {
@@ -17,11 +18,13 @@ export function SleepProgress({
17
18
  reason,
18
19
  }: SleepProgressProps) {
19
20
  const [now, setNow] = useState(() => Date.now());
21
+ const { isIdle } = useIdle();
20
22
 
21
23
  useEffect(() => {
24
+ if (isIdle) return;
22
25
  const id = setInterval(() => setNow(Date.now()), TICK_MS);
23
26
  return () => clearInterval(id);
24
- }, []);
27
+ }, [isIdle]);
25
28
 
26
29
  const totalMs = totalSeconds * 1000;
27
30
  const elapsedMs = Math.min(totalMs, Math.max(0, now - startedAt.getTime()));
@@ -34,7 +37,7 @@ export function SleepProgress({
34
37
  <Box flexDirection="column">
35
38
  <Box>
36
39
  <Text dimColor>{" "}</Text>
37
- <Text color={theme.accent}>{bar}</Text>
40
+ <Text color={isIdle ? "gray" : theme.accent}>{bar}</Text>
38
41
  <Text dimColor>
39
42
  {" "}
40
43
  {elapsedSec}s / {totalSeconds}s
@@ -2,6 +2,7 @@ import { Box, Text } from "ink";
2
2
  import { useEffect, useState } from "react";
3
3
  import { listTasks } from "../../tasks/store.ts";
4
4
  import { listWorkers } from "../../workers/store.ts";
5
+ import { useIdle } from "../idle.tsx";
5
6
  import { LogoChar } from "./Logo.tsx";
6
7
 
7
8
  interface StatusBarProps {
@@ -32,8 +33,10 @@ export function StatusBar({
32
33
  pendingCount: 0,
33
34
  inProgressCount: 0,
34
35
  });
36
+ const { isIdle } = useIdle();
35
37
 
36
38
  useEffect(() => {
39
+ if (isIdle) return;
37
40
  let mounted = true;
38
41
 
39
42
  // Errors here (e.g. transient DuckDB lock conflicts while a freshly
@@ -66,32 +69,32 @@ export function StatusBar({
66
69
  mounted = false;
67
70
  clearInterval(interval);
68
71
  };
69
- }, [projectDir, onWorkerStatusChange]);
72
+ }, [projectDir, onWorkerStatusChange, isIdle]);
70
73
 
71
74
  return (
72
75
  <Box paddingX={0}>
73
76
  <LogoChar />
74
- <Text bold color="blue">
77
+ <Text bold color={isIdle ? "gray" : "blue"}>
75
78
  Botholomew
76
79
  </Text>
77
80
  {chatTitle && (
78
81
  <>
79
82
  <Text dimColor> | </Text>
80
- <Text color="cyan" bold italic>
83
+ <Text color={isIdle ? "gray" : "cyan"} bold italic>
81
84
  {chatTitle.length > 30 ? `${chatTitle.slice(0, 29)}…` : chatTitle}
82
85
  </Text>
83
86
  </>
84
87
  )}
85
88
  <Text dimColor> | </Text>
86
89
  {status.workerCount > 0 ? (
87
- <Text color="green">
90
+ <Text color={isIdle ? "gray" : "green"}>
88
91
  {status.workerCount} worker{status.workerCount === 1 ? "" : "s"}
89
92
  </Text>
90
93
  ) : (
91
- <Text color="yellow">no workers</Text>
94
+ <Text color={isIdle ? "gray" : "yellow"}>no workers</Text>
92
95
  )}
93
96
  <Text dimColor> | </Text>
94
- <Text>
97
+ <Text dimColor={isIdle}>
95
98
  {status.pendingCount} pending, {status.inProgressCount} active
96
99
  </Text>
97
100
  </Box>
@@ -2,8 +2,9 @@ import { Box, Text } from "ink";
2
2
 
3
3
  export type TabId = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
4
4
 
5
- // Help uses "?" (no Ctrl) because Ctrl+H is delivered as backspace by most
6
- // terminals. The other panels use Ctrl+<letter>.
5
+ // Help uses Ctrl+G rather than Ctrl+H because most terminals deliver Ctrl+H
6
+ // as backspace. Ctrl+G also catches the Ctrl+/ keystroke on terminals that
7
+ // map it to BEL (0x07) — most macOS terminals do.
7
8
  const TABS: { id: TabId; label: string; key: string }[] = [
8
9
  { id: 1, label: "Chat", key: "^a" },
9
10
  { id: 2, label: "Tools", key: "^o" },
@@ -12,14 +13,30 @@ const TABS: { id: TabId; label: string; key: string }[] = [
12
13
  { id: 5, label: "Threads", key: "^r" },
13
14
  { id: 6, label: "Schedules", key: "^s" },
14
15
  { id: 7, label: "Workers", key: "^w" },
15
- { id: 8, label: "Help", key: "?" },
16
+ { id: 8, label: "Help", key: "^g" },
16
17
  ];
17
18
 
18
19
  interface TabBarProps {
19
20
  activeTab: TabId;
21
+ usage?: { used: number; max: number } | null;
20
22
  }
21
23
 
22
- export function TabBar({ activeTab }: TabBarProps) {
24
+ function formatK(n: number): string {
25
+ return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
26
+ }
27
+
28
+ export function TabBar({ activeTab, usage }: TabBarProps) {
29
+ const pct =
30
+ usage && usage.max > 0 ? Math.round((usage.used / usage.max) * 100) : null;
31
+ const usageColor =
32
+ pct === null
33
+ ? undefined
34
+ : pct >= 90
35
+ ? "red"
36
+ : pct >= 70
37
+ ? "yellow"
38
+ : "green";
39
+
23
40
  return (
24
41
  <Box paddingX={1} gap={1}>
25
42
  {TABS.map(({ id, label, key: shortcut }) => {
@@ -37,6 +54,14 @@ export function TabBar({ activeTab }: TabBarProps) {
37
54
  </Box>
38
55
  );
39
56
  })}
57
+ <Box flexGrow={1} />
58
+ {usage && (
59
+ <Box>
60
+ <Text color={usageColor}>
61
+ {formatK(usage.used)}/{formatK(usage.max)}
62
+ </Text>
63
+ </Box>
64
+ )}
40
65
  </Box>
41
66
  );
42
67
  }
@@ -12,7 +12,9 @@ import {
12
12
  handleListDetailKey,
13
13
  } from "../listDetailKeys.ts";
14
14
  import { ansi, theme } from "../theme.ts";
15
+ import { useDeleteConfirm } from "../useDeleteConfirm.ts";
15
16
  import { useLatestRef } from "../useLatestRef.ts";
17
+ import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
16
18
  import { Scrollbar } from "./Scrollbar.tsx";
17
19
 
18
20
  interface TaskPanelProps {
@@ -199,8 +201,18 @@ export const TaskPanel = memo(function TaskPanel({
199
201
  const selectedTaskRef = useLatestRef(selectedTask);
200
202
  const focusRef = useLatestRef(focus);
201
203
 
204
+ const deleteConfirm = useDeleteConfirm(() => {
205
+ const t = selectedTaskRef.current;
206
+ if (!t) return;
207
+ deleteTask(projectDir, t.id).then(() => {
208
+ forceRefresh();
209
+ });
210
+ });
211
+
202
212
  useInput(
203
213
  (input, key) => {
214
+ if (input !== "d") deleteConfirm.cancel();
215
+
204
216
  if (
205
217
  handleListDetailKey(input, key, {
206
218
  focusRef,
@@ -226,9 +238,7 @@ export const TaskPanel = memo(function TaskPanel({
226
238
  if (input === "d") {
227
239
  const t = selectedTaskRef.current;
228
240
  if (!t) return;
229
- deleteTask(projectDir, t.id).then(() => {
230
- forceRefresh();
231
- });
241
+ deleteConfirm.pressDelete(t.name || t.id);
232
242
  return;
233
243
  }
234
244
  if (input === "r") {
@@ -359,10 +369,14 @@ export const TaskPanel = memo(function TaskPanel({
359
369
  focused={focus === "detail"}
360
370
  />
361
371
  </Box>
372
+ <DeleteArmedBanner
373
+ armed={deleteConfirm.armed}
374
+ label={deleteConfirm.armedLabel}
375
+ />
362
376
  <Text dimColor>
363
377
  {focus === "detail"
364
378
  ? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
365
- : "↑↓ select · → enter detail · f filter · p priority · d delete · r refresh"}
379
+ : "↑↓ select · → enter detail · f filter · p priority · d delete (×2) · r refresh"}
366
380
  </Text>
367
381
  </Box>
368
382
  </Box>
@@ -15,7 +15,9 @@ import {
15
15
  handleListDetailKey,
16
16
  } from "../listDetailKeys.ts";
17
17
  import { ansi, theme } from "../theme.ts";
18
+ import { useDeleteConfirm } from "../useDeleteConfirm.ts";
18
19
  import { useLatestRef } from "../useLatestRef.ts";
20
+ import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
19
21
  import { Scrollbar } from "./Scrollbar.tsx";
20
22
 
21
23
  interface ThreadPanelProps {
@@ -168,7 +170,6 @@ export const ThreadPanel = memo(function ThreadPanel({
168
170
  const [focus, setFocus] = useState<FocusState>("list");
169
171
  const [typeFilter, setTypeFilter] = useState<Thread["type"] | null>(null);
170
172
  const [refreshTick, setRefreshTick] = useState(0);
171
- const [confirmDelete, setConfirmDelete] = useState(false);
172
173
  const [searching, setSearching] = useState(false);
173
174
  const [searchQuery, setSearchQuery] = useState("");
174
175
  const [selectedDetail, setSelectedDetail] = useState<{
@@ -329,11 +330,18 @@ export const ThreadPanel = memo(function ThreadPanel({
329
330
  const selectedThreadRef = useLatestRef(selectedThread);
330
331
  const selectedDetailRef = useLatestRef(selectedDetail);
331
332
  const searchingRef = useLatestRef(searching);
332
- const confirmDeleteRef = useLatestRef(confirmDelete);
333
333
  const isActiveSelectedRef = useLatestRef(isActiveSelected);
334
334
  const followingRef = useLatestRef(following);
335
335
  const focusRef = useLatestRef(focus);
336
336
 
337
+ const deleteConfirm = useDeleteConfirm(() => {
338
+ const t = selectedThreadRef.current;
339
+ if (!t || isActiveSelectedRef.current) return;
340
+ deleteThread(projectDir, t.id).then(() => {
341
+ forceRefresh();
342
+ });
343
+ });
344
+
337
345
  useInput(
338
346
  (input, key) => {
339
347
  // Search mode: capture typed characters
@@ -360,21 +368,7 @@ export const ThreadPanel = memo(function ThreadPanel({
360
368
  return;
361
369
  }
362
370
 
363
- // Delete confirmation mode
364
- if (confirmDeleteRef.current) {
365
- if (input === "y" || input === "d") {
366
- const t = selectedThreadRef.current;
367
- if (t && !isActiveSelectedRef.current) {
368
- deleteThread(projectDir, t.id).then(() => {
369
- forceRefresh();
370
- });
371
- }
372
- setConfirmDelete(false);
373
- } else {
374
- setConfirmDelete(false);
375
- }
376
- return;
377
- }
371
+ if (input !== "d") deleteConfirm.cancel();
378
372
 
379
373
  if (
380
374
  handleListDetailKey(input, key, {
@@ -396,7 +390,8 @@ export const ThreadPanel = memo(function ThreadPanel({
396
390
  }
397
391
  if (input === "d" && selectedThreadRef.current) {
398
392
  if (isActiveSelectedRef.current) return; // Can't delete active thread
399
- setConfirmDelete(true);
393
+ const t = selectedThreadRef.current;
394
+ deleteConfirm.pressDelete(t.title || "(untitled)");
400
395
  return;
401
396
  }
402
397
  if (input === "r") {
@@ -490,13 +485,6 @@ export const ThreadPanel = memo(function ThreadPanel({
490
485
  <Text color={theme.info}>▌</Text>
491
486
  </Box>
492
487
  )}
493
- {confirmDelete && selectedThread && (
494
- <Box paddingX={1}>
495
- <Text color="red" bold>
496
- Delete thread? (y/n)
497
- </Text>
498
- </Box>
499
- )}
500
488
  {sidebarVisible.map((thread, vi) => {
501
489
  const i = vi + sidebarScrollOffset;
502
490
  const isSelected = i === selectedIndex;
@@ -573,6 +561,10 @@ export const ThreadPanel = memo(function ThreadPanel({
573
561
  focused={focus === "detail"}
574
562
  />
575
563
  </Box>
564
+ <DeleteArmedBanner
565
+ armed={deleteConfirm.armed}
566
+ label={deleteConfirm.armedLabel}
567
+ />
576
568
  <Box>
577
569
  {following && (
578
570
  <Text color={theme.success} bold>
@@ -583,7 +575,7 @@ export const ThreadPanel = memo(function ThreadPanel({
583
575
  <Text dimColor>
584
576
  {focus === "detail"
585
577
  ? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
586
- : `↑↓ select · → enter detail · s search · f filter · d delete${selectedThread && !selectedThread.ended_at ? " · w follow" : ""} · r refresh`}
578
+ : `↑↓ select · → enter detail · s search · f filter · d delete (×2)${selectedThread && !selectedThread.ended_at ? " · w follow" : ""} · r refresh`}
587
579
  </Text>
588
580
  </Box>
589
581
  </Box>