botholomew 0.15.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/chat/agent.ts CHANGED
@@ -27,6 +27,11 @@ import {
27
27
  STYLE_RULES,
28
28
  } from "../worker/prompt.ts";
29
29
  import type { ChatSession } from "./session.ts";
30
+ import {
31
+ type ContextUsage,
32
+ estimateTokens,
33
+ partitionMessages,
34
+ } from "./usage.ts";
30
35
 
31
36
  registerAllTools();
32
37
 
@@ -156,6 +161,12 @@ export interface ChatTurnCallbacks {
156
161
  * the entire tool loop to finish. Each returned message is logged + pushed
157
162
  * to `messages` before the next `messages.stream(...)` call. */
158
163
  takeInjections?: () => string[];
164
+ /** Fired after each finalized assistant turn with the prompt size the
165
+ * server billed for (sum of fresh, cache-read, and cache-creation input
166
+ * tokens), the model's max input tokens, and a local estimate of where
167
+ * the bytes went. The TUI uses this to render the tab-bar indicator and
168
+ * the breakdown shown on the Help tab. */
169
+ onUsage?: (info: ContextUsage) => void;
159
170
  }
160
171
 
161
172
  /**
@@ -243,6 +254,14 @@ export async function runChatTurn(input: {
243
254
  config,
244
255
  hasMcpTools: mcpxClient != null,
245
256
  });
257
+ // Re-derive the persistent-context portion (prompts files) so the Help
258
+ // tab can show how much of the system prompt is user-authored prompts vs
259
+ // built-in instructions. Cheap — same FS read just hit by
260
+ // buildChatSystemPrompt is still hot.
261
+ const persistentContext = await loadPersistentContext(
262
+ projectDir,
263
+ keywordSource ? extractKeywords(keywordSource) : null,
264
+ );
246
265
 
247
266
  fitToContextWindow(messages, systemPrompt, maxInputTokens);
248
267
  const stream = client.messages.stream({
@@ -312,6 +331,27 @@ export async function runChatTurn(input: {
312
331
  const durationMs = Date.now() - startTime;
313
332
  const tokenCount =
314
333
  response.usage.input_tokens + response.usage.output_tokens;
334
+ const promptTokens =
335
+ response.usage.input_tokens +
336
+ (response.usage.cache_read_input_tokens ?? 0) +
337
+ (response.usage.cache_creation_input_tokens ?? 0);
338
+ if (callbacks.onUsage) {
339
+ const { textChars, toolIoChars } = partitionMessages(messages);
340
+ const promptsChars = persistentContext.length;
341
+ const instructionsChars = Math.max(0, systemPrompt.length - promptsChars);
342
+ const toolsChars = JSON.stringify(chatTools).length;
343
+ callbacks.onUsage({
344
+ used: promptTokens,
345
+ max: maxInputTokens,
346
+ breakdown: {
347
+ prompts: estimateTokens(promptsChars),
348
+ instructions: estimateTokens(instructionsChars),
349
+ tools: estimateTokens(toolsChars),
350
+ messages: estimateTokens(textChars),
351
+ toolIo: estimateTokens(toolIoChars),
352
+ },
353
+ });
354
+ }
315
355
 
316
356
  // Log assistant text
317
357
  if (assistantText) {
@@ -0,0 +1,69 @@
1
+ import type { MessageParam } from "@anthropic-ai/sdk/resources/messages";
2
+
3
+ /** Rough Anthropic-style estimate: ~4 characters per token. */
4
+ const CHARS_PER_TOKEN = 4;
5
+
6
+ /**
7
+ * Estimate of where the prompt's bytes went on the most recent assistant
8
+ * turn. The five categories sum to roughly the server-billed input-tokens
9
+ * total — they're estimates derived from string length / 4, so they don't
10
+ * line up exactly with the API's count.
11
+ */
12
+ export interface ContextBreakdown {
13
+ /** Persistent context files from `prompts/` (soul, beliefs, goals, capabilities, contextual). */
14
+ prompts: number;
15
+ /** Chat instructions block + MCP guidance + style rules + meta header. */
16
+ instructions: number;
17
+ /** Anthropic tool schemas (chat-allowed tools + MCP meta-tools). */
18
+ tools: number;
19
+ /** User and assistant text in the conversation history. */
20
+ messages: number;
21
+ /** `tool_use` and `tool_result` blocks accumulated during the conversation. */
22
+ toolIo: number;
23
+ }
24
+
25
+ export interface ContextUsage {
26
+ /** Prompt tokens billed by the server (input + cache_read + cache_creation). */
27
+ used: number;
28
+ /** Model's max input tokens (from the Anthropic Models API). */
29
+ max: number;
30
+ /** Local estimates per section. */
31
+ breakdown: ContextBreakdown;
32
+ }
33
+
34
+ export function estimateTokens(chars: number): number {
35
+ return Math.ceil(chars / CHARS_PER_TOKEN);
36
+ }
37
+
38
+ /** Walk a `messages` array and split chars into plain text vs. tool I/O. */
39
+ export function partitionMessages(messages: MessageParam[]): {
40
+ textChars: number;
41
+ toolIoChars: number;
42
+ } {
43
+ let textChars = 0;
44
+ let toolIoChars = 0;
45
+ for (const msg of messages) {
46
+ if (typeof msg.content === "string") {
47
+ textChars += msg.content.length;
48
+ continue;
49
+ }
50
+ if (!Array.isArray(msg.content)) continue;
51
+ for (const block of msg.content) {
52
+ if (!("type" in block)) continue;
53
+ if (block.type === "text") {
54
+ textChars += block.text.length;
55
+ } else if (block.type === "tool_use") {
56
+ toolIoChars += JSON.stringify(block).length;
57
+ } else if (block.type === "tool_result") {
58
+ toolIoChars +=
59
+ typeof block.content === "string"
60
+ ? block.content.length
61
+ : JSON.stringify(block.content).length;
62
+ } else {
63
+ // image, document, etc. — count under text for now.
64
+ textChars += JSON.stringify(block).length;
65
+ }
66
+ }
67
+ }
68
+ return { textChars, toolIoChars };
69
+ }
@@ -8,7 +8,7 @@ export function registerChatCommand(program: Command) {
8
8
  "Open the interactive chat TUI\n\n" +
9
9
  " Tab navigation (Ctrl+<letter> from any tab):\n" +
10
10
  " Ctrl+a Chat Ctrl+t Tasks Ctrl+w Workers\n" +
11
- " Ctrl+o Tools Ctrl+r Threads ? Help (non-chat)\n" +
11
+ " Ctrl+o Tools Ctrl+r Threads Ctrl+g Help\n" +
12
12
  " Ctrl+n Context Ctrl+s Schedules Esc Return to Chat\n\n" +
13
13
  " Chat input:\n" +
14
14
  " Enter Send message\n" +
@@ -171,14 +171,17 @@ export function normalizeContextPath(path: string): string {
171
171
  * or attempts to resolve into a protected area.
172
172
  *
173
173
  * `allowSymlinks` is the opt-in for read-side callers (read, list, tree,
174
- * info, reindex, delete). Mutating callers (write, edit, mv, cp, mkdir)
175
- * leave it `false` so user-placed symlinks under `context/` cannot be
176
- * traversed to modify external content.
174
+ * info, reindex). Mutating callers (write, edit, mv, cp, mkdir) leave it
175
+ * `false` so user-placed symlinks under `context/` cannot be traversed to
176
+ * modify external content. `allowSymlinkLeaf` is the narrower opt-in for
177
+ * `delete`: the leaf may be a symlink (so the agent can unlink it) but
178
+ * parent components may not, so a delete cannot reach external content
179
+ * through a symlinked parent directory.
177
180
  */
178
181
  async function resolveContext(
179
182
  projectDir: string,
180
183
  path: string,
181
- opts: { allowSymlinks?: boolean } = {},
184
+ opts: { allowSymlinks?: boolean; allowSymlinkLeaf?: boolean } = {},
182
185
  ): Promise<string> {
183
186
  const normalized = normalizeContextPath(path);
184
187
  if (PROTECTED_AREAS.has(normalized)) {
@@ -190,6 +193,7 @@ async function resolveContext(
190
193
  return resolveInRoot(projectDir, fromPosix(normalized), {
191
194
  area: CONTEXT_DIR,
192
195
  allowSymlinks: opts.allowSymlinks,
196
+ allowSymlinkLeaf: opts.allowSymlinkLeaf,
193
197
  });
194
198
  }
195
199
 
@@ -343,7 +347,9 @@ export async function deleteContextPath(
343
347
  path: string,
344
348
  opts: { recursive?: boolean } = {},
345
349
  ): Promise<{ removed: number; was_directory: boolean; was_symlink: boolean }> {
346
- const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
350
+ const abs = await resolveContext(projectDir, path, {
351
+ allowSymlinkLeaf: true,
352
+ });
347
353
  const normalized = normalizeContextPath(path);
348
354
  if (normalized === "") {
349
355
  throw new PathEscapeError("refusing to delete the context root", path);
@@ -94,6 +94,23 @@ export async function deleteIndexedPath(
94
94
  return result.changes;
95
95
  }
96
96
 
97
+ /**
98
+ * Remove every indexed entry whose path equals `prefix` or lives beneath
99
+ * `prefix/`. Used when a folder is deleted from `context/` and we need to
100
+ * drop all child entries in one shot.
101
+ */
102
+ export async function deleteIndexedPathsUnder(
103
+ conn: DbConnection,
104
+ prefix: string,
105
+ ): Promise<number> {
106
+ const result = await conn.queryRun(
107
+ "DELETE FROM context_index WHERE path = ?1 OR path LIKE ?2",
108
+ prefix,
109
+ `${prefix}/%`,
110
+ );
111
+ return result.changes;
112
+ }
113
+
97
114
  export interface IndexedPathSummary {
98
115
  path: string;
99
116
  content_hash: string;
package/src/fs/sandbox.ts CHANGED
@@ -36,6 +36,15 @@ export interface SandboxOptions {
36
36
  * content.
37
37
  */
38
38
  allowSymlinks?: boolean;
39
+ /**
40
+ * Permit a symlink only as the final path component. Parent components
41
+ * are still lstat-walked and rejected if any is a symlink. This is the
42
+ * mode for mutating callers that intentionally operate on a symlink
43
+ * leaf (e.g., `deleteContextPath` unlinking a user-placed symlink) but
44
+ * must not be coaxed into reaching outside content via a symlinked
45
+ * parent directory.
46
+ */
47
+ allowSymlinkLeaf?: boolean;
39
48
  }
40
49
 
41
50
  let cachedCanonicalRoot: string | null = null;
@@ -97,7 +106,11 @@ export async function resolveInRoot(
97
106
  ensureContainment(resolved, boundary, opts.allowRoot ?? true, userPath);
98
107
 
99
108
  if (!opts.allowSymlinks) {
100
- await assertNoSymlinkComponents(resolved, canonicalRoot);
109
+ await assertNoSymlinkComponents(
110
+ resolved,
111
+ canonicalRoot,
112
+ opts.allowSymlinkLeaf ?? false,
113
+ );
101
114
  }
102
115
  return resolved;
103
116
  }
@@ -122,7 +135,11 @@ export function resolveInRootSync(
122
135
  ensureContainment(resolved, boundary, opts.allowRoot ?? true, userPath);
123
136
 
124
137
  if (!opts.allowSymlinks) {
125
- assertNoSymlinkComponentsSync(resolved, canonicalRoot);
138
+ assertNoSymlinkComponentsSync(
139
+ resolved,
140
+ canonicalRoot,
141
+ opts.allowSymlinkLeaf ?? false,
142
+ );
126
143
  }
127
144
  return resolved;
128
145
  }
@@ -181,19 +198,24 @@ function ensureContainment(
181
198
  async function assertNoSymlinkComponents(
182
199
  resolved: string,
183
200
  canonicalRoot: string,
201
+ allowLeaf: boolean,
184
202
  ): Promise<void> {
185
203
  // Walk from canonical root toward the leaf, lstat'ing each existing
186
204
  // component. The root itself is canonical (already realpath'd) so we skip
187
205
  // it; we only care that nothing the agent writes goes through a symlink.
206
+ // When `allowLeaf` is true, the final component may itself be a symlink
207
+ // (e.g., delete unlinking a user-placed symlink) — only parents are checked.
188
208
  const rel = resolved.slice(canonicalRoot.length);
189
209
  if (!rel || rel === sep) return;
190
210
  const parts = rel.split(sep).filter((p) => p.length > 0);
191
211
  let current = canonicalRoot;
192
- for (const part of parts) {
193
- current = current + sep + part;
212
+ for (let i = 0; i < parts.length; i++) {
213
+ current = current + sep + parts[i];
214
+ const isLeaf = i === parts.length - 1;
194
215
  try {
195
216
  const st = await lstat(current);
196
217
  if (st.isSymbolicLink()) {
218
+ if (isLeaf && allowLeaf) continue;
197
219
  throw new PathEscapeError(
198
220
  `path traverses a symlink: ${current}`,
199
221
  resolved,
@@ -214,16 +236,19 @@ async function assertNoSymlinkComponents(
214
236
  function assertNoSymlinkComponentsSync(
215
237
  resolved: string,
216
238
  canonicalRoot: string,
239
+ allowLeaf: boolean,
217
240
  ): void {
218
241
  const rel = resolved.slice(canonicalRoot.length);
219
242
  if (!rel || rel === sep) return;
220
243
  const parts = rel.split(sep).filter((p) => p.length > 0);
221
244
  let current = canonicalRoot;
222
- for (const part of parts) {
223
- current = current + sep + part;
245
+ for (let i = 0; i < parts.length; i++) {
246
+ current = current + sep + parts[i];
247
+ const isLeaf = i === parts.length - 1;
224
248
  try {
225
249
  const st = lstatSync(current);
226
250
  if (st.isSymbolicLink()) {
251
+ if (isLeaf && allowLeaf) continue;
227
252
  throw new PathEscapeError(
228
253
  `path traverses a symlink: ${current}`,
229
254
  resolved,
package/src/tui/App.tsx CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  sendMessage,
9
9
  startChatSession,
10
10
  } from "../chat/session.ts";
11
+ import type { ContextUsage } from "../chat/usage.ts";
11
12
  import {
12
13
  BUILTIN_SLASH_COMMANDS,
13
14
  handleSlashCommand,
@@ -51,9 +52,14 @@ function msgId(): string {
51
52
 
52
53
  // Tab routing: Ctrl+<letter> jumps to a tab. Chosen for memorability — first
53
54
  // available letter that doesn't collide with other Ctrl bindings (Ctrl+C exit,
54
- // Ctrl+J/K/X/E queue ops on Chat). Help is bound to `?` instead of Ctrl+H
55
- // because most terminals send Ctrl+H as ASCII 0x08 (backspace), which Ink
56
- // reports as `key.backspace=true`, not `input='h'`.
55
+ // Ctrl+J/K/X/E queue ops on Chat).
56
+ //
57
+ // Help is bound to Ctrl+G rather than Ctrl+H because most terminals deliver
58
+ // Ctrl+H as ASCII 0x08 (backspace). Bonus: macOS Terminal.app and several
59
+ // other terminals map Ctrl+/ to BEL (0x07), the same byte as Ctrl+G — so this
60
+ // binding also catches the Ctrl+/ keystroke on those terminals "for free".
61
+ // We also accept "/" and "_" as fallbacks for terminals that deliver Ctrl+/
62
+ // as 0x1F or as the literal "/" with ctrl=true (Kitty keyboard protocol).
57
63
  const TAB_BY_CTRL_KEY: Record<string, TabId> = {
58
64
  a: 1, // ch[a]t
59
65
  o: 2, // t[o]ols
@@ -62,6 +68,9 @@ const TAB_BY_CTRL_KEY: Record<string, TabId> = {
62
68
  r: 5, // th[r]eads
63
69
  s: 6, // [s]chedules
64
70
  w: 7, // [w]orkers
71
+ g: 8, // help (also catches Ctrl+/ on terminals that map it to BEL)
72
+ "/": 8, // help (Kitty keyboard protocol)
73
+ _: 8, // help (terminals that send Ctrl+/ as 0x1F)
65
74
  };
66
75
 
67
76
  function detectToolError(output: string | undefined): boolean {
@@ -168,6 +177,7 @@ function AppInner({
168
177
  const { markActivity } = useIdle();
169
178
  const [messages, setMessages] = useState<ChatMessage[]>([]);
170
179
  const [messagesEpoch, setMessagesEpoch] = useState(0);
180
+ const [usage, setUsage] = useState<ContextUsage | null>(null);
171
181
  const [inputValue, setInputValue] = useState("");
172
182
  const [inputHistory, setInputHistory] = useState<string[]>([]);
173
183
  const [isLoading, setIsLoading] = useState(false);
@@ -324,14 +334,6 @@ function AppInner({
324
334
  }
325
335
  }
326
336
 
327
- // `?` jumps to the Help tab from any non-chat tab (on Chat, `?` is
328
- // a regular character). Acts as the Help shortcut since Ctrl+H is
329
- // unusable — most terminals send it as backspace.
330
- if (input === "?" && activeTabRef.current !== 1) {
331
- setActiveTab(8);
332
- return;
333
- }
334
-
335
337
  const tab = activeTabRef.current;
336
338
 
337
339
  // Esc on Chat tab while a turn is in flight: steer / interrupt.
@@ -474,6 +476,9 @@ function AppInner({
474
476
  }
475
477
  setActiveToolCalls([...pendingToolCalls]);
476
478
  },
479
+ onUsage: (info) => {
480
+ setUsage(info);
481
+ },
477
482
  takeInjections: () => {
478
483
  // Drain queued messages into the running turn so the agent sees
479
484
  // them on the next LLM call instead of after the whole tool loop.
@@ -570,7 +575,7 @@ function AppInner({
570
575
  if (trimmed === "/help") {
571
576
  const skills = sessionRef.current.skills;
572
577
  const lines: string[] = [
573
- "For the full keyboard reference, switch to the Help tab (`?` from any non-chat tab) — this message lists chat commands only.",
578
+ "For the full keyboard reference, switch to the Help tab (`Ctrl+g`) — this message lists chat commands only.",
574
579
  "",
575
580
  "Slash commands:",
576
581
  " /help Show this message",
@@ -663,6 +668,7 @@ function AppInner({
663
668
  setStreamingText("");
664
669
  setActiveToolCalls([]);
665
670
  setPreparingTool(null);
671
+ setUsage(null);
666
672
  } catch (err) {
667
673
  setMessages((prev) => [
668
674
  ...prev,
@@ -831,6 +837,7 @@ function AppInner({
831
837
  projectDir={projectDir}
832
838
  threadId={threadId}
833
839
  workerRunning={workerRunning}
840
+ usage={usage}
834
841
  />
835
842
  </Box>
836
843
 
@@ -852,7 +859,7 @@ function AppInner({
852
859
  header={inputBarHeader}
853
860
  slashCommands={slashCommands}
854
861
  />
855
- <TabBar activeTab={activeTab} />
862
+ <TabBar activeTab={activeTab} usage={usage} />
856
863
  </Box>
857
864
  );
858
865
  }
@@ -3,13 +3,17 @@ import { memo, useCallback, useEffect, useMemo, useState } from "react";
3
3
  import { getDbPath } from "../../constants.ts";
4
4
  import {
5
5
  type ContextEntry,
6
+ deleteContextPath,
6
7
  listContextDir,
7
8
  readContextFile,
8
9
  } from "../../context/store.ts";
9
10
  import { withDb } from "../../db/connection.ts";
10
11
  import {
12
+ deleteIndexedPath,
13
+ deleteIndexedPathsUnder,
11
14
  getIndexedPath,
12
15
  type IndexedPathSummary,
16
+ rebuildSearchIndex,
13
17
  } from "../../db/embeddings.ts";
14
18
  import {
15
19
  detailPaneBorderProps,
@@ -18,7 +22,9 @@ import {
18
22
  } from "../listDetailKeys.ts";
19
23
  import { isMarkdownPath, renderMarkdown } from "../markdown.ts";
20
24
  import { theme } from "../theme.ts";
25
+ import { useDeleteConfirm } from "../useDeleteConfirm.ts";
21
26
  import { useLatestRef } from "../useLatestRef.ts";
27
+ import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
22
28
  import { Scrollbar } from "./Scrollbar.tsx";
23
29
 
24
30
  interface ContextPanelProps {
@@ -163,8 +169,33 @@ export const ContextPanel = memo(function ContextPanel({
163
169
  const currentPathRef = useLatestRef(currentPath);
164
170
  const focusRef = useLatestRef(focus);
165
171
 
172
+ const deleteConfirm = useDeleteConfirm(() => {
173
+ const entry = selectedEntryRef.current;
174
+ if (!entry) return;
175
+ const path = entry.path;
176
+ const isDirectory = entry.is_directory;
177
+ (async () => {
178
+ try {
179
+ await deleteContextPath(projectDir, path, { recursive: isDirectory });
180
+ await withDb(getDbPath(projectDir), async (conn) => {
181
+ if (isDirectory) {
182
+ await deleteIndexedPathsUnder(conn, path);
183
+ } else {
184
+ await deleteIndexedPath(conn, path);
185
+ }
186
+ await rebuildSearchIndex(conn);
187
+ });
188
+ } catch {
189
+ // ignore — refresh will reflect any partial state
190
+ }
191
+ refresh(currentPathRef.current);
192
+ })();
193
+ });
194
+
166
195
  useInput(
167
196
  (input, key) => {
197
+ if (input !== "d") deleteConfirm.cancel();
198
+
168
199
  if (
169
200
  handleListDetailKey(input, key, {
170
201
  focusRef,
@@ -199,6 +230,12 @@ export const ContextPanel = memo(function ContextPanel({
199
230
  return;
200
231
  }
201
232
 
233
+ if (input === "d") {
234
+ const entry = selectedEntryRef.current;
235
+ if (!entry) return;
236
+ deleteConfirm.pressDelete(entry.path);
237
+ return;
238
+ }
202
239
  if (input === "r") {
203
240
  refresh(currentPathRef.current);
204
241
  }
@@ -322,11 +359,15 @@ export const ContextPanel = memo(function ContextPanel({
322
359
  ) : (
323
360
  <Text dimColor>(no item selected)</Text>
324
361
  )}
362
+ <DeleteArmedBanner
363
+ armed={deleteConfirm.armed}
364
+ label={deleteConfirm.armedLabel}
365
+ />
325
366
  <Box>
326
367
  <Text dimColor>
327
368
  {focus === "detail"
328
369
  ? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
329
- : "↑↓ select · → drill in/enter detail · ← up · r refresh"}
370
+ : "↑↓ select · → drill in/enter detail · ← up · d delete (×2) · r refresh"}
330
371
  </Text>
331
372
  </Box>
332
373
  </Box>
@@ -0,0 +1,18 @@
1
+ import { Box, Text } from "ink";
2
+ import { theme } from "../theme.ts";
3
+
4
+ interface DeleteArmedBannerProps {
5
+ armed: boolean;
6
+ label: string | null;
7
+ }
8
+
9
+ export function DeleteArmedBanner({ armed, label }: DeleteArmedBannerProps) {
10
+ if (!armed) return null;
11
+ return (
12
+ <Box>
13
+ <Text color={theme.error} bold>
14
+ ⚠ Press d again to delete {label ?? ""} (any other key cancels)
15
+ </Text>
16
+ </Box>
17
+ );
18
+ }
@@ -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
@@ -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>
@@ -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>
@@ -1,13 +1,20 @@
1
+ import { basename } from "node:path";
1
2
  import { Box, Text, useInput, useStdout } from "ink";
2
3
  import { memo, useEffect, useMemo, useState } from "react";
3
4
  import { readLogTail } from "../../worker/log-reader.ts";
4
- import { listWorkers, type Worker } from "../../workers/store.ts";
5
+ import {
6
+ deleteWorkerLog,
7
+ listWorkers,
8
+ type Worker,
9
+ } from "../../workers/store.ts";
5
10
  import {
6
11
  detailPaneBorderProps,
7
12
  type FocusState,
8
13
  handleListDetailKey,
9
14
  } from "../listDetailKeys.ts";
15
+ import { useDeleteConfirm } from "../useDeleteConfirm.ts";
10
16
  import { useLatestRef } from "../useLatestRef.ts";
17
+ import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
11
18
  import { Scrollbar } from "./Scrollbar.tsx";
12
19
 
13
20
  interface WorkerPanelProps {
@@ -161,6 +168,21 @@ export const WorkerPanel = memo(function WorkerPanel({
161
168
  const itemCountRef = useLatestRef(workers.length);
162
169
  const maxLogScrollRef = useLatestRef(maxLogScroll);
163
170
  const focusRef = useLatestRef(focus);
171
+ const viewModeRef = useLatestRef(viewMode);
172
+ const selectedLogPathRef = useLatestRef(selectedLogPath);
173
+
174
+ const deleteConfirm = useDeleteConfirm(() => {
175
+ const path = selectedLogPathRef.current;
176
+ if (!path) return;
177
+ deleteWorkerLog(projectDir, path)
178
+ .catch(() => {})
179
+ .finally(() => {
180
+ setLogContent("");
181
+ setLogSize(0);
182
+ setLogTruncated(false);
183
+ setLogScroll(0);
184
+ });
185
+ });
164
186
 
165
187
  // The right pane scrolls with arrows when focused. Tee the log scroll into
166
188
  // the follow-state so reaching the bottom resumes follow mode (and any
@@ -181,6 +203,8 @@ export const WorkerPanel = memo(function WorkerPanel({
181
203
  (input, key) => {
182
204
  if (!isActive) return;
183
205
 
206
+ if (input !== "d") deleteConfirm.cancel();
207
+
184
208
  // `l` toggles between detail (worker info) and log (tail) view in the
185
209
  // right pane.
186
210
  if (input === "l") {
@@ -206,6 +230,14 @@ export const WorkerPanel = memo(function WorkerPanel({
206
230
  setFilterIdx((i) => (i + 1) % STATUS_FILTERS.length);
207
231
  return;
208
232
  }
233
+
234
+ if (input === "d") {
235
+ if (viewModeRef.current !== "log") return;
236
+ const path = selectedLogPathRef.current;
237
+ if (!path) return;
238
+ deleteConfirm.pressDelete(`worker log: ${basename(path)}`);
239
+ return;
240
+ }
209
241
  },
210
242
  { isActive },
211
243
  );
@@ -225,10 +257,14 @@ export const WorkerPanel = memo(function WorkerPanel({
225
257
  {focus === "detail"
226
258
  ? " · ↑↓ scroll ⇧↑↓ page g/G top/bot ← back to list l toggle"
227
259
  : viewMode === "log"
228
- ? " · ↑↓ select → enter log l detail f filter"
260
+ ? " · ↑↓ select → enter log l detail f filter d delete log (×2)"
229
261
  : " · ↑↓ select → enter detail l view log f filter"}
230
262
  </Text>
231
263
  </Box>
264
+ <DeleteArmedBanner
265
+ armed={deleteConfirm.armed}
266
+ label={deleteConfirm.armedLabel}
267
+ />
232
268
 
233
269
  {workers.length === 0 ? (
234
270
  <Text dimColor>
@@ -0,0 +1,115 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ /**
4
+ * Two-press delete confirmation. First press arms; second press within
5
+ * `ttlMs` confirms. Any non-`d` keystroke should call `cancel()`. The TTL
6
+ * is a safety net for idle/escape.
7
+ */
8
+
9
+ export interface DeleteConfirmController {
10
+ isArmed(): boolean;
11
+ armedLabel(): string | null;
12
+ pressDelete: (label: string) => void;
13
+ cancel: () => void;
14
+ dispose: () => void;
15
+ }
16
+
17
+ export function createDeleteConfirmController(
18
+ onConfirm: () => void,
19
+ opts: { ttlMs?: number; onChange?: () => void } = {},
20
+ ): DeleteConfirmController {
21
+ const ttlMs = opts.ttlMs ?? 3000;
22
+ let armed = false;
23
+ let label: string | null = null;
24
+ let timer: ReturnType<typeof setTimeout> | null = null;
25
+
26
+ const clearTimer = () => {
27
+ if (timer) {
28
+ clearTimeout(timer);
29
+ timer = null;
30
+ }
31
+ };
32
+
33
+ const notify = () => {
34
+ opts.onChange?.();
35
+ };
36
+
37
+ const pressDelete = (next: string) => {
38
+ if (armed) {
39
+ clearTimer();
40
+ armed = false;
41
+ label = null;
42
+ notify();
43
+ onConfirm();
44
+ return;
45
+ }
46
+ armed = true;
47
+ label = next;
48
+ timer = setTimeout(() => {
49
+ armed = false;
50
+ label = null;
51
+ timer = null;
52
+ notify();
53
+ }, ttlMs);
54
+ notify();
55
+ };
56
+
57
+ const cancel = () => {
58
+ if (!armed && !timer) return;
59
+ clearTimer();
60
+ armed = false;
61
+ label = null;
62
+ notify();
63
+ };
64
+
65
+ const dispose = () => {
66
+ clearTimer();
67
+ };
68
+
69
+ return {
70
+ isArmed: () => armed,
71
+ armedLabel: () => label,
72
+ pressDelete,
73
+ cancel,
74
+ dispose,
75
+ };
76
+ }
77
+
78
+ export interface UseDeleteConfirmResult {
79
+ armed: boolean;
80
+ armedLabel: string | null;
81
+ pressDelete: (label: string) => void;
82
+ cancel: () => void;
83
+ }
84
+
85
+ export function useDeleteConfirm(
86
+ onConfirm: () => void,
87
+ opts: { ttlMs?: number } = {},
88
+ ): UseDeleteConfirmResult {
89
+ const [, setTick] = useState(0);
90
+
91
+ const onConfirmRef = useRef(onConfirm);
92
+ onConfirmRef.current = onConfirm;
93
+
94
+ const controllerRef = useRef<DeleteConfirmController | null>(null);
95
+ if (!controllerRef.current) {
96
+ controllerRef.current = createDeleteConfirmController(
97
+ () => onConfirmRef.current(),
98
+ { ttlMs: opts.ttlMs, onChange: () => setTick((t) => t + 1) },
99
+ );
100
+ }
101
+
102
+ useEffect(() => {
103
+ return () => {
104
+ controllerRef.current?.dispose();
105
+ };
106
+ }, []);
107
+
108
+ const c = controllerRef.current;
109
+ return {
110
+ armed: c.isArmed(),
111
+ armedLabel: c.armedLabel(),
112
+ pressDelete: c.pressDelete,
113
+ cancel: c.cancel,
114
+ };
115
+ }
@@ -1,6 +1,6 @@
1
1
  import { readdir, stat, unlink } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { getWorkersDir } from "../constants.ts";
2
+ import { join, resolve } from "node:path";
3
+ import { getWorkerLogsDir, getWorkersDir } from "../constants.ts";
4
4
  import { atomicWrite, readWithMtime } from "../fs/atomic.ts";
5
5
 
6
6
  export const WORKER_MODES = ["persist", "once"] as const;
@@ -217,6 +217,28 @@ export async function deleteWorker(
217
217
  }
218
218
  }
219
219
 
220
+ /**
221
+ * Delete a worker's on-disk log file. Refuses to touch anything outside
222
+ * `<projectDir>/logs/`. ENOENT is treated as success (idempotent).
223
+ */
224
+ export async function deleteWorkerLog(
225
+ projectDir: string,
226
+ logPath: string,
227
+ ): Promise<boolean> {
228
+ const logsDir = resolve(getWorkerLogsDir(projectDir));
229
+ const target = resolve(logPath);
230
+ if (target !== logsDir && !target.startsWith(`${logsDir}/`)) {
231
+ throw new Error(`refusing to delete log outside ${logsDir}: ${logPath}`);
232
+ }
233
+ try {
234
+ await unlink(target);
235
+ return true;
236
+ } catch (err) {
237
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
238
+ throw err;
239
+ }
240
+ }
241
+
220
242
  async function listWorkerIds(projectDir: string): Promise<string[]> {
221
243
  const dir = getWorkersDir(projectDir);
222
244
  try {