botholomew 0.15.0 → 0.15.2
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/agent.ts +40 -0
- package/src/chat/usage.ts +69 -0
- package/src/commands/chat.ts +5 -1
- package/src/config/schemas.ts +2 -0
- package/src/context/fetcher.ts +1 -1
- package/src/context/store.ts +11 -5
- package/src/db/embeddings.ts +17 -0
- package/src/fs/sandbox.ts +31 -6
- package/src/tui/App.tsx +55 -13
- package/src/tui/components/ContextPanel.tsx +42 -1
- package/src/tui/components/DeleteArmedBanner.tsx +18 -0
- package/src/tui/components/HelpPanel.tsx +73 -6
- package/src/tui/components/InputBar.tsx +6 -4
- package/src/tui/components/Logo.tsx +15 -5
- package/src/tui/components/SchedulePanel.tsx +18 -25
- package/src/tui/components/SleepProgress.tsx +5 -2
- package/src/tui/components/StatusBar.tsx +9 -6
- package/src/tui/components/TabBar.tsx +29 -4
- package/src/tui/components/TaskPanel.tsx +18 -4
- package/src/tui/components/ThreadPanel.tsx +18 -26
- package/src/tui/components/WorkerPanel.tsx +38 -2
- package/src/tui/idle.tsx +68 -0
- package/src/tui/useDeleteConfirm.ts +115 -0
- package/src/utils/frontmatter.ts +1 -1
- package/src/workers/store.ts +24 -2
|
@@ -1,17 +1,42 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import { memo } from "react";
|
|
3
|
+
import type { ContextUsage } from "../../chat/usage.ts";
|
|
3
4
|
|
|
4
5
|
interface HelpPanelProps {
|
|
5
6
|
projectDir: string;
|
|
6
7
|
threadId: string;
|
|
7
8
|
workerRunning: boolean;
|
|
9
|
+
usage?: ContextUsage | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function formatK(n: number): string {
|
|
13
|
+
return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function usageColorFor(pct: number): "red" | "yellow" | "green" {
|
|
17
|
+
if (pct >= 90) return "red";
|
|
18
|
+
if (pct >= 70) return "yellow";
|
|
19
|
+
return "green";
|
|
8
20
|
}
|
|
9
21
|
|
|
10
22
|
export const HelpPanel = memo(function HelpPanel({
|
|
11
23
|
projectDir,
|
|
12
24
|
threadId,
|
|
13
25
|
workerRunning,
|
|
26
|
+
usage,
|
|
14
27
|
}: HelpPanelProps) {
|
|
28
|
+
const pct =
|
|
29
|
+
usage && usage.max > 0 ? Math.round((usage.used / usage.max) * 100) : null;
|
|
30
|
+
const breakdownRows: { label: string; tokens: number }[] = usage
|
|
31
|
+
? [
|
|
32
|
+
{ label: "Prompts (files)", tokens: usage.breakdown.prompts },
|
|
33
|
+
{ label: "Instructions ", tokens: usage.breakdown.instructions },
|
|
34
|
+
{ label: "Tools ", tokens: usage.breakdown.tools },
|
|
35
|
+
{ label: "Messages ", tokens: usage.breakdown.messages },
|
|
36
|
+
{ label: "Tool I/O ", tokens: usage.breakdown.toolIo },
|
|
37
|
+
]
|
|
38
|
+
: [];
|
|
39
|
+
const breakdownTotal = breakdownRows.reduce((s, r) => s + r.tokens, 0);
|
|
15
40
|
return (
|
|
16
41
|
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
|
|
17
42
|
<Box marginTop={1} flexDirection="column">
|
|
@@ -40,7 +65,7 @@ export const HelpPanel = memo(function HelpPanel({
|
|
|
40
65
|
{" "}Ctrl+w{" "}Workers
|
|
41
66
|
</Text>
|
|
42
67
|
<Text>
|
|
43
|
-
{" "}
|
|
68
|
+
{" "}Ctrl+g{" "}Help (Ctrl+/ also works in most terminals)
|
|
44
69
|
</Text>
|
|
45
70
|
<Text>
|
|
46
71
|
{" "}Escape{" "}Return to Chat
|
|
@@ -112,18 +137,28 @@ export const HelpPanel = memo(function HelpPanel({
|
|
|
112
137
|
<Text bold color="cyan">
|
|
113
138
|
Per-panel actions
|
|
114
139
|
</Text>
|
|
140
|
+
<Text dimColor>
|
|
141
|
+
{" "}d delete needs two presses — arms first, confirms second
|
|
142
|
+
(cancels on any other key or after 3s)
|
|
143
|
+
</Text>
|
|
115
144
|
<Text>
|
|
116
|
-
{" "}Tasks{" "}f filter · p priority · d delete · r
|
|
145
|
+
{" "}Tasks{" "}f filter · p priority · d delete (×2) · r
|
|
146
|
+
refresh
|
|
117
147
|
</Text>
|
|
118
148
|
<Text>
|
|
119
|
-
{" "}Threads{" "}f filter · s/ search · w follow · d delete
|
|
120
|
-
r refresh
|
|
149
|
+
{" "}Threads{" "}f filter · s/ search · w follow · d delete
|
|
150
|
+
(×2) · r refresh
|
|
121
151
|
</Text>
|
|
122
152
|
<Text>
|
|
123
|
-
{" "}Schedules{" "}f filter · e toggle · d delete · r
|
|
153
|
+
{" "}Schedules{" "}f filter · e toggle · d delete (×2) · r
|
|
154
|
+
refresh
|
|
124
155
|
</Text>
|
|
125
156
|
<Text>
|
|
126
|
-
{" "}
|
|
157
|
+
{" "}Context{" "}d delete (×2) · r refresh
|
|
158
|
+
</Text>
|
|
159
|
+
<Text>
|
|
160
|
+
{" "}Workers{" "}f filter · l toggle log/detail · d delete log
|
|
161
|
+
(×2, log view)
|
|
127
162
|
</Text>
|
|
128
163
|
</Box>
|
|
129
164
|
|
|
@@ -139,6 +174,38 @@ export const HelpPanel = memo(function HelpPanel({
|
|
|
139
174
|
</Text>
|
|
140
175
|
</Box>
|
|
141
176
|
|
|
177
|
+
<Box marginTop={1} flexDirection="column">
|
|
178
|
+
<Text bold color="cyan">
|
|
179
|
+
Context usage
|
|
180
|
+
</Text>
|
|
181
|
+
{usage && pct !== null ? (
|
|
182
|
+
<>
|
|
183
|
+
<Text>
|
|
184
|
+
{" "}Total{" "}
|
|
185
|
+
<Text color={usageColorFor(pct)}>
|
|
186
|
+
{formatK(usage.used)}/{formatK(usage.max)} ({pct}%)
|
|
187
|
+
</Text>
|
|
188
|
+
</Text>
|
|
189
|
+
<Text dimColor>
|
|
190
|
+
{" "}Estimate (~4 chars/token, sums to ~{formatK(breakdownTotal)}
|
|
191
|
+
):
|
|
192
|
+
</Text>
|
|
193
|
+
{breakdownRows.map((row) => (
|
|
194
|
+
<Text key={row.label}>
|
|
195
|
+
{" "}
|
|
196
|
+
{row.label}
|
|
197
|
+
{" "}
|
|
198
|
+
{formatK(row.tokens)}
|
|
199
|
+
</Text>
|
|
200
|
+
))}
|
|
201
|
+
</>
|
|
202
|
+
) : (
|
|
203
|
+
<Text dimColor>
|
|
204
|
+
{" "}Send a message to see token usage for the next turn.
|
|
205
|
+
</Text>
|
|
206
|
+
)}
|
|
207
|
+
</Box>
|
|
208
|
+
|
|
142
209
|
<Box marginTop={1} flexDirection="column">
|
|
143
210
|
<Text bold color="cyan">
|
|
144
211
|
System Info
|
|
@@ -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
|
}
|
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
handleListDetailKey,
|
|
13
13
|
} from "../listDetailKeys.ts";
|
|
14
14
|
import { ansi, theme } from "../theme.ts";
|
|
15
|
+
import { useDeleteConfirm } from "../useDeleteConfirm.ts";
|
|
15
16
|
import { useLatestRef } from "../useLatestRef.ts";
|
|
17
|
+
import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
|
|
16
18
|
import { Scrollbar } from "./Scrollbar.tsx";
|
|
17
19
|
|
|
18
20
|
interface SchedulePanelProps {
|
|
@@ -94,7 +96,6 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
94
96
|
const [focus, setFocus] = useState<FocusState>("list");
|
|
95
97
|
const [enabledFilter, setEnabledFilter] = useState<boolean | null>(null);
|
|
96
98
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
97
|
-
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
98
99
|
|
|
99
100
|
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshTick triggers manual refresh
|
|
100
101
|
useEffect(() => {
|
|
@@ -159,25 +160,19 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
159
160
|
const itemCountRef = useLatestRef(schedules.length);
|
|
160
161
|
const maxDetailScrollRef = useLatestRef(maxDetailScroll);
|
|
161
162
|
const selectedScheduleRef = useLatestRef(selectedSchedule);
|
|
162
|
-
const confirmDeleteRef = useLatestRef(confirmDelete);
|
|
163
163
|
const focusRef = useLatestRef(focus);
|
|
164
164
|
|
|
165
|
+
const deleteConfirm = useDeleteConfirm(() => {
|
|
166
|
+
const s = selectedScheduleRef.current;
|
|
167
|
+
if (!s) return;
|
|
168
|
+
deleteSchedule(projectDir, s.id).then(() => {
|
|
169
|
+
forceRefresh();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
165
173
|
useInput(
|
|
166
174
|
(input, key) => {
|
|
167
|
-
if (
|
|
168
|
-
if (input === "y" || input === "d") {
|
|
169
|
-
const s = selectedScheduleRef.current;
|
|
170
|
-
if (s) {
|
|
171
|
-
deleteSchedule(projectDir, s.id).then(() => {
|
|
172
|
-
forceRefresh();
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
setConfirmDelete(false);
|
|
176
|
-
} else {
|
|
177
|
-
setConfirmDelete(false);
|
|
178
|
-
}
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
175
|
+
if (input !== "d") deleteConfirm.cancel();
|
|
181
176
|
|
|
182
177
|
if (
|
|
183
178
|
handleListDetailKey(input, key, {
|
|
@@ -208,7 +203,8 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
208
203
|
return;
|
|
209
204
|
}
|
|
210
205
|
if (input === "d" && selectedScheduleRef.current) {
|
|
211
|
-
|
|
206
|
+
const s = selectedScheduleRef.current;
|
|
207
|
+
deleteConfirm.pressDelete(s.name);
|
|
212
208
|
return;
|
|
213
209
|
}
|
|
214
210
|
if (input === "r") {
|
|
@@ -273,13 +269,6 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
273
269
|
</Text>
|
|
274
270
|
)}
|
|
275
271
|
</Box>
|
|
276
|
-
{confirmDelete && selectedSchedule && (
|
|
277
|
-
<Box paddingX={1}>
|
|
278
|
-
<Text color="red" bold>
|
|
279
|
-
Delete schedule? (y/n)
|
|
280
|
-
</Text>
|
|
281
|
-
</Box>
|
|
282
|
-
)}
|
|
283
272
|
{sidebarVisible.map((schedule, vi) => {
|
|
284
273
|
const i = vi + sidebarScrollOffset;
|
|
285
274
|
const isSelected = i === selectedIndex;
|
|
@@ -342,10 +331,14 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
342
331
|
focused={focus === "detail"}
|
|
343
332
|
/>
|
|
344
333
|
</Box>
|
|
334
|
+
<DeleteArmedBanner
|
|
335
|
+
armed={deleteConfirm.armed}
|
|
336
|
+
label={deleteConfirm.armedLabel}
|
|
337
|
+
/>
|
|
345
338
|
<Text dimColor>
|
|
346
339
|
{focus === "detail"
|
|
347
340
|
? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
|
|
348
|
-
: "↑↓ select · → enter detail · f filter · e toggle · d delete · r refresh"}
|
|
341
|
+
: "↑↓ select · → enter detail · f filter · e toggle · d delete (×2) · r refresh"}
|
|
349
342
|
</Text>
|
|
350
343
|
</Box>
|
|
351
344
|
</Box>
|
|
@@ -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
|
|
@@ -2,6 +2,7 @@ import { Box, Text } from "ink";
|
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
3
|
import { listTasks } from "../../tasks/store.ts";
|
|
4
4
|
import { listWorkers } from "../../workers/store.ts";
|
|
5
|
+
import { useIdle } from "../idle.tsx";
|
|
5
6
|
import { LogoChar } from "./Logo.tsx";
|
|
6
7
|
|
|
7
8
|
interface StatusBarProps {
|
|
@@ -32,8 +33,10 @@ export function StatusBar({
|
|
|
32
33
|
pendingCount: 0,
|
|
33
34
|
inProgressCount: 0,
|
|
34
35
|
});
|
|
36
|
+
const { isIdle } = useIdle();
|
|
35
37
|
|
|
36
38
|
useEffect(() => {
|
|
39
|
+
if (isIdle) return;
|
|
37
40
|
let mounted = true;
|
|
38
41
|
|
|
39
42
|
// Errors here (e.g. transient DuckDB lock conflicts while a freshly
|
|
@@ -66,32 +69,32 @@ export function StatusBar({
|
|
|
66
69
|
mounted = false;
|
|
67
70
|
clearInterval(interval);
|
|
68
71
|
};
|
|
69
|
-
}, [projectDir, onWorkerStatusChange]);
|
|
72
|
+
}, [projectDir, onWorkerStatusChange, isIdle]);
|
|
70
73
|
|
|
71
74
|
return (
|
|
72
75
|
<Box paddingX={0}>
|
|
73
76
|
<LogoChar />
|
|
74
|
-
<Text bold color="blue">
|
|
77
|
+
<Text bold color={isIdle ? "gray" : "blue"}>
|
|
75
78
|
Botholomew
|
|
76
79
|
</Text>
|
|
77
80
|
{chatTitle && (
|
|
78
81
|
<>
|
|
79
82
|
<Text dimColor> | </Text>
|
|
80
|
-
<Text color="cyan" bold italic>
|
|
83
|
+
<Text color={isIdle ? "gray" : "cyan"} bold italic>
|
|
81
84
|
{chatTitle.length > 30 ? `${chatTitle.slice(0, 29)}…` : chatTitle}
|
|
82
85
|
</Text>
|
|
83
86
|
</>
|
|
84
87
|
)}
|
|
85
88
|
<Text dimColor> | </Text>
|
|
86
89
|
{status.workerCount > 0 ? (
|
|
87
|
-
<Text color="green">
|
|
90
|
+
<Text color={isIdle ? "gray" : "green"}>
|
|
88
91
|
{status.workerCount} worker{status.workerCount === 1 ? "" : "s"}
|
|
89
92
|
</Text>
|
|
90
93
|
) : (
|
|
91
|
-
<Text color="yellow">no workers</Text>
|
|
94
|
+
<Text color={isIdle ? "gray" : "yellow"}>no workers</Text>
|
|
92
95
|
)}
|
|
93
96
|
<Text dimColor> | </Text>
|
|
94
|
-
<Text>
|
|
97
|
+
<Text dimColor={isIdle}>
|
|
95
98
|
{status.pendingCount} pending, {status.inProgressCount} active
|
|
96
99
|
</Text>
|
|
97
100
|
</Box>
|
|
@@ -2,8 +2,9 @@ import { Box, Text } from "ink";
|
|
|
2
2
|
|
|
3
3
|
export type TabId = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
|
4
4
|
|
|
5
|
-
// Help uses
|
|
6
|
-
//
|
|
5
|
+
// Help uses Ctrl+G rather than Ctrl+H because most terminals deliver Ctrl+H
|
|
6
|
+
// as backspace. Ctrl+G also catches the Ctrl+/ keystroke on terminals that
|
|
7
|
+
// map it to BEL (0x07) — most macOS terminals do.
|
|
7
8
|
const TABS: { id: TabId; label: string; key: string }[] = [
|
|
8
9
|
{ id: 1, label: "Chat", key: "^a" },
|
|
9
10
|
{ id: 2, label: "Tools", key: "^o" },
|
|
@@ -12,14 +13,30 @@ const TABS: { id: TabId; label: string; key: string }[] = [
|
|
|
12
13
|
{ id: 5, label: "Threads", key: "^r" },
|
|
13
14
|
{ id: 6, label: "Schedules", key: "^s" },
|
|
14
15
|
{ id: 7, label: "Workers", key: "^w" },
|
|
15
|
-
{ id: 8, label: "Help", key: "
|
|
16
|
+
{ id: 8, label: "Help", key: "^g" },
|
|
16
17
|
];
|
|
17
18
|
|
|
18
19
|
interface TabBarProps {
|
|
19
20
|
activeTab: TabId;
|
|
21
|
+
usage?: { used: number; max: number } | null;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
function formatK(n: number): string {
|
|
25
|
+
return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function TabBar({ activeTab, usage }: TabBarProps) {
|
|
29
|
+
const pct =
|
|
30
|
+
usage && usage.max > 0 ? Math.round((usage.used / usage.max) * 100) : null;
|
|
31
|
+
const usageColor =
|
|
32
|
+
pct === null
|
|
33
|
+
? undefined
|
|
34
|
+
: pct >= 90
|
|
35
|
+
? "red"
|
|
36
|
+
: pct >= 70
|
|
37
|
+
? "yellow"
|
|
38
|
+
: "green";
|
|
39
|
+
|
|
23
40
|
return (
|
|
24
41
|
<Box paddingX={1} gap={1}>
|
|
25
42
|
{TABS.map(({ id, label, key: shortcut }) => {
|
|
@@ -37,6 +54,14 @@ export function TabBar({ activeTab }: TabBarProps) {
|
|
|
37
54
|
</Box>
|
|
38
55
|
);
|
|
39
56
|
})}
|
|
57
|
+
<Box flexGrow={1} />
|
|
58
|
+
{usage && (
|
|
59
|
+
<Box>
|
|
60
|
+
<Text color={usageColor}>
|
|
61
|
+
{formatK(usage.used)}/{formatK(usage.max)}
|
|
62
|
+
</Text>
|
|
63
|
+
</Box>
|
|
64
|
+
)}
|
|
40
65
|
</Box>
|
|
41
66
|
);
|
|
42
67
|
}
|
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
handleListDetailKey,
|
|
13
13
|
} from "../listDetailKeys.ts";
|
|
14
14
|
import { ansi, theme } from "../theme.ts";
|
|
15
|
+
import { useDeleteConfirm } from "../useDeleteConfirm.ts";
|
|
15
16
|
import { useLatestRef } from "../useLatestRef.ts";
|
|
17
|
+
import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
|
|
16
18
|
import { Scrollbar } from "./Scrollbar.tsx";
|
|
17
19
|
|
|
18
20
|
interface TaskPanelProps {
|
|
@@ -199,8 +201,18 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
199
201
|
const selectedTaskRef = useLatestRef(selectedTask);
|
|
200
202
|
const focusRef = useLatestRef(focus);
|
|
201
203
|
|
|
204
|
+
const deleteConfirm = useDeleteConfirm(() => {
|
|
205
|
+
const t = selectedTaskRef.current;
|
|
206
|
+
if (!t) return;
|
|
207
|
+
deleteTask(projectDir, t.id).then(() => {
|
|
208
|
+
forceRefresh();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
202
212
|
useInput(
|
|
203
213
|
(input, key) => {
|
|
214
|
+
if (input !== "d") deleteConfirm.cancel();
|
|
215
|
+
|
|
204
216
|
if (
|
|
205
217
|
handleListDetailKey(input, key, {
|
|
206
218
|
focusRef,
|
|
@@ -226,9 +238,7 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
226
238
|
if (input === "d") {
|
|
227
239
|
const t = selectedTaskRef.current;
|
|
228
240
|
if (!t) return;
|
|
229
|
-
|
|
230
|
-
forceRefresh();
|
|
231
|
-
});
|
|
241
|
+
deleteConfirm.pressDelete(t.name || t.id);
|
|
232
242
|
return;
|
|
233
243
|
}
|
|
234
244
|
if (input === "r") {
|
|
@@ -359,10 +369,14 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
359
369
|
focused={focus === "detail"}
|
|
360
370
|
/>
|
|
361
371
|
</Box>
|
|
372
|
+
<DeleteArmedBanner
|
|
373
|
+
armed={deleteConfirm.armed}
|
|
374
|
+
label={deleteConfirm.armedLabel}
|
|
375
|
+
/>
|
|
362
376
|
<Text dimColor>
|
|
363
377
|
{focus === "detail"
|
|
364
378
|
? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
|
|
365
|
-
: "↑↓ select · → enter detail · f filter · p priority · d delete · r refresh"}
|
|
379
|
+
: "↑↓ select · → enter detail · f filter · p priority · d delete (×2) · r refresh"}
|
|
366
380
|
</Text>
|
|
367
381
|
</Box>
|
|
368
382
|
</Box>
|
|
@@ -15,7 +15,9 @@ import {
|
|
|
15
15
|
handleListDetailKey,
|
|
16
16
|
} from "../listDetailKeys.ts";
|
|
17
17
|
import { ansi, theme } from "../theme.ts";
|
|
18
|
+
import { useDeleteConfirm } from "../useDeleteConfirm.ts";
|
|
18
19
|
import { useLatestRef } from "../useLatestRef.ts";
|
|
20
|
+
import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
|
|
19
21
|
import { Scrollbar } from "./Scrollbar.tsx";
|
|
20
22
|
|
|
21
23
|
interface ThreadPanelProps {
|
|
@@ -168,7 +170,6 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
168
170
|
const [focus, setFocus] = useState<FocusState>("list");
|
|
169
171
|
const [typeFilter, setTypeFilter] = useState<Thread["type"] | null>(null);
|
|
170
172
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
171
|
-
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
172
173
|
const [searching, setSearching] = useState(false);
|
|
173
174
|
const [searchQuery, setSearchQuery] = useState("");
|
|
174
175
|
const [selectedDetail, setSelectedDetail] = useState<{
|
|
@@ -329,11 +330,18 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
329
330
|
const selectedThreadRef = useLatestRef(selectedThread);
|
|
330
331
|
const selectedDetailRef = useLatestRef(selectedDetail);
|
|
331
332
|
const searchingRef = useLatestRef(searching);
|
|
332
|
-
const confirmDeleteRef = useLatestRef(confirmDelete);
|
|
333
333
|
const isActiveSelectedRef = useLatestRef(isActiveSelected);
|
|
334
334
|
const followingRef = useLatestRef(following);
|
|
335
335
|
const focusRef = useLatestRef(focus);
|
|
336
336
|
|
|
337
|
+
const deleteConfirm = useDeleteConfirm(() => {
|
|
338
|
+
const t = selectedThreadRef.current;
|
|
339
|
+
if (!t || isActiveSelectedRef.current) return;
|
|
340
|
+
deleteThread(projectDir, t.id).then(() => {
|
|
341
|
+
forceRefresh();
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
337
345
|
useInput(
|
|
338
346
|
(input, key) => {
|
|
339
347
|
// Search mode: capture typed characters
|
|
@@ -360,21 +368,7 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
360
368
|
return;
|
|
361
369
|
}
|
|
362
370
|
|
|
363
|
-
|
|
364
|
-
if (confirmDeleteRef.current) {
|
|
365
|
-
if (input === "y" || input === "d") {
|
|
366
|
-
const t = selectedThreadRef.current;
|
|
367
|
-
if (t && !isActiveSelectedRef.current) {
|
|
368
|
-
deleteThread(projectDir, t.id).then(() => {
|
|
369
|
-
forceRefresh();
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
setConfirmDelete(false);
|
|
373
|
-
} else {
|
|
374
|
-
setConfirmDelete(false);
|
|
375
|
-
}
|
|
376
|
-
return;
|
|
377
|
-
}
|
|
371
|
+
if (input !== "d") deleteConfirm.cancel();
|
|
378
372
|
|
|
379
373
|
if (
|
|
380
374
|
handleListDetailKey(input, key, {
|
|
@@ -396,7 +390,8 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
396
390
|
}
|
|
397
391
|
if (input === "d" && selectedThreadRef.current) {
|
|
398
392
|
if (isActiveSelectedRef.current) return; // Can't delete active thread
|
|
399
|
-
|
|
393
|
+
const t = selectedThreadRef.current;
|
|
394
|
+
deleteConfirm.pressDelete(t.title || "(untitled)");
|
|
400
395
|
return;
|
|
401
396
|
}
|
|
402
397
|
if (input === "r") {
|
|
@@ -490,13 +485,6 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
490
485
|
<Text color={theme.info}>▌</Text>
|
|
491
486
|
</Box>
|
|
492
487
|
)}
|
|
493
|
-
{confirmDelete && selectedThread && (
|
|
494
|
-
<Box paddingX={1}>
|
|
495
|
-
<Text color="red" bold>
|
|
496
|
-
Delete thread? (y/n)
|
|
497
|
-
</Text>
|
|
498
|
-
</Box>
|
|
499
|
-
)}
|
|
500
488
|
{sidebarVisible.map((thread, vi) => {
|
|
501
489
|
const i = vi + sidebarScrollOffset;
|
|
502
490
|
const isSelected = i === selectedIndex;
|
|
@@ -573,6 +561,10 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
573
561
|
focused={focus === "detail"}
|
|
574
562
|
/>
|
|
575
563
|
</Box>
|
|
564
|
+
<DeleteArmedBanner
|
|
565
|
+
armed={deleteConfirm.armed}
|
|
566
|
+
label={deleteConfirm.armedLabel}
|
|
567
|
+
/>
|
|
576
568
|
<Box>
|
|
577
569
|
{following && (
|
|
578
570
|
<Text color={theme.success} bold>
|
|
@@ -583,7 +575,7 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
583
575
|
<Text dimColor>
|
|
584
576
|
{focus === "detail"
|
|
585
577
|
? "↑↓ 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`}
|
|
578
|
+
: `↑↓ select · → enter detail · s search · f filter · d delete (×2)${selectedThread && !selectedThread.ended_at ? " · w follow" : ""} · r refresh`}
|
|
587
579
|
</Text>
|
|
588
580
|
</Box>
|
|
589
581
|
</Box>
|