botholomew 0.14.0 → 0.15.0

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.
@@ -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
+ }
@@ -9,7 +9,14 @@ import {
9
9
  listThreads,
10
10
  type Thread,
11
11
  } from "../../threads/store.ts";
12
+ import {
13
+ detailPaneBorderProps,
14
+ type FocusState,
15
+ handleListDetailKey,
16
+ } from "../listDetailKeys.ts";
12
17
  import { ansi, theme } from "../theme.ts";
18
+ import { useLatestRef } from "../useLatestRef.ts";
19
+ import { Scrollbar } from "./Scrollbar.tsx";
13
20
 
14
21
  interface ThreadPanelProps {
15
22
  projectDir: string;
@@ -40,11 +47,6 @@ const TYPE_COLORS: Record<Thread["type"], string> = {
40
47
  chat_session: theme.info,
41
48
  };
42
49
 
43
- const TYPE_ANSI: Record<Thread["type"], string> = {
44
- worker_tick: ansi.accent,
45
- chat_session: ansi.info,
46
- };
47
-
48
50
  const ROLE_ANSI: Record<string, string> = {
49
51
  user: ansi.success,
50
52
  assistant: ansi.info,
@@ -76,44 +78,18 @@ function formatDate(d: Date): string {
76
78
  function buildThreadDetailAnsi(
77
79
  thread: Thread,
78
80
  interactions: Interaction[],
79
- isActiveThread: boolean,
81
+ _isActiveThread: boolean,
80
82
  ): string {
81
83
  const lines: string[] = [];
82
84
 
83
- lines.push(
84
- `${ansi.bold}${ansi.italic}${ansi.info}${thread.title || "(untitled)"}${ansi.reset}`,
85
- );
86
- lines.push("");
87
-
88
- const typeAnsi = TYPE_ANSI[thread.type];
89
- lines.push(
90
- `${ansi.bold}${ansi.primary}Type${ansi.reset} ${typeAnsi}${TYPE_ICONS[thread.type]} ${TYPE_LABELS[thread.type]}${ansi.reset}`,
91
- );
92
-
85
+ // Body only — title/type/timing live in the panel header.
93
86
  if (thread.task_id) {
94
87
  lines.push(
95
88
  `${ansi.bold}${ansi.primary}Task${ansi.reset} ${ansi.dim}${thread.task_id}${ansi.reset}`,
96
89
  );
97
- }
98
-
99
- lines.push(
100
- `${ansi.bold}${ansi.primary}Started${ansi.reset} ${ansi.dim}${formatDate(thread.started_at)}${ansi.reset}`,
101
- );
102
- lines.push(
103
- `${ansi.bold}${ansi.primary}Ended${ansi.reset} ${thread.ended_at ? `${ansi.dim}${formatDate(thread.ended_at)}${ansi.reset}` : `${ansi.success}ongoing${ansi.reset}`}`,
104
- );
105
- lines.push(
106
- `${ansi.bold}${ansi.primary}Duration${ansi.reset} ${ansi.dim}${formatDuration(thread.started_at, thread.ended_at)}${ansi.reset}`,
107
- );
108
-
109
- if (isActiveThread) {
110
90
  lines.push("");
111
- lines.push(
112
- `${ansi.bold}${ansi.success}★ Current session thread${ansi.reset}`,
113
- );
114
91
  }
115
92
 
116
- lines.push("");
117
93
  lines.push(
118
94
  `${ansi.bold}${ansi.primary}Interactions${ansi.reset} ${interactions.length} total`,
119
95
  );
@@ -189,6 +165,7 @@ export const ThreadPanel = memo(function ThreadPanel({
189
165
  const [threads, setThreads] = useState<Thread[]>([]);
190
166
  const [selectedIndex, setSelectedIndex] = useState(0);
191
167
  const [detailScroll, setDetailScroll] = useState(0);
168
+ const [focus, setFocus] = useState<FocusState>("list");
192
169
  const [typeFilter, setTypeFilter] = useState<Thread["type"] | null>(null);
193
170
  const [refreshTick, setRefreshTick] = useState(0);
194
171
  const [confirmDelete, setConfirmDelete] = useState(false);
@@ -346,10 +323,21 @@ export const ThreadPanel = memo(function ThreadPanel({
346
323
  setRefreshTick((t) => t + 1);
347
324
  }, []);
348
325
 
326
+ // Mirror state into refs to dodge Ink's stale-closure bug.
327
+ const itemCountRef = useLatestRef(filteredThreads.length);
328
+ const maxDetailScrollRef = useLatestRef(maxDetailScroll);
329
+ const selectedThreadRef = useLatestRef(selectedThread);
330
+ const selectedDetailRef = useLatestRef(selectedDetail);
331
+ const searchingRef = useLatestRef(searching);
332
+ const confirmDeleteRef = useLatestRef(confirmDelete);
333
+ const isActiveSelectedRef = useLatestRef(isActiveSelected);
334
+ const followingRef = useLatestRef(following);
335
+ const focusRef = useLatestRef(focus);
336
+
349
337
  useInput(
350
338
  (input, key) => {
351
339
  // Search mode: capture typed characters
352
- if (searching) {
340
+ if (searchingRef.current) {
353
341
  if (key.escape) {
354
342
  setSearching(false);
355
343
  setSearchQuery("");
@@ -373,10 +361,11 @@ export const ThreadPanel = memo(function ThreadPanel({
373
361
  }
374
362
 
375
363
  // Delete confirmation mode
376
- if (confirmDelete) {
364
+ if (confirmDeleteRef.current) {
377
365
  if (input === "y" || input === "d") {
378
- if (selectedThread && !isActiveSelected) {
379
- deleteThread(projectDir, selectedThread.id).then(() => {
366
+ const t = selectedThreadRef.current;
367
+ if (t && !isActiveSelectedRef.current) {
368
+ deleteThread(projectDir, t.id).then(() => {
380
369
  forceRefresh();
381
370
  });
382
371
  }
@@ -387,47 +376,17 @@ export const ThreadPanel = memo(function ThreadPanel({
387
376
  return;
388
377
  }
389
378
 
390
- if (key.upArrow) {
391
- if (key.shift) {
392
- setDetailScroll((s) => Math.max(0, s - 1));
393
- } else {
394
- setSelectedIndex((i) => Math.max(0, i - 1));
395
- }
396
- return;
397
- }
398
- if (key.downArrow) {
399
- if (key.shift) {
400
- setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
401
- } else {
402
- setSelectedIndex((i) => Math.min(filteredThreads.length - 1, i + 1));
403
- }
404
- return;
405
- }
406
-
407
- if (input === "j") {
408
- setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
409
- return;
410
- }
411
- if (input === "k") {
412
- setDetailScroll((s) => Math.max(0, s - 1));
413
- return;
414
- }
415
- if (input === "J") {
416
- setDetailScroll((s) =>
417
- Math.min(maxDetailScroll, s + PAGE_SCROLL_LINES),
418
- );
419
- return;
420
- }
421
- if (input === "K") {
422
- setDetailScroll((s) => Math.max(0, s - PAGE_SCROLL_LINES));
423
- return;
424
- }
425
- if (input === "g") {
426
- setDetailScroll(0);
427
- return;
428
- }
429
- if (input === "G") {
430
- setDetailScroll(maxDetailScroll);
379
+ if (
380
+ handleListDetailKey(input, key, {
381
+ focusRef,
382
+ setFocus,
383
+ itemCountRef,
384
+ maxDetailScrollRef,
385
+ setSelectedIndex,
386
+ setDetailScroll,
387
+ pageScrollLines: PAGE_SCROLL_LINES,
388
+ })
389
+ ) {
431
390
  return;
432
391
  }
433
392
 
@@ -435,8 +394,8 @@ export const ThreadPanel = memo(function ThreadPanel({
435
394
  setTypeFilter((f) => cycleFilter(f, THREAD_TYPES));
436
395
  return;
437
396
  }
438
- if (input === "d" && selectedThread) {
439
- if (isActiveSelected) return; // Can't delete active thread
397
+ if (input === "d" && selectedThreadRef.current) {
398
+ if (isActiveSelectedRef.current) return; // Can't delete active thread
440
399
  setConfirmDelete(true);
441
400
  return;
442
401
  }
@@ -449,14 +408,17 @@ export const ThreadPanel = memo(function ThreadPanel({
449
408
  setSearchQuery("");
450
409
  return;
451
410
  }
452
- if (input === "w" && selectedThread) {
453
- if (following) {
411
+ if (input === "w") {
412
+ const t = selectedThreadRef.current;
413
+ if (!t) return;
414
+ if (followingRef.current) {
454
415
  setFollowing(false);
455
- } else if (!selectedThread.ended_at) {
456
- const maxSeq = selectedDetail?.interactions.at(-1)?.sequence ?? 0;
416
+ } else if (!t.ended_at) {
417
+ const maxSeq =
418
+ selectedDetailRef.current?.interactions.at(-1)?.sequence ?? 0;
457
419
  lastSeenSequenceRef.current = maxSeq;
458
420
  setFollowing(true);
459
- setDetailScroll(maxDetailScroll);
421
+ setDetailScroll(maxDetailScrollRef.current);
460
422
  }
461
423
  return;
462
424
  }
@@ -583,46 +545,97 @@ export const ThreadPanel = memo(function ThreadPanel({
583
545
  flexGrow={1}
584
546
  height={visibleRows + 1}
585
547
  paddingX={1}
548
+ {...detailPaneBorderProps(focus)}
586
549
  overflow="hidden"
587
550
  >
588
- {detailVisible.map((line, i) => {
589
- const lineNum = detailScroll + i;
590
- return <Text key={lineNum}>{line || " "}</Text>;
591
- })}
592
- {detailLines.length > visibleRows && (
593
- <Box>
594
- {following && (
595
- <Text color={theme.success} bold>
596
- {" "}
597
- FOLLOWING{" "}
598
- </Text>
599
- )}
600
- <Text dimColor>
601
- s search · f filter · ↑↓ select · j/k scroll · d delete ·
602
- {selectedThread && !selectedThread.ended_at ? " w follow ·" : ""}{" "}
603
- r refresh · [{detailScroll + 1}–
604
- {Math.min(detailScroll + visibleRows, detailLines.length)} of{" "}
605
- {detailLines.length}]
606
- </Text>
607
- </Box>
551
+ {selectedThread && (
552
+ <ThreadDetailHeader
553
+ thread={selectedThread}
554
+ isActiveThread={isActiveSelected}
555
+ />
608
556
  )}
609
- {detailLines.length <= visibleRows && <Box flexGrow={1} />}
610
- {detailLines.length <= visibleRows && (
611
- <Box>
612
- {following && (
613
- <Text color={theme.success} bold>
614
- {" "}
615
- FOLLOWING{" "}
616
- </Text>
617
- )}
618
- <Text dimColor>
619
- s search · f filter · ↑↓ select · d delete ·
620
- {selectedThread && !selectedThread.ended_at ? " w follow ·" : ""}{" "}
621
- r refresh
622
- </Text>
557
+ <Box flexDirection="row" flexGrow={1} overflow="hidden">
558
+ <Box flexDirection="column" flexGrow={1} overflow="hidden">
559
+ {detailVisible.map((line, i) => {
560
+ const lineNum = detailScroll + i;
561
+ return (
562
+ <Text key={lineNum} wrap="truncate-end">
563
+ {line || " "}
564
+ </Text>
565
+ );
566
+ })}
623
567
  </Box>
624
- )}
568
+ <Scrollbar
569
+ total={detailLines.length}
570
+ visible={visibleRows - 3}
571
+ offset={detailScroll}
572
+ height={visibleRows - 3}
573
+ focused={focus === "detail"}
574
+ />
575
+ </Box>
576
+ <Box>
577
+ {following && (
578
+ <Text color={theme.success} bold>
579
+ {" "}
580
+ FOLLOWING{" "}
581
+ </Text>
582
+ )}
583
+ <Text dimColor>
584
+ {focus === "detail"
585
+ ? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
586
+ : `↑↓ select · → enter detail · s search · f filter · d delete${selectedThread && !selectedThread.ended_at ? " · w follow" : ""} · r refresh`}
587
+ </Text>
588
+ </Box>
625
589
  </Box>
626
590
  </Box>
627
591
  );
628
592
  });
593
+
594
+ function ThreadDetailHeader({
595
+ thread,
596
+ isActiveThread,
597
+ }: {
598
+ thread: Thread;
599
+ isActiveThread: boolean;
600
+ }) {
601
+ return (
602
+ <Box flexDirection="column">
603
+ <Box>
604
+ <Text wrap="truncate-end">
605
+ <Text bold italic color={theme.info}>
606
+ {thread.title || "(untitled)"}
607
+ </Text>
608
+ {isActiveThread && (
609
+ <Text bold color={theme.success}>
610
+ {" ★"}
611
+ </Text>
612
+ )}
613
+ </Text>
614
+ </Box>
615
+ <Box>
616
+ <Text wrap="truncate-end">
617
+ <Text color={TYPE_COLORS[thread.type]}>
618
+ {TYPE_ICONS[thread.type]} {TYPE_LABELS[thread.type]}
619
+ </Text>
620
+ <Text dimColor>
621
+ {" · started "}
622
+ {formatDate(thread.started_at)}
623
+ {" · "}
624
+ </Text>
625
+ {thread.ended_at ? (
626
+ <Text dimColor>ended {formatDate(thread.ended_at)}</Text>
627
+ ) : (
628
+ <Text color={theme.success}>ongoing</Text>
629
+ )}
630
+ <Text dimColor>
631
+ {" · "}
632
+ {formatDuration(thread.started_at, thread.ended_at)}
633
+ </Text>
634
+ </Text>
635
+ </Box>
636
+ <Box>
637
+ <Text dimColor>{"─".repeat(2)}</Text>
638
+ </Box>
639
+ </Box>
640
+ );
641
+ }