botholomew 0.18.5 → 0.18.7
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 +3 -3
- package/src/tools/mcp/exec.ts +13 -1
- package/src/tools/membot/adapter.ts +8 -6
- package/src/tools/membot/edit.ts +1 -1
- package/src/tools/util/read_large_result.ts +1 -1
- package/src/tui/components/ContextPanel.tsx +466 -74
- 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/tui/markdown.ts +45 -2
- package/src/tui/markdownTables.ts +288 -0
- package/src/worker/large-results.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "botholomew",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.7",
|
|
4
4
|
"description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sdk": "^0.95.2",
|
|
31
|
-
"@evantahler/mcpx": "0.21.
|
|
31
|
+
"@evantahler/mcpx": "0.21.8",
|
|
32
32
|
"ansis": "^4.3.0",
|
|
33
33
|
"commander": "^14.0.0",
|
|
34
34
|
"gray-matter": "^4.0.3",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"ink-spinner": "^5.0.0",
|
|
37
37
|
"ink-text-input": "^6.0.0",
|
|
38
38
|
"istextorbinary": "^9.5.0",
|
|
39
|
-
"membot": "^0.15.
|
|
39
|
+
"membot": "^0.15.4",
|
|
40
40
|
"nanospinner": "^1.2.2",
|
|
41
41
|
"react": "^19.2.6",
|
|
42
42
|
"uuid": "^14.0.0",
|
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) {
|
|
@@ -34,13 +34,14 @@ type MembotMethodName =
|
|
|
34
34
|
| "move"
|
|
35
35
|
| "remove"
|
|
36
36
|
| "refresh"
|
|
37
|
-
| "prune"
|
|
37
|
+
| "prune"
|
|
38
|
+
| "sources";
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
|
-
* Map an Operation's exposed name (`membot_add`, `
|
|
41
|
-
* `MembotClient` method that actually runs it.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
* Map an Operation's exposed name (`membot_add`, `membot_remove`, …) to the
|
|
42
|
+
* `MembotClient` method that actually runs it. Mostly 1:1 with the op name
|
|
43
|
+
* minus the `membot_` prefix; kept explicit so a renamed/added op fails
|
|
44
|
+
* loudly at registration instead of silently misrouting.
|
|
44
45
|
*/
|
|
45
46
|
const METHOD_BY_OP_NAME: Record<string, MembotMethodName> = {
|
|
46
47
|
membot_add: "add",
|
|
@@ -54,9 +55,10 @@ const METHOD_BY_OP_NAME: Record<string, MembotMethodName> = {
|
|
|
54
55
|
membot_diff: "diff",
|
|
55
56
|
membot_write: "write",
|
|
56
57
|
membot_move: "move",
|
|
57
|
-
|
|
58
|
+
membot_remove: "remove",
|
|
58
59
|
membot_refresh: "refresh",
|
|
59
60
|
membot_prune: "prune",
|
|
61
|
+
membot_sources: "sources",
|
|
60
62
|
};
|
|
61
63
|
|
|
62
64
|
/**
|
package/src/tools/membot/edit.ts
CHANGED
|
@@ -32,7 +32,7 @@ const outputSchema = z.object({
|
|
|
32
32
|
export const membotEditTool = {
|
|
33
33
|
name: "membot_edit",
|
|
34
34
|
description:
|
|
35
|
-
"[[ bash equivalent command: patch ]] Apply line-range edits to a stored file: reads the current version, applies bottom-up patches, and writes the result back as a new version. Prefer this over membot_write when you only need to change part of a file — the diff is small and the change_note travels with the new version. To replace the whole body, use membot_write. To delete the file, use
|
|
35
|
+
"[[ bash equivalent command: patch ]] Apply line-range edits to a stored file: reads the current version, applies bottom-up patches, and writes the result back as a new version. Prefer this over membot_write when you only need to change part of a file — the diff is small and the change_note travels with the new version. To replace the whole body, use membot_write. To delete the file, use membot_remove.",
|
|
36
36
|
group: "membot",
|
|
37
37
|
inputSchema,
|
|
38
38
|
outputSchema,
|
|
@@ -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
|
-
? renderMarkdown(fileContent.content)
|
|
243
|
+
? renderMarkdown(fileContent.content, detailWidth)
|
|
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>
|
package/src/tui/markdown.ts
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
|
-
|
|
1
|
+
import { extractTableBlocks, renderTable } from "./markdownTables.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render markdown to ANSI for a TUI detail pane. When `width` is provided,
|
|
5
|
+
* GFM tables are pulled out and rendered ourselves at that width before
|
|
6
|
+
* handing the rest off to `Bun.markdown.ansi` — Bun's renderer ignores any
|
|
7
|
+
* width hint and emits tables at their natural width, which `wrap-ansi` then
|
|
8
|
+
* shreds mid-cell.
|
|
9
|
+
*/
|
|
10
|
+
export function renderMarkdown(text: string, width?: number): string {
|
|
2
11
|
if (!text) return "";
|
|
3
|
-
|
|
12
|
+
if (width === undefined || width <= 0) {
|
|
13
|
+
return Bun.markdown.ansi(text).trimEnd();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const blocks = extractTableBlocks(text);
|
|
17
|
+
if (blocks.length === 0) {
|
|
18
|
+
return Bun.markdown.ansi(text).trimEnd();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const lines = text.split("\n");
|
|
22
|
+
const rendered: string[] = blocks.map((b) =>
|
|
23
|
+
renderTable(b.rows, b.aligns, width),
|
|
24
|
+
);
|
|
25
|
+
// Bun.markdown.ansi mangles NUL bytes (→ U+FFFD), so use a plain alphanumeric
|
|
26
|
+
// sentinel that survives the markdown pass intact. Wrap each block's
|
|
27
|
+
// line-range with a single sentinel line, then splice the pre-rendered
|
|
28
|
+
// table back in after Bun finishes styling the rest of the document.
|
|
29
|
+
const sentinel = (i: number) => `BHTBLSENTINEL${i}BHTBLEND`;
|
|
30
|
+
const out = lines.slice();
|
|
31
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
32
|
+
const b = blocks[i];
|
|
33
|
+
if (!b) continue;
|
|
34
|
+
out.splice(b.start, b.end - b.start + 1, sentinel(i));
|
|
35
|
+
}
|
|
36
|
+
const piped = Bun.markdown.ansi(out.join("\n")).trimEnd();
|
|
37
|
+
let stitched = piped;
|
|
38
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
39
|
+
// Bun wraps each paragraph with a trailing reset (`\x1b[0m`). Strip any
|
|
40
|
+
// SGR escapes that hug the sentinel so the table doesn't inherit them.
|
|
41
|
+
const re = new RegExp(
|
|
42
|
+
`(?:\\x1b\\[[0-9;]*m)*${sentinel(i)}(?:\\x1b\\[[0-9;]*m)*`,
|
|
43
|
+
);
|
|
44
|
+
stitched = stitched.replace(re, rendered[i] ?? "");
|
|
45
|
+
}
|
|
46
|
+
return stitched;
|
|
4
47
|
}
|
|
5
48
|
|
|
6
49
|
export function isMarkdownPath(path: string): boolean {
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GFM table extraction + width-aware ANSI rendering.
|
|
3
|
+
*
|
|
4
|
+
* `Bun.markdown.ansi` renders tables at their natural width and ignores the
|
|
5
|
+
* caller's column budget, so wide tables get hard-wrapped mid-cell by
|
|
6
|
+
* `wrap-ansi` in the detail pane. We pre-extract table blocks, render them
|
|
7
|
+
* ourselves at a width that fits, and let `Bun.markdown.ansi` handle the rest.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type Align = "left" | "center" | "right";
|
|
11
|
+
|
|
12
|
+
export interface TableBlock {
|
|
13
|
+
/** First line index (inclusive) of the table in the original text. */
|
|
14
|
+
start: number;
|
|
15
|
+
/** Last line index (inclusive). */
|
|
16
|
+
end: number;
|
|
17
|
+
/** First row is the header. */
|
|
18
|
+
rows: string[][];
|
|
19
|
+
aligns: Align[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DIM_ON = "\x1b[2m";
|
|
23
|
+
const BOLD_ON = "\x1b[1m";
|
|
24
|
+
const RESET = "\x1b[0m";
|
|
25
|
+
|
|
26
|
+
const SEPARATOR_CELL_RE = /^\s*:?-{1,}:?\s*$/;
|
|
27
|
+
const FENCE_RE = /^\s{0,3}(```|~~~)/;
|
|
28
|
+
|
|
29
|
+
export function extractTableBlocks(text: string): TableBlock[] {
|
|
30
|
+
const lines = text.split("\n");
|
|
31
|
+
const blocks: TableBlock[] = [];
|
|
32
|
+
let inFence = false;
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < lines.length) {
|
|
35
|
+
const line = lines[i] ?? "";
|
|
36
|
+
if (FENCE_RE.test(line)) {
|
|
37
|
+
inFence = !inFence;
|
|
38
|
+
i++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (inFence || !looksLikePipeRow(line)) {
|
|
42
|
+
i++;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const sep = lines[i + 1] ?? "";
|
|
46
|
+
if (!looksLikePipeRow(sep)) {
|
|
47
|
+
i++;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const sepCells = splitRow(sep);
|
|
51
|
+
if (!sepCells.every((c) => SEPARATOR_CELL_RE.test(c))) {
|
|
52
|
+
i++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const header = splitRow(line);
|
|
56
|
+
const colCount = Math.max(header.length, sepCells.length);
|
|
57
|
+
const aligns: Align[] = sepCells.slice(0, colCount).map(parseAlignCell);
|
|
58
|
+
while (aligns.length < colCount) aligns.push("left");
|
|
59
|
+
|
|
60
|
+
const rows: string[][] = [normalizeRow(header, colCount)];
|
|
61
|
+
let j = i + 2;
|
|
62
|
+
while (j < lines.length) {
|
|
63
|
+
const body = lines[j] ?? "";
|
|
64
|
+
if (!looksLikePipeRow(body)) break;
|
|
65
|
+
// A new separator (consecutive tables) terminates this one.
|
|
66
|
+
if (splitRow(body).every((c) => SEPARATOR_CELL_RE.test(c))) break;
|
|
67
|
+
rows.push(normalizeRow(splitRow(body), colCount));
|
|
68
|
+
j++;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
blocks.push({ start: i, end: j - 1, rows, aligns });
|
|
72
|
+
i = j;
|
|
73
|
+
}
|
|
74
|
+
return blocks;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderTable(
|
|
78
|
+
rows: string[][],
|
|
79
|
+
aligns: Align[],
|
|
80
|
+
width: number,
|
|
81
|
+
): string {
|
|
82
|
+
if (rows.length === 0) return "";
|
|
83
|
+
const colCount = rows[0]?.length ?? 0;
|
|
84
|
+
if (colCount === 0) return "";
|
|
85
|
+
|
|
86
|
+
const plain = rows.map((r) => r.map(stripInlineMarkdown));
|
|
87
|
+
|
|
88
|
+
// Per-column natural width (max visible width across all cells).
|
|
89
|
+
const naturalWidths: number[] = [];
|
|
90
|
+
for (let c = 0; c < colCount; c++) {
|
|
91
|
+
let w = 1;
|
|
92
|
+
for (const row of plain) {
|
|
93
|
+
const cell = row[c] ?? "";
|
|
94
|
+
if (visibleWidth(cell) > w) w = visibleWidth(cell);
|
|
95
|
+
}
|
|
96
|
+
naturalWidths.push(w);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Overhead: leading "│ " + trailing " │" + " │ " between cols.
|
|
100
|
+
const borderOverhead = colCount * 3 + 1;
|
|
101
|
+
const naturalTotal =
|
|
102
|
+
naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead;
|
|
103
|
+
|
|
104
|
+
let colWidths: number[];
|
|
105
|
+
if (naturalTotal <= width || width <= 0) {
|
|
106
|
+
colWidths = naturalWidths;
|
|
107
|
+
} else {
|
|
108
|
+
colWidths = shrinkColumns(naturalWidths, width - borderOverhead);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const lines: string[] = [];
|
|
112
|
+
lines.push(borderLine("┌", "┬", "┐", colWidths));
|
|
113
|
+
for (let r = 0; r < plain.length; r++) {
|
|
114
|
+
const cells = plain[r] ?? [];
|
|
115
|
+
const isHeader = r === 0;
|
|
116
|
+
lines.push(dataLine(cells, aligns, colWidths, isHeader));
|
|
117
|
+
if (isHeader) {
|
|
118
|
+
lines.push(borderLine("├", "┼", "┤", colWidths));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
lines.push(borderLine("└", "┴", "┘", colWidths));
|
|
122
|
+
return lines.join("\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function looksLikePipeRow(line: string): boolean {
|
|
126
|
+
// A GFM table row contains at least one unescaped pipe and (after trimming
|
|
127
|
+
// surrounding whitespace + optional pipes) is non-empty.
|
|
128
|
+
const stripped = line.trim();
|
|
129
|
+
if (stripped === "") return false;
|
|
130
|
+
if (!stripped.includes("|")) return false;
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function splitRow(line: string): string[] {
|
|
135
|
+
let s = line.trim();
|
|
136
|
+
if (s.startsWith("|")) s = s.slice(1);
|
|
137
|
+
if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
|
|
138
|
+
const cells: string[] = [];
|
|
139
|
+
let buf = "";
|
|
140
|
+
for (let i = 0; i < s.length; i++) {
|
|
141
|
+
const ch = s[i];
|
|
142
|
+
if (ch === "\\" && s[i + 1] === "|") {
|
|
143
|
+
buf += "|";
|
|
144
|
+
i++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (ch === "|") {
|
|
148
|
+
cells.push(buf.trim());
|
|
149
|
+
buf = "";
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
buf += ch;
|
|
153
|
+
}
|
|
154
|
+
cells.push(buf.trim());
|
|
155
|
+
return cells;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseAlignCell(cell: string): Align {
|
|
159
|
+
const c = cell.trim();
|
|
160
|
+
const left = c.startsWith(":");
|
|
161
|
+
const right = c.endsWith(":");
|
|
162
|
+
if (left && right) return "center";
|
|
163
|
+
if (right) return "right";
|
|
164
|
+
return "left";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeRow(cells: string[], colCount: number): string[] {
|
|
168
|
+
const out = cells.slice(0, colCount);
|
|
169
|
+
while (out.length < colCount) out.push("");
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function shrinkColumns(natural: number[], budget: number): number[] {
|
|
174
|
+
const MIN = 3;
|
|
175
|
+
const n = natural.length;
|
|
176
|
+
if (budget < n * MIN) {
|
|
177
|
+
// Not enough room even for ellipsis everywhere — give each column MIN
|
|
178
|
+
// and let the caller deal with overflow. (Detail pane minimum is much
|
|
179
|
+
// wider than this in practice.)
|
|
180
|
+
return new Array(n).fill(MIN);
|
|
181
|
+
}
|
|
182
|
+
const total = natural.reduce((a, b) => a + b, 0) || 1;
|
|
183
|
+
const raw = natural.map((w) => (w * budget) / total);
|
|
184
|
+
const floored = raw.map((v) => Math.max(MIN, Math.floor(v)));
|
|
185
|
+
let used = floored.reduce((a, b) => a + b, 0);
|
|
186
|
+
// Distribute the remainder to columns with the largest fractional part.
|
|
187
|
+
const remainders = raw
|
|
188
|
+
.map((v, i) => ({ i, frac: v - Math.floor(v) }))
|
|
189
|
+
.sort((a, b) => b.frac - a.frac);
|
|
190
|
+
let k = 0;
|
|
191
|
+
while (used < budget && k < remainders.length * 4) {
|
|
192
|
+
const idx = remainders[k % remainders.length]?.i ?? 0;
|
|
193
|
+
floored[idx] = (floored[idx] ?? MIN) + 1;
|
|
194
|
+
used++;
|
|
195
|
+
k++;
|
|
196
|
+
}
|
|
197
|
+
// If we overshot due to MIN clamping, trim from the widest column(s).
|
|
198
|
+
while (used > budget) {
|
|
199
|
+
let widest = 0;
|
|
200
|
+
for (let i = 1; i < n; i++) {
|
|
201
|
+
if ((floored[i] ?? 0) > (floored[widest] ?? 0)) widest = i;
|
|
202
|
+
}
|
|
203
|
+
if ((floored[widest] ?? 0) <= MIN) break;
|
|
204
|
+
floored[widest] = (floored[widest] ?? 0) - 1;
|
|
205
|
+
used--;
|
|
206
|
+
}
|
|
207
|
+
return floored;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function borderLine(
|
|
211
|
+
left: string,
|
|
212
|
+
mid: string,
|
|
213
|
+
right: string,
|
|
214
|
+
widths: number[],
|
|
215
|
+
): string {
|
|
216
|
+
const segs = widths.map((w) => "─".repeat(w + 2));
|
|
217
|
+
return DIM_ON + left + segs.join(mid) + right + RESET;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function dataLine(
|
|
221
|
+
cells: string[],
|
|
222
|
+
aligns: Align[],
|
|
223
|
+
widths: number[],
|
|
224
|
+
bold: boolean,
|
|
225
|
+
): string {
|
|
226
|
+
const parts: string[] = [];
|
|
227
|
+
parts.push(`${DIM_ON}│${RESET}`);
|
|
228
|
+
for (let i = 0; i < widths.length; i++) {
|
|
229
|
+
const w = widths[i] ?? 0;
|
|
230
|
+
const align = aligns[i] ?? "left";
|
|
231
|
+
const raw = cells[i] ?? "";
|
|
232
|
+
const fitted = padCell(raw, w, align);
|
|
233
|
+
const styled = bold ? `${BOLD_ON}${fitted}${RESET}` : fitted;
|
|
234
|
+
parts.push(` ${styled} `);
|
|
235
|
+
parts.push(`${DIM_ON}│${RESET}`);
|
|
236
|
+
}
|
|
237
|
+
return parts.join("");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function padCell(text: string, width: number, align: Align): string {
|
|
241
|
+
const truncated = truncateToWidth(text, width);
|
|
242
|
+
const pad = width - visibleWidth(truncated);
|
|
243
|
+
if (pad <= 0) return truncated;
|
|
244
|
+
if (align === "right") return " ".repeat(pad) + truncated;
|
|
245
|
+
if (align === "center") {
|
|
246
|
+
const l = Math.floor(pad / 2);
|
|
247
|
+
const r = pad - l;
|
|
248
|
+
return " ".repeat(l) + truncated + " ".repeat(r);
|
|
249
|
+
}
|
|
250
|
+
return truncated + " ".repeat(pad);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function truncateToWidth(text: string, width: number): string {
|
|
254
|
+
if (width <= 0) return "";
|
|
255
|
+
if (visibleWidth(text) <= width) return text;
|
|
256
|
+
if (width === 1) return "…";
|
|
257
|
+
const chars = Array.from(text);
|
|
258
|
+
let out = "";
|
|
259
|
+
let used = 0;
|
|
260
|
+
for (const ch of chars) {
|
|
261
|
+
if (used + 1 > width - 1) break;
|
|
262
|
+
out += ch;
|
|
263
|
+
used++;
|
|
264
|
+
}
|
|
265
|
+
return `${out}…`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function visibleWidth(text: string): number {
|
|
269
|
+
// Cell text has no ANSI (we strip markdown markers before measuring), so
|
|
270
|
+
// codepoint count is sufficient. East-Asian double-width chars would be
|
|
271
|
+
// undercounted; out of scope for v1.
|
|
272
|
+
return Array.from(text).length;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function stripInlineMarkdown(text: string): string {
|
|
276
|
+
// Strip a small set of inline markers so cell width measurement matches what
|
|
277
|
+
// the user sees. Order matters: longer markers first.
|
|
278
|
+
let s = text;
|
|
279
|
+
s = s.replace(/`([^`]+)`/g, "$1");
|
|
280
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, "$1");
|
|
281
|
+
s = s.replace(/__([^_]+)__/g, "$1");
|
|
282
|
+
s = s.replace(/~~([^~]+)~~/g, "$1");
|
|
283
|
+
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2");
|
|
284
|
+
s = s.replace(/(^|[^_])_([^_\n]+)_/g, "$1$2");
|
|
285
|
+
// Collapse \| escapes that survived splitRow.
|
|
286
|
+
s = s.replace(/\\\|/g, "|");
|
|
287
|
+
return s;
|
|
288
|
+
}
|
|
@@ -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
|
|