botholomew 0.16.4 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -41
- package/package.json +4 -9
- package/src/chat/agent.ts +37 -40
- package/src/chat/session.ts +10 -10
- package/src/cli.ts +0 -2
- package/src/commands/capabilities.ts +35 -33
- package/src/commands/context.ts +133 -221
- package/src/commands/init.ts +22 -1
- package/src/commands/mcpx.ts +21 -8
- package/src/commands/nuke.ts +52 -15
- package/src/commands/prepare.ts +16 -13
- package/src/config/loader.ts +1 -8
- package/src/config/schemas.ts +6 -0
- package/src/constants.ts +16 -32
- package/src/init/index.ts +52 -27
- package/src/mcpx/client.ts +21 -5
- package/src/mem/client.ts +33 -0
- package/src/{context → prompts}/capabilities.ts +11 -7
- package/src/schedules/store.ts +1 -1
- package/src/tasks/store.ts +1 -1
- package/src/threads/store.ts +1 -1
- package/src/tools/capabilities/refresh.ts +1 -1
- package/src/tools/membot/adapter.ts +111 -0
- package/src/tools/membot/copy.ts +59 -0
- package/src/tools/membot/count_lines.ts +53 -0
- package/src/tools/membot/edit.ts +72 -0
- package/src/tools/membot/exists.ts +54 -0
- package/src/tools/membot/index.ts +26 -0
- package/src/tools/{context → membot}/pipe.ts +34 -32
- package/src/tools/registry.ts +6 -37
- package/src/tools/tool.ts +6 -8
- package/src/tui/App.tsx +3 -4
- package/src/tui/components/ContextPanel.tsx +109 -226
- package/src/tui/components/HelpPanel.tsx +2 -2
- package/src/tui/components/StatusBar.tsx +0 -6
- package/src/tui/components/ThreadPanel.tsx +8 -7
- package/src/tui/wrapDetail.ts +11 -0
- package/src/worker/heartbeat.ts +0 -20
- package/src/worker/index.ts +13 -13
- package/src/worker/llm.ts +7 -9
- package/src/worker/prompt.ts +25 -13
- package/src/worker/spawn.ts +1 -1
- package/src/worker/tick.ts +10 -9
- package/src/commands/db.ts +0 -119
- package/src/commands/with-db.ts +0 -22
- package/src/context/chunker.ts +0 -275
- package/src/context/embedder-impl.ts +0 -100
- package/src/context/embedder.ts +0 -9
- package/src/context/fetcher-errors.ts +0 -8
- package/src/context/fetcher.ts +0 -515
- package/src/context/locks.ts +0 -146
- package/src/context/markdown-converter.ts +0 -186
- package/src/context/reindex.ts +0 -198
- package/src/context/store.ts +0 -841
- package/src/context/url-utils.ts +0 -25
- package/src/db/connection.ts +0 -255
- package/src/db/doctor.ts +0 -235
- package/src/db/embeddings.ts +0 -317
- package/src/db/query.ts +0 -56
- package/src/db/schema.ts +0 -93
- package/src/db/sql/1-core_tables.sql +0 -53
- package/src/db/sql/10-dedupe_context_items.sql +0 -26
- package/src/db/sql/11-rebuild_hnsw.sql +0 -8
- package/src/db/sql/12-workers.sql +0 -66
- package/src/db/sql/13-drive-paths.sql +0 -47
- package/src/db/sql/14-drop_hnsw_index.sql +0 -8
- package/src/db/sql/15-fts_index.sql +0 -8
- package/src/db/sql/16-source_url.sql +0 -7
- package/src/db/sql/17-worker_log_path.sql +0 -3
- package/src/db/sql/18-reset_embeddings_for_local.sql +0 -39
- package/src/db/sql/19-disk_backed_index.sql +0 -36
- package/src/db/sql/2-logging_tables.sql +0 -24
- package/src/db/sql/20-drop_db_tables_for_files.sql +0 -19
- package/src/db/sql/3-daemon_state.sql +0 -5
- package/src/db/sql/4-unique_context_path.sql +0 -1
- package/src/db/sql/5-reset_embeddings_for_openai.sql +0 -1
- package/src/db/sql/6-vss_index.sql +0 -7
- package/src/db/sql/7-drop_embeddings_fk.sql +0 -23
- package/src/db/sql/8-task_output.sql +0 -1
- package/src/db/sql/9-source-type.sql +0 -1
- package/src/tools/context/read-large-result.ts +0 -33
- package/src/tools/dir/create.ts +0 -47
- package/src/tools/dir/size.ts +0 -77
- package/src/tools/dir/tree.ts +0 -124
- package/src/tools/file/copy.ts +0 -73
- package/src/tools/file/count-lines.ts +0 -54
- package/src/tools/file/delete.ts +0 -83
- package/src/tools/file/edit.ts +0 -76
- package/src/tools/file/exists.ts +0 -33
- package/src/tools/file/info.ts +0 -66
- package/src/tools/file/move.ts +0 -66
- package/src/tools/file/read.ts +0 -67
- package/src/tools/file/write.ts +0 -58
- package/src/tools/search/fuse.ts +0 -96
- package/src/tools/search/index.ts +0 -127
- package/src/tools/search/regexp.ts +0 -82
- package/src/tools/search/semantic.ts +0 -167
- /package/src/{db → utils}/uuid.ts +0 -0
|
@@ -1,20 +1,7 @@
|
|
|
1
1
|
import { Box, Text, useInput } from "ink";
|
|
2
|
+
import type { MembotClient } from "membot";
|
|
2
3
|
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
type ContextEntry,
|
|
6
|
-
deleteContextPath,
|
|
7
|
-
listContextDir,
|
|
8
|
-
readContextFile,
|
|
9
|
-
} from "../../context/store.ts";
|
|
10
|
-
import { withDb } from "../../db/connection.ts";
|
|
11
|
-
import {
|
|
12
|
-
deleteIndexedPath,
|
|
13
|
-
deleteIndexedPathsUnder,
|
|
14
|
-
getIndexedPath,
|
|
15
|
-
type IndexedPathSummary,
|
|
16
|
-
rebuildSearchIndex,
|
|
17
|
-
} from "../../db/embeddings.ts";
|
|
4
|
+
import { openMembot } from "../../mem/client.ts";
|
|
18
5
|
import {
|
|
19
6
|
detailPaneBorderProps,
|
|
20
7
|
type FocusState,
|
|
@@ -34,9 +21,24 @@ interface ContextPanelProps {
|
|
|
34
21
|
isActive: boolean;
|
|
35
22
|
}
|
|
36
23
|
|
|
37
|
-
|
|
24
|
+
interface ContextEntry {
|
|
25
|
+
logical_path: string;
|
|
26
|
+
version_id: string;
|
|
27
|
+
size_bytes: number | null;
|
|
28
|
+
mime_type: string | null;
|
|
29
|
+
description: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SIDEBAR_WIDTH = 40;
|
|
38
33
|
const PAGE_SCROLL_LINES = 10;
|
|
39
34
|
|
|
35
|
+
/**
|
|
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 context tree` /
|
|
40
|
+
* `botholomew context search` for hierarchical or content-based discovery.
|
|
41
|
+
*/
|
|
40
42
|
export const ContextPanel = memo(function ContextPanel({
|
|
41
43
|
projectDir,
|
|
42
44
|
isActive,
|
|
@@ -44,51 +46,55 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
44
46
|
const { rows: termRows, cols: termCols } = useTerminalSize();
|
|
45
47
|
const detailWidth = Math.max(1, termCols - SIDEBAR_WIDTH - 5);
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
// One MembotClient per panel mount. Membot manages its DB lock per-op so
|
|
50
|
+
// sharing the file with the chat session / workers is safe.
|
|
51
|
+
const [client, setClient] = useState<MembotClient | null>(null);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const c = openMembot(projectDir);
|
|
54
|
+
setClient(c);
|
|
55
|
+
return () => {
|
|
56
|
+
void c.close();
|
|
57
|
+
};
|
|
58
|
+
}, [projectDir]);
|
|
59
|
+
|
|
48
60
|
const [entries, setEntries] = useState<ContextEntry[]>([]);
|
|
49
61
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
50
62
|
const [sidebarScrollOffset, setSidebarScrollOffset] = useState(0);
|
|
51
63
|
const [detailScroll, setDetailScroll] = useState(0);
|
|
52
64
|
const [focus, setFocus] = useState<FocusState>("list");
|
|
53
65
|
const [fileContent, setFileContent] = useState<{
|
|
54
|
-
|
|
66
|
+
logical_path: string;
|
|
55
67
|
content: string;
|
|
56
68
|
} | null>(null);
|
|
57
|
-
const [indexStatus, setIndexStatus] = useState<{
|
|
58
|
-
path: string;
|
|
59
|
-
summary: IndexedPathSummary | null;
|
|
60
|
-
} | null>(null);
|
|
61
69
|
|
|
62
70
|
const visibleRows = Math.max(1, termRows - 6);
|
|
63
71
|
|
|
64
|
-
const refresh = useCallback(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
);
|
|
72
|
+
const refresh = useCallback(async () => {
|
|
73
|
+
if (!client) return;
|
|
74
|
+
try {
|
|
75
|
+
const out = await client.list({ limit: 500 });
|
|
76
|
+
const list = out.entries.map((e) => ({
|
|
77
|
+
logical_path: e.logical_path,
|
|
78
|
+
version_id: e.version_id,
|
|
79
|
+
size_bytes: e.size_bytes,
|
|
80
|
+
mime_type: e.mime_type,
|
|
81
|
+
description: e.description,
|
|
82
|
+
}));
|
|
83
|
+
list.sort((a, b) => a.logical_path.localeCompare(b.logical_path));
|
|
84
|
+
setEntries(list);
|
|
85
|
+
setSelectedIndex(0);
|
|
86
|
+
setSidebarScrollOffset(0);
|
|
87
|
+
} catch {
|
|
88
|
+
setEntries([]);
|
|
89
|
+
setSelectedIndex(0);
|
|
90
|
+
setSidebarScrollOffset(0);
|
|
91
|
+
}
|
|
92
|
+
}, [client]);
|
|
85
93
|
|
|
86
94
|
useEffect(() => {
|
|
87
|
-
refresh(
|
|
88
|
-
}, [
|
|
95
|
+
refresh();
|
|
96
|
+
}, [refresh]);
|
|
89
97
|
|
|
90
|
-
// Keep the sidebar's selection visible by scrolling its viewport when the
|
|
91
|
-
// cursor approaches the edges.
|
|
92
98
|
useEffect(() => {
|
|
93
99
|
if (selectedIndex < sidebarScrollOffset) {
|
|
94
100
|
setSidebarScrollOffset(selectedIndex);
|
|
@@ -99,57 +105,38 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
99
105
|
|
|
100
106
|
const selectedEntry = entries[selectedIndex];
|
|
101
107
|
|
|
102
|
-
// Auto-load file content when the selection lands on a textual file.
|
|
103
|
-
// Folders and non-textual files clear the right pane.
|
|
104
108
|
useEffect(() => {
|
|
105
109
|
let cancelled = false;
|
|
106
|
-
if (!selectedEntry) {
|
|
107
|
-
setFileContent(null);
|
|
108
|
-
setDetailScroll(0);
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
if (selectedEntry.is_directory || !selectedEntry.is_textual) {
|
|
110
|
+
if (!selectedEntry || !client) {
|
|
112
111
|
setFileContent(null);
|
|
113
112
|
setDetailScroll(0);
|
|
114
113
|
return;
|
|
115
114
|
}
|
|
116
115
|
setDetailScroll(0);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
});
|
|
121
|
-
return () => {
|
|
122
|
-
cancelled = true;
|
|
123
|
-
};
|
|
124
|
-
}, [projectDir, selectedEntry]);
|
|
125
|
-
|
|
126
|
-
// Look up the file's index status so we can show "indexed (N chunks)"
|
|
127
|
-
// vs "not indexed" in the header. Skips for folders.
|
|
128
|
-
useEffect(() => {
|
|
129
|
-
let cancelled = false;
|
|
130
|
-
if (!selectedEntry || selectedEntry.is_directory) {
|
|
131
|
-
setIndexStatus(null);
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
const path = selectedEntry.path;
|
|
135
|
-
const dbPath = getDbPath(projectDir);
|
|
136
|
-
withDb(dbPath, (conn) => getIndexedPath(conn, path))
|
|
137
|
-
.then((summary) => {
|
|
116
|
+
client
|
|
117
|
+
.read({ logical_path: selectedEntry.logical_path })
|
|
118
|
+
.then((result) => {
|
|
138
119
|
if (cancelled) return;
|
|
139
|
-
|
|
120
|
+
setFileContent({
|
|
121
|
+
logical_path: selectedEntry.logical_path,
|
|
122
|
+
content: result.content ?? "",
|
|
123
|
+
});
|
|
140
124
|
})
|
|
141
125
|
.catch(() => {
|
|
142
126
|
if (cancelled) return;
|
|
143
|
-
|
|
127
|
+
setFileContent({
|
|
128
|
+
logical_path: selectedEntry.logical_path,
|
|
129
|
+
content: "(failed to read this entry — it may have been removed)",
|
|
130
|
+
});
|
|
144
131
|
});
|
|
145
132
|
return () => {
|
|
146
133
|
cancelled = true;
|
|
147
134
|
};
|
|
148
|
-
}, [
|
|
135
|
+
}, [client, selectedEntry]);
|
|
149
136
|
|
|
150
137
|
const detailLines = useMemo(() => {
|
|
151
138
|
if (!fileContent || !selectedEntry) return [];
|
|
152
|
-
const body = isMarkdownPath(fileContent.
|
|
139
|
+
const body = isMarkdownPath(fileContent.logical_path)
|
|
153
140
|
? renderMarkdown(fileContent.content)
|
|
154
141
|
: fileContent.content;
|
|
155
142
|
return wrapDetailLines(body, detailWidth);
|
|
@@ -163,34 +150,22 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
163
150
|
[entries, sidebarScrollOffset, visibleRows],
|
|
164
151
|
);
|
|
165
152
|
|
|
166
|
-
// Refs read by the keyboard handler so it always sees the latest committed
|
|
167
|
-
// values (Ink 7's useInput intermittently leaves a stale closure).
|
|
168
153
|
const itemCountRef = useLatestRef(entries.length);
|
|
169
154
|
const maxDetailScrollRef = useLatestRef(maxDetailScroll);
|
|
170
155
|
const selectedEntryRef = useLatestRef(selectedEntry);
|
|
171
|
-
const currentPathRef = useLatestRef(currentPath);
|
|
172
156
|
const focusRef = useLatestRef(focus);
|
|
173
157
|
|
|
174
158
|
const deleteConfirm = useDeleteConfirm(() => {
|
|
175
159
|
const entry = selectedEntryRef.current;
|
|
176
|
-
if (!entry) return;
|
|
177
|
-
const path = entry.
|
|
178
|
-
const isDirectory = entry.is_directory;
|
|
160
|
+
if (!entry || !client) return;
|
|
161
|
+
const path = entry.logical_path;
|
|
179
162
|
(async () => {
|
|
180
163
|
try {
|
|
181
|
-
await
|
|
182
|
-
await withDb(getDbPath(projectDir), async (conn) => {
|
|
183
|
-
if (isDirectory) {
|
|
184
|
-
await deleteIndexedPathsUnder(conn, path);
|
|
185
|
-
} else {
|
|
186
|
-
await deleteIndexedPath(conn, path);
|
|
187
|
-
}
|
|
188
|
-
await rebuildSearchIndex(conn);
|
|
189
|
-
});
|
|
164
|
+
await client.remove({ paths: [path] });
|
|
190
165
|
} catch {
|
|
191
166
|
// ignore — refresh will reflect any partial state
|
|
192
167
|
}
|
|
193
|
-
refresh(
|
|
168
|
+
refresh();
|
|
194
169
|
})();
|
|
195
170
|
});
|
|
196
171
|
|
|
@@ -207,26 +182,6 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
207
182
|
setSelectedIndex,
|
|
208
183
|
setDetailScroll,
|
|
209
184
|
pageScrollLines: PAGE_SCROLL_LINES,
|
|
210
|
-
// Context-specific: → on a folder drills in (when list-focused);
|
|
211
|
-
// ← in list-focus goes up a directory.
|
|
212
|
-
onRightArrow: () => {
|
|
213
|
-
if (focusRef.current !== "list") return false;
|
|
214
|
-
const entry = selectedEntryRef.current;
|
|
215
|
-
if (entry?.is_directory) {
|
|
216
|
-
setCurrentPath(entry.path);
|
|
217
|
-
return true;
|
|
218
|
-
}
|
|
219
|
-
return false;
|
|
220
|
-
},
|
|
221
|
-
onLeftArrow: () => {
|
|
222
|
-
if (focusRef.current !== "list") return false;
|
|
223
|
-
const cwd = currentPathRef.current;
|
|
224
|
-
if (cwd === "") return true; // already at root, swallow the key
|
|
225
|
-
const parts = cwd.split("/");
|
|
226
|
-
parts.pop();
|
|
227
|
-
setCurrentPath(parts.join("/"));
|
|
228
|
-
return true;
|
|
229
|
-
},
|
|
230
185
|
})
|
|
231
186
|
) {
|
|
232
187
|
return;
|
|
@@ -235,20 +190,17 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
235
190
|
if (input === "d") {
|
|
236
191
|
const entry = selectedEntryRef.current;
|
|
237
192
|
if (!entry) return;
|
|
238
|
-
deleteConfirm.pressDelete(entry.
|
|
193
|
+
deleteConfirm.pressDelete(entry.logical_path);
|
|
239
194
|
return;
|
|
240
195
|
}
|
|
241
196
|
if (key.ctrl && (input === "r" || input === "R")) {
|
|
242
|
-
refresh(
|
|
197
|
+
refresh();
|
|
243
198
|
return;
|
|
244
199
|
}
|
|
245
200
|
},
|
|
246
201
|
{ isActive },
|
|
247
202
|
);
|
|
248
203
|
|
|
249
|
-
const headerLabel =
|
|
250
|
-
currentPath === "" ? "context/" : `context/${currentPath}/`;
|
|
251
|
-
|
|
252
204
|
const detailVisible = detailLines.slice(
|
|
253
205
|
detailScroll,
|
|
254
206
|
detailScroll + visibleDetailRows,
|
|
@@ -256,7 +208,6 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
256
208
|
|
|
257
209
|
return (
|
|
258
210
|
<Box flexGrow={1} height={visibleRows + 1} overflow="hidden">
|
|
259
|
-
{/* Left: file tree */}
|
|
260
211
|
<Box
|
|
261
212
|
flexDirection="column"
|
|
262
213
|
width={SIDEBAR_WIDTH}
|
|
@@ -271,35 +222,26 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
271
222
|
>
|
|
272
223
|
<Box paddingX={1}>
|
|
273
224
|
<Text bold dimColor wrap="truncate-end">
|
|
274
|
-
{
|
|
225
|
+
membot ({entries.length})
|
|
275
226
|
</Text>
|
|
276
227
|
</Box>
|
|
277
228
|
{entries.length === 0 ? (
|
|
278
229
|
<Box paddingX={1}>
|
|
279
|
-
<Text dimColor>(empty)</Text>
|
|
230
|
+
<Text dimColor>(empty — try `botholomew context add …`)</Text>
|
|
280
231
|
</Box>
|
|
281
232
|
) : (
|
|
282
233
|
visibleItems.map((entry, vi) => {
|
|
283
234
|
const i = vi + sidebarScrollOffset;
|
|
284
235
|
const isSelected = i === selectedIndex;
|
|
285
|
-
const name = entry.path.split("/").pop() ?? entry.path;
|
|
286
|
-
const icon = entry.is_directory ? "📁" : "📄";
|
|
287
236
|
return (
|
|
288
|
-
<Box key={entry.
|
|
237
|
+
<Box key={entry.logical_path} paddingX={1}>
|
|
289
238
|
<Text
|
|
290
239
|
backgroundColor={isSelected ? theme.selectionBg : undefined}
|
|
291
|
-
color={
|
|
292
|
-
isSelected
|
|
293
|
-
? theme.info
|
|
294
|
-
: entry.is_directory
|
|
295
|
-
? theme.accent
|
|
296
|
-
: undefined
|
|
297
|
-
}
|
|
240
|
+
color={isSelected ? theme.info : undefined}
|
|
298
241
|
bold={isSelected}
|
|
299
242
|
wrap="truncate-end"
|
|
300
243
|
>
|
|
301
|
-
{isSelected ? "▸" : " "} {
|
|
302
|
-
{entry.is_directory ? "/" : ""}
|
|
244
|
+
{isSelected ? "▸" : " "} {entry.logical_path}
|
|
303
245
|
</Text>
|
|
304
246
|
</Box>
|
|
305
247
|
);
|
|
@@ -307,7 +249,6 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
307
249
|
)}
|
|
308
250
|
</Box>
|
|
309
251
|
|
|
310
|
-
{/* Right: file content (or placeholder) */}
|
|
311
252
|
<Box
|
|
312
253
|
flexDirection="column"
|
|
313
254
|
flexGrow={1}
|
|
@@ -318,49 +259,29 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
318
259
|
>
|
|
319
260
|
{selectedEntry ? (
|
|
320
261
|
<>
|
|
321
|
-
<ContextDetailHeader
|
|
322
|
-
entry={selectedEntry}
|
|
323
|
-
indexStatus={
|
|
324
|
-
indexStatus && indexStatus.path === selectedEntry.path
|
|
325
|
-
? indexStatus.summary
|
|
326
|
-
: null
|
|
327
|
-
}
|
|
328
|
-
indexLoaded={
|
|
329
|
-
!!indexStatus && indexStatus.path === selectedEntry.path
|
|
330
|
-
}
|
|
331
|
-
/>
|
|
262
|
+
<ContextDetailHeader entry={selectedEntry} />
|
|
332
263
|
<Box flexDirection="row" flexGrow={1} overflow="hidden">
|
|
333
264
|
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
334
|
-
{
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
<Text key={lineNum} wrap="truncate-end">
|
|
343
|
-
{line || " "}
|
|
344
|
-
</Text>
|
|
345
|
-
);
|
|
346
|
-
})
|
|
347
|
-
)}
|
|
265
|
+
{detailVisible.map((line, i) => {
|
|
266
|
+
const lineNum = detailScroll + i;
|
|
267
|
+
return (
|
|
268
|
+
<Text key={lineNum} wrap="truncate-end">
|
|
269
|
+
{line || " "}
|
|
270
|
+
</Text>
|
|
271
|
+
);
|
|
272
|
+
})}
|
|
348
273
|
</Box>
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
height={visibleDetailRows - 3}
|
|
357
|
-
focused={focus === "detail"}
|
|
358
|
-
/>
|
|
359
|
-
)}
|
|
274
|
+
<Scrollbar
|
|
275
|
+
total={detailLines.length}
|
|
276
|
+
visible={visibleDetailRows - 3}
|
|
277
|
+
offset={detailScroll}
|
|
278
|
+
height={visibleDetailRows - 3}
|
|
279
|
+
focused={focus === "detail"}
|
|
280
|
+
/>
|
|
360
281
|
</Box>
|
|
361
282
|
</>
|
|
362
283
|
) : (
|
|
363
|
-
<Text dimColor>(no
|
|
284
|
+
<Text dimColor>(no entry selected)</Text>
|
|
364
285
|
)}
|
|
365
286
|
<DeleteArmedBanner
|
|
366
287
|
armed={deleteConfirm.armed}
|
|
@@ -370,7 +291,7 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
370
291
|
<Text dimColor>
|
|
371
292
|
{focus === "detail"
|
|
372
293
|
? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
|
|
373
|
-
: "↑↓ select · →
|
|
294
|
+
: "↑↓ select · → detail · d delete (×2) · ^R refresh"}
|
|
374
295
|
</Text>
|
|
375
296
|
</Box>
|
|
376
297
|
</Box>
|
|
@@ -378,72 +299,34 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
378
299
|
);
|
|
379
300
|
});
|
|
380
301
|
|
|
381
|
-
function formatSize(bytes: number): string {
|
|
302
|
+
function formatSize(bytes: number | null): string {
|
|
303
|
+
if (bytes === null) return "-";
|
|
382
304
|
if (bytes < 1024) return `${bytes} B`;
|
|
383
305
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
384
306
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
385
307
|
}
|
|
386
308
|
|
|
387
|
-
function
|
|
388
|
-
return d.toLocaleString([], {
|
|
389
|
-
month: "short",
|
|
390
|
-
day: "numeric",
|
|
391
|
-
hour: "2-digit",
|
|
392
|
-
minute: "2-digit",
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
function ContextDetailHeader({
|
|
397
|
-
entry,
|
|
398
|
-
indexStatus,
|
|
399
|
-
indexLoaded,
|
|
400
|
-
}: {
|
|
401
|
-
entry: ContextEntry;
|
|
402
|
-
indexStatus: IndexedPathSummary | null;
|
|
403
|
-
indexLoaded: boolean;
|
|
404
|
-
}) {
|
|
309
|
+
function ContextDetailHeader({ entry }: { entry: ContextEntry }) {
|
|
405
310
|
return (
|
|
406
311
|
<Box flexDirection="column" width="100%" backgroundColor={theme.headerBg}>
|
|
407
312
|
<Box>
|
|
408
313
|
<Text bold color="cyan" wrap="truncate-end">
|
|
409
|
-
|
|
410
|
-
|
|
314
|
+
📄 {entry.logical_path}
|
|
315
|
+
</Text>
|
|
316
|
+
</Box>
|
|
317
|
+
<Box>
|
|
318
|
+
<Text dimColor wrap="truncate-end">
|
|
319
|
+
{entry.mime_type ?? "?"} · {formatSize(entry.size_bytes)} · v=
|
|
320
|
+
{entry.version_id}
|
|
411
321
|
</Text>
|
|
412
322
|
</Box>
|
|
413
|
-
{entry.
|
|
323
|
+
{entry.description ? (
|
|
414
324
|
<Box>
|
|
415
325
|
<Text dimColor wrap="truncate-end">
|
|
416
|
-
|
|
326
|
+
{entry.description}
|
|
417
327
|
</Text>
|
|
418
328
|
</Box>
|
|
419
|
-
) :
|
|
420
|
-
<>
|
|
421
|
-
<Box>
|
|
422
|
-
<Text dimColor wrap="truncate-end">
|
|
423
|
-
{entry.mime_type} · {formatSize(entry.size)} · updated{" "}
|
|
424
|
-
{formatDate(entry.mtime)}
|
|
425
|
-
</Text>
|
|
426
|
-
</Box>
|
|
427
|
-
<Box>
|
|
428
|
-
<Text wrap="truncate-end">
|
|
429
|
-
{!indexLoaded ? (
|
|
430
|
-
<Text dimColor>checking index…</Text>
|
|
431
|
-
) : indexStatus ? (
|
|
432
|
-
<Text color={theme.success}>
|
|
433
|
-
● indexed
|
|
434
|
-
<Text dimColor>
|
|
435
|
-
{" ("}
|
|
436
|
-
{indexStatus.chunk_count}
|
|
437
|
-
{indexStatus.chunk_count === 1 ? " chunk" : " chunks"})
|
|
438
|
-
</Text>
|
|
439
|
-
</Text>
|
|
440
|
-
) : (
|
|
441
|
-
<Text color={theme.muted}>○ not indexed</Text>
|
|
442
|
-
)}
|
|
443
|
-
</Text>
|
|
444
|
-
</Box>
|
|
445
|
-
</>
|
|
446
|
-
)}
|
|
329
|
+
) : null}
|
|
447
330
|
</Box>
|
|
448
331
|
);
|
|
449
332
|
}
|
|
@@ -149,8 +149,8 @@ export const HelpPanel = memo(function HelpPanel({
|
|
|
149
149
|
{" "}Tasks{" "}f filter · p priority · d delete (×2)
|
|
150
150
|
</Text>
|
|
151
151
|
<Text>
|
|
152
|
-
{" "}Threads{" "}f filter · s/ search · w
|
|
153
|
-
(×2)
|
|
152
|
+
{" "}Threads{" "}f filter · s/ search · w tail live thread · d
|
|
153
|
+
delete (×2)
|
|
154
154
|
</Text>
|
|
155
155
|
<Text>
|
|
156
156
|
{" "}Schedules{" "}f filter · e toggle · d delete (×2)
|
|
@@ -7,11 +7,6 @@ import { LogoChar } from "./Logo.tsx";
|
|
|
7
7
|
|
|
8
8
|
interface StatusBarProps {
|
|
9
9
|
projectDir: string;
|
|
10
|
-
/**
|
|
11
|
-
* Retained for callers that pass it; unused now that workers + tasks
|
|
12
|
-
* both live on disk. Drop on the next TUI cleanup pass.
|
|
13
|
-
*/
|
|
14
|
-
dbPath?: string;
|
|
15
10
|
chatTitle?: string;
|
|
16
11
|
onWorkerStatusChange?: (running: boolean) => void;
|
|
17
12
|
}
|
|
@@ -24,7 +19,6 @@ interface Status {
|
|
|
24
19
|
|
|
25
20
|
export function StatusBar({
|
|
26
21
|
projectDir,
|
|
27
|
-
dbPath: _dbPath,
|
|
28
22
|
chatTitle,
|
|
29
23
|
onWorkerStatusChange,
|
|
30
24
|
}: StatusBarProps) {
|
|
@@ -18,7 +18,7 @@ import { ansi, theme } from "../theme.ts";
|
|
|
18
18
|
import { useDeleteConfirm } from "../useDeleteConfirm.ts";
|
|
19
19
|
import { useLatestRef } from "../useLatestRef.ts";
|
|
20
20
|
import { useTerminalSize } from "../useTerminalSize.ts";
|
|
21
|
-
import { wrapDetailLines } from "../wrapDetail.ts";
|
|
21
|
+
import { clampScroll, wrapDetailLines } from "../wrapDetail.ts";
|
|
22
22
|
import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
|
|
23
23
|
import { Scrollbar } from "./Scrollbar.tsx";
|
|
24
24
|
|
|
@@ -449,9 +449,10 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
449
449
|
sidebarScrollOffset + visibleRows,
|
|
450
450
|
);
|
|
451
451
|
|
|
452
|
+
const safeDetailScroll = clampScroll(detailScroll, maxDetailScroll);
|
|
452
453
|
const detailVisible = detailLines.slice(
|
|
453
|
-
|
|
454
|
-
|
|
454
|
+
safeDetailScroll,
|
|
455
|
+
safeDetailScroll + visibleRows,
|
|
455
456
|
);
|
|
456
457
|
|
|
457
458
|
return (
|
|
@@ -547,7 +548,7 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
547
548
|
<Box flexDirection="row" flexGrow={1} overflow="hidden">
|
|
548
549
|
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
549
550
|
{detailVisible.map((line, i) => {
|
|
550
|
-
const lineNum =
|
|
551
|
+
const lineNum = safeDetailScroll + i;
|
|
551
552
|
return (
|
|
552
553
|
<Text key={lineNum} wrap="truncate-end">
|
|
553
554
|
{line || " "}
|
|
@@ -558,7 +559,7 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
558
559
|
<Scrollbar
|
|
559
560
|
total={detailLines.length}
|
|
560
561
|
visible={visibleRows - 3}
|
|
561
|
-
offset={
|
|
562
|
+
offset={safeDetailScroll}
|
|
562
563
|
height={visibleRows - 3}
|
|
563
564
|
focused={focus === "detail"}
|
|
564
565
|
/>
|
|
@@ -571,13 +572,13 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
571
572
|
{following && (
|
|
572
573
|
<Text color={theme.success} bold>
|
|
573
574
|
{" "}
|
|
574
|
-
|
|
575
|
+
● TAILING{" "}
|
|
575
576
|
</Text>
|
|
576
577
|
)}
|
|
577
578
|
<Text dimColor>
|
|
578
579
|
{focus === "detail"
|
|
579
580
|
? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
|
|
580
|
-
: `↑↓ select · → enter detail · s search · f filter ·
|
|
581
|
+
: `↑↓ select · → enter detail · s search · f filter · ${selectedThread && !selectedThread.ended_at ? "w tail (live) · " : ""}d delete (×2) · ^R refresh`}
|
|
581
582
|
</Text>
|
|
582
583
|
</Box>
|
|
583
584
|
</Box>
|
package/src/tui/wrapDetail.ts
CHANGED
|
@@ -13,3 +13,14 @@ export function wrapDetailLines(text: string, width: number): string[] {
|
|
|
13
13
|
if (width <= 0) return text.split("\n");
|
|
14
14
|
return wrapAnsi(text, width, { hard: true, trim: false }).split("\n");
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Clamp a scroll offset into the valid range for a detail pane. The Threads
|
|
19
|
+
* pane uses `Number.MAX_SAFE_INTEGER` as a "stay-at-bottom" sentinel during
|
|
20
|
+
* tail mode, and any panel can end up with an out-of-range scroll after a
|
|
21
|
+
* terminal resize shrinks `visibleRows`. Without clamping, `Array.slice`
|
|
22
|
+
* silently returns `[]`.
|
|
23
|
+
*/
|
|
24
|
+
export function clampScroll(scroll: number, maxScroll: number): number {
|
|
25
|
+
return Math.min(Math.max(0, scroll), Math.max(0, maxScroll));
|
|
26
|
+
}
|
package/src/worker/heartbeat.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { reapOrphanContextLocks } from "../context/locks.ts";
|
|
2
1
|
import { reapOrphanScheduleLocks } from "../schedules/store.ts";
|
|
3
2
|
import { reapOrphanLocks as reapOrphanTaskLocks } from "../tasks/store.ts";
|
|
4
3
|
import { logger } from "../utils/logger.ts";
|
|
@@ -82,25 +81,6 @@ export function startReaper(
|
|
|
82
81
|
logger.warn(`schedule lock reap failed: ${err}`);
|
|
83
82
|
}
|
|
84
83
|
|
|
85
|
-
try {
|
|
86
|
-
// Context locks store either a `workerId` (worker holders) or a
|
|
87
|
-
// free-form id like `chat` / `pid:<n>` (chat sessions, CLI). Only
|
|
88
|
-
// expire holders that look like worker ids; conservatively treat
|
|
89
|
-
// any other holder as alive — we don't manage the chat session's
|
|
90
|
-
// lifecycle here.
|
|
91
|
-
const released = await reapOrphanContextLocks(projectDir, async (id) => {
|
|
92
|
-
if (id.startsWith("pid:") || id.startsWith("chat")) return true;
|
|
93
|
-
return await isAlive(id);
|
|
94
|
-
});
|
|
95
|
-
if (released.length > 0) {
|
|
96
|
-
logger.warn(
|
|
97
|
-
`released ${released.length} orphan context lock(s): ${released.join(", ")}`,
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
} catch (err) {
|
|
101
|
-
logger.warn(`context lock reap failed: ${err}`);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
84
|
try {
|
|
105
85
|
const pruned = await pruneStoppedWorkers(
|
|
106
86
|
projectDir,
|