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
|
@@ -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 (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
{
|
|
244
|
-
? " ·
|
|
245
|
-
:
|
|
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
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
)}
|
package/src/tui/idle.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
const CHECK_INTERVAL_MS = 10_000;
|
|
12
|
+
|
|
13
|
+
export function shouldBeIdle(
|
|
14
|
+
lastActivity: number,
|
|
15
|
+
now: number,
|
|
16
|
+
timeoutMs: number,
|
|
17
|
+
): boolean {
|
|
18
|
+
if (timeoutMs <= 0) return false;
|
|
19
|
+
return now - lastActivity >= timeoutMs;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface IdleContextValue {
|
|
23
|
+
isIdle: boolean;
|
|
24
|
+
markActivity: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const IdleContext = createContext<IdleContextValue>({
|
|
28
|
+
isIdle: false,
|
|
29
|
+
markActivity: () => {},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
interface IdleProviderProps {
|
|
33
|
+
timeoutMs: number;
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function IdleProvider({ timeoutMs, children }: IdleProviderProps) {
|
|
38
|
+
const lastActivityRef = useRef(Date.now());
|
|
39
|
+
const [isIdle, setIsIdle] = useState(false);
|
|
40
|
+
const isIdleRef = useRef(isIdle);
|
|
41
|
+
isIdleRef.current = isIdle;
|
|
42
|
+
|
|
43
|
+
const markActivity = useCallback(() => {
|
|
44
|
+
lastActivityRef.current = Date.now();
|
|
45
|
+
if (isIdleRef.current) {
|
|
46
|
+
setIsIdle(false);
|
|
47
|
+
}
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (timeoutMs <= 0) return;
|
|
52
|
+
const id = setInterval(() => {
|
|
53
|
+
const idle = shouldBeIdle(lastActivityRef.current, Date.now(), timeoutMs);
|
|
54
|
+
setIsIdle((prev) => (prev === idle ? prev : idle));
|
|
55
|
+
}, CHECK_INTERVAL_MS);
|
|
56
|
+
return () => clearInterval(id);
|
|
57
|
+
}, [timeoutMs]);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<IdleContext.Provider value={{ isIdle, markActivity }}>
|
|
61
|
+
{children}
|
|
62
|
+
</IdleContext.Provider>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function useIdle(): IdleContextValue {
|
|
67
|
+
return useContext(IdleContext);
|
|
68
|
+
}
|
|
@@ -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
|
+
}
|
package/src/utils/frontmatter.ts
CHANGED
|
@@ -3,7 +3,7 @@ import matter from "gray-matter";
|
|
|
3
3
|
export interface ContextFileMeta {
|
|
4
4
|
loading?: "always" | "contextual";
|
|
5
5
|
"agent-modification"?: boolean;
|
|
6
|
-
// Set by `
|
|
6
|
+
// Set by `botholomew context import <url>` so the saved file remembers
|
|
7
7
|
// where it came from. Optional so files written by other paths
|
|
8
8
|
// (prompts/, beliefs/, agent-authored notes) aren't required to
|
|
9
9
|
// carry import metadata.
|
package/src/worker/prompt.ts
CHANGED
|
@@ -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: ${
|
|
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"}
|