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.
Files changed (98) hide show
  1. package/README.md +46 -41
  2. package/package.json +4 -9
  3. package/src/chat/agent.ts +37 -40
  4. package/src/chat/session.ts +10 -10
  5. package/src/cli.ts +0 -2
  6. package/src/commands/capabilities.ts +35 -33
  7. package/src/commands/context.ts +133 -221
  8. package/src/commands/init.ts +22 -1
  9. package/src/commands/mcpx.ts +21 -8
  10. package/src/commands/nuke.ts +52 -15
  11. package/src/commands/prepare.ts +16 -13
  12. package/src/config/loader.ts +1 -8
  13. package/src/config/schemas.ts +6 -0
  14. package/src/constants.ts +16 -32
  15. package/src/init/index.ts +52 -27
  16. package/src/mcpx/client.ts +21 -5
  17. package/src/mem/client.ts +33 -0
  18. package/src/{context → prompts}/capabilities.ts +11 -7
  19. package/src/schedules/store.ts +1 -1
  20. package/src/tasks/store.ts +1 -1
  21. package/src/threads/store.ts +1 -1
  22. package/src/tools/capabilities/refresh.ts +1 -1
  23. package/src/tools/membot/adapter.ts +111 -0
  24. package/src/tools/membot/copy.ts +59 -0
  25. package/src/tools/membot/count_lines.ts +53 -0
  26. package/src/tools/membot/edit.ts +72 -0
  27. package/src/tools/membot/exists.ts +54 -0
  28. package/src/tools/membot/index.ts +26 -0
  29. package/src/tools/{context → membot}/pipe.ts +34 -32
  30. package/src/tools/registry.ts +6 -37
  31. package/src/tools/tool.ts +6 -8
  32. package/src/tui/App.tsx +3 -4
  33. package/src/tui/components/ContextPanel.tsx +109 -226
  34. package/src/tui/components/HelpPanel.tsx +2 -2
  35. package/src/tui/components/StatusBar.tsx +0 -6
  36. package/src/tui/components/ThreadPanel.tsx +8 -7
  37. package/src/tui/wrapDetail.ts +11 -0
  38. package/src/worker/heartbeat.ts +0 -20
  39. package/src/worker/index.ts +13 -13
  40. package/src/worker/llm.ts +7 -9
  41. package/src/worker/prompt.ts +25 -13
  42. package/src/worker/spawn.ts +1 -1
  43. package/src/worker/tick.ts +10 -9
  44. package/src/commands/db.ts +0 -119
  45. package/src/commands/with-db.ts +0 -22
  46. package/src/context/chunker.ts +0 -275
  47. package/src/context/embedder-impl.ts +0 -100
  48. package/src/context/embedder.ts +0 -9
  49. package/src/context/fetcher-errors.ts +0 -8
  50. package/src/context/fetcher.ts +0 -515
  51. package/src/context/locks.ts +0 -146
  52. package/src/context/markdown-converter.ts +0 -186
  53. package/src/context/reindex.ts +0 -198
  54. package/src/context/store.ts +0 -841
  55. package/src/context/url-utils.ts +0 -25
  56. package/src/db/connection.ts +0 -255
  57. package/src/db/doctor.ts +0 -235
  58. package/src/db/embeddings.ts +0 -317
  59. package/src/db/query.ts +0 -56
  60. package/src/db/schema.ts +0 -93
  61. package/src/db/sql/1-core_tables.sql +0 -53
  62. package/src/db/sql/10-dedupe_context_items.sql +0 -26
  63. package/src/db/sql/11-rebuild_hnsw.sql +0 -8
  64. package/src/db/sql/12-workers.sql +0 -66
  65. package/src/db/sql/13-drive-paths.sql +0 -47
  66. package/src/db/sql/14-drop_hnsw_index.sql +0 -8
  67. package/src/db/sql/15-fts_index.sql +0 -8
  68. package/src/db/sql/16-source_url.sql +0 -7
  69. package/src/db/sql/17-worker_log_path.sql +0 -3
  70. package/src/db/sql/18-reset_embeddings_for_local.sql +0 -39
  71. package/src/db/sql/19-disk_backed_index.sql +0 -36
  72. package/src/db/sql/2-logging_tables.sql +0 -24
  73. package/src/db/sql/20-drop_db_tables_for_files.sql +0 -19
  74. package/src/db/sql/3-daemon_state.sql +0 -5
  75. package/src/db/sql/4-unique_context_path.sql +0 -1
  76. package/src/db/sql/5-reset_embeddings_for_openai.sql +0 -1
  77. package/src/db/sql/6-vss_index.sql +0 -7
  78. package/src/db/sql/7-drop_embeddings_fk.sql +0 -23
  79. package/src/db/sql/8-task_output.sql +0 -1
  80. package/src/db/sql/9-source-type.sql +0 -1
  81. package/src/tools/context/read-large-result.ts +0 -33
  82. package/src/tools/dir/create.ts +0 -47
  83. package/src/tools/dir/size.ts +0 -77
  84. package/src/tools/dir/tree.ts +0 -124
  85. package/src/tools/file/copy.ts +0 -73
  86. package/src/tools/file/count-lines.ts +0 -54
  87. package/src/tools/file/delete.ts +0 -83
  88. package/src/tools/file/edit.ts +0 -76
  89. package/src/tools/file/exists.ts +0 -33
  90. package/src/tools/file/info.ts +0 -66
  91. package/src/tools/file/move.ts +0 -66
  92. package/src/tools/file/read.ts +0 -67
  93. package/src/tools/file/write.ts +0 -58
  94. package/src/tools/search/fuse.ts +0 -96
  95. package/src/tools/search/index.ts +0 -127
  96. package/src/tools/search/regexp.ts +0 -82
  97. package/src/tools/search/semantic.ts +0 -167
  98. /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 { getDbPath } from "../../constants.ts";
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
- const SIDEBAR_WIDTH = 32;
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
- const [currentPath, setCurrentPath] = useState("");
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
- path: string;
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
- async (path: string) => {
66
- try {
67
- const list = await listContextDir(projectDir, path, {
68
- recursive: false,
69
- });
70
- list.sort((a, b) => {
71
- if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1;
72
- return a.path.localeCompare(b.path);
73
- });
74
- setEntries(list);
75
- setSelectedIndex(0);
76
- setSidebarScrollOffset(0);
77
- } catch {
78
- setEntries([]);
79
- setSelectedIndex(0);
80
- setSidebarScrollOffset(0);
81
- }
82
- },
83
- [projectDir],
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(currentPath);
88
- }, [currentPath, refresh]);
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
- readContextFile(projectDir, selectedEntry.path).then((content) => {
118
- if (cancelled) return;
119
- setFileContent({ path: selectedEntry.path, content });
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
- setIndexStatus({ path, summary });
120
+ setFileContent({
121
+ logical_path: selectedEntry.logical_path,
122
+ content: result.content ?? "",
123
+ });
140
124
  })
