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.
@@ -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
- const TABS: { id: TabId; label: string }[] = [
6
- { id: 1, label: "Chat" },
7
- { id: 2, label: "Tools" },
8
- { id: 3, label: "Context" },
9
- { id: 4, label: "Tasks" },
10
- { id: 5, label: "Threads" },
11
- { id: 6, label: "Schedules" },
12
- { id: 7, label: "Workers" },
13
- { id: 8, label: "Help" },
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
- {` ${id} ${label} `}
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
- lines.push(`${ansi.bold}${ansi.info}${task.name}${ansi.reset}`);
76
- lines.push("");
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 (key.upArrow) {
224
- if (key.shift) {
225
- setDetailScroll((s) => Math.max(0, s - 1));
226
- } else {
227
- setSelectedIndex((i) => Math.max(0, i - 1));
228
- }
229
- return;
230
- }
231
- if (key.downArrow) {
232
- if (key.shift) {
233
- setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
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" && selectedTask) {
276
- deleteTask(projectDir, selectedTask.id).then(() => {
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
- {detailVisible.map((line, i) => {
389
- const lineNum = detailScroll + i;
390
- return <Text key={lineNum}>{line || " "}</Text>;
391
- })}
392
- {detailLines.length > visibleRows && (
393
- <Box>
394
- <Text dimColor>
395
- f filter · p priority · ↑↓ select · j/k scroll · d delete · r
396
- refresh · [{detailScroll + 1}–
397
- {Math.min(detailScroll + visibleRows, detailLines.length)} of{" "}
398
- {detailLines.length}]
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
- {detailLines.length <= visibleRows && <Box flexGrow={1} />}
403
- {detailLines.length <= visibleRows && (
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
- f filter · p priority · ↑↓ select · d delete · r refresh
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
+ }