botholomew 0.14.0 → 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.
- package/package.json +1 -1
- package/src/chat/session.ts +4 -0
- package/src/commands/chat.ts +14 -6
- package/src/commands/context.ts +33 -8
- package/src/context/fetcher-errors.ts +8 -0
- package/src/context/fetcher.ts +96 -27
- package/src/context/markdown-converter.ts +186 -0
- package/src/tui/App.tsx +75 -86
- package/src/tui/components/ContextPanel.tsx +325 -151
- package/src/tui/components/HelpPanel.tsx +42 -43
- package/src/tui/components/SchedulePanel.tsx +98 -97
- package/src/tui/components/Scrollbar.tsx +73 -0
- 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/listDetailKeys.ts +124 -0
- package/src/tui/useLatestRef.ts +18 -0
- package/src/utils/frontmatter.ts +10 -2
- package/src/worker/prompt.ts +10 -1
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { Box, Text, useInput, useStdout } from "ink";
|
|
2
2
|
import { memo, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
detailPaneBorderProps,
|
|
5
|
+
type FocusState,
|
|
6
|
+
handleListDetailKey,
|
|
7
|
+
} from "../listDetailKeys.ts";
|
|
3
8
|
import { ansi, theme } from "../theme.ts";
|
|
9
|
+
import { useLatestRef } from "../useLatestRef.ts";
|
|
10
|
+
import { Scrollbar } from "./Scrollbar.tsx";
|
|
4
11
|
import { resolveToolDisplay, type ToolCallData } from "./ToolCall.tsx";
|
|
5
12
|
|
|
6
13
|
interface ToolPanelProps {
|
|
@@ -68,26 +75,9 @@ function colorizeValue(value: unknown, indent: number): string {
|
|
|
68
75
|
function buildDetailAnsi(tool: ToolCallData): string {
|
|
69
76
|
const lines: string[] = [];
|
|
70
77
|
|
|
71
|
-
const
|
|
72
|
-
hour: "2-digit",
|
|
73
|
-
minute: "2-digit",
|
|
74
|
-
second: "2-digit",
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
const { displayName, displayInput } = resolveToolDisplay(
|
|
78
|
-
tool.name,
|
|
79
|
-
tool.input,
|
|
80
|
-
);
|
|
81
|
-
lines.push(`${ansi.bold}${ansi.info}${displayName}${ansi.reset}`);
|
|
82
|
-
if (tool.name === "mcp_exec") {
|
|
83
|
-
lines.push(`${ansi.dim}via mcp_exec${ansi.reset}`);
|
|
84
|
-
}
|
|
85
|
-
lines.push(`${ansi.dim}Time: ${time}${ansi.reset}`);
|
|
86
|
-
if (tool.running) {
|
|
87
|
-
lines.push(`${ansi.accent}⟳ running${ansi.reset}`);
|
|
88
|
-
}
|
|
89
|
-
lines.push("");
|
|
78
|
+
const { displayInput } = resolveToolDisplay(tool.name, tool.input);
|
|
90
79
|
|
|
80
|
+
// Body only — name/server/status/time live in the panel header now.
|
|
91
81
|
lines.push(`${ansi.bold}${ansi.primary}Input${ansi.reset}`);
|
|
92
82
|
lines.push(colorizeJson(displayInput));
|
|
93
83
|
lines.push("");
|
|
@@ -123,6 +113,7 @@ export const ToolPanel = memo(function ToolPanel({
|
|
|
123
113
|
const termRows = stdout?.rows ?? 24;
|
|
124
114
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
125
115
|
const [detailScroll, setDetailScroll] = useState(0);
|
|
116
|
+
const [focus, setFocus] = useState<FocusState>("list");
|
|
126
117
|
|
|
127
118
|
// Reverse-chronological order (most recent first)
|
|
128
119
|
const reversedCalls = useMemo(() => [...toolCalls].reverse(), [toolCalls]);
|
|
@@ -163,57 +154,21 @@ export const ToolPanel = memo(function ToolPanel({
|
|
|
163
154
|
setDetailScroll(0);
|
|
164
155
|
}, [selectedIndex]);
|
|
165
156
|
|
|
157
|
+
const itemCountRef = useLatestRef(reversedCalls.length);
|
|
158
|
+
const maxDetailScrollRef = useLatestRef(maxDetailScroll);
|
|
159
|
+
const focusRef = useLatestRef(focus);
|
|
160
|
+
|
|
166
161
|
useInput(
|
|
167
162
|
(input, key) => {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
if (key.downArrow) {
|
|
178
|
-
if (key.shift) {
|
|
179
|
-
setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
|
|
180
|
-
} else {
|
|
181
|
-
setSelectedIndex((i) => Math.min(reversedCalls.length - 1, i + 1));
|
|
182
|
-
}
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// j/k vim-style for detail scrolling (single line)
|
|
187
|
-
if (input === "j") {
|
|
188
|
-
setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
if (input === "k") {
|
|
192
|
-
setDetailScroll((s) => Math.max(0, s - 1));
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// J/K for page scrolling (hold shift or caps)
|
|
197
|
-
if (input === "J") {
|
|
198
|
-
setDetailScroll((s) =>
|
|
199
|
-
Math.min(maxDetailScroll, s + PAGE_SCROLL_LINES),
|
|
200
|
-
);
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
if (input === "K") {
|
|
204
|
-
setDetailScroll((s) => Math.max(0, s - PAGE_SCROLL_LINES));
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// g/G for top/bottom
|
|
209
|
-
if (input === "g") {
|
|
210
|
-
setDetailScroll(0);
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
if (input === "G") {
|
|
214
|
-
setDetailScroll(maxDetailScroll);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
163
|
+
handleListDetailKey(input, key, {
|
|
164
|
+
focusRef,
|
|
165
|
+
setFocus,
|
|
166
|
+
itemCountRef,
|
|
167
|
+
maxDetailScrollRef,
|
|
168
|
+
setSelectedIndex,
|
|
169
|
+
setDetailScroll,
|
|
170
|
+
pageScrollLines: PAGE_SCROLL_LINES,
|
|
171
|
+
});
|
|
217
172
|
},
|
|
218
173
|
{ isActive },
|
|
219
174
|
);
|
|
@@ -316,27 +271,71 @@ export const ToolPanel = memo(function ToolPanel({
|
|
|
316
271
|
flexGrow={1}
|
|
317
272
|
height={visibleRows + 1}
|
|
318
273
|
paddingX={1}
|
|
274
|
+
{...detailPaneBorderProps(focus)}
|
|
319
275
|
overflow="hidden"
|
|
320
276
|
>
|
|
321
|
-
{
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
</Text>
|
|
277
|
+
{selectedTool && <ToolDetailHeader tool={selectedTool} />}
|
|
278
|
+
<Box flexDirection="row" flexGrow={1} overflow="hidden">
|
|
279
|
+
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
280
|
+
{detailVisible.map((line, i) => {
|
|
281
|
+
const lineNum = detailScroll + i;
|
|
282
|
+
return (
|
|
283
|
+
<Text key={lineNum} wrap="truncate-end">
|
|
284
|
+
{line || " "}
|
|
285
|
+
</Text>
|
|
286
|
+
);
|
|
287
|
+
})}
|
|
333
288
|
</Box>
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
289
|
+
<Scrollbar
|
|
290
|
+
total={detailLines.length}
|
|
291
|
+
visible={visibleRows - 3}
|
|
292
|
+
offset={detailScroll}
|
|
293
|
+
height={visibleRows - 3}
|
|
294
|
+
focused={focus === "detail"}
|
|
295
|
+
/>
|
|
296
|
+
</Box>
|
|
297
|
+
<Text dimColor>
|
|
298
|
+
{focus === "detail"
|
|
299
|
+
? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
|
|
300
|
+
: "↑↓ select · → enter detail"}
|
|
301
|
+
</Text>
|
|
339
302
|
</Box>
|
|
340
303
|
</Box>
|
|
341
304
|
);
|
|
342
305
|
});
|
|
306
|
+
|
|
307
|
+
function ToolDetailHeader({ tool }: { tool: ToolCallData }) {
|
|
308
|
+
const { displayName } = resolveToolDisplay(tool.name, tool.input);
|
|
309
|
+
const time = tool.timestamp.toLocaleTimeString([], {
|
|
310
|
+
hour: "2-digit",
|
|
311
|
+
minute: "2-digit",
|
|
312
|
+
second: "2-digit",
|
|
313
|
+
});
|
|
314
|
+
const isMcp = tool.name === "mcp_exec";
|
|
315
|
+
const status = tool.running
|
|
316
|
+
? { color: theme.accent, label: "⟳ running" }
|
|
317
|
+
: tool.isError
|
|
318
|
+
? { color: theme.error, label: "✘ error" }
|
|
319
|
+
: tool.output
|
|
320
|
+
? { color: theme.success, label: "✔ done" }
|
|
321
|
+
: { color: theme.muted, label: "— no output" };
|
|
322
|
+
return (
|
|
323
|
+
<Box flexDirection="column">
|
|
324
|
+
<Box>
|
|
325
|
+
<Text bold color={theme.info} wrap="truncate-end">
|
|
326
|
+
{displayName}
|
|
327
|
+
</Text>
|
|
328
|
+
</Box>
|
|
329
|
+
<Box>
|
|
330
|
+
<Text wrap="truncate-end">
|
|
331
|
+
<Text dimColor>{isMcp ? "mcp_exec · " : ""}</Text>
|
|
332
|
+
<Text color={status.color}>{status.label}</Text>
|
|
333
|
+
<Text dimColor> · {time}</Text>
|
|
334
|
+
</Text>
|
|
335
|
+
</Box>
|
|
336
|
+
<Box>
|
|
337
|
+
<Text dimColor>{"─".repeat(2)}</Text>
|
|
338
|
+
</Box>
|
|
339
|
+
</Box>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
@@ -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
|
)}
|
|
@@ -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
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import matter from "gray-matter";
|
|
2
2
|
|
|
3
3
|
export interface ContextFileMeta {
|
|
4
|
-
loading
|
|
5
|
-
"agent-modification"
|
|
4
|
+
loading?: "always" | "contextual";
|
|
5
|
+
"agent-modification"?: boolean;
|
|
6
|
+
// Set by `bothy context import <url>` so the saved file remembers
|
|
7
|
+
// where it came from. Optional so files written by other paths
|
|
8
|
+
// (prompts/, beliefs/, agent-authored notes) aren't required to
|
|
9
|
+
// carry import metadata.
|
|
10
|
+
source_url?: string;
|
|
11
|
+
imported_at?: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
[key: string]: unknown;
|
|
6
14
|
}
|
|
7
15
|
|
|
8
16
|
export function parseContextFile(raw: string): {
|
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"}
|