141
125
  .catch(() => {
142
126
  if (cancelled) return;
143
- setIndexStatus({ path, summary: null });
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
- }, [projectDir, selectedEntry]);
135
+ }, [client, selectedEntry]);
149
136
 
150
137
  const detailLines = useMemo(() => {
151
138
  if (!fileContent || !selectedEntry) return [];
152
- const body = isMarkdownPath(fileContent.path)
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.path;
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 deleteContextPath(projectDir, path, { recursive: isDirectory });
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(currentPathRef.current);
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.path);
193
+ deleteConfirm.pressDelete(entry.logical_path);
239
194
  return;
240
195
  }
241
196
  if (key.ctrl && (input === "r" || input === "R")) {
242
- refresh(currentPathRef.current);
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
- {headerLabel}
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.path} paddingX={1}>
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 ? "▸" : " "} {icon} {name}
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
- {selectedEntry.is_directory ? (
335
- <Text dimColor>(folder press to drill in)</Text>
336
- ) : !selectedEntry.is_textual ? (
337
- <Text dimColor>(binary file — no preview)</Text>
338
- ) : (
339
- detailVisible.map((line, i) => {
340
- const lineNum = detailScroll + i;
341
- return (
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
- {selectedEntry &&
350
- !selectedEntry.is_directory &&
351
- selectedEntry.is_textual && (
352
- <Scrollbar
353
- total={detailLines.length}
354
- visible={visibleDetailRows - 3}
355
- offset={detailScroll}
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 item selected)</Text>
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 · → drill in/enter detail · ← up · d delete (×2) · ^R refresh"}
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 formatDate(d: Date): string {
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
- {entry.is_directory ? "📁" : "📄"} context/{entry.path}
410
- {entry.is_directory ? "/" : ""}
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.is_directory ? (
323
+ {entry.description ? (
414
324
  <Box>
415
325
  <Text dimColor wrap="truncate-end">
416
- directory · → to open
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 follow · d delete
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
- detailScroll,
454
- detailScroll + visibleRows,
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 = detailScroll + i;
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={detailScroll}
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
- FOLLOWING{" "}
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 · d delete (×2)${selectedThread && !selectedThread.ended_at ? " · w follow" : ""} · ^R refresh`}
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>
@@ -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
+ }
@@ -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,