botholomew 0.14.2 → 0.15.1
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 +2 -0
- package/package.json +3 -2
- package/src/chat/session.ts +4 -0
- package/src/commands/chat.ts +18 -6
- package/src/config/schemas.ts +2 -0
- package/src/context/fetcher.ts +1 -1
- package/src/tui/App.tsx +110 -86
- package/src/tui/components/ContextPanel.tsx +325 -151
- package/src/tui/components/HelpPanel.tsx +42 -43
- package/src/tui/components/InputBar.tsx +6 -4
- package/src/tui/components/Logo.tsx +15 -5
- package/src/tui/components/SchedulePanel.tsx +98 -97
- package/src/tui/components/Scrollbar.tsx +73 -0
- package/src/tui/components/SleepProgress.tsx +5 -2
- package/src/tui/components/StatusBar.tsx +9 -6
- package/src/tui/components/TabBar.tsx +13 -13
- package/src/tui/components/TaskPanel.tsx +86 -95
- package/src/tui/components/ThreadPanel.tsx +133 -120
- package/src/tui/components/ToolPanel.tsx +84 -85
- package/src/tui/components/WorkerPanel.tsx +77 -77
- package/src/tui/idle.tsx +68 -0
- package/src/tui/listDetailKeys.ts +124 -0
- package/src/tui/useLatestRef.ts +18 -0
- package/src/utils/frontmatter.ts +1 -1
- package/src/worker/prompt.ts +10 -1
|
@@ -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
|
|
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 [
|
|
27
|
-
const [
|
|
28
|
-
const [
|
|
29
|
-
|
|
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 [
|
|
49
|
+
const [indexStatus, setIndexStatus] = useState<{
|
|
50
|
+
path: string;
|
|
51
|
+
summary: IndexedPathSummary | null;
|
|
52
|
+
} | null>(null);
|
|
33
53
|
|
|
34
|
-
const visibleRows = Math.max(1, termRows -
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
setPreview(null);
|
|
67
|
+
setSelectedIndex(0);
|
|
68
|
+
setSidebarScrollOffset(0);
|
|
57
69
|
} catch {
|
|
58
70
|
setEntries([]);
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
}, [
|
|
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
|
-
() =>
|
|
84
|
-
[
|
|
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 (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 (
|
|
107
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
155
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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"
|
|
362
|
+
<Box flexDirection="column">
|
|
187
363
|
<Box>
|
|
188
|
-
<Text bold color="cyan">
|
|
189
|
-
{
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|