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.
@@ -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
 
@@ -9,6 +9,7 @@ import {
9
9
  useState,
10
10
  } from "react";
11
11
  import type { SlashCommand } from "../../skills/commands.ts";
12
+ import { useIdle } from "../idle.tsx";
12
13
  import { getSlashMatches, shouldSubmitOnEnter } from "../slashCompletion.ts";
13
14
  import { SlashCommandPopup } from "./SlashCommandPopup.tsx";
14
15
 
@@ -38,6 +39,7 @@ export const InputBar = memo(function InputBar({
38
39
  const [popupDismissed, setPopupDismissed] = useState(false);
39
40
  const savedInput = useRef("");
40
41
  const lastActivity = useRef(Date.now());
42
+ const { isIdle } = useIdle();
41
43
 
42
44
  // Refs for values read inside the input handler — eagerly updated so rapid
43
45
  // keystrokes that arrive before React re-renders always see fresh state.
@@ -94,7 +96,7 @@ export const InputBar = memo(function InputBar({
94
96
  // Blink cursor when input is active — skip ticks while typing so the
95
97
  // cursor stays solid and we avoid unnecessary renders during rapid input.
96
98
  useEffect(() => {
97
- if (disabled) {
99
+ if (disabled || isIdle) {
98
100
  setCursorVisible(true);
99
101
  return;
100
102
  }
@@ -105,7 +107,7 @@ export const InputBar = memo(function InputBar({
105
107
  setCursorVisible((prev) => (prev === phase ? prev : phase));
106
108
  }, 530);
107
109
  return () => clearInterval(id);
108
- }, [disabled]);
110
+ }, [disabled, isIdle]);
109
111
 
110
112
  // Stable input handler — the callback reference never changes, which
111
113
  // prevents Ink's useInput from removing/re-adding the stdin listener on
@@ -337,14 +339,14 @@ export const InputBar = memo(function InputBar({
337
339
  <Box
338
340
  flexDirection="column"
339
341
  borderStyle="single"
340
- borderColor={disabled ? "gray" : "green"}
342
+ borderColor={disabled || isIdle ? "gray" : "green"}
341
343
  paddingX={1}
342
344
  >
343
345
  {header}
344
346
  {!disabled && (
345
347
  <Box flexDirection="column">
346
348
  <Box>
347
- <Text color="green">{"› "}</Text>
349
+ <Text color={isIdle ? "gray" : "green"}>{"› "}</Text>
348
350
  {placeholder ? (
349
351
  <Text dimColor>Type a message...</Text>
350
352
  ) : (
@@ -1,5 +1,6 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { useEffect, useState } from "react";
3
+ import { useIdle } from "../idle.tsx";
3
4
  import { theme } from "../theme.ts";
4
5
 
5
6
  const STARTUP_FRAMES = [
@@ -23,8 +24,10 @@ const IDLE_MS = 2000;
23
24
  export function AnimatedLogo() {
24
25
  const [frameIndex, setFrameIndex] = useState(0);
25
26
  const [startupDone, setStartupDone] = useState(false);
27
+ const { isIdle } = useIdle();
26
28
 
27
29
  useEffect(() => {
30
+ if (isIdle) return;
28
31
  const interval = setInterval(
29
32
  () => {
30
33
  setFrameIndex((prev) => {
@@ -42,20 +45,21 @@ export function AnimatedLogo() {
42
45
  startupDone ? IDLE_MS : STARTUP_MS,
43
46
  );
44
47
  return () => clearInterval(interval);
45
- }, [startupDone]);
48
+ }, [startupDone, isIdle]);
46
49
 
47
50
  const frames = startupDone ? IDLE_FRAMES : STARTUP_FRAMES;
48
51
  // biome-ignore lint: frameIndex is always in bounds
49
52
  const frame = frames[frameIndex]!;
53
+ const color = isIdle ? "gray" : theme.accent;
50
54
 
51
55
  return (
52
56
  <Box flexDirection="column" alignItems="center" justifyContent="center">
53
57
  {frame.map((line) => (
54
- <Text key={line} color={theme.accent}>
58
+ <Text key={line} color={color}>
55
59
  {line}
56
60
  </Text>
57
61
  ))}
58
- <Text bold color={theme.accent}>
62
+ <Text bold color={color}>
59
63
  Botholomew
60
64
  </Text>
61
65
  <Text dimColor>Starting chat session...</Text>
@@ -67,13 +71,19 @@ const CHAR_FRAMES = ["{o,o}", "{o,o}", "{-,-}", "{o,o}"];
67
71
 
68
72
  export function LogoChar() {
69
73
  const [frameIndex, setFrameIndex] = useState(0);
74
+ const { isIdle } = useIdle();
70
75
 
71
76
  useEffect(() => {
77
+ if (isIdle) return;
72
78
  const interval = setInterval(() => {
73
79
  setFrameIndex((prev) => (prev + 1) % CHAR_FRAMES.length);
74
80
  }, IDLE_MS);
75
81
  return () => clearInterval(interval);
76
- }, []);
82
+ }, [isIdle]);
77
83
 
78
- return <Text color={theme.accent}>{CHAR_FRAMES[frameIndex]} </Text>;
84
+ return (
85
+ <Text color={isIdle ? "gray" : theme.accent}>
86
+ {CHAR_FRAMES[frameIndex]}{" "}
87
+ </Text>
88
+ );
79
89
  }
@@ -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
+ });
@@ -1,5 +1,6 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { useEffect, useState } from "react";
3
+ import { useIdle } from "../idle.tsx";
3
4
  import { theme } from "../theme.ts";
4
5
 
5
6
  interface SleepProgressProps {
@@ -17,11 +18,13 @@ export function SleepProgress({
17
18
  reason,
18
19
  }: SleepProgressProps) {
19
20
  const [now, setNow] = useState(() => Date.now());
21
+ const { isIdle } = useIdle();
20
22
 
21
23
  useEffect(() => {
24
+ if (isIdle) return;
22
25
  const id = setInterval(() => setNow(Date.now()), TICK_MS);
23
26
  return () => clearInterval(id);
24
- }, []);
27
+ }, [isIdle]);
25
28
 
26
29
  const totalMs = totalSeconds * 1000;
27
30
  const elapsedMs = Math.min(totalMs, Math.max(0, now - startedAt.getTime()));
@@ -34,7 +37,7 @@ export function SleepProgress({
34
37
  <Box flexDirection="column">
35
38
  <Box>
36
39
  <Text dimColor>{" "}</Text>
37
- <Text color={theme.accent}>{bar}</Text>
40
+ <Text color={isIdle ? "gray" : theme.accent}>{bar}</Text>
38
41
  <Text dimColor>
39
42
  {" "}
40
43
  {elapsedSec}s / {totalSeconds}s