botholomew 0.3.0 → 0.3.2

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 (70) hide show
  1. package/README.md +9 -0
  2. package/package.json +3 -1
  3. package/src/chat/agent.ts +87 -23
  4. package/src/chat/session.ts +19 -6
  5. package/src/cli.ts +2 -0
  6. package/src/commands/chat.ts +5 -2
  7. package/src/commands/context.ts +91 -35
  8. package/src/commands/thread.ts +180 -0
  9. package/src/config/schemas.ts +3 -1
  10. package/src/context/embedder.ts +0 -3
  11. package/src/daemon/context.ts +146 -0
  12. package/src/daemon/large-results.ts +100 -0
  13. package/src/daemon/llm.ts +45 -19
  14. package/src/daemon/prompt.ts +1 -6
  15. package/src/daemon/tick.ts +9 -0
  16. package/src/db/sql/4-unique_context_path.sql +1 -0
  17. package/src/db/threads.ts +17 -0
  18. package/src/init/templates.ts +2 -1
  19. package/src/tools/context/read-large-result.ts +33 -0
  20. package/src/tools/context/search.ts +2 -0
  21. package/src/tools/context/update-beliefs.ts +2 -0
  22. package/src/tools/context/update-goals.ts +2 -0
  23. package/src/tools/dir/create.ts +3 -2
  24. package/src/tools/dir/list.ts +2 -1
  25. package/src/tools/dir/size.ts +2 -1
  26. package/src/tools/dir/tree.ts +3 -2
  27. package/src/tools/file/copy.ts +12 -3
  28. package/src/tools/file/count-lines.ts +2 -1
  29. package/src/tools/file/delete.ts +3 -2
  30. package/src/tools/file/edit.ts +3 -2
  31. package/src/tools/file/exists.ts +2 -1
  32. package/src/tools/file/info.ts +2 -0
  33. package/src/tools/file/move.ts +12 -3
  34. package/src/tools/file/read.ts +2 -1
  35. package/src/tools/file/write.ts +5 -4
  36. package/src/tools/mcp/exec.ts +70 -3
  37. package/src/tools/mcp/info.ts +8 -0
  38. package/src/tools/mcp/list-tools.ts +18 -6
  39. package/src/tools/mcp/search.ts +38 -10
  40. package/src/tools/registry.ts +4 -0
  41. package/src/tools/schedule/create.ts +2 -0
  42. package/src/tools/schedule/list.ts +2 -0
  43. package/src/tools/search/grep.ts +3 -2
  44. package/src/tools/search/semantic.ts +2 -0
  45. package/src/tools/task/complete.ts +2 -0
  46. package/src/tools/task/create.ts +17 -4
  47. package/src/tools/task/fail.ts +2 -0
  48. package/src/tools/task/list.ts +2 -0
  49. package/src/tools/task/update.ts +87 -0
  50. package/src/tools/task/view.ts +3 -1
  51. package/src/tools/task/wait.ts +2 -0
  52. package/src/tools/thread/list.ts +2 -0
  53. package/src/tools/thread/view.ts +3 -1
  54. package/src/tools/tool.ts +7 -3
  55. package/src/tui/App.tsx +323 -78
  56. package/src/tui/components/ContextPanel.tsx +415 -0
  57. package/src/tui/components/Divider.tsx +14 -0
  58. package/src/tui/components/HelpPanel.tsx +166 -0
  59. package/src/tui/components/InputBar.tsx +157 -47
  60. package/src/tui/components/Logo.tsx +79 -0
  61. package/src/tui/components/MessageList.tsx +50 -23
  62. package/src/tui/components/QueuePanel.tsx +57 -0
  63. package/src/tui/components/StatusBar.tsx +21 -9
  64. package/src/tui/components/TabBar.tsx +40 -0
  65. package/src/tui/components/TaskPanel.tsx +409 -0
  66. package/src/tui/components/ThreadPanel.tsx +541 -0
  67. package/src/tui/components/ToolCall.tsx +68 -5
  68. package/src/tui/components/ToolPanel.tsx +295 -281
  69. package/src/tui/theme.ts +75 -0
  70. package/src/utils/title.ts +47 -0
