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,409 @@
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
+ deleteTask,
6
+ listTasks,
7
+ TASK_PRIORITIES,
8
+ TASK_STATUSES,
9
+ type Task,
10
+ } from "../../db/tasks.ts";
11
+ import { ansi, theme } from "../theme.ts";
12
+
13
+ interface TaskPanelProps {
14
+ conn: DbConnection;
15
+ isActive: boolean;
16
+ }
17
+
18
+ const SIDEBAR_WIDTH = 42;
19
+ const PAGE_SCROLL_LINES = 10;
20
+
21
+ const STATUS_ICONS: Record<Task["status"], string> = {
22
+ pending: "○",
23
+ in_progress: "●",
24
+ waiting: "◌",
25
+ failed: "✖",
26
+ complete: "✔",
27
+ };
28
+
29
+ const STATUS_COLORS: Record<Task["status"], string> = {
30
+ pending: theme.muted,
31
+ in_progress: theme.accent,
32
+ waiting: theme.info,
33
+ failed: theme.error,
34
+ complete: theme.success,
35
+ };
36
+
37
+ const STATUS_ANSI: Record<Task["status"], string> = {
38
+ pending: ansi.muted,
39
+ in_progress: ansi.accent,
40
+ waiting: ansi.info,
41
+ failed: ansi.error,
42
+ complete: ansi.success,
43
+ };
44
+
45
+ const PRIORITY_LABELS: Record<Task["priority"], string> = {
46
+ high: "HI",
47
+ medium: "MD",
48
+ low: "LO",
49
+ };
50
+
51
+ const PRIORITY_COLORS: Record<Task["priority"], string> = {
52
+ high: theme.error,
53
+ medium: theme.accent,
54
+ low: theme.muted,
55
+ };
56
+
57
+ const PRIORITY_ANSI: Record<Task["priority"], string> = {
58
+ high: ansi.error,
59
+ medium: ansi.accent,
60
+ low: ansi.muted,
61
+ };
62
+
63
+ function buildTaskDetailAnsi(task: Task): string {
64
+ const lines: string[] = [];
65
+
66
+ lines.push(`${ansi.bold}${ansi.info}${task.name}${ansi.reset}`);
67
+ lines.push("");
68
+
69
+ const statusAnsi = STATUS_ANSI[task.status];
70
+ lines.push(
71
+ `${ansi.bold}${ansi.primary}Status${ansi.reset} ${statusAnsi}${STATUS_ICONS[task.status]} ${task.status}${ansi.reset}`,
72
+ );
73
+
74
+ const priorityAnsi = PRIORITY_ANSI[task.priority];
75
+ lines.push(
76
+ `${ansi.bold}${ansi.primary}Priority${ansi.reset} ${priorityAnsi}${task.priority}${ansi.reset}`,
77
+ );
78
+
79
+ lines.push(
80
+ `${ansi.bold}${ansi.primary}Claimed${ansi.reset} ${task.claimed_by ? task.claimed_by : `${ansi.dim}(unclaimed)${ansi.reset}`}`,
81
+ );
82
+
83
+ const created = task.created_at.toLocaleString([], {
84
+ month: "short",
85
+ day: "numeric",
86
+ hour: "2-digit",
87
+ minute: "2-digit",
88
+ });
89
+ const updated = task.updated_at.toLocaleString([], {
90
+ month: "short",
91
+ day: "numeric",
92
+ hour: "2-digit",
93
+ minute: "2-digit",
94
+ });
95
+ lines.push(
96
+ `${ansi.bold}${ansi.primary}Created${ansi.reset} ${ansi.dim}${created}${ansi.reset}`,
97
+ );
98
+ lines.push(
99
+ `${ansi.bold}${ansi.primary}Updated${ansi.reset} ${ansi.dim}${updated}${ansi.reset}`,
100
+ );
101
+ lines.push("");
102
+
103
+ if (task.description) {
104
+ lines.push(`${ansi.bold}${ansi.primary}Description${ansi.reset}`);
105
+ lines.push(task.description);
106
+ lines.push("");
107
+ }
108
+
109
+ if (task.status === "waiting" && task.waiting_reason) {
110
+ lines.push(`${ansi.bold}${ansi.primary}Waiting Reason${ansi.reset}`);
111
+ lines.push(`${ansi.accent}${task.waiting_reason}${ansi.reset}`);
112
+ lines.push("");
113
+ }
114
+
115
+ if (task.blocked_by.length > 0) {
116
+ lines.push(`${ansi.bold}${ansi.primary}Blocked By${ansi.reset}`);
117
+ for (const id of task.blocked_by) {
118
+ lines.push(` ${ansi.dim}• ${id}${ansi.reset}`);
119
+ }
120
+ lines.push("");
121
+ }
122
+
123
+ if (task.context_ids.length > 0) {
124
+ lines.push(`${ansi.bold}${ansi.primary}Context IDs${ansi.reset}`);
125
+ for (const id of task.context_ids) {
126
+ lines.push(` ${ansi.dim}• ${id}${ansi.reset}`);
127
+ }
128
+ }
129
+
130
+ return lines.join("\n");
131
+ }
132
+
133
+ // Cycle through filter options: null -> ...values -> null
134
+ function cycleFilter<T>(current: T | null, values: readonly T[]): T | null {
135
+ if (current === null) return values[0] ?? null;
136
+ const idx = values.indexOf(current);
137
+ if (idx === -1 || idx === values.length - 1) return null;
138
+ return values[idx + 1] ?? null;
139
+ }
140
+
141
+ export const TaskPanel = memo(function TaskPanel({
142
+ conn,
143
+ isActive,
144
+ }: TaskPanelProps) {
145
+ const { stdout } = useStdout();
146
+ const termRows = stdout?.rows ?? 24;
147
+ const [tasks, setTasks] = useState<Task[]>([]);
148
+ const [selectedIndex, setSelectedIndex] = useState(0);
149
+ const [detailScroll, setDetailScroll] = useState(0);
150
+ const [statusFilter, setStatusFilter] = useState<Task["status"] | null>(null);
151
+ const [priorityFilter, setPriorityFilter] = useState<Task["priority"] | null>(
152
+ null,
153
+ );
154
+ const [refreshTick, setRefreshTick] = useState(0);
155
+
156
+ // Fetch tasks on mount, filter change, and every 5 seconds
157
+ // biome-ignore lint/correctness/useExhaustiveDependencies: refreshTick triggers manual refresh
158
+ useEffect(() => {
159
+ let mounted = true;
160
+
161
+ const refresh = async () => {
162
+ const filters: {
163
+ status?: Task["status"];
164
+ priority?: Task["priority"];
165
+ } = {};
166
+ if (statusFilter) filters.status = statusFilter;
167
+ if (priorityFilter) filters.priority = priorityFilter;
168
+ const result = await listTasks(conn, filters);
169
+ if (mounted) {
170
+ setTasks(result);
171
+ setSelectedIndex((prev) =>
172
+ Math.min(prev, Math.max(0, result.length - 1)),
173
+ );
174
+ }
175
+ };
176
+
177
+ refresh();
178
+ const interval = setInterval(refresh, 5000);
179
+ return () => {
180
+ mounted = false;
181
+ clearInterval(interval);
182
+ };
183
+ }, [conn, statusFilter, priorityFilter, refreshTick]);
184
+
185
+ const selectedTask = tasks[selectedIndex];
186
+
187
+ const renderedDetail = useMemo(() => {
188
+ if (!selectedTask) return "";
189
+ return buildTaskDetailAnsi(selectedTask);
190
+ }, [selectedTask]);
191
+
192
+ const detailLines = useMemo(
193
+ () => renderedDetail.split("\n"),
194
+ [renderedDetail],
195
+ );
196
+
197
+ const visibleRows = Math.max(1, termRows - 6);
198
+ const maxDetailScroll = Math.max(0, detailLines.length - visibleRows);
199
+ const sidebarScrollOffset = Math.max(
200
+ 0,
201
+ Math.min(
202
+ selectedIndex - Math.floor(visibleRows / 2),
203
+ tasks.length - visibleRows,
204
+ ),
205
+ );
206
+
207
+ // Reset detail scroll when selection changes
208
+ // biome-ignore lint/correctness/useExhaustiveDependencies: selectedIndex is the intentional trigger
209
+ useEffect(() => {
210
+ setDetailScroll(0);
211
+ }, [selectedIndex]);
212
+
213
+ const forceRefresh = useCallback(() => {
214
+ setRefreshTick((t) => t + 1);
215
+ }, []);
216
+
217
+ useInput(
218
+ (input, key) => {
219
+ if (key.upArrow) {
220
+ if (key.shift) {
221
+ setDetailScroll((s) => Math.max(0, s - 1));
222
+ } else {
223
+ setSelectedIndex((i) => Math.max(0, i - 1));
224
+ }
225
+ return;
226
+ }
227
+ if (key.downArrow) {
228
+ if (key.shift) {
229
+ setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
230
+ } else {
231
+ setSelectedIndex((i) => Math.min(tasks.length - 1, i + 1));
232
+ }
233
+ return;
234
+ }
235
+
236
+ if (input === "j") {
237
+ setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
238
+ return;
239
+ }
240
+ if (input === "k") {
241
+ setDetailScroll((s) => Math.max(0, s - 1));
242
+ return;
243
+ }
244
+ if (input === "J") {
245
+ setDetailScroll((s) =>
246
+ Math.min(maxDetailScroll, s + PAGE_SCROLL_LINES),
247
+ );
248
+ return;
249
+ }
250
+ if (input === "K") {
251
+ setDetailScroll((s) => Math.max(0, s - PAGE_SCROLL_LINES));
252
+ return;
253
+ }
254
+ if (input === "g") {
255
+ setDetailScroll(0);
256
+ return;
257
+ }
258
+ if (input === "G") {
259
+ setDetailScroll(maxDetailScroll);
260
+ return;
261
+ }
262
+
263
+ if (input === "f") {
264
+ setStatusFilter((f) => cycleFilter(f, TASK_STATUSES));
265
+ return;
266
+ }
267
+ if (input === "p") {
268
+ setPriorityFilter((f) => cycleFilter(f, TASK_PRIORITIES));
269
+ return;
270
+ }
271
+ if (input === "d" && selectedTask) {
272
+ deleteTask(conn, selectedTask.id).then(() => {
273
+ forceRefresh();
274
+ });
275
+ return;
276
+ }
277
+ if (input === "r") {
278
+ forceRefresh();
279
+ return;
280
+ }
281
+ },
282
+ { isActive },
283
+ );
284
+
285
+ if (tasks.length === 0) {
286
+ return (
287
+ <Box flexDirection="column" flexGrow={1} paddingX={1}>
288
+ <Text dimColor>
289
+ {statusFilter || priorityFilter
290
+ ? "No tasks match the current filters. Press f/p to change filters."
291
+ : "No tasks found. Tasks will appear here as they are created."}
292
+ </Text>
293
+ {(statusFilter || priorityFilter) && (
294
+ <Box marginTop={1}>
295
+ {statusFilter && (
296
+ <Text color={theme.info}>status: {statusFilter}</Text>
297
+ )}
298
+ {statusFilter && priorityFilter && <Text dimColor> · </Text>}
299
+ {priorityFilter && (
300
+ <Text color={theme.accent}>priority: {priorityFilter}</Text>
301
+ )}
302
+ </Box>
303
+ )}
304
+ </Box>
305
+ );
306
+ }
307
+
308
+ const sidebarVisible = tasks.slice(
309
+ sidebarScrollOffset,
310
+ sidebarScrollOffset + visibleRows,
311
+ );
312
+
313
+ const detailVisible = detailLines.slice(
314
+ detailScroll,
315
+ detailScroll + visibleRows,
316
+ );
317
+
318
+ return (
319
+ <Box flexGrow={1} height={visibleRows + 1} overflow="hidden">
320
+ {/* Left sidebar: task list */}
321
+ <Box
322
+ flexDirection="column"
323
+ width={SIDEBAR_WIDTH}
324
+ height={visibleRows + 1}
325
+ borderStyle="single"
326
+ borderColor={theme.muted}
327
+ borderRight
328
+ borderTop={false}
329
+ borderBottom={false}
330
+ borderLeft={false}
331
+ overflow="hidden"
332
+ >
333
+ <Box paddingX={1} gap={1}>
334
+ <Text bold dimColor>
335
+ Tasks ({tasks.length})
336
+ </Text>
337
+ {statusFilter && <Text color={theme.info}>[{statusFilter}]</Text>}
338
+ {priorityFilter && (
339
+ <Text color={theme.accent}>[{priorityFilter}]</Text>
340
+ )}
341
+ </Box>
342
+ {sidebarVisible.map((task, vi) => {
343
+ const i = vi + sidebarScrollOffset;
344
+ const isSelected = i === selectedIndex;
345
+ const icon = STATUS_ICONS[task.status];
346
+ const priorityLabel = PRIORITY_LABELS[task.priority];
347
+ const maxName = SIDEBAR_WIDTH - 11; // icon + priority + padding
348
+ const nameDisplay =
349
+ task.name.length > maxName
350
+ ? `${task.name.slice(0, maxName - 1)}…`
351
+ : task.name;
352
+ return (
353
+ <Box key={task.id} paddingX={1}>
354
+ <Text
355
+ backgroundColor={isSelected ? theme.selectionBg : undefined}
356
+ bold={isSelected}
357
+ color={isSelected ? theme.info : undefined}
358
+ wrap="truncate-end"
359
+ >
360
+ {isSelected ? "▸" : " "}{" "}
361
+ <Text color={STATUS_COLORS[task.status]} bold={false}>
362
+ {icon}
363
+ </Text>{" "}
364
+ {nameDisplay}
365
+ <Text
366
+ color={PRIORITY_COLORS[task.priority]}
367
+ dimColor={task.priority === "low"}
368
+ >
369
+ {" "}
370
+ {priorityLabel}
371
+ </Text>
372
+ </Text>
373
+ </Box>
374
+ );
375
+ })}
376
+ </Box>
377
+
378
+ {/* Right detail pane */}
379
+ <Box
380
+ flexDirection="column"
381
+ flexGrow={1}
382
+ height={visibleRows + 1}
383
+ paddingX={1}
384
+ overflow="hidden"
385
+ >
386
+ {detailVisible.map((line, i) => {
387
+ const lineNum = detailScroll + i;
388
+ return <Text key={lineNum}>{line || " "}</Text>;
389
+ })}
390
+ {detailLines.length > visibleRows && (
391
+ <Box>
392
+ <Text dimColor>
393
+ f filter · p priority · ↑↓ select · j/k scroll · d delete · r
394
+ refresh · [{detailScroll + 1}–
395
+ {Math.min(detailScroll + visibleRows, detailLines.length)} of{" "}
396
+ {detailLines.length}]
397
+ </Text>
398
+ </Box>
399
+ )}
400
+ {detailLines.length <= visibleRows && <Box flexGrow={1} />}
401
+ {detailLines.length <= visibleRows && (
402
+ <Text dimColor>
403
+ f filter · p priority · ↑↓ select · d delete · r refresh
404
+ </Text>
405
+ )}
406
+ </Box>
407
+ </Box>
408
+ );
409
+ });