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 +1 -1
- package/src/tools/mcp/exec.ts +13 -1
- package/src/tools/util/read_large_result.ts +1 -1
- package/src/tui/components/ContextPanel.tsx +465 -73
- package/src/tui/components/SchedulePanel.tsx +8 -3
- package/src/tui/components/TaskPanel.tsx +8 -3
- package/src/tui/components/ThreadPanel.tsx +3 -3
- package/src/worker/large-results.ts +1 -1
package/package.json
CHANGED
package/src/tools/mcp/exec.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
139
|
+
},
|
|
140
|
+
[withMem],
|
|
141
|
+
);
|
|
103
142
|
|
|
104
143
|
useEffect(() => {
|
|
105
|
-
|
|
106
|
-
}, [
|
|
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
|
-
|
|
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 (!
|
|
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:
|
|
211
|
+
withMem((mem) => mem.read({ logical_path: selectedReadPath }))
|
|
127
212
|
.then((result) => {
|
|
128
213
|
if (cancelled) return;
|
|
129
214
|
setFileContent({
|
|
130
|
-
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:
|
|
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,
|
|
229
|
+
}, [withMem, selectedReadPath]);
|
|
145
230
|
|
|
146
231
|
const detailLines = useMemo(() => {
|
|
147
|
-
if (!
|
|
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
|
-
}, [
|
|
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
|
-
() =>
|
|
159
|
-
[
|
|
252
|
+
() => rows.slice(sidebarScrollOffset, sidebarScrollOffset + visibleRows),
|
|
253
|
+
[rows, sidebarScrollOffset, visibleRows],
|
|
160
254
|
);
|
|
161
255
|
|
|
162
|
-
const itemCountRef = useLatestRef(
|
|
256
|
+
const itemCountRef = useLatestRef(rows.length);
|
|
163
257
|
const maxDetailScrollRef = useLatestRef(maxDetailScroll);
|
|
164
|
-
const
|
|
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
|
|
169
|
-
if (!
|
|
170
|
-
|
|
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
|
-
|
|
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
|
|
201
|
-
if (!
|
|
202
|
-
|
|
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
|
-
|
|
531
|
+
{sidebarHeader}
|
|
235
532
|
</Text>
|
|
236
533
|
</Box>
|
|
237
|
-
{
|
|
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>
|
|
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((
|
|
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={
|
|
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 ? "▸" : " "} {
|
|
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
|
-
{
|
|
590
|
+
{selectedRow ? (
|
|
270
591
|
<>
|
|
271
|
-
<ContextDetailHeader
|
|
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
|
-
:
|
|
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({
|
|
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%"
|
|
714
|
+
<Box flexDirection="column" width="100%">
|
|
321
715
|
<Box>
|
|
322
|
-
<Text
|
|
323
|
-
|
|
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
|
-
{
|
|
329
|
-
{
|
|
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%"
|
|
353
|
+
<Box flexDirection="column" width="100%">
|
|
354
354
|
<Box>
|
|
355
|
-
<Text
|
|
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%"
|
|
390
|
+
<Box flexDirection="column" width="100%">
|
|
391
391
|
<Box>
|
|
392
|
-
<Text
|
|
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%"
|
|
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
|
-
`
|
|
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
|
|