botholomew 0.6.3 → 0.7.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 +218 -1
- package/package.json +1 -1
- package/src/commands/chat.ts +1 -1
- package/src/commands/context.ts +227 -66
- package/src/context/chunker.ts +98 -1
- package/src/context/fetcher.ts +436 -0
- package/src/context/url-utils.ts +48 -0
- package/src/db/context.ts +8 -2
- package/src/db/sql/9-source-type.sql +1 -0
- package/src/skills/commands.ts +12 -1
- package/src/tui/App.tsx +33 -18
- package/src/tui/components/HelpPanel.tsx +1 -1
- package/src/tui/components/InputBar.tsx +176 -99
- package/src/tui/components/SlashCommandPopup.tsx +50 -0
- package/src/tui/slashCompletion.ts +38 -0
|
@@ -4,9 +4,13 @@ import {
|
|
|
4
4
|
type ReactNode,
|
|
5
5
|
useCallback,
|
|
6
6
|
useEffect,
|
|
7
|
+
useMemo,
|
|
7
8
|
useRef,
|
|
8
9
|
useState,
|
|
9
10
|
} from "react";
|
|
11
|
+
import type { SlashCommand } from "../../skills/commands.ts";
|
|
12
|
+
import { getSlashMatches } from "../slashCompletion.ts";
|
|
13
|
+
import { SlashCommandPopup } from "./SlashCommandPopup.tsx";
|
|
10
14
|
|
|
11
15
|
interface InputBarProps {
|
|
12
16
|
value: string;
|
|
@@ -15,8 +19,7 @@ interface InputBarProps {
|
|
|
15
19
|
disabled: boolean;
|
|
16
20
|
history: string[];
|
|
17
21
|
header?: ReactNode;
|
|
18
|
-
|
|
19
|
-
onTabConsumed?: () => void;
|
|
22
|
+
slashCommands?: SlashCommand[];
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export const InputBar = memo(function InputBar({
|
|
@@ -26,12 +29,13 @@ export const InputBar = memo(function InputBar({
|
|
|
26
29
|
disabled,
|
|
27
30
|
history,
|
|
28
31
|
header,
|
|
29
|
-
|
|
30
|
-
onTabConsumed,
|
|
32
|
+
slashCommands,
|
|
31
33
|
}: InputBarProps) {
|
|
32
34
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
33
35
|
const [cursorPos, setCursorPos] = useState(0);
|
|
34
36
|
const [cursorVisible, setCursorVisible] = useState(true);
|
|
37
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
38
|
+
const [popupDismissed, setPopupDismissed] = useState(false);
|
|
35
39
|
const savedInput = useRef("");
|
|
36
40
|
const lastActivity = useRef(Date.now());
|
|
37
41
|
|
|
@@ -43,9 +47,9 @@ export const InputBar = memo(function InputBar({
|
|
|
43
47
|
const onChangeRef = useRef(onChange);
|
|
44
48
|
const onSubmitRef = useRef(onSubmit);
|
|
45
49
|
const historyRef = useRef(history);
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
50
|
+
const slashCommandsRef = useRef(slashCommands);
|
|
51
|
+
const selectedIndexRef = useRef(selectedIndex);
|
|
52
|
+
const popupDismissedRef = useRef(popupDismissed);
|
|
49
53
|
|
|
50
54
|
valueRef.current = value;
|
|
51
55
|
cursorPosRef.current = cursorPos;
|
|
@@ -53,8 +57,39 @@ export const InputBar = memo(function InputBar({
|
|
|
53
57
|
onChangeRef.current = onChange;
|
|
54
58
|
onSubmitRef.current = onSubmit;
|
|
55
59
|
historyRef.current = history;
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
slashCommandsRef.current = slashCommands;
|
|
61
|
+
selectedIndexRef.current = selectedIndex;
|
|
62
|
+
popupDismissedRef.current = popupDismissed;
|
|
63
|
+
|
|
64
|
+
// Matches visible in the autocomplete popup, or null when it should be
|
|
65
|
+
// hidden (non-slash input, space typed, no matches, or user escaped).
|
|
66
|
+
const popupMatches = useMemo(() => {
|
|
67
|
+
if (popupDismissed) return null;
|
|
68
|
+
return getSlashMatches(value, slashCommands ?? []);
|
|
69
|
+
}, [value, slashCommands, popupDismissed]);
|
|
70
|
+
|
|
71
|
+
// Reset highlight to top whenever the match list changes.
|
|
72
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset on match-list change, not value change
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
setSelectedIndex(0);
|
|
75
|
+
}, [popupMatches?.length]);
|
|
76
|
+
|
|
77
|
+
// Clamp highlight if the list shrank (defensive — the effect above usually handles it).
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (popupMatches && selectedIndex >= popupMatches.length) {
|
|
80
|
+
setSelectedIndex(Math.max(0, popupMatches.length - 1));
|
|
81
|
+
}
|
|
82
|
+
}, [popupMatches, selectedIndex]);
|
|
83
|
+
|
|
84
|
+
// Clear the dismissed flag as soon as the user edits the value,
|
|
85
|
+
// so a fresh "/" reopens the popup.
|
|
86
|
+
const prevValueRef = useRef(value);
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (prevValueRef.current !== value && popupDismissed) {
|
|
89
|
+
setPopupDismissed(false);
|
|
90
|
+
}
|
|
91
|
+
prevValueRef.current = value;
|
|
92
|
+
}, [value, popupDismissed]);
|
|
58
93
|
|
|
59
94
|
// Blink cursor when input is active — skip ticks while typing so the
|
|
60
95
|
// cursor stays solid and we avoid unnecessary renders during rapid input.
|
|
@@ -87,8 +122,43 @@ export const InputBar = memo(function InputBar({
|
|
|
87
122
|
const hIdx = historyIndexRef.current;
|
|
88
123
|
const hist = historyRef.current;
|
|
89
124
|
|
|
90
|
-
//
|
|
125
|
+
// Is the slash popup visible right now? Recompute from the authoritative
|
|
126
|
+
// ref-state so we don't depend on stale closure values.
|
|
127
|
+
const popupOpen = !popupDismissedRef.current
|
|
128
|
+
? getSlashMatches(val, slashCommandsRef.current ?? [])
|
|
129
|
+
: null;
|
|
130
|
+
|
|
131
|
+
const acceptSelection = () => {
|
|
132
|
+
if (!popupOpen) return false;
|
|
133
|
+
const chosen =
|
|
134
|
+
popupOpen[Math.min(selectedIndexRef.current, popupOpen.length - 1)];
|
|
135
|
+
if (!chosen) return false;
|
|
136
|
+
const completed = `/${chosen.name} `;
|
|
137
|
+
valueRef.current = completed;
|
|
138
|
+
cursorPosRef.current = completed.length;
|
|
139
|
+
onChangeRef.current(completed);
|
|
140
|
+
setCursorPos(completed.length);
|
|
141
|
+
// A trailing space makes the popup disappear naturally via regex,
|
|
142
|
+
// but set dismissed too so stray state can't re-open it.
|
|
143
|
+
setPopupDismissed(true);
|
|
144
|
+
return true;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Escape: close popup if open, keep value untouched
|
|
148
|
+
if (key.escape) {
|
|
149
|
+
if (popupOpen) {
|
|
150
|
+
setPopupDismissed(true);
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Enter: if popup is open, accept selection (do not submit).
|
|
156
|
+
// Otherwise submit as before.
|
|
91
157
|
if (key.return) {
|
|
158
|
+
if (popupOpen && !key.shift && !key.meta) {
|
|
159
|
+
acceptSelection();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
92
162
|
if (key.shift || key.meta) {
|
|
93
163
|
const before = val.slice(0, pos);
|
|
94
164
|
const after = val.slice(pos);
|
|
@@ -109,6 +179,14 @@ export const InputBar = memo(function InputBar({
|
|
|
109
179
|
return;
|
|
110
180
|
}
|
|
111
181
|
|
|
182
|
+
// Tab: accept popup selection if open. No-op otherwise.
|
|
183
|
+
if (key.tab) {
|
|
184
|
+
if (popupOpen) {
|
|
185
|
+
acceptSelection();
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
112
190
|
// Backspace
|
|
113
191
|
if (key.backspace || key.delete) {
|
|
114
192
|
if (pos > 0) {
|
|
@@ -138,81 +216,71 @@ export const InputBar = memo(function InputBar({
|
|
|
138
216
|
return;
|
|
139
217
|
}
|
|
140
218
|
|
|
141
|
-
//
|
|
142
|
-
if (key.upArrow
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
historyIndexRef.current = nextIndex;
|
|
149
|
-
setHistoryIndex(nextIndex);
|
|
150
|
-
const entry = hist[hist.length - 1 - nextIndex];
|
|
151
|
-
if (entry !== undefined) {
|
|
152
|
-
valueRef.current = entry;
|
|
153
|
-
cursorPosRef.current = entry.length;
|
|
154
|
-
onChangeRef.current(entry);
|
|
155
|
-
setCursorPos(entry.length);
|
|
156
|
-
}
|
|
219
|
+
// Up/Down: popup navigation when open, history otherwise
|
|
220
|
+
if (key.upArrow) {
|
|
221
|
+
if (popupOpen) {
|
|
222
|
+
const next = Math.max(0, selectedIndexRef.current - 1);
|
|
223
|
+
selectedIndexRef.current = next;
|
|
224
|
+
setSelectedIndex(next);
|
|
225
|
+
return;
|
|
157
226
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
227
|
+
if (hist.length > 0) {
|
|
228
|
+
const nextIndex = hIdx + 1;
|
|
229
|
+
if (nextIndex < hist.length) {
|
|
230
|
+
if (hIdx === -1) {
|
|
231
|
+
savedInput.current = val;
|
|
232
|
+
}
|
|
233
|
+
historyIndexRef.current = nextIndex;
|
|
234
|
+
setHistoryIndex(nextIndex);
|
|
235
|
+
const entry = hist[hist.length - 1 - nextIndex];
|
|
236
|
+
if (entry !== undefined) {
|
|
237
|
+
valueRef.current = entry;
|
|
238
|
+
cursorPosRef.current = entry.length;
|
|
239
|
+
onChangeRef.current(entry);
|
|
240
|
+
setCursorPos(entry.length);
|
|
241
|
+
}
|
|
172
242
|
}
|
|
173
|
-
} else if (hIdx === 0) {
|
|
174
|
-
historyIndexRef.current = -1;
|
|
175
|
-
setHistoryIndex(-1);
|
|
176
|
-
const saved = savedInput.current;
|
|
177
|
-
valueRef.current = saved;
|
|
178
|
-
cursorPosRef.current = saved.length;
|
|
179
|
-
onChangeRef.current(saved);
|
|
180
|
-
setCursorPos(saved.length);
|
|
181
243
|
}
|
|
182
244
|
return;
|
|
183
245
|
}
|
|
184
246
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
247
|
+
if (key.downArrow) {
|
|
248
|
+
if (popupOpen) {
|
|
249
|
+
const next = Math.min(
|
|
250
|
+
popupOpen.length - 1,
|
|
251
|
+
selectedIndexRef.current + 1,
|
|
252
|
+
);
|
|
253
|
+
selectedIndexRef.current = next;
|
|
254
|
+
setSelectedIndex(next);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (hist.length > 0) {
|
|
258
|
+
if (hIdx > 0) {
|
|
259
|
+
const nextIndex = hIdx - 1;
|
|
260
|
+
historyIndexRef.current = nextIndex;
|
|
261
|
+
setHistoryIndex(nextIndex);
|
|
262
|
+
const entry = hist[hist.length - 1 - nextIndex];
|
|
263
|
+
if (entry !== undefined) {
|
|
264
|
+
valueRef.current = entry;
|
|
265
|
+
cursorPosRef.current = entry.length;
|
|
266
|
+
onChangeRef.current(entry);
|
|
267
|
+
setCursorPos(entry.length);
|
|
268
|
+
}
|
|
269
|
+
} else if (hIdx === 0) {
|
|
270
|
+
historyIndexRef.current = -1;
|
|
271
|
+
setHistoryIndex(-1);
|
|
272
|
+
const saved = savedInput.current;
|
|
273
|
+
valueRef.current = saved;
|
|
274
|
+
cursorPosRef.current = saved.length;
|
|
275
|
+
onChangeRef.current(saved);
|
|
276
|
+
setCursorPos(saved.length);
|
|
205
277
|
}
|
|
206
|
-
onTabConsumedRef.current?.();
|
|
207
278
|
}
|
|
208
279
|
return;
|
|
209
280
|
}
|
|
210
281
|
|
|
211
|
-
// Reset tab cycle on any non-tab key
|
|
212
|
-
tabCycleRef.current = -1;
|
|
213
|
-
|
|
214
282
|
// Ignore other control keys
|
|
215
|
-
if (key.ctrl
|
|
283
|
+
if (key.ctrl) {
|
|
216
284
|
return;
|
|
217
285
|
}
|
|
218
286
|
|
|
@@ -239,36 +307,45 @@ export const InputBar = memo(function InputBar({
|
|
|
239
307
|
|
|
240
308
|
const isMultiline = value.includes("\n");
|
|
241
309
|
const placeholder = !value && !disabled;
|
|
310
|
+
const showPopup = !disabled && popupMatches !== null;
|
|
242
311
|
|
|
243
312
|
return (
|
|
244
|
-
<Box
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
<Text inverse={cursorVisible}>{value[cursorPos] ?? " "}</Text>
|
|
261
|
-
{value.slice(cursorPos + 1)}
|
|
262
|
-
</Text>
|
|
263
|
-
)}
|
|
264
|
-
</Box>
|
|
265
|
-
{isMultiline && (
|
|
313
|
+
<Box flexDirection="column">
|
|
314
|
+
{showPopup && popupMatches && (
|
|
315
|
+
<SlashCommandPopup
|
|
316
|
+
matches={popupMatches}
|
|
317
|
+
selectedIndex={selectedIndex}
|
|
318
|
+
/>
|
|
319
|
+
)}
|
|
320
|
+
<Box
|
|
321
|
+
flexDirection="column"
|
|
322
|
+
borderStyle="single"
|
|
323
|
+
borderColor={disabled ? "gray" : "green"}
|
|
324
|
+
paddingX={1}
|
|
325
|
+
>
|
|
326
|
+
{header}
|
|
327
|
+
{!disabled && (
|
|
328
|
+
<Box flexDirection="column">
|
|
266
329
|
<Box>
|
|
267
|
-
<Text
|
|
330
|
+
<Text color="green">{"› "}</Text>
|
|
331
|
+
{placeholder ? (
|
|
332
|
+
<Text dimColor>Type a message...</Text>
|
|
333
|
+
) : (
|
|
334
|
+
<Text>
|
|
335
|
+
{value.slice(0, cursorPos)}
|
|
336
|
+
<Text inverse={cursorVisible}>{value[cursorPos] ?? " "}</Text>
|
|
337
|
+
{value.slice(cursorPos + 1)}
|
|
338
|
+
</Text>
|
|
339
|
+
)}
|
|
268
340
|
</Box>
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
341
|
+
{isMultiline && (
|
|
342
|
+
<Box>
|
|
343
|
+
<Text dimColor> alt+return for newline, return to send</Text>
|
|
344
|
+
</Box>
|
|
345
|
+
)}
|
|
346
|
+
</Box>
|
|
347
|
+
)}
|
|
348
|
+
</Box>
|
|
272
349
|
</Box>
|
|
273
350
|
);
|
|
274
351
|
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { memo } from "react";
|
|
3
|
+
import type { SlashCommand } from "../../skills/commands.ts";
|
|
4
|
+
|
|
5
|
+
interface SlashCommandPopupProps {
|
|
6
|
+
matches: SlashCommand[];
|
|
7
|
+
selectedIndex: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const SlashCommandPopup = memo(function SlashCommandPopup({
|
|
11
|
+
matches,
|
|
12
|
+
selectedIndex,
|
|
13
|
+
}: SlashCommandPopupProps) {
|
|
14
|
+
if (matches.length === 0) return null;
|
|
15
|
+
|
|
16
|
+
const nameWidth = matches.reduce(
|
|
17
|
+
(max, c) => Math.max(max, c.name.length + 1),
|
|
18
|
+
0,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Box
|
|
23
|
+
flexDirection="column"
|
|
24
|
+
borderStyle="round"
|
|
25
|
+
borderColor="gray"
|
|
26
|
+
paddingX={1}
|
|
27
|
+
>
|
|
28
|
+
{matches.map((cmd, i) => {
|
|
29
|
+
const active = i === selectedIndex;
|
|
30
|
+
const marker = active ? "›" : " ";
|
|
31
|
+
const padded = `/${cmd.name}`.padEnd(nameWidth + 1);
|
|
32
|
+
return (
|
|
33
|
+
<Box key={cmd.name}>
|
|
34
|
+
<Text color={active ? "green" : undefined} bold={active}>
|
|
35
|
+
{marker} {padded}
|
|
36
|
+
</Text>
|
|
37
|
+
<Text dimColor={!active}>
|
|
38
|
+
{cmd.description || "(no description)"}
|
|
39
|
+
</Text>
|
|
40
|
+
</Box>
|
|
41
|
+
);
|
|
42
|
+
})}
|
|
43
|
+
<Box marginTop={0}>
|
|
44
|
+
<Text dimColor>
|
|
45
|
+
↑↓ to navigate · tab/return to accept · esc to close
|
|
46
|
+
</Text>
|
|
47
|
+
</Box>
|
|
48
|
+
</Box>
|
|
49
|
+
);
|
|
50
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { SlashCommand } from "../skills/commands.ts";
|
|
2
|
+
|
|
3
|
+
export const MAX_VISIBLE_COMPLETIONS = 8;
|
|
4
|
+
|
|
5
|
+
const SLASH_QUERY = /^\/([\w-]*)$/;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Given the current input value, return the list of slash commands that
|
|
9
|
+
* should appear in the autocomplete popup. Returns `null` when the popup
|
|
10
|
+
* should not be shown at all (e.g. the input isn't a slash query, or the
|
|
11
|
+
* user has already typed a space to start writing arguments).
|
|
12
|
+
*/
|
|
13
|
+
export function getSlashMatches(
|
|
14
|
+
value: string,
|
|
15
|
+
commands: SlashCommand[],
|
|
16
|
+
): SlashCommand[] | null {
|
|
17
|
+
const match = SLASH_QUERY.exec(value);
|
|
18
|
+
if (!match) return null;
|
|
19
|
+
|
|
20
|
+
const query = (match[1] ?? "").toLowerCase();
|
|
21
|
+
const filtered = commands.filter((c) =>
|
|
22
|
+
c.name.toLowerCase().startsWith(query),
|
|
23
|
+
);
|
|
24
|
+
if (filtered.length === 0) return null;
|
|
25
|
+
|
|
26
|
+
return filtered.slice(0, MAX_VISIBLE_COMPLETIONS);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildSlashCommands(
|
|
30
|
+
builtins: SlashCommand[],
|
|
31
|
+
skills: Iterable<{ name: string; description: string }>,
|
|
32
|
+
): SlashCommand[] {
|
|
33
|
+
const out: SlashCommand[] = [...builtins];
|
|
34
|
+
for (const s of skills) {
|
|
35
|
+
out.push({ name: s.name, description: s.description });
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|