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
|
@@ -2,6 +2,7 @@ import { Box, Text } from "ink";
|
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
3
|
import { listTasks } from "../../tasks/store.ts";
|
|
4
4
|
import { listWorkers } from "../../workers/store.ts";
|
|
5
|
+
import { useIdle } from "../idle.tsx";
|
|
5
6
|
import { LogoChar } from "./Logo.tsx";
|
|
6
7
|
|
|
7
8
|
interface StatusBarProps {
|
|
@@ -32,8 +33,10 @@ export function StatusBar({
|
|
|
32
33
|
pendingCount: 0,
|
|
33
34
|
inProgressCount: 0,
|
|
34
35
|
});
|
|
36
|
+
const { isIdle } = useIdle();
|
|
35
37
|
|
|
36
38
|
useEffect(() => {
|
|
39
|
+
if (isIdle) return;
|
|
37
40
|
let mounted = true;
|
|
38
41
|
|
|
39
42
|
// Errors here (e.g. transient DuckDB lock conflicts while a freshly
|
|
@@ -66,32 +69,32 @@ export function StatusBar({
|
|
|
66
69
|
mounted = false;
|
|
67
70
|
clearInterval(interval);
|
|
68
71
|
};
|
|
69
|
-
}, [projectDir, onWorkerStatusChange]);
|
|
72
|
+
}, [projectDir, onWorkerStatusChange, isIdle]);
|
|
70
73
|
|
|
71
74
|
return (
|
|
72
75
|
<Box paddingX={0}>
|
|
73
76
|
<LogoChar />
|
|
74
|
-
<Text bold color="blue">
|
|
77
|
+
<Text bold color={isIdle ? "gray" : "blue"}>
|
|
75
78
|
Botholomew
|
|
76
79
|
</Text>
|
|
77
80
|
{chatTitle && (
|
|
78
81
|
<>
|
|
79
82
|
<Text dimColor> | </Text>
|
|
80
|
-
<Text color="cyan" bold italic>
|
|
83
|
+
<Text color={isIdle ? "gray" : "cyan"} bold italic>
|
|
81
84
|
{chatTitle.length > 30 ? `${chatTitle.slice(0, 29)}…` : chatTitle}
|
|
82
85
|
</Text>
|
|
83
86
|
</>
|
|
84
87
|
)}
|
|
85
88
|
<Text dimColor> | </Text>
|
|
86
89
|
{status.workerCount > 0 ? (
|
|
87
|
-
<Text color="green">
|
|
90
|
+
<Text color={isIdle ? "gray" : "green"}>
|
|
88
91
|
{status.workerCount} worker{status.workerCount === 1 ? "" : "s"}
|
|
89
92
|
</Text>
|
|
90
93
|
) : (
|
|
91
|
-
<Text color="yellow">no workers</Text>
|
|
94
|
+
<Text color={isIdle ? "gray" : "yellow"}>no workers</Text>
|
|
92
95
|
)}
|
|
93
96
|
<Text dimColor> | </Text>
|
|
94
|
-
<Text>
|
|
97
|
+
<Text dimColor={isIdle}>
|
|
95
98
|
{status.pendingCount} pending, {status.inProgressCount} active
|
|
96
99
|
</Text>
|
|
97
100
|
</Box>
|
|
@@ -2,15 +2,17 @@ import { Box, Text } from "ink";
|
|
|
2
2
|
|
|
3
3
|
export type TabId = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
{ id:
|
|
9
|
-
{ id:
|
|
10
|
-
{ id:
|
|
11
|
-
{ id:
|
|
12
|
-
{ id:
|
|
13
|
-
{ id:
|
|
5
|
+
// Help uses "?" (no Ctrl) because Ctrl+H is delivered as backspace by most
|
|
6
|
+
// terminals. The other panels use Ctrl+<letter>.
|
|
7
|
+
const TABS: { id: TabId; label: string; key: string }[] = [
|
|
8
|
+
{ id: 1, label: "Chat", key: "^a" },
|
|
9
|
+
{ id: 2, label: "Tools", key: "^o" },
|
|
10
|
+
{ id: 3, label: "Context", key: "^n" },
|
|
11
|
+
{ id: 4, label: "Tasks", key: "^t" },
|
|
12
|
+
{ id: 5, label: "Threads", key: "^r" },
|
|
13
|
+
{ id: 6, label: "Schedules", key: "^s" },
|
|
14
|
+
{ id: 7, label: "Workers", key: "^w" },
|
|
15
|
+
{ id: 8, label: "Help", key: "?" },
|
|
14
16
|
];
|
|
15
17
|
|
|
16
18
|
interface TabBarProps {
|
|
@@ -20,7 +22,7 @@ interface TabBarProps {
|
|
|
20
22
|
export function TabBar({ activeTab }: TabBarProps) {
|
|
21
23
|
return (
|
|
22
24
|
<Box paddingX={1} gap={1}>
|
|
23
|
-
{TABS.map(({ id, label }) => {
|
|
25
|
+
{TABS.map(({ id, label, key: shortcut }) => {
|
|
24
26
|
const active = id === activeTab;
|
|
25
27
|
return (
|
|
26
28
|
<Box key={id}>
|
|
@@ -30,13 +32,11 @@ export function TabBar({ activeTab }: TabBarProps) {
|
|
|
30
32
|
dimColor={!active}
|
|
31
33
|
backgroundColor={active ? "#1a3a5c" : undefined}
|
|
32
34
|
>
|
|
33
|
-
{` ${
|
|
35
|
+
{` ${shortcut} ${label} `}
|
|
34
36
|
</Text>
|
|
35
37
|
</Box>
|
|
36
38
|
);
|
|
37
39
|
})}
|
|
38
|
-
<Box flexGrow={1} />
|
|
39
|
-
<Text dimColor>tab to switch</Text>
|
|
40
40
|
</Box>
|
|
41
41
|
);
|
|
42
42
|
}
|
|
@@ -6,7 +6,14 @@ import {
|
|
|
6
6
|
type Task,
|
|
7
7
|
} from "../../tasks/schema.ts";
|
|
8
8
|
import { deleteTask, listTasks } from "../../tasks/store.ts";
|
|
9
|
+
import {
|
|
10
|
+
detailPaneBorderProps,
|
|
11
|
+
type FocusState,
|
|
12
|
+
handleListDetailKey,
|
|
13
|
+
} from "../listDetailKeys.ts";
|
|
9
14
|
import { ansi, theme } from "../theme.ts";
|
|
15
|
+
import { useLatestRef } from "../useLatestRef.ts";
|
|
16
|
+
import { Scrollbar } from "./Scrollbar.tsx";
|
|
10
17
|
|
|
11
18
|
interface TaskPanelProps {
|
|
12
19
|
projectDir: string;
|
|
@@ -32,14 +39,6 @@ const STATUS_COLORS: Record<Task["status"], string> = {
|
|
|
32
39
|
complete: theme.success,
|
|
33
40
|
};
|
|
34
41
|
|
|
35
|
-
const STATUS_ANSI: Record<Task["status"], string> = {
|
|
36
|
-
pending: ansi.muted,
|
|
37
|
-
in_progress: ansi.accent,
|
|
38
|
-
waiting: ansi.info,
|
|
39
|
-
failed: ansi.error,
|
|
40
|
-
complete: ansi.success,
|
|
41
|
-
};
|
|
42
|
-
|
|
43
42
|
const PRIORITY_LABELS: Record<Task["priority"], string> = {
|
|
44
43
|
high: "HI",
|
|
45
44
|
medium: "MD",
|
|
@@ -52,12 +51,6 @@ const PRIORITY_COLORS: Record<Task["priority"], string> = {
|
|
|
52
51
|
low: theme.muted,
|
|
53
52
|
};
|
|
54
53
|
|
|
55
|
-
const PRIORITY_ANSI: Record<Task["priority"], string> = {
|
|
56
|
-
high: ansi.error,
|
|
57
|
-
medium: ansi.accent,
|
|
58
|
-
low: ansi.muted,
|
|
59
|
-
};
|
|
60
|
-
|
|
61
54
|
function formatTimestamp(iso: string): string {
|
|
62
55
|
const d = new Date(iso);
|
|
63
56
|
if (Number.isNaN(d.getTime())) return iso;
|
|
@@ -72,29 +65,11 @@ function formatTimestamp(iso: string): string {
|
|
|
72
65
|
function buildTaskDetailAnsi(task: Task): string {
|
|
73
66
|
const lines: string[] = [];
|
|
74
67
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const statusAnsi = STATUS_ANSI[task.status];
|
|
79
|
-
lines.push(
|
|
80
|
-
`${ansi.bold}${ansi.primary}Status${ansi.reset} ${statusAnsi}${STATUS_ICONS[task.status]} ${task.status}${ansi.reset}`,
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
const priorityAnsi = PRIORITY_ANSI[task.priority];
|
|
84
|
-
lines.push(
|
|
85
|
-
`${ansi.bold}${ansi.primary}Priority${ansi.reset} ${priorityAnsi}${task.priority}${ansi.reset}`,
|
|
86
|
-
);
|
|
87
|
-
|
|
68
|
+
// Body only — name/status/priority/claim/timestamps are rendered in the
|
|
69
|
+
// panel header.
|
|
88
70
|
lines.push(
|
|
89
71
|
`${ansi.bold}${ansi.primary}Claimed${ansi.reset} ${task.claimed_by ? task.claimed_by : `${ansi.dim}(unclaimed)${ansi.reset}`}`,
|
|
90
72
|
);
|
|
91
|
-
|
|
92
|
-
lines.push(
|
|
93
|
-
`${ansi.bold}${ansi.primary}Created${ansi.reset} ${ansi.dim}${formatTimestamp(task.created_at)}${ansi.reset}`,
|
|
94
|
-
);
|
|
95
|
-
lines.push(
|
|
96
|
-
`${ansi.bold}${ansi.primary}Updated${ansi.reset} ${ansi.dim}${formatTimestamp(task.updated_at)}${ansi.reset}`,
|
|
97
|
-
);
|
|
98
73
|
lines.push("");
|
|
99
74
|
|
|
100
75
|
if (task.description) {
|
|
@@ -149,6 +124,7 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
149
124
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
150
125
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
151
126
|
const [detailScroll, setDetailScroll] = useState(0);
|
|
127
|
+
const [focus, setFocus] = useState<FocusState>("list");
|
|
152
128
|
const [statusFilter, setStatusFilter] = useState<Task["status"] | null>(null);
|
|
153
129
|
const [priorityFilter, setPriorityFilter] = useState<Task["priority"] | null>(
|
|
154
130
|
null,
|
|
@@ -218,49 +194,24 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
218
194
|
setRefreshTick((t) => t + 1);
|
|
219
195
|
}, []);
|
|
220
196
|
|
|
197
|
+
const itemCountRef = useLatestRef(tasks.length);
|
|
198
|
+
const maxDetailScrollRef = useLatestRef(maxDetailScroll);
|
|
199
|
+
const selectedTaskRef = useLatestRef(selectedTask);
|
|
200
|
+
const focusRef = useLatestRef(focus);
|
|
201
|
+
|
|
221
202
|
useInput(
|
|
222
203
|
(input, key) => {
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
} else {
|
|
235
|
-
setSelectedIndex((i) => Math.min(tasks.length - 1, i + 1));
|
|
236
|
-
}
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (input === "j") {
|
|
241
|
-
setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
if (input === "k") {
|
|
245
|
-
setDetailScroll((s) => Math.max(0, s - 1));
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
if (input === "J") {
|
|
249
|
-
setDetailScroll((s) =>
|
|
250
|
-
Math.min(maxDetailScroll, s + PAGE_SCROLL_LINES),
|
|
251
|
-
);
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
if (input === "K") {
|
|
255
|
-
setDetailScroll((s) => Math.max(0, s - PAGE_SCROLL_LINES));
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
if (input === "g") {
|
|
259
|
-
setDetailScroll(0);
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
if (input === "G") {
|
|
263
|
-
setDetailScroll(maxDetailScroll);
|
|
204
|
+
if (
|
|
205
|
+
handleListDetailKey(input, key, {
|
|
206
|
+
focusRef,
|
|
207
|
+
setFocus,
|
|
208
|
+
itemCountRef,
|
|
209
|
+
maxDetailScrollRef,
|
|
210
|
+
setSelectedIndex,
|
|
211
|
+
setDetailScroll,
|
|
212
|
+
pageScrollLines: PAGE_SCROLL_LINES,
|
|
213
|
+
})
|
|
214
|
+
) {
|
|
264
215
|
return;
|
|
265
216
|
}
|
|
266
217
|
|
|
@@ -272,8 +223,10 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
272
223
|
setPriorityFilter((f) => cycleFilter(f, TASK_PRIORITIES));
|
|
273
224
|
return;
|
|
274
225
|
}
|
|
275
|
-
if (input === "d"
|
|
276
|
-
|
|
226
|
+
if (input === "d") {
|
|
227
|
+
const t = selectedTaskRef.current;
|
|
228
|
+
if (!t) return;
|
|
229
|
+
deleteTask(projectDir, t.id).then(() => {
|
|
277
230
|
forceRefresh();
|
|
278
231
|
});
|
|
279
232
|
return;
|
|
@@ -383,29 +336,67 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
383
336
|
flexGrow={1}
|
|
384
337
|
height={visibleRows + 1}
|
|
385
338
|
paddingX={1}
|
|
339
|
+
{...detailPaneBorderProps(focus)}
|
|
386
340
|
overflow="hidden"
|
|
387
341
|
>
|
|
388
|
-
{
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
</Text>
|
|
342
|
+
{selectedTask && <TaskDetailHeader task={selectedTask} />}
|
|
343
|
+
<Box flexDirection="row" flexGrow={1} overflow="hidden">
|
|
344
|
+
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
345
|
+
{detailVisible.map((line, i) => {
|
|
346
|
+
const lineNum = detailScroll + i;
|
|
347
|
+
return (
|
|
348
|
+
<Text key={lineNum} wrap="truncate-end">
|
|
349
|
+
{line || " "}
|
|
350
|
+
</Text>
|
|
351
|
+
);
|
|
352
|
+
})}
|
|
400
353
|
</Box>
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
354
|
+
<Scrollbar
|
|
355
|
+
total={detailLines.length}
|
|
356
|
+
visible={visibleRows - 3}
|
|
357
|
+
offset={detailScroll}
|
|
358
|
+
height={visibleRows - 3}
|
|
359
|
+
focused={focus === "detail"}
|
|
360
|
+
/>
|
|
361
|
+
</Box>
|
|
362
|
+
<Text dimColor>
|
|
363
|
+
{focus === "detail"
|
|
364
|
+
? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
|
|
365
|
+
: "↑↓ select · → enter detail · f filter · p priority · d delete · r refresh"}
|
|
366
|
+
</Text>
|
|
367
|
+
</Box>
|
|
368
|
+
</Box>
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
function TaskDetailHeader({ task }: { task: Task }) {
|
|
373
|
+
return (
|
|
374
|
+
<Box flexDirection="column">
|
|
375
|
+
<Box>
|
|
376
|
+
<Text bold color={theme.info} wrap="truncate-end">
|
|
377
|
+
{task.name}
|
|
378
|
+
</Text>
|
|
379
|
+
</Box>
|
|
380
|
+
<Box>
|
|
381
|
+
<Text wrap="truncate-end">
|
|
382
|
+
<Text color={STATUS_COLORS[task.status]}>
|
|
383
|
+
{STATUS_ICONS[task.status]} {task.status}
|
|
384
|
+
</Text>
|
|
385
|
+
<Text dimColor> · </Text>
|
|
386
|
+
<Text color={PRIORITY_COLORS[task.priority]}>
|
|
387
|
+
{PRIORITY_LABELS[task.priority]}
|
|
388
|
+
</Text>
|
|
404
389
|
<Text dimColor>
|
|
405
|
-
|
|
390
|
+
{" · created "}
|
|
391
|
+
{formatTimestamp(task.created_at)}
|
|
392
|
+
{" · updated "}
|
|
393
|
+
{formatTimestamp(task.updated_at)}
|
|
406
394
|
</Text>
|
|
407
|
-
|
|
395
|
+
</Text>
|
|
396
|
+
</Box>
|
|
397
|
+
<Box>
|
|
398
|
+
<Text dimColor>{"─".repeat(2)}</Text>
|
|
408
399
|
</Box>
|
|
409
400
|
</Box>
|
|
410
401
|
);
|
|
411
|
-
}
|
|
402
|
+
}
|