@@ -0,0 +1,415 @@
1
+ import { Box, Text, useInput, useStdout } from "ink";
2
+ import { memo, useCallback, useEffect, useMemo, useState } from "react";
3
+ import type { DbConnection } from "../../db/connection.ts";
4
+ import {
5
+ type ContextItem,
6
+ deleteContextItem,
7
+ deleteContextItemsByPrefix,
8
+ getDistinctDirectories,
9
+ listContextItemsByPrefix,
10
+ searchContextByKeyword,
11
+ } from "../../db/context.ts";
12
+
13
+ interface ContextPanelProps {
14
+ conn: DbConnection;
15
+ isActive: boolean;
16
+ }
17
+
18
+ interface DirEntry {
19
+ type: "directory";
20
+ name: string;
21
+ path: string;
22
+ }
23
+
24
+ interface FileEntry {
25
+ type: "file";
26
+ item: ContextItem;
27
+ }
28
+
29
+ type Entry = DirEntry | FileEntry;
30
+
31
+ // Reserve lines for header, search bar, padding, tab bar, status/input bar
32
+ const CHROME_LINES = 8;
33
+
34
+ export const ContextPanel = memo(function ContextPanel({
35
+ conn,
36
+ isActive,
37
+ }: ContextPanelProps) {
38
+ const { stdout } = useStdout();
39
+ const termRows = stdout?.rows ?? 24;
40
+
41
+ const [currentPath, setCurrentPath] = useState("/");
42
+ const [entries, setEntries] = useState<Entry[]>([]);
43
+ const [cursor, setCursor] = useState(0);
44
+ const [scrollOffset, setScrollOffset] = useState(0);
45
+ const [preview, setPreview] = useState<ContextItem | null>(null);
46
+ const [previewScroll, setPreviewScroll] = useState(0);
47
+ const [searchMode, setSearchMode] = useState(false);
48
+ const [searchQuery, setSearchQuery] = useState("");
49
+ const [searchResults, setSearchResults] = useState<ContextItem[] | null>(
50
+ null,
51
+ );
52
+ const [confirmDelete, setConfirmDelete] = useState(false);
53
+
54
+ const visibleRows = Math.max(1, termRows - CHROME_LINES);
55
+
56
+ // Keep cursor in view by adjusting scroll offset
57
+ useEffect(() => {
58
+ if (cursor < scrollOffset) {
59
+ setScrollOffset(cursor);
60
+ } else if (cursor >= scrollOffset + visibleRows) {
61
+ setScrollOffset(cursor - visibleRows + 1);
62
+ }
63
+ }, [cursor, scrollOffset, visibleRows]);
64
+
65
+ const loadEntries = useCallback(
66
+ async (path: string) => {
67
+ const dirs = await getDistinctDirectories(conn, path);
68
+ const files = await listContextItemsByPrefix(conn, path, {
69
+ recursive: false,
70
+ });
71
+
72
+ const dirEntries: DirEntry[] = dirs.map((d) => ({
73
+ type: "directory",
74
+ name: d,
75
+ path: `${d}/`,
76
+ }));
77
+
78
+ const fileEntries: FileEntry[] = files
79
+ .filter((f) => !dirs.some((d) => f.context_path.startsWith(`${d}/`)))
80
+ .map((f) => ({ type: "file", item: f }));
81
+
82
+ setEntries([...dirEntries, ...fileEntries]);
83
+ setCursor(0);
84
+ setScrollOffset(0);
85
+ setPreview(null);
86
+ },
87
+ [conn],
88
+ );
89
+
90
+ useEffect(() => {
91
+ if (searchResults === null) {
92
+ loadEntries(currentPath);
93
+ }
94
+ }, [currentPath, loadEntries, searchResults]);
95
+
96
+ const executeSearch = useCallback(
97
+ async (query: string) => {
98
+ if (!query.trim()) {
99
+ setSearchResults(null);
100
+ return;
101
+ }
102
+ const results = await searchContextByKeyword(conn, query.trim(), 50);
103
+ setSearchResults(results);
104
+ setCursor(0);
105
+ setScrollOffset(0);
106
+ setPreview(null);
107
+ },
108
+ [conn],
109
+ );
110
+
111
+ // Compute the items list and visible window for the current view
112
+ const items = searchResults ?? entries;
113
+ const itemCount = items.length;
114
+ const visibleItems = useMemo(
115
+ () => items.slice(scrollOffset, scrollOffset + visibleRows),
116
+ [items, scrollOffset, visibleRows],
117
+ );
118
+
119
+ // Preview content split into lines for scrolling
120
+ const previewLines = useMemo(() => {
121
+ if (!preview?.content) return [];
122
+ return preview.content.split("\n");
123
+ }, [preview]);
124
+
125
+ useInput(
126
+ (input, key) => {
127
+ // Search mode: capture text input
128
+ if (searchMode) {
129
+ if (key.return) {
130
+ setSearchMode(false);
131
+ executeSearch(searchQuery);
132
+ return;
133
+ }
134
+ if (key.escape) {
135
+ setSearchMode(false);
136
+ setSearchQuery("");
137
+ setSearchResults(null);
138
+ return;
139
+ }
140
+ if (key.backspace || key.delete) {
141
+ setSearchQuery((q) => q.slice(0, -1));
142
+ return;
143
+ }
144
+ if (input && !key.ctrl && !key.meta) {
145
+ setSearchQuery((q) => q + input);
146
+ }
147
+ return;
148
+ }
149
+
150
+ // Preview mode: scroll content
151
+ if (preview) {
152
+ if (key.upArrow) {
153
+ setPreviewScroll((s) => Math.max(0, s - 1));
154
+ return;
155
+ }
156
+ if (key.downArrow) {
157
+ const maxScroll = Math.max(0, previewLines.length - visibleRows + 2);
158
+ setPreviewScroll((s) => Math.min(maxScroll, s + 1));
159
+ return;
160
+ }
161
+ if (key.escape) {
162
+ setPreview(null);
163
+ setPreviewScroll(0);
164
+ return;
165
+ }
166
+ return;
167
+ }
168
+
169
+ // Delete confirmation mode
170
+ if (confirmDelete) {
171
+ if (input === "y" || input === "d") {
172
+ const entry = entries[cursor];
173
+ if (entry) {
174
+ if (entry.type === "directory") {
175
+ deleteContextItemsByPrefix(conn, entry.path);
176
+ } else {
177
+ deleteContextItem(conn, entry.item.id);
178
+ }
179
+ setConfirmDelete(false);
180
+ loadEntries(currentPath);
181
+ }
182
+ } else {
183
+ setConfirmDelete(false);
184
+ }
185
+ return;
186
+ }
187
+
188
+ // Normal navigation
189
+ if (input === "d" && itemCount > 0 && searchResults === null) {
190
+ setConfirmDelete(true);
191
+ return;
192
+ }
193
+
194
+ if (input === "/") {
195
+ setSearchMode(true);
196
+ setSearchQuery("");
197
+ return;
198
+ }
199
+
200
+ if (key.escape) {
201
+ if (searchResults !== null) {
202
+ setSearchResults(null);
203
+ setPreview(null);
204
+ setScrollOffset(0);
205
+ return;
206
+ }
207
+ }
208
+
209
+ if (key.upArrow) {
210
+ setCursor((c) => Math.max(0, c - 1));
211
+ return;
212
+ }
213
+ if (key.downArrow) {
214
+ setCursor((c) => Math.min(itemCount - 1, c + 1));
215
+ return;
216
+ }
217
+
218
+ if (key.return) {
219
+ if (searchResults !== null) {
220
+ const item = searchResults[cursor];
221
+ if (item) {
222
+ setPreview(item);
223
+ setPreviewScroll(0);
224
+ }
225
+ return;
226
+ }
227
+ const entry = entries[cursor];
228
+ if (!entry) return;
229
+ if (entry.type === "directory") {
230
+ setCurrentPath(entry.path);
231
+ } else {
232
+ setPreview(entry.item);
233
+ setPreviewScroll(0);
234
+ }
235
+ return;
236
+ }
237
+
238
+ if (key.backspace || key.delete) {
239
+ if (currentPath !== "/") {
240
+ const parts = currentPath.replace(/\/$/, "").split("/");
241
+ parts.pop();
242
+ const parent = parts.length <= 1 ? "/" : `${parts.join("/")}/`;
243
+ setCurrentPath(parent);
244
+ }
245
+ }
246
+ },
247
+ { isActive },
248
+ );
249
+
250
+ // Render search results view
251
+ if (searchResults !== null && !preview) {
252
+ return (
253
+ <Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
254
+ <Box>
255
+ <Text bold color="cyan">
256
+ Search results for: &quot;{searchQuery}&quot;
257
+ </Text>
258
+ <Text dimColor> ({searchResults.length} matches · esc to clear)</Text>
259
+ </Box>
260
+ <Box flexDirection="column" marginTop={1} flexGrow={1}>
261
+ {searchResults.length === 0 && <Text dimColor>No results found</Text>}
262
+ {visibleItems.map((item, vi) => {
263
+ const i = vi + scrollOffset;
264
+ const ci = item as ContextItem;
265
+ return (
266
+ <Box key={ci.id}>
267
+ <Text
268
+ backgroundColor={i === cursor ? "#333" : undefined}
269
+ color={i === cursor ? "cyan" : undefined}
270
+ >
271
+ {" "}📄 {ci.context_path}
272
+ <Text dimColor> ({ci.mime_type})</Text>
273
+ </Text>
274
+ </Box>
275
+ );
276
+ })}
277
+ </Box>
278
+ {itemCount > visibleRows && (
279
+ <Box>
280
+ <Text dimColor>
281
+ [{scrollOffset + 1}–
282
+ {Math.min(scrollOffset + visibleRows, itemCount)} of {itemCount}]
283
+ </Text>
284
+ </Box>
285
+ )}
286
+ </Box>
287
+ );
288
+ }
289
+
290
+ // Render file preview with scrolling
291
+ if (preview) {
292
+ const visiblePreviewLines = previewLines.slice(
293
+ previewScroll,
294
+ previewScroll + visibleRows - 2,
295
+ );
296
+ return (
297
+ <Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
298
+ <Box>
299
+ <Text bold color="cyan">
300
+ {preview.context_path}
301
+ </Text>
302
+ <Text dimColor> (esc to go back · ↑↓ to scroll)</Text>
303
+ </Box>
304
+ <Box marginTop={1} flexDirection="column">
305
+ <Text dimColor>
306
+ Type: {preview.mime_type} · Title: {preview.title}
307
+ {preview.description ? ` · ${preview.description}` : ""}
308
+ </Text>
309
+ <Text dimColor>
310
+ Source: {preview.source_path ?? "n/a"} ·{" "}
311
+ {preview.indexed_at ? "Indexed" : "Not indexed"} · Updated:{" "}
312
+ {preview.updated_at.toLocaleDateString()}
313
+ </Text>
314
+ </Box>
315
+ <Box
316
+ marginTop={1}
317
+ flexDirection="column"
318
+ flexGrow={1}
319
+ overflow="hidden"
320
+ >
321
+ {preview.content ? (
322
+ visiblePreviewLines.map((line, i) => {
323
+ const lineNum = previewScroll + i;
324
+ return <Text key={lineNum}>{line || " "}</Text>;
325
+ })
326
+ ) : (
327
+ <Text dimColor>(binary or empty content)</Text>
328
+ )}
329
+ </Box>
330
+ {previewLines.length > visibleRows - 2 && (
331
+ <Box>
332
+ <Text dimColor>
333
+ [line {previewScroll + 1}–
334
+ {Math.min(previewScroll + visibleRows - 2, previewLines.length)}{" "}
335
+ of {previewLines.length}]
336
+ </Text>
337
+ </Box>
338
+ )}
339
+ </Box>
340
+ );
341
+ }
342
+
343
+ // Render directory listing with scroll window
344
+ return (
345
+ <Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
346
+ <Box>
347
+ <Text bold color="cyan">
348
+ {currentPath}
349
+ </Text>
350
+ <Text dimColor>
351
+ {" "}
352
+ ({entries.length} items · / search · d delete · backspace up)
353
+ </Text>
354
+ </Box>
355
+ {searchMode && (
356
+ <Box marginTop={1}>
357
+ <Text color="green">search: </Text>
358
+ <Text>{searchQuery}</Text>
359
+ <Text dimColor>█</Text>
360
+ </Box>
361
+ )}
362
+ {confirmDelete && entries[cursor] && (
363
+ <Box marginTop={1}>
364
+ <Text color="red" bold>
365
+ Delete{" "}
366
+ {entries[cursor].type === "directory"
367
+ ? `${entries[cursor].name}/ and all contents`
368
+ : (entries[cursor] as FileEntry).item.title}
369
+ ? (y/n)
370
+ </Text>
371
+ </Box>
372
+ )}
373
+ <Box flexDirection="column" marginTop={1} flexGrow={1}>
374
+ {entries.length === 0 && <Text dimColor>No context items found</Text>}
375
+ {visibleItems.map((raw, vi) => {
376
+ const i = vi + scrollOffset;
377
+ const entry = raw as Entry;
378
+ const isSelected = i === cursor;
379
+ if (entry.type === "directory") {
380
+ return (
381
+ <Box key={entry.path}>
382
+ <Text
383
+ backgroundColor={isSelected ? "#333" : undefined}
384
+ color={isSelected ? "cyan" : "blue"}
385
+ bold={isSelected}
386
+ >
387
+ {" "}📁 {entry.name}/
388
+ </Text>
389
+ </Box>
390
+ );
391
+ }
392
+ return (
393
+ <Box key={entry.item.id}>
394
+ <Text
395
+ backgroundColor={isSelected ? "#333" : undefined}
396
+ color={isSelected ? "cyan" : undefined}
397
+ >
398
+ {" "}📄 {entry.item.title}
399
+ <Text dimColor> ({entry.item.mime_type})</Text>
400
+ </Text>
401
+ </Box>
402
+ );
403
+ })}
404
+ </Box>
405
+ {itemCount > visibleRows && (
406
+ <Box>
407
+ <Text dimColor>
408
+ [{scrollOffset + 1}–
409
+ {Math.min(scrollOffset + visibleRows, itemCount)} of {itemCount}]
410
+ </Text>
411
+ </Box>
412
+ )}
413
+ </Box>
414
+ );
415
+ });
@@ -0,0 +1,14 @@
1
+ import { Text, useStdout } from "ink";
2
+ import { theme } from "../theme.ts";
3
+
4
+ interface DividerProps {
5
+ isLoading: boolean;
6
+ }
7
+
8
+ export function Divider({ isLoading }: DividerProps) {
9
+ const { stdout } = useStdout();
10
+ const cols = stdout?.columns ?? 80;
11
+ const line = "─".repeat(cols);
12
+
13
+ return <Text color={isLoading ? theme.accent : theme.muted}>{line}</Text>;
14
+ }
@@ -0,0 +1,166 @@
1
+ import { Box, Text } from "ink";
2
+ import { memo } from "react";
3
+
4
+ interface HelpPanelProps {
5
+ projectDir: string;
6
+ threadId: string;
7
+ daemonRunning: boolean;
8
+ }
9
+
10
+ export const HelpPanel = memo(function HelpPanel({
11
+ projectDir,
12
+ threadId,
13
+ daemonRunning,
14
+ }: HelpPanelProps) {
15
+ return (
16
+ <Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
17
+ <Box marginTop={1} flexDirection="column">
18
+ <Text bold color="cyan">
19
+ Navigation
20
+ </Text>
21
+ <Text>
22
+ {" "}Tab{" "}Cycle between tabs
23
+ </Text>
24
+ <Text>
25
+ {" "}1-6{" "}Jump to tab (non-chat tabs)
26
+ </Text>
27
+ <Text>
28
+ {" "}Escape{" "}Return to Chat tab
29
+ </Text>
30
+ </Box>
31
+
32
+ <Box marginTop={1} flexDirection="column">
33
+ <Text bold color="cyan">
34
+ Chat (Tab 1)
35
+ </Text>
36
+ <Text>
37
+ {" "}Enter{" "}Send message
38
+ </Text>
39
+ <Text>
40
+ {" "}⌥+Enter{" "}Insert newline
41
+ </Text>
42
+ <Text>
43
+ {" "}↑/↓{" "}Browse input history
44
+ </Text>
45
+ </Box>
46
+
47
+ <Box marginTop={1} flexDirection="column">
48
+ <Text bold color="cyan">
49
+ Tools (Tab 2)
50
+ </Text>
51
+ <Text>
52
+ {" "}↑/↓{" "}Select tool call
53
+ </Text>
54
+ <Text>
55
+ {" "}Shift+↑/↓{" "}Scroll detail pane
56
+ </Text>
57
+ <Text>
58
+ {" "}j / k{" "}Scroll detail pane
59
+ </Text>
60
+ </Box>
61
+
62
+ <Box marginTop={1} flexDirection="column">
63
+ <Text bold color="cyan">
64
+ Context (Tab 3)
65
+ </Text>
66
+ <Text>
67
+ {" "}↑/↓{" "}Navigate items
68
+ </Text>
69
+ <Text>
70
+ {" "}Enter{" "}Expand directory / preview file
71
+ </Text>
72
+ <Text>
73
+ {" "}Backspace{" "}Go up one directory
74
+ </Text>
75
+ <Text>
76
+ {" "}/{" "}Search context
77
+ </Text>
78
+ <Text>
79
+ {" "}d{" "}Delete selected item (with confirmation)
80
+ </Text>
81
+ </Box>
82
+
83
+ <Box marginTop={1} flexDirection="column">
84
+ <Text bold color="cyan">
85
+ Tasks (Tab 4)
86
+ </Text>
87
+ <Text>
88
+ {" "}↑/↓{" "}Navigate task list
89
+ </Text>
90
+ <Text>
91
+ {" "}Shift+↑/↓{" "}Scroll detail pane
92
+ </Text>
93
+ <Text>
94
+ {" "}j / k{" "}Scroll detail pane
95
+ </Text>
96
+ <Text>
97
+ {" "}f{" "}Cycle status filter
98
+ </Text>
99
+ <Text>
100
+ {" "}p{" "}Cycle priority filter
101
+ </Text>
102
+ <Text>
103
+ {" "}r{" "}Refresh tasks
104
+ </Text>
105
+ </Box>
106
+
107
+ <Box marginTop={1} flexDirection="column">
108
+ <Text bold color="cyan">
109
+ Threads (Tab 5)
110
+ </Text>
111
+ <Text>
112
+ {" "}↑/↓{" "}Navigate thread list
113
+ </Text>
114
+ <Text>
115
+ {" "}Shift+↑/↓{" "}Scroll detail pane
116
+ </Text>
117
+ <Text>
118
+ {" "}j / k{" "}Scroll detail pane
119
+ </Text>
120
+ <Text>
121
+ {" "}f{" "}Cycle type filter
122
+ </Text>
123
+ <Text>
124
+ {" "}d{" "}Delete thread (with confirmation)
125
+ </Text>
126
+ <Text>
127
+ {" "}r{" "}Refresh threads
128
+ </Text>
129
+ </Box>
130
+
131
+ <Box marginTop={1} flexDirection="column">
132
+ <Text bold color="cyan">
133
+ Commands
134
+ </Text>
135
+ <Text>
136
+ {" "}/help{" "}Show help in chat
137
+ </Text>
138
+ <Text>
139
+ {" "}/quit, /exit{" "}End the chat session
140
+ </Text>
141
+ </Box>
142
+
143
+ <Box marginTop={1} flexDirection="column">
144
+ <Text bold color="cyan">
145
+ System Info
146
+ </Text>
147
+ <Text>
148
+ {" "}Project{" "}
149
+ {projectDir}
150
+ </Text>
151
+ <Text>
152
+ {" "}Thread{" "}
153
+ {threadId}
154
+ </Text>
155
+ <Text>
156
+ {" "}Daemon{" "}
157
+ {daemonRunning ? (
158
+ <Text color="green">running</Text>
159
+ ) : (
160
+ <Text color="red">off</Text>
161
+ )}
162
+ </Text>
163
+ </Box>
164
+ </Box>
165
+ );
166
+ });