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.
- package/README.md +2 -0
- package/package.json +3 -2
- package/src/chat/session.ts +4 -0
- package/src/commands/chat.ts +18 -6
- package/src/config/schemas.ts +2 -0
- package/src/context/fetcher.ts +1 -1
- package/src/tui/App.tsx +110 -86
- package/src/tui/components/ContextPanel.tsx +325 -151
- package/src/tui/components/HelpPanel.tsx +42 -43
- package/src/tui/components/InputBar.tsx +6 -4
- package/src/tui/components/Logo.tsx +15 -5
- package/src/tui/components/SchedulePanel.tsx +98 -97
- package/src/tui/components/Scrollbar.tsx +73 -0
- package/src/tui/components/SleepProgress.tsx +5 -2
- package/src/tui/components/StatusBar.tsx +9 -6
- package/src/tui/components/TabBar.tsx +13 -13
- package/src/tui/components/TaskPanel.tsx +86 -95
- package/src/tui/components/ThreadPanel.tsx +133 -120
- package/src/tui/components/ToolPanel.tsx +84 -85
- package/src/tui/components/WorkerPanel.tsx +77 -77
- package/src/tui/idle.tsx +68 -0
- package/src/tui/listDetailKeys.ts +124 -0
- package/src/tui/useLatestRef.ts +18 -0
- package/src/utils/frontmatter.ts +1 -1
- package/src/worker/prompt.ts +10 -1
|
@@ -19,112 +19,111 @@ export const HelpPanel = memo(function HelpPanel({
|
|
|
19
19
|
Navigation
|
|
20
20
|
</Text>
|
|
21
21
|
<Text>
|
|
22
|
-
{" "}
|
|
22
|
+
{" "}Ctrl+a{" "}Chat
|
|
23
23
|
</Text>
|
|
24
24
|
<Text>
|
|
25
|
-
{" "}
|
|
25
|
+
{" "}Ctrl+o{" "}Tools
|
|
26
26
|
</Text>
|
|
27
27
|
<Text>
|
|
28
|
-
{" "}
|
|
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
|
-
{" "}
|
|
31
|
+
{" "}Ctrl+t{" "}Tasks
|
|
38
32
|
</Text>
|
|
39
33
|
<Text>
|
|
40
|
-
{" "}
|
|
34
|
+
{" "}Ctrl+r{" "}Threads
|
|
41
35
|
</Text>
|
|
42
36
|
<Text>
|
|
43
|
-
{" "}
|
|
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
|
-
{" "}
|
|
40
|
+
{" "}Ctrl+w{" "}Workers
|
|
53
41
|
</Text>
|
|
54
42
|
<Text>
|
|
55
|
-
{" "}
|
|
43
|
+
{" "}?{" "}Help (from any non-chat tab)
|
|
56
44
|
</Text>
|
|
57
45
|
<Text>
|
|
58
|
-
{" "}
|
|
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
|
-
|
|
65
|
-
</Text>
|
|
66
|
-
<Text>
|
|
67
|
-
{" "}↑/↓{" "}Navigate items
|
|
52
|
+
Chat
|
|
68
53
|
</Text>
|
|
69
54
|
<Text>
|
|
70
|
-
{" "}Enter{" "}
|
|
55
|
+
{" "}Enter{" "}Send message
|
|
71
56
|
</Text>
|
|
72
57
|
<Text>
|
|
73
|
-
{" "}
|
|
58
|
+
{" "}⌥+Enter{" "}Insert newline
|
|
74
59
|
</Text>
|
|
75
60
|
<Text>
|
|
76
|
-
{" "}
|
|
61
|
+
{" "}↑/↓{" "}Browse input history
|
|
77
62
|
</Text>
|
|
78
63
|
<Text>
|
|
79
|
-
{" "}
|
|
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
|
-
|
|
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
|
-
{" "}↑/↓{" "}
|
|
76
|
+
{" "}↑/↓{" "}Move list selection
|
|
89
77
|
</Text>
|
|
90
78
|
<Text>
|
|
91
|
-
{" "}
|
|
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
|
-
{" "}
|
|
83
|
+
{" "}↑/↓{" "}Scroll the right pane (one line)
|
|
95
84
|
</Text>
|
|
96
85
|
<Text>
|
|
97
|
-
{" "}
|
|
86
|
+
{" "}Shift+↑/↓{" "}Page-scroll the right pane
|
|
98
87
|
</Text>
|
|
99
88
|
<Text>
|
|
100
|
-
{" "}
|
|
89
|
+
{" "}g / G{" "}Top / bottom of the right pane
|
|
101
90
|
</Text>
|
|
102
91
|
<Text>
|
|
103
|
-
{" "}
|
|
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
|
-
|
|
98
|
+
Context (extras)
|
|
99
|
+
</Text>
|
|
100
|
+
<Text>
|
|
101
|
+
{" "}→{" "}Drill into selected folder
|
|
110
102
|
</Text>
|
|
111
103
|
<Text>
|
|
112
|
-
{" "}
|
|
104
|
+
{" "}←{" "}Go up one directory
|
|
113
105
|
</Text>
|
|
114
106
|
<Text>
|
|
115
|
-
{" "}
|
|
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
|
-
{" "}
|
|
116
|
+
{" "}Tasks{" "}f filter · p priority · d delete · r refresh
|
|
119
117
|
</Text>
|
|
120
118
|
<Text>
|
|
121
|
-
{" "}
|
|
119
|
+
{" "}Threads{" "}f filter · s/ search · w follow · d delete ·
|
|
120
|
+
r refresh
|
|
122
121
|
</Text>
|
|
123
122
|
<Text>
|
|
124
|
-
{" "}
|
|
123
|
+
{" "}Schedules{" "}f filter · e toggle · d delete · r refresh
|
|
125
124
|
</Text>
|
|
126
125
|
<Text>
|
|
127
|
-
{" "}
|
|
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={
|
|
58
|
+
<Text key={line} color={color}>
|
|
55
59
|
{line}
|
|
56
60
|
</Text>
|
|
57
61
|
))}
|
|
58
|
-
<Text bold color={
|
|
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
|
|
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
|
-
|
|
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}
|
|
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 (
|
|
167
|
+
if (confirmDeleteRef.current) {
|
|
177
168
|
if (input === "y" || input === "d") {
|
|
178
|
-
|
|
179
|
-
|
|
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 (
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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"
|
|
239
|
-
|
|
240
|
-
|
|
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" &&
|
|
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
|
-
{
|
|
359
|
-
|
|
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
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|