botholomew 0.14.2 → 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.
@@ -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
+ }
@@ -2,6 +2,13 @@ import { Box, Text, useInput, useStdout } from "ink";
2
2
  import { memo, useEffect, useMemo, useState } from "react";
3
3
  import { readLogTail } from "../../worker/log-reader.ts";
4
4
  import { listWorkers, type Worker } from "../../workers/store.ts";
5
+ import {
6
+ detailPaneBorderProps,
7
+ type FocusState,
8
+ handleListDetailKey,
9
+ } from "../listDetailKeys.ts";
10
+ import { useLatestRef } from "../useLatestRef.ts";
11
+ import { Scrollbar } from "./Scrollbar.tsx";
5
12
 
6
13
  interface WorkerPanelProps {
7
14
  projectDir: string;
@@ -63,6 +70,7 @@ export const WorkerPanel = memo(function WorkerPanel({
63
70
  const [logTruncated, setLogTruncated] = useState(false);
64
71
  const [logScroll, setLogScroll] = useState(0);
65
72
  const [logFollow, setLogFollow] = useState(true);
73
+ const [focus, setFocus] = useState<FocusState>("list");
66
74
 
67
75
  useEffect(() => {
68
76
  let mounted = true;
@@ -150,76 +158,50 @@ export const WorkerPanel = memo(function WorkerPanel({
150
158
  }
151
159
  }, [viewMode, logFollow, maxLogScroll]);
152
160
 
161
+ const itemCountRef = useLatestRef(workers.length);
162
+ const maxLogScrollRef = useLatestRef(maxLogScroll);
163
+ const focusRef = useLatestRef(focus);
164
+
165
+ // The right pane scrolls with arrows when focused. Tee the log scroll into
166
+ // the follow-state so reaching the bottom resumes follow mode (and any
167
+ // explicit scroll-up pauses it).
168
+ const setLogScrollWithFollow = (
169
+ next: number | ((prev: number) => number),
170
+ ) => {
171
+ setLogScroll((s) => {
172
+ const v = typeof next === "function" ? next(s) : next;
173
+ const max = maxLogScrollRef.current;
174
+ const clamped = Math.max(0, Math.min(max, v));
175
+ setLogFollow(clamped >= max);
176
+ return clamped;
177
+ });
178
+ };
179
+
153
180
  useInput(
154
181
  (input, key) => {
155
182
  if (!isActive) return;
156
183
 
184
+ // `l` toggles between detail (worker info) and log (tail) view in the
185
+ // right pane.
157
186
  if (input === "l") {
158
187
  setViewMode((m) => (m === "log" ? "detail" : "log"));
159
188
  return;
160
189
  }
161
190
 
162
- if (key.upArrow) {
163
- if (viewMode === "log" && key.shift) {
164
- setLogFollow(false);
165
- setLogScroll((s) => Math.max(0, s - 1));
166
- return;
167
- }
168
- setSelectedIndex((i) => Math.max(0, i - 1));
169
- return;
170
- }
171
- if (key.downArrow) {
172
- if (viewMode === "log" && key.shift) {
173
- setLogScroll((s) => {
174
- const next = Math.min(maxLogScroll, s + 1);
175
- if (next >= maxLogScroll) setLogFollow(true);
176
- return next;
177
- });
178
- return;
179
- }
180
- setSelectedIndex((i) => Math.min(workers.length - 1, i + 1));
191
+ if (
192
+ handleListDetailKey(input, key, {
193
+ focusRef,
194
+ setFocus,
195
+ itemCountRef,
196
+ maxDetailScrollRef: maxLogScrollRef,
197
+ setSelectedIndex,
198
+ setDetailScroll: setLogScrollWithFollow,
199
+ pageScrollLines: PAGE_SCROLL_LINES,
200
+ })
201
+ ) {
181
202
  return;
182
203
  }
183
204
 
184
- if (viewMode === "log") {
185
- if (input === "j") {
186
- setLogScroll((s) => {
187
- const next = Math.min(maxLogScroll, s + 1);
188
- if (next >= maxLogScroll) setLogFollow(true);
189
- return next;
190
- });
191
- return;
192
- }
193
- if (input === "k") {
194
- setLogFollow(false);
195
- setLogScroll((s) => Math.max(0, s - 1));
196
- return;
197
- }
198
- if (input === "J") {
199
- setLogScroll((s) => {
200
- const next = Math.min(maxLogScroll, s + PAGE_SCROLL_LINES);
201
- if (next >= maxLogScroll) setLogFollow(true);
202
- return next;
203
- });
204
- return;
205
- }
206
- if (input === "K") {
207
- setLogFollow(false);
208
- setLogScroll((s) => Math.max(0, s - PAGE_SCROLL_LINES));
209
- return;
210
- }
211
- if (input === "g") {
212
- setLogFollow(false);
213
- setLogScroll(0);
214
- return;
215
- }
216
- if (input === "G") {
217
- setLogFollow(true);
218
- setLogScroll(maxLogScroll);
219
- return;
220
- }
221
- }
222
-
223
205
  if (input === "f") {
224
206
  setFilterIdx((i) => (i + 1) % STATUS_FILTERS.length);
225
207
  return;
@@ -240,9 +222,11 @@ export const WorkerPanel = memo(function WorkerPanel({
240
222
  <Text dimColor> · filter: </Text>
241
223
  <Text color="yellow">{filterLabel}</Text>
242
224
  <Text dimColor>
243
- {viewMode === "log"
244
- ? " · [l] back [↑↓] select [j/k] scroll [g/G] top/bot [f] filter"
245
- : " · [l] view log [f] cycle filter [↑↓] select"}
225
+ {focus === "detail"
226
+ ? " · ↑↓ scroll ⇧↑↓ page g/G top/bot back to list l toggle"
227
+ : viewMode === "log"
228
+ ? " · ↑↓ select → enter log l detail f filter"
229
+ : " · ↑↓ select → enter detail l view log f filter"}
246
230
  </Text>
247
231
  </Box>
248
232
 
@@ -287,22 +271,38 @@ export const WorkerPanel = memo(function WorkerPanel({
287
271
  );
288
272
  })}
289
273
  </Box>
290
- <Box flexDirection="column" flexGrow={1}>
291
- {selected ? (
292
- viewMode === "log" ? (
293
- <WorkerLogView
294
- worker={selected}
295
- lines={logLines}
296
- scroll={logScroll}
297
- visibleRows={visibleRows}
298
- truncated={logTruncated}
299
- size={logSize}
300
- follow={logFollow}
301
- />
302
- ) : (
303
- <WorkerDetail worker={selected} now={now} />
304
- )
305
- ) : null}
274
+ <Box
275
+ flexDirection="row"
276
+ flexGrow={1}
277
+ paddingX={1}
278
+ {...detailPaneBorderProps(focus)}
279
+ >
280
+ <Box flexDirection="column" flexGrow={1}>
281
+ {selected ? (
282
+ viewMode === "log" ? (
283
+ <WorkerLogView
284
+ worker={selected}
285
+ lines={logLines}
286
+ scroll={logScroll}
287
+ visibleRows={visibleRows}
288
+ truncated={logTruncated}
289
+ size={logSize}
290
+ follow={logFollow}
291
+ />
292
+ ) : (
293
+ <WorkerDetail worker={selected} now={now} />
294
+ )
295
+ ) : null}
296
+ </Box>
297
+ {viewMode === "log" && (
298
+ <Scrollbar
299
+ total={logLines.length}
300
+ visible={visibleRows - 1}
301
+ offset={logScroll}
302
+ height={visibleRows - 1}
303
+ focused={focus === "detail"}
304
+ />
305
+ )}
306
306
  </Box>
307
307
  </Box>
308
308
  )}
@@ -0,0 +1,124 @@
1
+ import type { RefObject } from "react";
2
+
3
+ /**
4
+ * Standard list+detail keyboard model used by every non-chat panel.
5
+ *
6
+ * Two columns: a list/tree on the left and a detail/preview pane on the
7
+ * right. The right pane has an explicit focus state — visualized by its
8
+ * border (dashed when unfocused, solid yellow when focused) — and the
9
+ * arrow keys mean different things depending on it.
10
+ *
11
+ * focus = "list" (default)
12
+ * ↑ / ↓ Move list selection
13
+ * → Move focus into the detail pane
14
+ * ← (panel-specific; e.g. Context goes up a directory)
15
+ *
16
+ * focus = "detail"
17
+ * ↑ / ↓ Scroll the detail pane (one line)
18
+ * Shift+↑/↓ Page-scroll the detail pane
19
+ * g / G Jump to top / bottom of the detail pane
20
+ * ← Return focus to the list
21
+ *
22
+ * Panels can intercept ←/→ via `onLeftArrow` / `onRightArrow` to add their
23
+ * own semantics (Context uses → on a folder to drill in). Returning `true`
24
+ * from those callbacks means "I handled it, don't fall through to the
25
+ * default focus transition".
26
+ *
27
+ * State is read through refs because Ink 7's `useInput` (wrapped in React's
28
+ * `useEffectEvent`) intermittently sees a stale closure on Bun + React 19.2.
29
+ */
30
+ export type FocusState = "list" | "detail";
31
+
32
+ export interface ListDetailKeyOptions {
33
+ focusRef: RefObject<FocusState>;
34
+ setFocus: (next: FocusState) => void;
35
+ itemCountRef: RefObject<number>;
36
+ maxDetailScrollRef: RefObject<number>;
37
+ setSelectedIndex: (updater: (prev: number) => number) => void;
38
+ setDetailScroll: (next: number | ((prev: number) => number)) => void;
39
+ pageScrollLines?: number;
40
+ /** Return true if the panel handled ←; otherwise falls through to default. */
41
+ onLeftArrow?: () => boolean;
42
+ /** Return true if the panel handled →; otherwise falls through to default. */
43
+ onRightArrow?: () => boolean;
44
+ }
45
+
46
+ const DEFAULT_PAGE_SCROLL = 10;
47
+
48
+ export function handleListDetailKey(
49
+ input: string,
50
+ // biome-ignore lint/suspicious/noExplicitAny: Ink's Key type is not exported
51
+ key: any,
52
+ opts: ListDetailKeyOptions,
53
+ ): boolean {
54
+ const page = opts.pageScrollLines ?? DEFAULT_PAGE_SCROLL;
55
+ const focus = opts.focusRef.current;
56
+
57
+ if (key.rightArrow) {
58
+ if (opts.onRightArrow?.()) return true;
59
+ if (focus === "list") opts.setFocus("detail");
60
+ return true;
61
+ }
62
+ if (key.leftArrow) {
63
+ if (opts.onLeftArrow?.()) return true;
64
+ if (focus === "detail") opts.setFocus("list");
65
+ return true;
66
+ }
67
+ if (key.upArrow) {
68
+ if (focus === "detail") {
69
+ const step = key.shift ? page : 1;
70
+ opts.setDetailScroll((s) => Math.max(0, s - step));
71
+ } else {
72
+ opts.setSelectedIndex((i) => Math.max(0, i - 1));
73
+ }
74
+ return true;
75
+ }
76
+ if (key.downArrow) {
77
+ if (focus === "detail") {
78
+ const step = key.shift ? page : 1;
79
+ opts.setDetailScroll((s) =>
80
+ Math.min(opts.maxDetailScrollRef.current, s + step),
81
+ );
82
+ } else {
83
+ opts.setSelectedIndex((i) =>
84
+ Math.min(opts.itemCountRef.current - 1, i + 1),
85
+ );
86
+ }
87
+ return true;
88
+ }
89
+ // Jump keys only make sense in the detail pane.
90
+ if (focus === "detail") {
91
+ if (input === "g") {
92
+ opts.setDetailScroll(0);
93
+ return true;
94
+ }
95
+ if (input === "G") {
96
+ opts.setDetailScroll(opts.maxDetailScrollRef.current);
97
+ return true;
98
+ }
99
+ }
100
+ return false;
101
+ }
102
+
103
+ /**
104
+ * Visual style for the right pane's border. Panels render the right column
105
+ * inside a `<Box>` with these props so the focus state is obvious at a
106
+ * glance: dashed dim border when the list owns focus, bold yellow border
107
+ * when the detail pane owns it.
108
+ */
109
+ const DASHED_BORDER = {
110
+ topLeft: "┌",
111
+ top: "┄",
112
+ topRight: "┐",
113
+ left: "┆",
114
+ bottomLeft: "└",
115
+ bottom: "┄",
116
+ bottomRight: "┘",
117
+ right: "┆",
118
+ } as const;
119
+
120
+ export function detailPaneBorderProps(focus: FocusState) {
121
+ return focus === "detail"
122
+ ? { borderStyle: "bold" as const, borderColor: "yellow" as const }
123
+ : { borderStyle: DASHED_BORDER, borderColor: "gray" as const };
124
+ }
@@ -0,0 +1,18 @@
1
+ import { useRef } from "react";
2
+
3
+ /**
4
+ * Returns a ref whose `.current` is always the latest committed value.
5
+ *
6
+ * Workaround for a stale-closure issue we hit with Ink 7's `useInput`: the
7
+ * callback we pass is wrapped in React's `useEffectEvent`, but on Bun + React
8
+ * 19.2 the keyboard handler often sees the *initial* render's closure even
9
+ * after subsequent commits (e.g. an `entries` array still appearing empty
10
+ * after the populating `setState` has rendered). Reading from a ref that's
11
+ * eagerly assigned during render side-steps the issue — refs always read the
12
+ * latest assigned value regardless of which closure the caller is in.
13
+ */
14
+ export function useLatestRef<T>(value: T) {
15
+ const ref = useRef<T>(value);
16
+ ref.current = value;
17
+ return ref;
18
+ }
@@ -76,8 +76,17 @@ export async function loadPersistentContext(
76
76
  * Build common meta header (version, time, OS, user).
77
77
  */
78
78
  export function buildMetaHeader(projectDir: string): string {
79
+ const now = new Date();
80
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
81
+ const localTime = now.toLocaleString("en-US", {
82
+ timeZone: timezone,
83
+ dateStyle: "full",
84
+ timeStyle: "long",
85
+ });
79
86
  return `# Botholomew v${pkg.version}
80
- Current time: ${new Date().toISOString()}
87
+ Current time (UTC): ${now.toISOString()}
88
+ Current time (local): ${localTime}
89
+ Timezone: ${timezone}
81
90
  Project directory: ${projectDir}
82
91
  OS: ${process.platform} ${process.arch}
83
92
  User: ${process.env.USER || process.env.USERNAME || "unknown"}