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.
@@ -19,112 +19,111 @@ export const HelpPanel = memo(function HelpPanel({
19
19
  Navigation
20
20
  </Text>
21
21
  <Text>
22
- {" "}Tab{" "}Cycle between tabs
22
+ {" "}Ctrl+a{" "}Chat
23
23
  </Text>
24
24
  <Text>
25
- {" "}1-6{" "}Jump to tab (non-chat tabs)
25
+ {" "}Ctrl+o{" "}Tools
26
26
  </Text>
27
27
  <Text>
28
- {" "}Escape{" "}Return to Chat tab
29
- </Text>
30
- </Box>
31
-
32
- <Box marginTop={1} flexDirection="column">
33
- <Text bold color="cyan">
34
- Chat (Tab 1)
28
+ {" "}Ctrl+n{" "}Context
35
29
  </Text>
36
30
  <Text>
37
- {" "}Enter{" "}Send message
31
+ {" "}Ctrl+t{" "}Tasks
38
32
  </Text>
39
33
  <Text>
40
- {" "}⌥+Enter{" "}Insert newline
34
+ {" "}Ctrl+r{" "}Threads
41
35
  </Text>
42
36
  <Text>
43
- {" "}↑/↓{" "}Browse input history
44
- </Text>
45
- </Box>
46
-
47
- <Box marginTop={1} flexDirection="column">
48
- <Text bold color="cyan">
49
- Tools (Tab 2)
37
+ {" "}Ctrl+s{" "}Schedules
50
38
  </Text>
51
39
  <Text>
52
- {" "}↑/↓{" "}Select tool call
40
+ {" "}Ctrl+w{" "}Workers
53
41
  </Text>
54
42
  <Text>
55
- {" "}Shift+↑/↓{" "}Scroll detail pane
43
+ {" "}?{" "}Help (from any non-chat tab)
56
44
  </Text>
57
45
  <Text>
58
- {" "}j / k{" "}Scroll detail pane
46
+ {" "}Escape{" "}Return to Chat
59
47
  </Text>
60
48
  </Box>
61
49
 
62
50
  <Box marginTop={1} flexDirection="column">
63
51
  <Text bold color="cyan">
64
- Context (Tab 3)
65
- </Text>
66
- <Text>
67
- {" "}↑/↓{" "}Navigate items
52
+ Chat
68
53
  </Text>
69
54
  <Text>
70
- {" "}Enter{" "}Expand directory / preview file
55
+ {" "}Enter{" "}Send message
71
56
  </Text>
72
57
  <Text>
73
- {" "}Backspace{" "}Go up one directory
58
+ {" "}⌥+Enter{" "}Insert newline
74
59
  </Text>
75
60
  <Text>
76
- {" "}/{" "}Search context
61
+ {" "}↑/↓{" "}Browse input history
77
62
  </Text>
78
63
  <Text>
79
- {" "}d{" "}Delete selected item (with confirmation)
64
+ {" "}Esc{" "}Steer / abort in-flight turn
80
65
  </Text>
81
66
  </Box>
82
67
 
83
68
  <Box marginTop={1} flexDirection="column">
84
69
  <Text bold color="cyan">
85
- Tasks (Tab 4)
70
+ List panels (Tools/Tasks/Threads/Schedules/Workers/Context)
71
+ </Text>
72
+ <Text dimColor>
73
+ {" "}List focus (default — dashed border on right pane):
86
74
  </Text>
87
75
  <Text>
88
- {" "}↑/↓{" "}Navigate task list
76
+ {" "}↑/↓{" "}Move list selection
89
77
  </Text>
90
78
  <Text>
91
- {" "}Shift+↑/↓{" "}Scroll detail pane
79
+ {" "}{" "}Enter the right pane (border turns yellow)
92
80
  </Text>
81
+ <Text dimColor>{" "}Detail focus (yellow border on right pane):</Text>
93
82
  <Text>
94
- {" "}j / k{" "}Scroll detail pane
83
+ {" "}↑/↓{" "}Scroll the right pane (one line)
95
84
  </Text>
96
85
  <Text>
97
- {" "}f{" "}Cycle status filter
86
+ {" "}Shift+↑/↓{" "}Page-scroll the right pane
98
87
  </Text>
99
88
  <Text>
100
- {" "}p{" "}Cycle priority filter
89
+ {" "}g / G{" "}Top / bottom of the right pane
101
90
  </Text>
102
91
  <Text>
103
- {" "}r{" "}Refresh tasks
92
+ {" "}{" "}Return to the list
104
93
  </Text>
105
94
  </Box>
106
95
 
107
96
  <Box marginTop={1} flexDirection="column">
108
97
  <Text bold color="cyan">
109
- Threads (Tab 5)
98
+ Context (extras)
99
+ </Text>
100
+ <Text>
101
+ {" "}→{" "}Drill into selected folder
110
102
  </Text>
111
103
  <Text>
112
- {" "}↑/↓{" "}Navigate thread list
104
+ {" "}{" "}Go up one directory
113
105
  </Text>
114
106
  <Text>
115
- {" "}Shift+↑/↓{" "}Scroll detail pane
107
+ {" "}/{" "}Search context
108
+ </Text>
109
+ </Box>
110
+
111
+ <Box marginTop={1} flexDirection="column">
112
+ <Text bold color="cyan">
113
+ Per-panel actions
116
114
  </Text>
117
115
  <Text>
118
- {" "}j / k{" "}Scroll detail pane
116
+ {" "}Tasks{" "}f filter · p priority · d delete · r refresh
119
117
  </Text>
120
118
  <Text>
121
- {" "}f{" "}Cycle type filter
119
+ {" "}Threads{" "}f filter · s/ search · w follow · d delete ·
120
+ r refresh
122
121
  </Text>
123
122
  <Text>
124
- {" "}d{" "}Delete thread (with confirmation)
123
+ {" "}Schedules{" "}f filter · e toggle · d delete · r refresh
125
124
  </Text>
126
125
  <Text>
127
- {" "}r{" "}Refresh threads
126
+ {" "}Workers{" "}f filter · l toggle log/detail
128
127
  </Text>
129
128
  </Box>
130
129
 
@@ -6,7 +6,14 @@ import {
6
6
  listSchedules,
7
7
  updateSchedule,
8
8
  } from "../../schedules/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 SchedulePanelProps {
12
19
  projectDir: string;
@@ -28,11 +35,6 @@ const ENABLED_COLORS: Record<string, string> = {
28
35
  false: theme.muted,
29
36
  };
30
37
 
31
- const ENABLED_ANSI: Record<string, string> = {
32
- true: ansi.success,
33
- false: ansi.muted,
34
- };
35
-
36
38
  const ENABLED_LABELS: Record<string, string> = {
37
39
  true: "enabled",
38
40
  false: "disabled",
@@ -53,31 +55,7 @@ function formatTimestamp(iso: string | null): string {
53
55
  function buildScheduleDetailAnsi(schedule: Schedule): string {
54
56
  const lines: string[] = [];
55
57
 
56
- lines.push(`${ansi.bold}${ansi.info}${schedule.name}${ansi.reset}`);
57
- lines.push("");
58
-
59
- const enabledKey = String(schedule.enabled);
60
- const statusAnsi = ENABLED_ANSI[enabledKey];
61
- lines.push(
62
- `${ansi.bold}${ansi.primary}Status${ansi.reset} ${statusAnsi}${ENABLED_ICONS[enabledKey]} ${ENABLED_LABELS[enabledKey]}${ansi.reset}`,
63
- );
64
-
65
- lines.push(
66
- `${ansi.bold}${ansi.primary}Frequency${ansi.reset} ${ansi.accent}${schedule.frequency}${ansi.reset}`,
67
- );
68
-
69
- lines.push(
70
- `${ansi.bold}${ansi.primary}Created${ansi.reset} ${ansi.dim}${formatTimestamp(schedule.created_at)}${ansi.reset}`,
71
- );
72
- lines.push(
73
- `${ansi.bold}${ansi.primary}Updated${ansi.reset} ${ansi.dim}${formatTimestamp(schedule.updated_at)}${ansi.reset}`,
74
- );
75
-
76
- lines.push(
77
- `${ansi.bold}${ansi.primary}Last Run${ansi.reset} ${formatTimestamp(schedule.last_run_at)}`,
78
- );
79
- lines.push("");
80
-
58
+ // Body only — name/status/frequency/last-run live in the panel header.
81
59
  if (schedule.description) {
82
60
  lines.push(`${ansi.bold}${ansi.primary}Description${ansi.reset}`);
83
61
  lines.push(schedule.description);
@@ -85,7 +63,13 @@ function buildScheduleDetailAnsi(schedule: Schedule): string {
85
63
  }
86
64
 
87
65
  lines.push(
88
- `${ansi.bold}${ansi.primary}ID${ansi.reset} ${ansi.dim}${schedule.id}${ansi.reset}`,
66
+ `${ansi.bold}${ansi.primary}Created${ansi.reset} ${ansi.dim}${formatTimestamp(schedule.created_at)}${ansi.reset}`,
67
+ );
68
+ lines.push(
69
+ `${ansi.bold}${ansi.primary}Updated${ansi.reset} ${ansi.dim}${formatTimestamp(schedule.updated_at)}${ansi.reset}`,
70
+ );
71
+ lines.push(
72
+ `${ansi.bold}${ansi.primary}ID${ansi.reset} ${ansi.dim}${schedule.id}${ansi.reset}`,
89
73
  );
90
74
 
91
75
  return lines.join("\n");
@@ -107,6 +91,7 @@ export const SchedulePanel = memo(function SchedulePanel({
107
91
  const [schedules, setSchedules] = useState<Schedule[]>([]);
108
92
  const [selectedIndex, setSelectedIndex] = useState(0);
109
93
  const [detailScroll, setDetailScroll] = useState(0);
94
+ const [focus, setFocus] = useState<FocusState>("list");
110
95
  const [enabledFilter, setEnabledFilter] = useState<boolean | null>(null);
111
96
  const [refreshTick, setRefreshTick] = useState(0);
112
97
  const [confirmDelete, setConfirmDelete] = useState(false);
@@ -171,12 +156,19 @@ export const SchedulePanel = memo(function SchedulePanel({
171
156
  setRefreshTick((t) => t + 1);
172
157
  }, []);
173
158
 
159
+ const itemCountRef = useLatestRef(schedules.length);
160
+ const maxDetailScrollRef = useLatestRef(maxDetailScroll);
161
+ const selectedScheduleRef = useLatestRef(selectedSchedule);
162
+ const confirmDeleteRef = useLatestRef(confirmDelete);
163
+ const focusRef = useLatestRef(focus);
164
+
174
165
  useInput(
175
166
  (input, key) => {
176
- if (confirmDelete) {
167
+ if (confirmDeleteRef.current) {
177
168
  if (input === "y" || input === "d") {
178
- if (selectedSchedule) {
179
- deleteSchedule(projectDir, selectedSchedule.id).then(() => {
169
+ const s = selectedScheduleRef.current;
170
+ if (s) {
171
+ deleteSchedule(projectDir, s.id).then(() => {
180
172
  forceRefresh();
181
173
  });
182
174
  }
@@ -187,47 +179,17 @@ export const SchedulePanel = memo(function SchedulePanel({
187
179
  return;
188
180
  }
189
181
 
190
- if (key.upArrow) {
191
- if (key.shift) {
192
- setDetailScroll((s) => Math.max(0, s - 1));
193
- } else {
194
- setSelectedIndex((i) => Math.max(0, i - 1));
195
- }
196
- return;
197
- }
198
- if (key.downArrow) {
199
- if (key.shift) {
200
- setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
201
- } else {
202
- setSelectedIndex((i) => Math.min(schedules.length - 1, i + 1));
203
- }
204
- return;
205
- }
206
-
207
- if (input === "j") {
208
- setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
209
- return;
210
- }
211
- if (input === "k") {
212
- setDetailScroll((s) => Math.max(0, s - 1));
213
- return;
214
- }
215
- if (input === "J") {
216
- setDetailScroll((s) =>
217
- Math.min(maxDetailScroll, s + PAGE_SCROLL_LINES),
218
- );
219
- return;
220
- }
221
- if (input === "K") {
222
- setDetailScroll((s) => Math.max(0, s - PAGE_SCROLL_LINES));
223
- return;
224
- }
225
- if (input === "g") {
226
- setDetailScroll(0);
227
- return;
228
- }
229
- if (input === "G") {
230
- setDetailScroll(maxDetailScroll);
182
+ if (
183
+ handleListDetailKey(input, key, {
184
+ focusRef,
185
+ setFocus,
186
+ itemCountRef,
187
+ maxDetailScrollRef,
188
+ setSelectedIndex,
189
+ setDetailScroll,
190
+ pageScrollLines: PAGE_SCROLL_LINES,
191
+ })
192
+ ) {
231
193
  return;
232
194
  }
233
195
 
@@ -235,15 +197,17 @@ export const SchedulePanel = memo(function SchedulePanel({
235
197
  setEnabledFilter((f) => cycleFilter(f, ENABLED_FILTERS));
236
198
  return;
237
199
  }
238
- if (input === "e" && selectedSchedule) {
239
- updateSchedule(projectDir, selectedSchedule.id, {
240
- enabled: !selectedSchedule.enabled,
200
+ if (input === "e") {
201
+ const s = selectedScheduleRef.current;
202
+ if (!s) return;
203
+ updateSchedule(projectDir, s.id, {
204
+ enabled: !s.enabled,
241
205
  }).then(() => {
242
206
  forceRefresh();
243
207
  });
244
208
  return;
245
209
  }
246
- if (input === "d" && selectedSchedule) {
210
+ if (input === "d" && selectedScheduleRef.current) {
247
211
  setConfirmDelete(true);
248
212
  return;
249
213
  }
@@ -353,29 +317,66 @@ export const SchedulePanel = memo(function SchedulePanel({
353
317
  flexGrow={1}
354
318
  height={visibleRows + 1}
355
319
  paddingX={1}
320
+ {...detailPaneBorderProps(focus)}
356
321
  overflow="hidden"
357
322
  >
358
- {detailVisible.map((line, i) => {
359
- const lineNum = detailScroll + i;
360
- return <Text key={lineNum}>{line || " "}</Text>;
361
- })}
362
- {detailLines.length > visibleRows && (
363
- <Box>
364
- <Text dimColor>
365
- f filter · e toggle · ↑↓ select · j/k scroll · d delete · r
366
- refresh · [{detailScroll + 1}–
367
- {Math.min(detailScroll + visibleRows, detailLines.length)} of{" "}
368
- {detailLines.length}]
369
- </Text>
370
- </Box>
323
+ {selectedSchedule && (
324
+ <ScheduleDetailHeader schedule={selectedSchedule} />
371
325
  )}
372
- {detailLines.length <= visibleRows && <Box flexGrow={1} />}
373
- {detailLines.length <= visibleRows && (
326
+ <Box flexDirection="row" flexGrow={1} overflow="hidden">
327
+ <Box flexDirection="column" flexGrow={1} overflow="hidden">
328
+ {detailVisible.map((line, i) => {
329
+ const lineNum = detailScroll + i;
330
+ return (
331
+ <Text key={lineNum} wrap="truncate-end">
332
+ {line || " "}
333
+ </Text>
334
+ );
335
+ })}
336
+ </Box>
337
+ <Scrollbar
338
+ total={detailLines.length}
339
+ visible={visibleRows - 3}
340
+ offset={detailScroll}
341
+ height={visibleRows - 3}
342
+ focused={focus === "detail"}
343
+ />
344
+ </Box>
345
+ <Text dimColor>
346
+ {focus === "detail"
347
+ ? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
348
+ : "↑↓ select · → enter detail · f filter · e toggle · d delete · r refresh"}
349
+ </Text>
350
+ </Box>
351
+ </Box>
352
+ );
353
+ });
354
+
355
+ function ScheduleDetailHeader({ schedule }: { schedule: Schedule }) {
356
+ const enabledKey = String(schedule.enabled);
357
+ return (
358
+ <Box flexDirection="column">
359
+ <Box>
360
+ <Text bold color={theme.info} wrap="truncate-end">
361
+ {schedule.name}
362
+ </Text>
363
+ </Box>
364
+ <Box>
365
+ <Text wrap="truncate-end">
366
+ <Text color={ENABLED_COLORS[enabledKey]}>
367
+ {ENABLED_ICONS[enabledKey]} {ENABLED_LABELS[enabledKey]}
368
+ </Text>
369
+ <Text dimColor> · </Text>
370
+ <Text color={theme.accent}>{schedule.frequency}</Text>
374
371
  <Text dimColor>
375
- f filter · e toggle · ↑↓ select · d delete · r refresh
372
+ {" · last run "}
373
+ {formatTimestamp(schedule.last_run_at)}
376
374
  </Text>
377
- )}
375
+ </Text>
376
+ </Box>
377
+ <Box>
378
+ <Text dimColor>{"─".repeat(2)}</Text>
378
379
  </Box>
379
380
  </Box>
380
381
  );
381
- });
382
+ }
@@ -0,0 +1,73 @@
1
+ import { Box, Text } from "ink";
2
+ import { memo } from "react";
3
+
4
+ interface ScrollbarProps {
5
+ /** Total number of lines in the document. */
6
+ total: number;
7
+ /** Number of lines currently visible. */
8
+ visible: number;
9
+ /** Scroll offset (top visible line). */
10
+ offset: number;
11
+ /** Height of the scrollbar in rows. */
12
+ height: number;
13
+ /** Whether the parent pane is currently focused — colors the thumb. */
14
+ focused?: boolean;
15
+ }
16
+
17
+ /**
18
+ * Vertical scrollbar rendered as a column of unicode block characters.
19
+ * The thumb's height and position are proportional to how much of the
20
+ * document is visible. Used in detail panes so the user can see at a glance
21
+ * where they are within a long document.
22
+ */
23
+ export const Scrollbar = memo(function Scrollbar({
24
+ total,
25
+ visible,
26
+ offset,
27
+ height,
28
+ focused,
29
+ }: ScrollbarProps) {
30
+ if (height <= 0 || total <= 0 || total <= visible) {
31
+ // Nothing to scroll — render an empty column to preserve layout.
32
+ return (
33
+ <Box flexDirection="column" width={1} height={height}>
34
+ {Array.from({ length: Math.max(0, height) }, (_, i) => (
35
+ // biome-ignore lint/suspicious/noArrayIndexKey: positional rows
36
+ <Text key={i} dimColor>
37
+ {" "}
38
+ </Text>
39
+ ))}
40
+ </Box>
41
+ );
42
+ }
43
+
44
+ const thumbHeight = Math.max(1, Math.round((visible / total) * height));
45
+ const maxOffset = Math.max(1, total - visible);
46
+ const thumbStart = Math.min(
47
+ height - thumbHeight,
48
+ Math.round((offset / maxOffset) * (height - thumbHeight)),
49
+ );
50
+
51
+ const cells: Array<{ thumb: boolean }> = [];
52
+ for (let i = 0; i < height; i++) {
53
+ cells.push({ thumb: i >= thumbStart && i < thumbStart + thumbHeight });
54
+ }
55
+
56
+ return (
57
+ <Box flexDirection="column" width={1} height={height}>
58
+ {cells.map((cell, i) =>
59
+ cell.thumb ? (
60
+ // biome-ignore lint/suspicious/noArrayIndexKey: positional rows
61
+ <Text key={i} color={focused ? "yellow" : "gray"}>
62
+
63
+ </Text>
64
+ ) : (
65
+ // biome-ignore lint/suspicious/noArrayIndexKey: positional rows
66
+ <Text key={i} dimColor>
67
+
68
+ </Text>
69
+ ),
70
+ )}
71
+ </Box>
72
+ );
73
+ });
@@ -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
  }