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.
@@ -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
+ }
@@ -1,6 +1,13 @@
1
1
  import { Box, Text, useInput, useStdout } from "ink";
2
2
  import { memo, useEffect, useMemo, useState } from "react";
3
+ import {
4
+ detailPaneBorderProps,
5
+ type FocusState,
6
+ handleListDetailKey,
7
+ } from "../listDetailKeys.ts";
3
8
  import { ansi, theme } from "../theme.ts";
9
+ import { useLatestRef } from "../useLatestRef.ts";
10
+ import { Scrollbar } from "./Scrollbar.tsx";
4
11
  import { resolveToolDisplay, type ToolCallData } from "./ToolCall.tsx";
5
12
 
6
13
  interface ToolPanelProps {
@@ -68,26 +75,9 @@ function colorizeValue(value: unknown, indent: number): string {
68
75
  function buildDetailAnsi(tool: ToolCallData): string {
69
76
  const lines: string[] = [];
70
77
 
71
- const time = tool.timestamp.toLocaleTimeString([], {
72
- hour: "2-digit",
73
- minute: "2-digit",
74
- second: "2-digit",
75
- });
76
-
77
- const { displayName, displayInput } = resolveToolDisplay(
78
- tool.name,
79
- tool.input,
80
- );
81
- lines.push(`${ansi.bold}${ansi.info}${displayName}${ansi.reset}`);
82
- if (tool.name === "mcp_exec") {
83
- lines.push(`${ansi.dim}via mcp_exec${ansi.reset}`);
84
- }
85
- lines.push(`${ansi.dim}Time: ${time}${ansi.reset}`);
86
- if (tool.running) {
87
- lines.push(`${ansi.accent}⟳ running${ansi.reset}`);
88
- }
89
- lines.push("");
78
+ const { displayInput } = resolveToolDisplay(tool.name, tool.input);
90
79
 
80
+ // Body only — name/server/status/time live in the panel header now.
91
81
  lines.push(`${ansi.bold}${ansi.primary}Input${ansi.reset}`);
92
82
  lines.push(colorizeJson(displayInput));
93
83
  lines.push("");
@@ -123,6 +113,7 @@ export const ToolPanel = memo(function ToolPanel({
123
113
  const termRows = stdout?.rows ?? 24;
124
114
  const [selectedIndex, setSelectedIndex] = useState(0);
125
115
  const [detailScroll, setDetailScroll] = useState(0);
116
+ const [focus, setFocus] = useState<FocusState>("list");
126
117
 
127
118
  // Reverse-chronological order (most recent first)
128
119
  const reversedCalls = useMemo(() => [...toolCalls].reverse(), [toolCalls]);
@@ -163,57 +154,21 @@ export const ToolPanel = memo(function ToolPanel({
163
154
  setDetailScroll(0);
164
155
  }, [selectedIndex]);
165
156
 
157
+ const itemCountRef = useLatestRef(reversedCalls.length);
158
+ const maxDetailScrollRef = useLatestRef(maxDetailScroll);
159
+ const focusRef = useLatestRef(focus);
160
+
166
161
  useInput(
167
162
  (input, key) => {
168
- if (key.upArrow) {
169
- if (key.shift) {
170
- // Shift+up scrolls detail
171
- setDetailScroll((s) => Math.max(0, s - 1));
172
- } else {
173
- setSelectedIndex((i) => Math.max(0, i - 1));
174
- }
175
- return;
176
- }
177
- if (key.downArrow) {
178
- if (key.shift) {
179
- setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
180
- } else {
181
- setSelectedIndex((i) => Math.min(reversedCalls.length - 1, i + 1));
182
- }
183
- return;
184
- }
185
-
186
- // j/k vim-style for detail scrolling (single line)
187
- if (input === "j") {
188
- setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
189
- return;
190
- }
191
- if (input === "k") {
192
- setDetailScroll((s) => Math.max(0, s - 1));
193
- return;
194
- }
195
-
196
- // J/K for page scrolling (hold shift or caps)
197
- if (input === "J") {
198
- setDetailScroll((s) =>
199
- Math.min(maxDetailScroll, s + PAGE_SCROLL_LINES),
200
- );
201
- return;
202
- }
203
- if (input === "K") {
204
- setDetailScroll((s) => Math.max(0, s - PAGE_SCROLL_LINES));
205
- return;
206
- }
207
-
208
- // g/G for top/bottom
209
- if (input === "g") {
210
- setDetailScroll(0);
211
- return;
212
- }
213
- if (input === "G") {
214
- setDetailScroll(maxDetailScroll);
215
- return;
216
- }
163
+ handleListDetailKey(input, key, {
164
+ focusRef,
165
+ setFocus,
166
+ itemCountRef,
167
+ maxDetailScrollRef,
168
+ setSelectedIndex,
169
+ setDetailScroll,
170
+ pageScrollLines: PAGE_SCROLL_LINES,
171
+ });
217
172
  },
218
173
  { isActive },
219
174
  );
@@ -316,27 +271,71 @@ export const ToolPanel = memo(function ToolPanel({
316
271
  flexGrow={1}
317
272
  height={visibleRows + 1}
318
273
  paddingX={1}
274
+ {...detailPaneBorderProps(focus)}
319
275
  overflow="hidden"
320
276
  >
321
- {detailVisible.map((line, i) => {
322
- const lineNum = detailScroll + i;
323
- return <Text key={lineNum}>{line || " "}</Text>;
324
- })}
325
- {detailLines.length > visibleRows && (
326
- <Box>
327
- <Text dimColor>
328
- ↑↓ select · j/k scroll · J/K page · g/G top/bottom · [
329
- {detailScroll + 1}–
330
- {Math.min(detailScroll + visibleRows, detailLines.length)} of{" "}
331
- {detailLines.length}]
332
- </Text>
277
+ {selectedTool && <ToolDetailHeader tool={selectedTool} />}
278
+ <Box flexDirection="row" flexGrow={1} overflow="hidden">
279
+ <Box flexDirection="column" flexGrow={1} overflow="hidden">
280
+ {detailVisible.map((line, i) => {
281
+ const lineNum = detailScroll + i;
282
+ return (
283
+ <Text key={lineNum} wrap="truncate-end">
284
+ {line || " "}
285
+ </Text>
286
+ );
287
+ })}
333
288
  </Box>
334
- )}
335
- {detailLines.length <= visibleRows && <Box flexGrow={1} />}
336
- {detailLines.length <= visibleRows && (
337
- <Text dimColor>↑↓ select tool calls</Text>
338
- )}
289
+ <Scrollbar
290
+ total={detailLines.length}
291
+ visible={visibleRows - 3}
292
+ offset={detailScroll}
293
+ height={visibleRows - 3}
294
+ focused={focus === "detail"}
295
+ />
296
+ </Box>
297
+ <Text dimColor>
298
+ {focus === "detail"
299
+ ? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
300
+ : "↑↓ select · → enter detail"}
301
+ </Text>
339
302
  </Box>
340
303
  </Box>
341
304
  );
342
305
  });
306
+
307
+ function ToolDetailHeader({ tool }: { tool: ToolCallData }) {
308
+ const { displayName } = resolveToolDisplay(tool.name, tool.input);
309
+ const time = tool.timestamp.toLocaleTimeString([], {
310
+ hour: "2-digit",
311
+ minute: "2-digit",
312
+ second: "2-digit",
313
+ });
314
+ const isMcp = tool.name === "mcp_exec";
315
+ const status = tool.running
316
+ ? { color: theme.accent, label: "⟳ running" }
317
+ : tool.isError
318
+ ? { color: theme.error, label: "✘ error" }
319
+ : tool.output
320
+ ? { color: theme.success, label: "✔ done" }
321
+ : { color: theme.muted, label: "— no output" };
322
+ return (
323
+ <Box flexDirection="column">
324
+ <Box>
325
+ <Text bold color={theme.info} wrap="truncate-end">
326
+ {displayName}
327
+ </Text>
328
+ </Box>
329
+ <Box>
330
+ <Text wrap="truncate-end">
331
+ <Text dimColor>{isMcp ? "mcp_exec · " : ""}</Text>
332
+ <Text color={status.color}>{status.label}</Text>
333
+ <Text dimColor> · {time}</Text>
334
+ </Text>
335
+ </Box>
336
+ <Box>
337
+ <Text dimColor>{"─".repeat(2)}</Text>
338
+ </Box>
339
+ </Box>
340
+ );
341
+ }