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.
package/README.md CHANGED
@@ -76,6 +76,8 @@ Requires [Bun](https://bun.sh) 1.1+.
76
76
  bun install -g botholomew
77
77
  ```
78
78
 
79
+ The CLI installs as both `botholomew` and `bothy` — the same binary, two names.
80
+
79
81
  Or run the dev build from a checkout:
80
82
 
81
83
  ```bash
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.15.0",
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": {
7
- "botholomew": "./src/cli.ts"
7
+ "botholomew": "./src/cli.ts",
8
+ "bothy": "./src/cli.ts"
8
9
  },
9
10
  "files": [
10
11
  "src",
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
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Command } from "commander";
2
+ import { loadConfig } from "../config/loader.ts";
2
3
 
3
4
  export function registerChatCommand(program: Command) {
4
5
  program
@@ -7,7 +8,7 @@ export function registerChatCommand(program: Command) {
7
8
  "Open the interactive chat TUI\n\n" +
8
9
  " Tab navigation (Ctrl+<letter> from any tab):\n" +
9
10
  " Ctrl+a Chat Ctrl+t Tasks Ctrl+w Workers\n" +
10
- " Ctrl+o Tools Ctrl+r Threads ? Help (non-chat)\n" +
11
+ " Ctrl+o Tools Ctrl+r Threads Ctrl+g Help\n" +
11
12
  " Ctrl+n Context Ctrl+s Schedules Esc Return to Chat\n\n" +
12
13
  " Chat input:\n" +
13
14
  " Enter Send message\n" +
@@ -29,6 +30,8 @@ export function registerChatCommand(program: Command) {
29
30
  const React = await import("react");
30
31
  const { App } = await import("../tui/App.tsx");
31
32
  const dir = program.opts().dir;
33
+ const config = await loadConfig(dir);
34
+ const idleTimeoutMs = config.tui_idle_timeout_seconds * 1000;
32
35
 
33
36
  // VHS/ttyd doesn't fully negotiate the Kitty Keyboard protocol, so
34
37
  // Ink's "enabled" mode drops non-text keystrokes (Tab, Escape) under
@@ -41,6 +44,7 @@ export function registerChatCommand(program: Command) {
41
44
  projectDir: dir,
42
45
  threadId: opts.threadId,
43
46
  initialPrompt: opts.prompt,
47
+ idleTimeoutMs,
44
48
  }),
45
49
  {
46
50
  exitOnCtrlC: false,
@@ -14,6 +14,7 @@ export interface BotholomewConfig {
14
14
  worker_stopped_retention_seconds?: number;
15
15
  schedule_min_interval_seconds?: number;
16
16
  schedule_claim_stale_seconds?: number;
17
+ tui_idle_timeout_seconds?: number;
17
18
  log_level?: string;
18
19
  }
19
20
 
@@ -33,5 +34,6 @@ export const DEFAULT_CONFIG: Required<BotholomewConfig> = {
33
34
  worker_stopped_retention_seconds: 3600,
34
35
  schedule_min_interval_seconds: 60,
35
36
  schedule_claim_stale_seconds: 300,
37
+ tui_idle_timeout_seconds: 180,
36
38
  log_level: "",
37
39
  };
@@ -39,7 +39,7 @@ export interface FetchedContent {
39
39
  /**
40
40
  * MCP server that produced the content (e.g. "google-docs", "github",
41
41
  * "firecrawl"), or null when we fell back to a plain HTTP fetch. Useful
42
- * for `bothy context import` to pick a default destination subdirectory.
42
+ * for `botholomew context import` to pick a default destination subdirectory.
43
43
  */
44
44
  source: string | null;
45
45
  }
@@ -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,
@@ -33,6 +34,7 @@ import { ThreadPanel } from "./components/ThreadPanel.tsx";
33
34
  import type { ToolCallData } from "./components/ToolCall.tsx";
34
35
  import { ToolPanel } from "./components/ToolPanel.tsx";
35
36
  import { WorkerPanel } from "./components/WorkerPanel.tsx";
37
+ import { IdleProvider, useIdle } from "./idle.tsx";
36
38
  import { buildSlashCommands, getSlashMatches } from "./slashCompletion.ts";
37
39
  import { ansi } from "./theme.ts";
38
40
 
@@ -40,6 +42,7 @@ interface AppProps {
40
42
  projectDir: string;
41
43
  threadId?: string;
42
44
  initialPrompt?: string;
45
+ idleTimeoutMs: number;
43
46
  }
44
47
 
45
48
  let nextMsgId = 0;
@@ -49,9 +52,14 @@ function msgId(): string {
49
52
 
50
53
  // Tab routing: Ctrl+<letter> jumps to a tab. Chosen for memorability — first
51
54
  // available letter that doesn't collide with other Ctrl bindings (Ctrl+C exit,
52
- // Ctrl+J/K/X/E queue ops on Chat). Help is bound to `?` instead of Ctrl+H
53
- // because most terminals send Ctrl+H as ASCII 0x08 (backspace), which Ink
54
- // 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).
55
63
  const TAB_BY_CTRL_KEY: Record<string, TabId> = {
56
64
  a: 1, // ch[a]t
57
65
  o: 2, // t[o]ols
@@ -60,6 +68,9 @@ const TAB_BY_CTRL_KEY: Record<string, TabId> = {
60
68
  r: 5, // th[r]eads
61
69
  s: 6, // [s]chedules
62
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)
63
74
  };
64
75
 
65
76
  function detectToolError(output: string | undefined): boolean {
@@ -138,10 +149,35 @@ export function App({
138
149
  projectDir,
139
150
  threadId: resumeThreadId,
140
151
  initialPrompt,
152
+ idleTimeoutMs,
141
153
  }: AppProps) {
154
+ return (
155
+ <IdleProvider timeoutMs={idleTimeoutMs}>
156
+ <AppInner
157
+ projectDir={projectDir}
158
+ threadId={resumeThreadId}
159
+ initialPrompt={initialPrompt}
160
+ />
161
+ </IdleProvider>
162
+ );
163
+ }
164
+
165
+ interface AppInnerProps {
166
+ projectDir: string;
167
+ threadId?: string;
168
+ initialPrompt?: string;
169
+ }
170
+
171
+ function AppInner({
172
+ projectDir,
173
+ threadId: resumeThreadId,
174
+ initialPrompt,
175
+ }: AppInnerProps) {
142
176
  const { exit } = useApp();
177
+ const { markActivity } = useIdle();
143
178
  const [messages, setMessages] = useState<ChatMessage[]>([]);
144
179
  const [messagesEpoch, setMessagesEpoch] = useState(0);
180
+ const [usage, setUsage] = useState<ContextUsage | null>(null);
145
181
  const [inputValue, setInputValue] = useState("");
146
182
  const [inputHistory, setInputHistory] = useState<string[]>([]);
147
183
  const [isLoading, setIsLoading] = useState(false);
@@ -265,9 +301,14 @@ export function App({
265
301
  const slashCommandsRef = useRef<SlashCommand[]>([]);
266
302
  const inputValueRef = useRef("");
267
303
 
304
+ const markActivityRef = useRef(markActivity);
305
+ markActivityRef.current = markActivity;
306
+
268
307
  const stableAppHandler = useCallback(
269
308
  // biome-ignore lint/suspicious/noExplicitAny: Ink's Key type is not exported
270
309
  (input: string, key: any) => {
310
+ markActivityRef.current();
311
+
271
312
  // Ctrl+C exits
272
313
  if (input === "c" && key.ctrl) {
273
314
  exit();
@@ -293,14 +334,6 @@ export function App({
293
334
  }
294
335
  }
295
336
 
296
- // `?` jumps to the Help tab from any non-chat tab (on Chat, `?` is
297
- // a regular character). Acts as the Help shortcut since Ctrl+H is
298
- // unusable — most terminals send it as backspace.
299
- if (input === "?" && activeTabRef.current !== 1) {
300
- setActiveTab(8);
301
- return;
302
- }
303
-
304
337
  const tab = activeTabRef.current;
305
338
 
306
339
  // Esc on Chat tab while a turn is in flight: steer / interrupt.
@@ -407,12 +440,15 @@ export function App({
407
440
  if (now - lastStreamFlush >= 50) {
408
441
  setStreamingText(currentText);
409
442
  lastStreamFlush = now;
443
+ markActivityRef.current();
410
444
  }
411
445
  },
412
446
  onToolPreparing: (id, name) => {
447
+ markActivityRef.current();
413
448
  setPreparingTool({ id, name });
414
449
  },
415
450
  onToolStart: (id, name, input) => {
451
+ markActivityRef.current();
416
452
  if (currentText) {
417
453
  finalizeSegment();
418
454
  }
@@ -428,6 +464,7 @@ export function App({
428
464
  setPreparingTool(null);
429
465
  },
430
466
  onToolEnd: (id, _name, output, isError, meta) => {
467
+ markActivityRef.current();
431
468
  const tc = pendingToolCalls.find((t) => t.id === id);
432
469
  if (tc) {
433
470
  tc.running = false;
@@ -439,6 +476,9 @@ export function App({
439
476
  }
440
477
  setActiveToolCalls([...pendingToolCalls]);
441
478
  },
479
+ onUsage: (info) => {
480
+ setUsage(info);
481
+ },
442
482
  takeInjections: () => {
443
483
  // Drain queued messages into the running turn so the agent sees
444
484
  // them on the next LLM call instead of after the whole tool loop.
@@ -535,7 +575,7 @@ export function App({
535
575
  if (trimmed === "/help") {
536
576
  const skills = sessionRef.current.skills;
537
577
  const lines: string[] = [
538
- "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.",
539
579
  "",
540
580
  "Slash commands:",
541
581
  " /help Show this message",
@@ -628,6 +668,7 @@ export function App({
628
668
  setStreamingText("");
629
669
  setActiveToolCalls([]);
630
670
  setPreparingTool(null);
671
+ setUsage(null);
631
672
  } catch (err) {
632
673
  setMessages((prev) => [
633
674
  ...prev,
@@ -796,6 +837,7 @@ export function App({
796
837
  projectDir={projectDir}
797
838
  threadId={threadId}
798
839
  workerRunning={workerRunning}
840
+ usage={usage}
799
841
  />
800
842
  </Box>
801
843
 
@@ -817,7 +859,7 @@ export function App({
817
859
  header={inputBarHeader}
818
860
  slashCommands={slashCommands}
819
861
  />
820
- <TabBar activeTab={activeTab} />
862
+ <TabBar activeTab={activeTab} usage={usage} />
821
863
  </Box>
822
864
  );
823
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
+ }