botholomew 0.14.0 → 0.15.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.
@@ -1,18 +1,33 @@
1
1
  import { Box, Text, useInput, useStdout } from "ink";
2
2
  import { memo, useCallback, useEffect, useMemo, useState } from "react";
3
+ import { getDbPath } from "../../constants.ts";
3
4
  import {
4
5
  type ContextEntry,
5
6
  listContextDir,
6
7
  readContextFile,
7
8
  } from "../../context/store.ts";
9
+ import { withDb } from "../../db/connection.ts";
10
+ import {
11
+ getIndexedPath,
12
+ type IndexedPathSummary,
13
+ } from "../../db/embeddings.ts";
14
+ import {
15
+ detailPaneBorderProps,
16
+ type FocusState,
17
+ handleListDetailKey,
18
+ } from "../listDetailKeys.ts";
8
19
  import { isMarkdownPath, renderMarkdown } from "../markdown.ts";
20
+ import { theme } from "../theme.ts";
21
+ import { useLatestRef } from "../useLatestRef.ts";
22
+ import { Scrollbar } from "./Scrollbar.tsx";
9
23
 
10
24
  interface ContextPanelProps {
11
25
  projectDir: string;
12
26
  isActive: boolean;
13
27
  }
14
28
 
15
- const CHROME_LINES = 8;
29
+ const SIDEBAR_WIDTH = 32;
30
+ const PAGE_SCROLL_LINES = 10;
16
31
 
