botholomew 0.18.5 → 0.18.6

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.18.5",
3
+ "version": "0.18.6",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { formatCallToolResult } from "../../mcpx/client.ts";
3
3
  import { fakeMcpExec, isCaptureMode } from "../../worker/fake-mcp.ts";
4
- import type { ToolDefinition } from "../tool.ts";
4
+ import { getTool, type ToolDefinition } from "../tool.ts";
5
5
 
6
6
  const inputSchema = z.object({
7
7
  server: z.string().describe("MCP server name"),
@@ -82,6 +82,18 @@ export const mcpExecTool = {
82
82
  inputSchema,
83
83
  outputSchema,
84
84
  execute: async (input, ctx) => {
85
+ // Guard: the agent sometimes routes a top-level Botholomew tool through
86
+ // mcp_exec (e.g. read_large_result on a payload that originated from an
87
+ // MCP server). Bounce with a clear redirect rather than forwarding to a
88
+ // server that doesn't have the tool.
89
+ if (getTool(input.tool)) {
90
+ return {
91
+ result: `\`${input.tool}\` is a top-level Botholomew tool, not an MCP tool. Call it directly by name instead of routing it through mcp_exec.`,
92
+ is_error: true,
93
+ error_kind: "input_error" as const,
94
+ hint: `Re-emit a tool_use block with name="${input.tool}" and its own input schema. Do not wrap it in mcp_exec.`,
95
+ };
96
+ }
85
97
  if (isCaptureMode()) {
86
98
  const canned = fakeMcpExec(input.server, input.tool, input.args);
87
99
  if (canned) {
@@ -38,7 +38,7 @@ const outputSchema = z.object({
38
38
 
39
39
  export const readLargeResultTool = {
40
40
  name: "read_large_result",
41
- description: `[[ bash equivalent command: sed -n '<page>p' ]] Read one page of a large tool result that was cached because its inline payload exceeded the response budget. Use the id from the "Paginated for LLM" stub. Pages are 1-based and ~${PAGE_SIZE_CHARS} chars each; loop from page=1 to total_pages.`,
41
+ description: `[[ bash equivalent command: sed -n '<page>p' ]] Read one page of a large tool result that Botholomew cached because its inline payload exceeded the response budget. This is a TOP-LEVEL Botholomew tool — call it directly by name, do NOT route it through mcp_exec (it is not an MCP tool, even if the originating result came from an MCP server). Use the id from the "Paginated for LLM" stub. Pages are 1-based and ~${PAGE_SIZE_CHARS} chars each; loop from page=1 to total_pages.`,
42
42
  group: "util",
43
43
  inputSchema,
44
44
  outputSchema,
@@ -29,15 +29,48 @@ interface ContextEntry {
29
29
  description: string | null;
30
30
  }
31
31
 
32
+ interface SearchHit {
33
+ logical_path: string;
34
+ version_id: string;
35
+ chunk_index: number;
36
+ snippet: string;
37
+ score: number;
38
+ }
39
+
40
+ type SidebarRow =
41
+ | {
42
+ kind: "dir";
43
+ name: string;
44
+ full_path: string;
45
+ child_count: number;
46
+ }
47
+ | { kind: "file"; entry: ContextEntry }
48
+ | { kind: "hit"; hit: SearchHit };
49
+
50
+ type ViewMode = "tree" | "search";
51
+
32
52
  const SIDEBAR_WIDTH = 40;
33
53
  const PAGE_SCROLL_LINES = 10;
54
+ const LIST_LIMIT = 1000;
55
+ const SEARCH_LIMIT = 50;
34
56
 
35
57
  /**
36
- * Browse the membot knowledge store. Each row is a current-version entry; the
37
- * detail pane shows the cleaned markdown surrogate. Membot has no real
38
- * directories `logical_path` segments are just slashes so this is a flat
39
- * paginated list rather than a tree drill-in. Use `botholomew membot tree` /
40
- * `botholomew membot search` for hierarchical or content-based discovery.
58
+ * Browse the membot knowledge store. Two modes share the panel:
59
+ *
60
+ * tree sidebar shows the immediate children (directories + files)
61
+ * of a `currentPrefix` segment of the logical-path namespace.
62
+ * `→` on a directory drills in; `←` from the list pops one
63
+ * segment back up. Directories are synthesised from `/`
64
+ * separators in `logical_path` (membot has no real folders).
65
+ *
66
+ * search — `/` or `s` opens an inline input; `Enter` runs hybrid
67
+ * semantic + BM25 search via `mem.search()` and replaces the
68
+ * sidebar with ranked hits + snippets. `Esc` returns to tree.
69
+ *
70
+ * The DuckDB lock is opened per op (`scopedWithMem`) — never held for the
71
+ * panel's mount lifetime — so concurrent workers / chat turns / the
72
+ * membot CLI can claim the shared `~/.membot` store while this panel
73
+ * sits idle.
41
74
  */
42
75
  export const ContextPanel = memo(function ContextPanel({
43
76
  projectDir,
@@ -46,10 +79,6 @@ export const ContextPanel = memo(function ContextPanel({
46
79
  const { rows: termRows, cols: termCols } = useTerminalSize();
47
80
  const detailWidth = Math.max(1, termCols - SIDEBAR_WIDTH - 5);
48
81
 
49
- // Open a fresh membot client per op (list/read/delete) instead of holding
50
- // one for the panel's lifetime. Holding the DuckDB file lock for the panel
51
- // mount would block other Botholomew processes (workers, chat turns, the
52
- // membot CLI) from the shared `~/.membot` store while this panel sits idle.
53
82
  const [membotDir, setMembotDir] = useState<string | null>(null);
54
83
  useEffect(() => {
55
84
  let cancelled = false;
@@ -67,7 +96,14 @@ export const ContextPanel = memo(function ContextPanel({
67
96
  [membotDir],
68
97
  );
69
98
 
99
+ const [mode, setMode] = useState<ViewMode>("tree");
100
+ const [currentPrefix, setCurrentPrefix] = useState("");
70
101
  const [entries, setEntries] = useState<ContextEntry[]>([]);
102
+ const [searching, setSearching] = useState(false);
103
+ const [searchQuery, setSearchQuery] = useState("");
104
+ const [searchResults, setSearchResults] = useState<SearchHit[]>([]);
105
+ const [searchError, setSearchError] = useState<string | null>(null);
106
+ const [searchRunning, setSearchRunning] = useState(false);
71
107
  const [selectedIndex, setSelectedIndex] = useState(0);
72
108
  const [sidebarScrollOffset, setSidebarScrollOffset] = useState(0);
73
109
  const [detailScroll, setDetailScroll] = useState(0);
@@ -79,31 +115,74 @@ export const ContextPanel = memo(function ContextPanel({
79
115
 
80
116
  const visibleRows = Math.max(1, termRows - 6);
81
117
 
82
- const refresh = useCallback(async () => {
83
- if (!withMem) return;
84
- try {
85
- const out = await withMem((mem) => mem.list({ limit: 500 }));
86
- const list = out.entries.map((e) => ({
87
- logical_path: e.logical_path,
88
- version_id: e.version_id,
89
- size_bytes: e.size_bytes,
90
- mime_type: e.mime_type,
91
- description: e.description,
92
- }));
93
- list.sort((a, b) => a.logical_path.localeCompare(b.logical_path));
94
- setEntries(list);
95
- setSelectedIndex(0);
96
- setSidebarScrollOffset(0);
97
- } catch {
98
- setEntries([]);
118
+ const loadTree = useCallback(
119
+ async (prefix: string) => {
120
+ if (!withMem) return;
121
+ try {
122
+ const out = await withMem((mem) =>
123
+ mem.list({ prefix: prefix || undefined, limit: LIST_LIMIT }),
124
+ );
125
+ setEntries(
126
+ out.entries.map((e) => ({
127
+ logical_path: e.logical_path,
128
+ version_id: e.version_id,
129
+ size_bytes: e.size_bytes,
130
+ mime_type: e.mime_type,
131
+ description: e.description,
132
+ })),
133
+ );
134
+ } catch {
135
+ setEntries([]);
136
+ }
99
137
  setSelectedIndex(0);
100
138
  setSidebarScrollOffset(0);
101
- }
102
- }, [withMem]);
139
+ },
140
+ [withMem],
141
+ );
103
142
 
104
143
  useEffect(() => {
105
- refresh();
106
- }, [refresh]);
144
+ if (mode === "tree") loadTree(currentPrefix);
145
+ }, [mode, currentPrefix, loadTree]);
146
+
147
+ // Derived: immediate children at `currentPrefix`. Directories are
148
+ // grouped by their first remaining segment; files are entries whose
149
+ // suffix has no further `/`.
150
+ const treeRows: SidebarRow[] = useMemo(() => {
151
+ const dirs = new Map<string, number>();
152
+ const files: ContextEntry[] = [];
153
+ for (const e of entries) {
154
+ if (!e.logical_path.startsWith(currentPrefix)) continue;
155
+ const suffix = e.logical_path.slice(currentPrefix.length);
156
+ if (!suffix) continue;
157
+ const slash = suffix.indexOf("/");
158
+ if (slash < 0) {
159
+ files.push(e);
160
+ } else {
161
+ const name = suffix.slice(0, slash);
162
+ dirs.set(name, (dirs.get(name) ?? 0) + 1);
163
+ }
164
+ }
165
+ const dirRows: SidebarRow[] = [...dirs.entries()]
166
+ .sort((a, b) => a[0].localeCompare(b[0]))
167
+ .map(([name, count]) => ({
168
+ kind: "dir",
169
+ name,
170
+ full_path: currentPrefix + name,
171
+ child_count: count,
172
+ }));
173
+ const fileRows: SidebarRow[] = files
174
+ .sort((a, b) => a.logical_path.localeCompare(b.logical_path))
175
+ .map((entry) => ({ kind: "file", entry }));
176
+ return [...dirRows, ...fileRows];
177
+ }, [entries, currentPrefix]);
178
+
179
+ const searchRows: SidebarRow[] = useMemo(
180
+ () => searchResults.map((hit) => ({ kind: "hit", hit })),
181
+ [searchResults],
182
+ );
183
+
184
+ const rows = mode === "tree" ? treeRows : searchRows;
185
+ const selectedRow = rows[selectedIndex];
107
186
 
108
187
  useEffect(() => {
109
188
  if (selectedIndex < sidebarScrollOffset) {
@@ -113,75 +192,259 @@ export const ContextPanel = memo(function ContextPanel({
113
192
  }
114
193
  }, [selectedIndex, sidebarScrollOffset, visibleRows]);
115
194
 
116
- const selectedEntry = entries[selectedIndex];
195
+ // Fetch detail content for the selected row.
196
+ const selectedReadPath = useMemo(() => {
197
+ if (!selectedRow) return null;
198
+ if (selectedRow.kind === "file") return selectedRow.entry.logical_path;
199
+ if (selectedRow.kind === "hit") return selectedRow.hit.logical_path;
200
+ return null; // directory rows have no readable body
201
+ }, [selectedRow]);
117
202
 
118
203
  useEffect(() => {
119
204
  let cancelled = false;
120
- if (!selectedEntry || !withMem) {
205
+ if (!selectedReadPath || !withMem) {
121
206
  setFileContent(null);
122
207
  setDetailScroll(0);
123
208
  return;
124
209
  }
125
210
  setDetailScroll(0);
126
- withMem((mem) => mem.read({ logical_path: selectedEntry.logical_path }))
211
+ withMem((mem) => mem.read({ logical_path: selectedReadPath }))
127
212
  .then((result) => {
128
213
  if (cancelled) return;
129
214
  setFileContent({
130
- logical_path: selectedEntry.logical_path,
215
+ logical_path: selectedReadPath,
131
216
  content: result.content ?? "",
132
217
  });
133
218
  })
134
219
  .catch(() => {
135
220
  if (cancelled) return;
136
221
  setFileContent({
137
- logical_path: selectedEntry.logical_path,
222
+ logical_path: selectedReadPath,
138
223
  content: "(failed to read this entry — it may have been removed)",
139
224
  });
140
225
  });
141
226
  return () => {
142
227
  cancelled = true;
143
228
  };
144
- }, [withMem, selectedEntry]);
229
+ }, [withMem, selectedReadPath]);
145
230
 
146
231
  const detailLines = useMemo(() => {
147
- if (!fileContent || !selectedEntry) return [];
232
+ if (!selectedRow) return [];
233
+ if (selectedRow.kind === "dir") {
234
+ const body = `📁 ${selectedRow.full_path}/\n\n${selectedRow.child_count} item${selectedRow.child_count === 1 ? "" : "s"} under this prefix.\n\nPress → to drill in.`;
235
+ return wrapDetailLines(body, detailWidth);
236
+ }
237
+ if (!fileContent) return [];
238
+ const snippetHeader =
239
+ selectedRow.kind === "hit"
240
+ ? `🔍 match (score=${selectedRow.hit.score.toFixed(3)}, chunk #${selectedRow.hit.chunk_index})\n${selectedRow.hit.snippet}\n\n---\n\n`
241
+ : "";
148
242
  const body = isMarkdownPath(fileContent.logical_path)
149
243
  ? renderMarkdown(fileContent.content)
150
244
  : fileContent.content;
151
- return wrapDetailLines(body, detailWidth);
152
- }, [fileContent, selectedEntry, detailWidth]);
245
+ return wrapDetailLines(snippetHeader + body, detailWidth);
246
+ }, [selectedRow, fileContent, detailWidth]);
153
247
 
154
248
  const visibleDetailRows = Math.max(1, visibleRows - 2);
155
249
  const maxDetailScroll = Math.max(0, detailLines.length - visibleDetailRows);
156
250
 
157
251
  const visibleItems = useMemo(
158
- () => entries.slice(sidebarScrollOffset, sidebarScrollOffset + visibleRows),
159
- [entries, sidebarScrollOffset, visibleRows],
252
+ () => rows.slice(sidebarScrollOffset, sidebarScrollOffset + visibleRows),
253
+ [rows, sidebarScrollOffset, visibleRows],
160
254
  );
161
255
 
162
- const itemCountRef = useLatestRef(entries.length);
256
+ const itemCountRef = useLatestRef(rows.length);
163
257
  const maxDetailScrollRef = useLatestRef(maxDetailScroll);
164
- const selectedEntryRef = useLatestRef(selectedEntry);
258
+ const selectedRowRef = useLatestRef(selectedRow);
165
259
  const focusRef = useLatestRef(focus);
260
+ const modeRef = useLatestRef(mode);
261
+ const currentPrefixRef = useLatestRef(currentPrefix);
262
+ const searchingRef = useLatestRef(searching);
263
+
264
+ const refresh = useCallback(async () => {
265
+ if (modeRef.current === "tree") {
266
+ await loadTree(currentPrefixRef.current);
267
+ }
268
+ // Search results are a snapshot of a user-issued query; ^R doesn't
269
+ // re-run the query (it might be expensive and the user can re-press
270
+ // `/` and Enter).
271
+ }, [loadTree, modeRef, currentPrefixRef]);
272
+
273
+ const runSearch = useCallback(
274
+ async (query: string) => {
275
+ if (!withMem) return;
276
+ const trimmed = query.trim();
277
+ if (!trimmed) return;
278
+ // Flip into search mode synchronously so the sidebar shows the
279
+ // "🔍 query (searching…)" state immediately — otherwise the user
280
+ // sees no visible change between pressing Enter and the embedding
281
+ // round-trip completing.
282
+ setMode("search");
283
+ setSearchResults([]);
284
+ setSearchRunning(true);
285
+ setSearchError(null);
286
+ setSelectedIndex(0);
287
+ setSidebarScrollOffset(0);
288
+ try {
289
+ const out = await withMem((mem) =>
290
+ mem.search({ query: trimmed, pattern: trimmed, limit: SEARCH_LIMIT }),
291
+ );
292
+ setSearchResults(
293
+ out.hits.map((h) => ({
294
+ logical_path: h.logical_path,
295
+ version_id: h.version_id,
296
+ chunk_index: h.chunk_index,
297
+ snippet: h.snippet,
298
+ score: h.score,
299
+ })),
300
+ );
301
+ } catch (err) {
302
+ setSearchResults([]);
303
+ setSearchError(err instanceof Error ? err.message : String(err));
304
+ } finally {
305
+ setSearchRunning(false);
306
+ }
307
+ },
308
+ [withMem],
309
+ );
310
+
311
+ const exitSearch = useCallback(() => {
312
+ setMode("tree");
313
+ setSearchResults([]);
314
+ setSearchQuery("");
315
+ setSearchError(null);
316
+ setSelectedIndex(0);
317
+ setSidebarScrollOffset(0);
318
+ }, []);
319
+
320
+ // Debounced live search. While the user is in search-typing mode (or
321
+ // has committed a non-empty query and we're still on the search view),
322
+ // any change to `searchQuery` schedules a `mem.search()` 300ms later.
323
+ // Subsequent keystrokes within the window reset the timer, so a fast
324
+ // typist makes at most one DB-lock round-trip per pause. The 300ms
325
+ // figure keeps the DB lock available to workers / chat between bursts
326
+ // without feeling laggy to the user.
327
+ useEffect(() => {
328
+ if (mode !== "search") return;
329
+ const trimmed = searchQuery.trim();
330
+ if (!trimmed) {
331
+ setSearchResults([]);
332
+ setSearchError(null);
333
+ setSearchRunning(false);
334
+ return;
335
+ }
336
+ const handle = setTimeout(() => {
337
+ runSearch(trimmed);
338
+ }, 300);
339
+ return () => clearTimeout(handle);
340
+ }, [mode, searchQuery, runSearch]);
341
+
342
+ const drillIn = useCallback((dirFullPath: string) => {
343
+ setCurrentPrefix(`${dirFullPath}/`);
344
+ setSelectedIndex(0);
345
+ setSidebarScrollOffset(0);
346
+ setFocus("list");
347
+ }, []);
348
+
349
+ const popPrefix = useCallback(() => {
350
+ const prefix = currentPrefixRef.current;
351
+ if (!prefix) return false;
352
+ // prefix always ends in "/"; strip it, then remove the last segment.
353
+ const trimmed = prefix.replace(/\/$/, "");
354
+ const slash = trimmed.lastIndexOf("/");
355
+ const next = slash < 0 ? "" : `${trimmed.slice(0, slash)}/`;
356
+ setCurrentPrefix(next);
357
+ setSelectedIndex(0);
358
+ setSidebarScrollOffset(0);
359
+ return true;
360
+ }, [currentPrefixRef]);
166
361
 
167
362
  const deleteConfirm = useDeleteConfirm(() => {
168
- const entry = selectedEntryRef.current;
169
- if (!entry || !withMem) return;
170
- const path = entry.logical_path;
363
+ const row = selectedRowRef.current;
364
+ if (!row || !withMem) return;
365
+ if (row.kind === "dir") {
366
+ const path = row.full_path;
367
+ (async () => {
368
+ try {
369
+ await withMem((mem) =>
370
+ mem.remove({ paths: [path], recursive: true }),
371
+ );
372
+ } catch {
373
+ // ignore — refresh will reflect any partial state
374
+ }
375
+ await refresh();
376
+ })();
377
+ return;
378
+ }
379
+ const path =
380
+ row.kind === "file" ? row.entry.logical_path : row.hit.logical_path;
171
381
  (async () => {
172
382
  try {
173
383
  await withMem((mem) => mem.remove({ paths: [path] }));
174
384
  } catch {
175
385
  // ignore — refresh will reflect any partial state
176
386
  }
177
- refresh();
387
+ if (modeRef.current === "search") {
388
+ // Drop the deleted hit from the local results so the list updates.
389
+ setSearchResults((prev) => prev.filter((h) => h.logical_path !== path));
390
+ setSelectedIndex((i) =>
391
+ Math.max(0, Math.min(i, searchRows.length - 2)),
392
+ );
393
+ } else {
394
+ await refresh();
395
+ }
178
396
  })();
179
397
  });
180
398
 
181
399
  useInput(
182
400
  (input, key) => {
401
+ // Search-typing mode: capture characters; the debounced effect on
402
+ // `searchQuery` does the actual `mem.search()` call. Enter closes
403
+ // the input bar but leaves results visible. Esc cancels back to
404
+ // tree. Arrow keys and other navigation fall through to
405
+ // handleListDetailKey below so the user can ↑↓ through results
406
+ // while the input is still open.
407
+ if (searchingRef.current) {
408
+ if (key.escape) {
409
+ setSearching(false);
410
+ exitSearch();
411
+ return;
412
+ }
413
+ if (key.return) {
414
+ setSearching(false);
415
+ return;
416
+ }
417
+ if (key.backspace || key.delete) {
418
+ setSearchQuery((q) => q.slice(0, -1));
419
+ return;
420
+ }
421
+ const isNavKey =
422
+ key.upArrow ||
423
+ key.downArrow ||
424
+ key.leftArrow ||
425
+ key.rightArrow ||
426
+ key.pageUp ||
427
+ key.pageDown ||
428
+ key.tab;
429
+ if (!isNavKey && input && !key.ctrl && !key.meta) {
430
+ // Flip to search mode on the first keystroke so the sidebar
431
+ // header updates immediately and the debounced effect runs.
432
+ if (modeRef.current !== "search") setMode("search");
433
+ setSearchQuery((q) => q + input);
434
+ return;
435
+ }
436
+ if (!isNavKey) return;
437
+ // fall through to handleListDetailKey for nav keys
438
+ }
439
+
183
440
  if (input !== "d") deleteConfirm.cancel();
184
441
 
442
+ // Esc in search-results mode returns to the tree view.
443
+ if (key.escape && modeRef.current === "search") {
444
+ exitSearch();
445
+ return;
446
+ }
447
+
185
448
  if (
186
449
  handleListDetailKey(input, key, {
187
450
  focusRef,
@@ -191,21 +454,50 @@ export const ContextPanel = memo(function ContextPanel({
191
454
  setSelectedIndex,
192
455
  setDetailScroll,
193
456
  pageScrollLines: PAGE_SCROLL_LINES,
457
+ onRightArrow: () => {
458
+ if (focusRef.current !== "list") return false;
459
+ const row = selectedRowRef.current;
460
+ if (row?.kind === "dir") {
461
+ drillIn(row.full_path);
462
+ return true;
463
+ }
464
+ return false;
465
+ },
466
+ onLeftArrow: () => {
467
+ if (focusRef.current !== "list") return false;
468
+ if (modeRef.current === "search") {
469
+ exitSearch();
470
+ return true;
471
+ }
472
+ return popPrefix();
473
+ },
194
474
  })
195
475
  ) {
196
476
  return;
197
477
  }
198
478
 
199
479
  if (input === "d") {
200
- const entry = selectedEntryRef.current;
201
- if (!entry) return;
202
- deleteConfirm.pressDelete(entry.logical_path);
480
+ const row = selectedRowRef.current;
481
+ if (!row) return;
482
+ const label =
483
+ row.kind === "dir"
484
+ ? `${row.full_path}/ (${row.child_count} items)`
485
+ : row.kind === "file"
486
+ ? row.entry.logical_path
487
+ : row.hit.logical_path;
488
+ deleteConfirm.pressDelete(label);
203
489
  return;
204
490
  }
205
491
  if (key.ctrl && (input === "r" || input === "R")) {
206
492
  refresh();
207
493
  return;
208
494
  }
495
+ if (input === "/" || input === "s") {
496
+ setSearching(true);
497
+ setSearchQuery("");
498
+ setSearchError(null);
499
+ return;
500
+ }
209
501
  },
210
502
  { isActive },
211
503
  );
@@ -215,6 +507,11 @@ export const ContextPanel = memo(function ContextPanel({
215
507
  detailScroll + visibleDetailRows,
216
508
  );
217
509
 
510
+ const sidebarHeader =
511
+ mode === "search"
512
+ ? `🔍 "${searchQuery || "(empty)"}" (${searchResults.length})`
513
+ : `membot · /${currentPrefix} (${rows.length})`;
514
+
218
515
  return (
219
516
  <Box flexGrow={1} height={visibleRows + 1} overflow="hidden">
220
517
  <Box
@@ -231,26 +528,50 @@ export const ContextPanel = memo(function ContextPanel({
231
528
  >
232
529
  <Box paddingX={1}>
233
530
  <Text bold dimColor wrap="truncate-end">
234
- membot ({entries.length})
531
+ {sidebarHeader}
235
532
  </Text>
236
533
  </Box>
237
- {entries.length === 0 ? (
534
+ {searching && (
535
+ <Box paddingX={1}>
536
+ <Text color={theme.info}>🔍 </Text>
537
+ <Text color={theme.info}>{searchQuery}</Text>
538
+ <Text color={theme.info}>▌</Text>
539
+ </Box>
540
+ )}
541
+ {rows.length === 0 ? (
238
542
  <Box paddingX={1}>
239
- <Text dimColor>(empty — try `botholomew membot add …`)</Text>
543
+ <Text dimColor>
544
+ {mode === "search"
545
+ ? searchRunning
546
+ ? "(searching…)"
547
+ : searchError
548
+ ? `(error: ${searchError})`
549
+ : "(no hits — Esc to return)"
550
+ : currentPrefix
551
+ ? "(empty — ← to go back)"
552
+ : "(empty — try `botholomew membot add …`)"}
553
+ </Text>
240
554
  </Box>
241
555
  ) : (
242
- visibleItems.map((entry, vi) => {
556
+ visibleItems.map((row, vi) => {
243
557
  const i = vi + sidebarScrollOffset;
244
558
  const isSelected = i === selectedIndex;
559
+ const label = renderRowLabel(row);
560
+ const key =
561
+ row.kind === "dir"
562
+ ? `d:${row.full_path}`
563
+ : row.kind === "file"
564
+ ? `f:${row.entry.logical_path}`
565
+ : `h:${row.hit.logical_path}:${row.hit.chunk_index}`;
245
566
  return (
246
- <Box key={entry.logical_path} paddingX={1}>
567
+ <Box key={key} paddingX={1}>
247
568
  <Text
248
569
  backgroundColor={isSelected ? theme.selectionBg : undefined}
249
570
  color={isSelected ? theme.info : undefined}
250
571
  bold={isSelected}
251
572
  wrap="truncate-end"
252
573
  >
253
- {isSelected ? "▸" : " "} {entry.logical_path}
574
+ {isSelected ? "▸" : " "} {label}
254
575
  </Text>
255
576
  </Box>
256
577
  );
@@ -266,9 +587,9 @@ export const ContextPanel = memo(function ContextPanel({
266
587
  {...detailPaneBorderProps(focus)}
267
588
  overflow="hidden"
268
589
  >
269
- {selectedEntry ? (
590
+ {selectedRow ? (
270
591
  <>
271
- <ContextDetailHeader entry={selectedEntry} />
592
+ <ContextDetailHeader row={selectedRow} />
272
593
  <Box flexDirection="row" flexGrow={1} overflow="hidden">
273
594
  <Box flexDirection="column" flexGrow={1} overflow="hidden">
274
595
  {detailVisible.map((line, i) => {
@@ -300,7 +621,11 @@ export const ContextPanel = memo(function ContextPanel({
300
621
  <Text dimColor>
301
622
  {focus === "detail"
302
623
  ? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
303
- : "↑↓ select · → detail · d delete (×2) · ^R refresh"}
624
+ : mode === "search"
625
+ ? "↑↓ select · → detail · Esc/← back to tree · d delete (×2) · / new search"
626
+ : currentPrefix
627
+ ? "↑↓ select · → drill/detail · ← up · / search · d delete (×2) · ^R refresh"
628
+ : "↑↓ select · → drill/detail · / search · d delete (×2) · ^R refresh"}
304
629
  </Text>
305
630
  </Box>
306
631
  </Box>
@@ -308,6 +633,22 @@ export const ContextPanel = memo(function ContextPanel({
308
633
  );
309
634
  });
310
635
 
636
+ function renderRowLabel(row: SidebarRow): string {
637
+ if (row.kind === "dir") {
638
+ return `📁 ${row.name}/`;
639
+ }
640
+ if (row.kind === "file") {
641
+ return `📄 ${lastSegment(row.entry.logical_path)}`;
642
+ }
643
+ // search hit: show path + score
644
+ return `${row.hit.score.toFixed(2)} ${row.hit.logical_path}`;
645
+ }
646
+
647
+ function lastSegment(path: string): string {
648
+ const i = path.lastIndexOf("/");
649
+ return i < 0 ? path : path.slice(i + 1);
650
+ }
651
+
311
652
  function formatSize(bytes: number | null): string {
312
653
  if (bytes === null) return "-";
313
654
  if (bytes < 1024) return `${bytes} B`;
@@ -315,27 +656,78 @@ function formatSize(bytes: number | null): string {
315
656
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
316
657
  }
317
658
 
318
- function ContextDetailHeader({ entry }: { entry: ContextEntry }) {
659
+ function ContextDetailHeader({ row }: { row: SidebarRow }) {
660
+ if (row.kind === "dir") {
661
+ return (
662
+ <Box flexDirection="column" width="100%">
663
+ <Box>
664
+ <Text
665
+ bold
666
+ color="cyan"
667
+ backgroundColor={theme.headerBg}
668
+ wrap="truncate-end"
669
+ >
670
+ 📁 {row.full_path}/
671
+ </Text>
672
+ </Box>
673
+ <Box>
674
+ <Text dimColor backgroundColor={theme.headerBg} wrap="truncate-end">
675
+ directory · {row.child_count} item
676
+ {row.child_count === 1 ? "" : "s"}
677
+ </Text>
678
+ </Box>
679
+ </Box>
680
+ );
681
+ }
682
+ if (row.kind === "file") {
683
+ const entry = row.entry;
684
+ return (
685
+ <Box flexDirection="column" width="100%">
686
+ <Box>
687
+ <Text
688
+ bold
689
+ color="cyan"
690
+ backgroundColor={theme.headerBg}
691
+ wrap="truncate-end"
692
+ >
693
+ 📄 {entry.logical_path}
694
+ </Text>
695
+ </Box>
696
+ <Box>
697
+ <Text dimColor backgroundColor={theme.headerBg} wrap="truncate-end">
698
+ {entry.mime_type ?? "?"} · {formatSize(entry.size_bytes)} · v=
699
+ {entry.version_id}
700
+ </Text>
701
+ </Box>
702
+ {entry.description ? (
703
+ <Box>
704
+ <Text dimColor backgroundColor={theme.headerBg} wrap="truncate-end">
705
+ {entry.description}
706
+ </Text>
707
+ </Box>
708
+ ) : null}
709
+ </Box>
710
+ );
711
+ }
712
+ const hit = row.hit;
319
713
  return (
320
- <Box flexDirection="column" width="100%" backgroundColor={theme.headerBg}>
714
+ <Box flexDirection="column" width="100%">
321
715
  <Box>
322
- <Text bold color="cyan" wrap="truncate-end">
323
- 📄 {entry.logical_path}
716
+ <Text
717
+ bold
718
+ color="cyan"
719
+ backgroundColor={theme.headerBg}
720
+ wrap="truncate-end"
721
+ >
722
+ 📄 {hit.logical_path}
324
723
  </Text>
325
724
  </Box>
326
725
  <Box>
327
- <Text dimColor wrap="truncate-end">
328
- {entry.mime_type ?? "?"} · {formatSize(entry.size_bytes)} · v=
329
- {entry.version_id}
726
+ <Text dimColor backgroundColor={theme.headerBg} wrap="truncate-end">
727
+ hit · score={hit.score.toFixed(3)} · chunk #{hit.chunk_index} · v=
728
+ {hit.version_id}
330
729
  </Text>
331
730
  </Box>
332
- {entry.description ? (
333
- <Box>
334
- <Text dimColor wrap="truncate-end">
335
- {entry.description}
336
- </Text>
337
- </Box>
338
- ) : null}
339
731
  </Box>
340
732
  );
341
733
  }
@@ -350,14 +350,19 @@ export const SchedulePanel = memo(function SchedulePanel({
350
350
  function ScheduleDetailHeader({ schedule }: { schedule: Schedule }) {
351
351
  const enabledKey = String(schedule.enabled);
352
352
  return (
353
- <Box flexDirection="column" width="100%" backgroundColor={theme.headerBg}>
353
+ <Box flexDirection="column" width="100%">
354
354
  <Box>
355
- <Text bold color={theme.info} wrap="truncate-end">
355
+ <Text
356
+ bold
357
+ color={theme.info}
358
+ backgroundColor={theme.headerBg}
359
+ wrap="truncate-end"
360
+ >
356
361
  {schedule.name}
357
362
  </Text>
358
363
  </Box>
359
364
  <Box>
360
- <Text wrap="truncate-end">
365
+ <Text backgroundColor={theme.headerBg} wrap="truncate-end">
361
366
  <Text color={ENABLED_COLORS[enabledKey]}>
362
367
  {ENABLED_ICONS[enabledKey]} {ENABLED_LABELS[enabledKey]}
363
368
  </Text>
@@ -387,14 +387,19 @@ export const TaskPanel = memo(function TaskPanel({
387
387
 
388
388
  function TaskDetailHeader({ task }: { task: Task }) {
389
389
  return (
390
- <Box flexDirection="column" width="100%" backgroundColor={theme.headerBg}>
390
+ <Box flexDirection="column" width="100%">
391
391
  <Box>
392
- <Text bold color={theme.info} wrap="truncate-end">
392
+ <Text
393
+ bold
394
+ color={theme.info}
395
+ backgroundColor={theme.headerBg}
396
+ wrap="truncate-end"
397
+ >
393
398
  {task.name}
394
399
  </Text>
395
400
  </Box>
396
401
  <Box>
397
- <Text wrap="truncate-end">
402
+ <Text backgroundColor={theme.headerBg} wrap="truncate-end">
398
403
  <Text color={STATUS_COLORS[task.status]}>
399
404
  {STATUS_ICONS[task.status]} {task.status}
400
405
  </Text>
@@ -594,9 +594,9 @@ function ThreadDetailHeader({
594
594
  isActiveThread: boolean;
595
595
  }) {
596
596
  return (
597
- <Box flexDirection="column" width="100%" backgroundColor={theme.headerBg}>
597
+ <Box flexDirection="column" width="100%">
598
598
  <Box>
599
- <Text wrap="truncate-end">
599
+ <Text backgroundColor={theme.headerBg} wrap="truncate-end">
600
600
  <Text bold italic color={theme.info}>
601
601
  {thread.title || "(untitled)"}
602
602
  </Text>
@@ -608,7 +608,7 @@ function ThreadDetailHeader({
608
608
  </Text>
609
609
  </Box>
610
610
  <Box>
611
- <Text wrap="truncate-end">
611
+ <Text backgroundColor={theme.headerBg} wrap="truncate-end">
612
612
  <Text color={TYPE_COLORS[thread.type]}>
613
613
  {TYPE_ICONS[thread.type]} {TYPE_LABELS[thread.type]}
614
614
  </Text>
@@ -80,7 +80,7 @@ export function buildResultStub(
80
80
  preview,
81
81
  preview.length < content.length ? "..." : "",
82
82
  "",
83
- `Use read_large_result with id="${id}" and page=<n> (1–${totalPages}) to read it in ~${PAGE_SIZE_CHARS}-char pages.`,
83
+ `To read the full result, call the top-level Botholomew tool \`read_large_result\` (NOT via mcp_exec — it is not an MCP tool) with id="${id}" and page=<n> (1–${totalPages}). Each page is ~${PAGE_SIZE_CHARS} chars.`,
84
84
  ].join("\n");
85
85
  }
86
86