17
32
  export const ContextPanel = memo(function ContextPanel({
18
33
  projectDir,
@@ -23,22 +38,20 @@ export const ContextPanel = memo(function ContextPanel({
23
38
 
24
39
  const [currentPath, setCurrentPath] = useState("");
25
40
  const [entries, setEntries] = useState<ContextEntry[]>([]);
26
- const [cursor, setCursor] = useState(0);
27
- const [scrollOffset, setScrollOffset] = useState(0);
28
- const [preview, setPreview] = useState<{
29
- entry: ContextEntry;
41
+ const [selectedIndex, setSelectedIndex] = useState(0);
42
+ const [sidebarScrollOffset, setSidebarScrollOffset] = useState(0);
43
+ const [detailScroll, setDetailScroll] = useState(0);
44
+ const [focus, setFocus] = useState<FocusState>("list");
45
+ const [fileContent, setFileContent] = useState<{
46
+ path: string;
30
47
  content: string;
31
48
  } | null>(null);
32
- const [previewScroll, setPreviewScroll] = useState(0);
49
+ const [indexStatus, setIndexStatus] = useState<{
50
+ path: string;
51
+ summary: IndexedPathSummary | null;
52
+ } | null>(null);
33
53
 
34
- const visibleRows = Math.max(1, termRows - CHROME_LINES);
35
-
36
- useEffect(() => {
37
- if (cursor < scrollOffset) setScrollOffset(cursor);
38
- else if (cursor >= scrollOffset + visibleRows) {
39
- setScrollOffset(cursor - visibleRows + 1);
40
- }
41
- }, [cursor, scrollOffset, visibleRows]);
54
+ const visibleRows = Math.max(1, termRows - 6);
42
55
 
43
56
  const refresh = useCallback(
44
57
  async (path: string) => {
@@ -51,14 +64,12 @@ export const ContextPanel = memo(function ContextPanel({
51
64
  return a.path.localeCompare(b.path);
52
65
  });
53
66
  setEntries(list);
54
- setCursor(0);
55
- setScrollOffset(0);
56
- setPreview(null);
67
+ setSelectedIndex(0);
68
+ setSidebarScrollOffset(0);
57
69
  } catch {
58
70
  setEntries([]);
59
- setCursor(0);
60
- setScrollOffset(0);
61
- setPreview(null);
71
+ setSelectedIndex(0);
72
+ setSidebarScrollOffset(0);
62
73
  }
63
74
  },
64
75
  [projectDir],
@@ -68,167 +79,330 @@ export const ContextPanel = memo(function ContextPanel({
68
79
  refresh(currentPath);
69
80
  }, [currentPath, refresh]);
70
81
 
71
- const previewLines = useMemo(() => {
72
- if (!preview) return [];
73
- const body =
74
- isMarkdownPath(preview.entry.path) && preview.entry.is_textual
75
- ? renderMarkdown(preview.content)
76
- : preview.content;
82
+ // Keep the sidebar's selection visible by scrolling its viewport when the
83
+ // cursor approaches the edges.
84
+ useEffect(() => {
85
+ if (selectedIndex < sidebarScrollOffset) {
86
+ setSidebarScrollOffset(selectedIndex);
87
+ } else if (selectedIndex >= sidebarScrollOffset + visibleRows) {
88
+ setSidebarScrollOffset(selectedIndex - visibleRows + 1);
89
+ }
90
+ }, [selectedIndex, sidebarScrollOffset, visibleRows]);
91
+
92
+ const selectedEntry = entries[selectedIndex];
93
+
94
+ // Auto-load file content when the selection lands on a textual file.
95
+ // Folders and non-textual files clear the right pane.
96
+ useEffect(() => {
97
+ let cancelled = false;
98
+ if (!selectedEntry) {
99
+ setFileContent(null);
100
+ setDetailScroll(0);
101
+ return;
102
+ }
103
+ if (selectedEntry.is_directory || !selectedEntry.is_textual) {
104
+ setFileContent(null);
105
+ setDetailScroll(0);
106
+ return;
107
+ }
108
+ setDetailScroll(0);
109
+ readContextFile(projectDir, selectedEntry.path).then((content) => {
110
+ if (cancelled) return;
111
+ setFileContent({ path: selectedEntry.path, content });
112
+ });
113
+ return () => {
114
+ cancelled = true;
115
+ };
116
+ }, [projectDir, selectedEntry]);
117
+
118
+ // Look up the file's index status so we can show "indexed (N chunks)"
119
+ // vs "not indexed" in the header. Skips for folders.
120
+ useEffect(() => {
121
+ let cancelled = false;
122
+ if (!selectedEntry || selectedEntry.is_directory) {
123
+ setIndexStatus(null);
124
+ return;
125
+ }
126
+ const path = selectedEntry.path;
127
+ const dbPath = getDbPath(projectDir);
128
+ withDb(dbPath, (conn) => getIndexedPath(conn, path))
129
+ .then((summary) => {
130
+ if (cancelled) return;
131
+ setIndexStatus({ path, summary });
132
+ })
133
+ .catch(() => {
134
+ if (cancelled) return;
135
+ setIndexStatus({ path, summary: null });
136
+ });
137
+ return () => {
138
+ cancelled = true;
139
+ };
140
+ }, [projectDir, selectedEntry]);
141
+
142
+ const detailLines = useMemo(() => {
143
+ if (!fileContent || !selectedEntry) return [];
144
+ const body = isMarkdownPath(fileContent.path)
145
+ ? renderMarkdown(fileContent.content)
146
+ : fileContent.content;
77
147
  return body.split("\n");
78
- }, [preview]);
148
+ }, [fileContent, selectedEntry]);
149
+
150
+ const visibleDetailRows = Math.max(1, visibleRows - 2);
151
+ const maxDetailScroll = Math.max(0, detailLines.length - visibleDetailRows);
79
152
 
80
- const items = entries;
81
- const itemCount = items.length;
82
153
  const visibleItems = useMemo(
83
- () => items.slice(scrollOffset, scrollOffset + visibleRows),
84
- [items, scrollOffset, visibleRows],
154
+ () => entries.slice(sidebarScrollOffset, sidebarScrollOffset + visibleRows),
155
+ [entries, sidebarScrollOffset, visibleRows],
85
156
  );
86
157
 
158
+ // Refs read by the keyboard handler so it always sees the latest committed
159
+ // values (Ink 7's useInput intermittently leaves a stale closure).
160
+ const itemCountRef = useLatestRef(entries.length);
161
+ const maxDetailScrollRef = useLatestRef(maxDetailScroll);
162
+ const selectedEntryRef = useLatestRef(selectedEntry);
163
+ const currentPathRef = useLatestRef(currentPath);
164
+ const focusRef = useLatestRef(focus);
165
+
87
166
  useInput(
88
167
  (input, key) => {
89
- if (preview) {
90
- if (key.upArrow) {
91
- setPreviewScroll((s) => Math.max(0, s - 1));
92
- return;
93
- }
94
- if (key.downArrow) {
95
- const maxScroll = Math.max(0, previewLines.length - visibleRows + 2);
96
- setPreviewScroll((s) => Math.min(maxScroll, s + 1));
97
- return;
98
- }
99
- if (key.escape || input === "q") {
100
- setPreview(null);
101
- setPreviewScroll(0);
102
- }
168
+ if (
169
+ handleListDetailKey(input, key, {
170
+ focusRef,
171
+ setFocus,
172
+ itemCountRef,
173
+ maxDetailScrollRef,
174
+ setSelectedIndex,
175
+ setDetailScroll,
176
+ pageScrollLines: PAGE_SCROLL_LINES,
177
+ // Context-specific: → on a folder drills in (when list-focused);
178
+ // in list-focus goes up a directory.
179
+ onRightArrow: () => {
180
+ if (focusRef.current !== "list") return false;
181
+ const entry = selectedEntryRef.current;
182
+ if (entry?.is_directory) {
183
+ setCurrentPath(entry.path);
184
+ return true;
185
+ }
186
+ return false;
187
+ },
188
+ onLeftArrow: () => {
189
+ if (focusRef.current !== "list") return false;
190
+ const cwd = currentPathRef.current;
191
+ if (cwd === "") return true; // already at root, swallow the key
192
+ const parts = cwd.split("/");
193
+ parts.pop();
194
+ setCurrentPath(parts.join("/"));
195
+ return true;
196
+ },
197
+ })
198
+ ) {
103
199
  return;
104
200
  }
105
201
 
106
- if (key.upArrow) {
107
- setCursor((c) => Math.max(0, c - 1));
108
- return;
202
+ if (input === "r") {
203
+ refresh(currentPathRef.current);
109
204
  }
110
- if (key.downArrow) {
111
- setCursor((c) => Math.min(itemCount - 1, c + 1));
112
- return;
113
- }
114
- if (key.return) {
115
- const entry = entries[cursor];
116
- if (!entry) return;
117
- if (entry.is_directory) {
118
- setCurrentPath(entry.path);
119
- return;
120
- }
121
- if (!entry.is_textual) return;
122
- readContextFile(projectDir, entry.path).then((content) => {
123
- setPreview({ entry, content });
124
- setPreviewScroll(0);
125
- });
126
- return;
127
- }
128
- if (key.backspace || key.delete || input === "h") {
129
- if (currentPath === "") return;
130
- const parts = currentPath.split("/");
131
- parts.pop();
132
- setCurrentPath(parts.join("/"));
133
- }
134
- if (input === "r") refresh(currentPath);
135
205
  },
136
206
  { isActive },
137
207
  );
138
208
 
139
- if (preview) {
140
- const visiblePreviewLines = previewLines.slice(
141
- previewScroll,
142
- previewScroll + visibleRows - 2,
143
- );
144
- return (
145
- <Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
146
- <Box>
147
- <Text bold color="cyan">
148
- context/{preview.entry.path}
209
+ const headerLabel =
210
+ currentPath === "" ? "context/" : `context/${currentPath}/`;
211
+
212
+ const detailVisible = detailLines.slice(
213
+ detailScroll,
214
+ detailScroll + visibleDetailRows,
215
+ );
216
+
217
+ return (
218
+ <Box flexGrow={1} height={visibleRows + 1} overflow="hidden">
219
+ {/* Left: file tree */}
220
+ <Box
221
+ flexDirection="column"
222
+ width={SIDEBAR_WIDTH}
223
+ height={visibleRows + 1}
224
+ borderStyle="single"
225
+ borderColor={theme.muted}
226
+ borderRight
227
+ borderTop={false}
228
+ borderBottom={false}
229
+ borderLeft={false}
230
+ overflow="hidden"
231
+ >
232
+ <Box paddingX={1}>
233
+ <Text bold dimColor wrap="truncate-end">
234
+ {headerLabel}
149
235
  </Text>
150
- <Text dimColor> (esc/q to go back · ↑↓ to scroll)</Text>
151
236
  </Box>
152
- <Box marginTop={1} flexDirection="column">
237
+ {entries.length === 0 ? (
238
+ <Box paddingX={1}>
239
+ <Text dimColor>(empty)</Text>
240
+ </Box>
241
+ ) : (
242
+ visibleItems.map((entry, vi) => {
243
+ const i = vi + sidebarScrollOffset;
244
+ const isSelected = i === selectedIndex;
245
+ const name = entry.path.split("/").pop() ?? entry.path;
246
+ const icon = entry.is_directory ? "📁" : "📄";
247
+ return (
248
+ <Box key={entry.path} paddingX={1}>
249
+ <Text
250
+ backgroundColor={isSelected ? theme.selectionBg : undefined}
251
+ color={
252
+ isSelected
253
+ ? theme.info
254
+ : entry.is_directory
255
+ ? theme.accent
256
+ : undefined
257
+ }
258
+ bold={isSelected}
259
+ wrap="truncate-end"
260
+ >
261
+ {isSelected ? "▸" : " "} {icon} {name}
262
+ {entry.is_directory ? "/" : ""}
263
+ </Text>
264
+ </Box>
265
+ );
266
+ })
267
+ )}
268
+ </Box>
269
+
270
+ {/* Right: file content (or placeholder) */}
271
+ <Box
272
+ flexDirection="column"
273
+ flexGrow={1}
274
+ height={visibleRows + 1}
275
+ paddingX={1}
276
+ {...detailPaneBorderProps(focus)}
277
+ overflow="hidden"
278
+ >
279
+ {selectedEntry ? (
280
+ <>
281
+ <ContextDetailHeader
282
+ entry={selectedEntry}
283
+ indexStatus={
284
+ indexStatus && indexStatus.path === selectedEntry.path
285
+ ? indexStatus.summary
286
+ : null
287
+ }
288
+ indexLoaded={
289
+ !!indexStatus && indexStatus.path === selectedEntry.path
290
+ }
291
+ />
292
+ <Box flexDirection="row" flexGrow={1} overflow="hidden">
293
+ <Box flexDirection="column" flexGrow={1} overflow="hidden">
294
+ {selectedEntry.is_directory ? (
295
+ <Text dimColor>(folder — press → to drill in)</Text>
296
+ ) : !selectedEntry.is_textual ? (
297
+ <Text dimColor>(binary file — no preview)</Text>
298
+ ) : (
299
+ detailVisible.map((line, i) => {
300
+ const lineNum = detailScroll + i;
301
+ return (
302
+ <Text key={lineNum} wrap="truncate-end">
303
+ {line || " "}
304
+ </Text>
305
+ );
306
+ })
307
+ )}
308
+ </Box>
309
+ {selectedEntry &&
310
+ !selectedEntry.is_directory &&
311
+ selectedEntry.is_textual && (
312
+ <Scrollbar
313
+ total={detailLines.length}
314
+ visible={visibleDetailRows - 3}
315
+ offset={detailScroll}
316
+ height={visibleDetailRows - 3}
317
+ focused={focus === "detail"}
318
+ />
319
+ )}
320
+ </Box>
321
+ </>
322
+ ) : (
323
+ <Text dimColor>(no item selected)</Text>
324
+ )}
325
+ <Box>
153
326
  <Text dimColor>
154
- {preview.entry.mime_type} · {preview.entry.size} bytes · updated{" "}
155
- {preview.entry.mtime.toLocaleDateString()}
327
+ {focus === "detail"
328
+ ? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
329
+ : "↑↓ select · → drill in/enter detail · ← up · r refresh"}
156
330
  </Text>
157
331
  </Box>
158
- <Box
159
- marginTop={1}
160
- flexDirection="column"
161
- flexGrow={1}
162
- overflow="hidden"
163
- >
164
- {visiblePreviewLines.map((line, i) => {
165
- const lineNum = previewScroll + i;
166
- return <Text key={lineNum}>{line || " "}</Text>;
167
- })}
168
- </Box>
169
- {previewLines.length > visibleRows - 2 && (
170
- <Box>
171
- <Text dimColor>
172
- [line {previewScroll + 1}–
173
- {Math.min(previewScroll + visibleRows - 2, previewLines.length)}{" "}
174
- of {previewLines.length}]
175
- </Text>
176
- </Box>
177
- )}
178
332
  </Box>
179
- );
180
- }
333
+ </Box>
334
+ );
335
+ });
181
336
 
182
- const headerLabel =
183
- currentPath === "" ? "context/" : `context/${currentPath}/`;
337
+ function formatSize(bytes: number): string {
338
+ if (bytes < 1024) return `${bytes} B`;
339
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
340
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
341
+ }
184
342
 
343
+ function formatDate(d: Date): string {
344
+ return d.toLocaleString([], {
345
+ month: "short",
346
+ day: "numeric",
347
+ hour: "2-digit",
348
+ minute: "2-digit",
349
+ });
350
+ }
351
+
352
+ function ContextDetailHeader({
353
+ entry,
354
+ indexStatus,
355
+ indexLoaded,
356
+ }: {
357
+ entry: ContextEntry;
358
+ indexStatus: IndexedPathSummary | null;
359
+ indexLoaded: boolean;
360
+ }) {
185
361
  return (
186
- <Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
362
+ <Box flexDirection="column">
187
363
  <Box>
188
- <Text bold color="cyan">
189
- {headerLabel}
190
- </Text>
191
- <Text dimColor>
192
- {" "}
193
- ({entries.length} entries · ↑↓ select · ⏎ open · backspace up · r
194
- refresh)
364
+ <Text bold color="cyan" wrap="truncate-end">
365
+ {entry.is_directory ? "📁" : "📄"} context/{entry.path}
366
+ {entry.is_directory ? "/" : ""}
195
367
  </Text>
196
368
  </Box>
197
- <Box flexDirection="column" marginTop={1} flexGrow={1}>
198
- {entries.length === 0 && <Text dimColor>(empty)</Text>}
199
- {visibleItems.map((entry, vi) => {
200
- const i = vi + scrollOffset;
201
- const isSelected = i === cursor;
202
- const name = entry.path.split("/").pop() ?? entry.path;
203
- const icon = entry.is_directory ? "📁" : "📄";
204
- return (
205
- <Box key={entry.path}>
206
- <Text
207
- backgroundColor={isSelected ? "#333" : undefined}
208
- color={
209
- isSelected ? "cyan" : entry.is_directory ? "blue" : undefined
210
- }
211
- bold={isSelected}
212
- >
213
- {" "}
214
- {icon} {name}
215
- {entry.is_directory ? "/" : ""}
216
- {!entry.is_directory && (
217
- <Text dimColor> ({entry.mime_type})</Text>
218
- )}
219
- </Text>
220
- </Box>
221
- );
222
- })}
223
- </Box>
224
- {itemCount > visibleRows && (
369
+ {entry.is_directory ? (
225
370
  <Box>
226
- <Text dimColor>
227
- [{scrollOffset + 1}–
228
- {Math.min(scrollOffset + visibleRows, itemCount)} of {itemCount}]
371
+ <Text dimColor wrap="truncate-end">
372
+ directory · → to open
229
373
  </Text>
230
374
  </Box>
375
+ ) : (
376
+ <>
377
+ <Box>
378
+ <Text dimColor wrap="truncate-end">
379
+ {entry.mime_type} · {formatSize(entry.size)} · updated{" "}
380
+ {formatDate(entry.mtime)}
381
+ </Text>
382
+ </Box>
383
+ <Box>
384
+ <Text wrap="truncate-end">
385
+ {!indexLoaded ? (
386
+ <Text dimColor>checking index…</Text>
387
+ ) : indexStatus ? (
388
+ <Text color={theme.success}>
389
+ ● indexed
390
+ <Text dimColor>
391
+ {" ("}
392
+ {indexStatus.chunk_count}
393
+ {indexStatus.chunk_count === 1 ? " chunk" : " chunks"})
394
+ </Text>
395
+ </Text>
396
+ ) : (
397
+ <Text color={theme.muted}>○ not indexed</Text>
398
+ )}
399
+ </Text>
400
+ </Box>
401
+ </>
231
402
  )}
403
+ <Box>
404
+ <Text dimColor>{"─".repeat(2)}</Text>
405
+ </Box>
232
406
  </Box>
233
407
  );
234
- });
408
+ }