botholomew 0.7.0 → 0.7.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/src/db/context.ts CHANGED
@@ -428,6 +428,17 @@ export async function deleteContextItemByPath(
428
428
  return deleteContextItem(db, item.id);
429
429
  }
430
430
 
431
+ export async function deleteAllContextItems(
432
+ db: DbConnection,
433
+ ): Promise<{ contextItems: number; embeddings: number }> {
434
+ const embeddings = await db.queryRun("DELETE FROM embeddings");
435
+ const contextItems = await db.queryRun("DELETE FROM context_items");
436
+ return {
437
+ contextItems: contextItems.changes,
438
+ embeddings: embeddings.changes,
439
+ };
440
+ }
441
+
431
442
  export async function deleteContextItemsByPrefix(
432
443
  db: DbConnection,
433
444
  prefix: string,
@@ -0,0 +1,6 @@
1
+ import type { DbConnection } from "./connection.ts";
2
+
3
+ export async function deleteAllDaemonState(db: DbConnection): Promise<number> {
4
+ const result = await db.queryRun("DELETE FROM daemon_state");
5
+ return result.changes;
6
+ }
@@ -127,6 +127,11 @@ export async function deleteSchedule(
127
127
  return result.changes > 0;
128
128
  }
129
129
 
130
+ export async function deleteAllSchedules(db: DbConnection): Promise<number> {
131
+ const result = await db.queryRun("DELETE FROM schedules");
132
+ return result.changes;
133
+ }
134
+
130
135
  export async function markScheduleRun(
131
136
  db: DbConnection,
132
137
  id: string,
package/src/db/tasks.ts CHANGED
@@ -229,6 +229,11 @@ export async function deleteTask(
229
229
  return result.changes > 0;
230
230
  }
231
231
 
232
+ export async function deleteAllTasks(db: DbConnection): Promise<number> {
233
+ const result = await db.queryRun("DELETE FROM tasks");
234
+ return result.changes;
235
+ }
236
+
232
237
  export async function resetTask(
233
238
  db: DbConnection,
234
239
  id: string,
package/src/db/threads.ts CHANGED
@@ -205,6 +205,17 @@ export async function deleteThread(
205
205
  return result.changes > 0;
206
206
  }
207
207
 
208
+ export async function deleteAllThreads(
209
+ db: DbConnection,
210
+ ): Promise<{ threads: number; interactions: number }> {
211
+ const interactions = await db.queryRun("DELETE FROM interactions");
212
+ const threads = await db.queryRun("DELETE FROM threads");
213
+ return {
214
+ threads: threads.changes,
215
+ interactions: interactions.changes,
216
+ };
217
+ }
218
+
208
219
  export async function getInteractionsAfter(
209
220
  db: DbConnection,
210
221
  threadId: string,
@@ -1,6 +1,17 @@
1
1
  import type { SkillDefinition } from "./parser.ts";
2
2
  import { renderSkill } from "./parser.ts";
3
3
 
4
+ export interface SlashCommand {
5
+ name: string;
6
+ description: string;
7
+ }
8
+
9
+ export const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
10
+ { name: "help", description: "Show command reference and shortcuts" },
11
+ { name: "skills", description: "List available skills" },
12
+ { name: "exit", description: "End the chat session" },
13
+ ];
14
+
4
15
  export interface SlashCommandContext {
5
16
  skills: Map<string, SkillDefinition>;
6
17
  addSystemMessage: (content: string) => void;
@@ -22,7 +33,7 @@ export function handleSlashCommand(
22
33
  const name = commandPart.slice(1).toLowerCase(); // remove leading /
23
34
 
24
35
  // Built-in commands
25
- if (name === "quit" || name === "exit") {
36
+ if (name === "exit") {
26
37
  ctx.exit();
27
38
  return true;
28
39
  }
package/src/tui/App.tsx CHANGED
@@ -9,7 +9,11 @@ import {
9
9
  import { MAX_INLINE_CHARS, PAGE_SIZE_CHARS } from "../daemon/large-results.ts";
10
10
  import type { Interaction } from "../db/threads.ts";
11
11
  import { getThread } from "../db/threads.ts";
12
- import { handleSlashCommand } from "../skills/commands.ts";
12
+ import {
13
+ BUILTIN_SLASH_COMMANDS,
14
+ handleSlashCommand,
15
+ type SlashCommand,
16
+ } from "../skills/commands.ts";
13
17
  import { ContextPanel } from "./components/ContextPanel.tsx";
14
18
  import { HelpPanel } from "./components/HelpPanel.tsx";
15
19
  import { InputBar } from "./components/InputBar.tsx";
@@ -27,6 +31,7 @@ import { TaskPanel } from "./components/TaskPanel.tsx";
27
31
  import { ThreadPanel } from "./components/ThreadPanel.tsx";
28
32
  import type { ToolCallData } from "./components/ToolCall.tsx";
29
33
  import { ToolPanel } from "./components/ToolPanel.tsx";
34
+ import { buildSlashCommands, getSlashMatches } from "./slashCompletion.ts";
30
35
  import { ansi } from "./theme.ts";
31
36
 
32
37
  interface AppProps {
@@ -210,10 +215,8 @@ export function App({
210
215
  queuedMessagesRef.current = queuedMessages;
211
216
  selectedQueueIndexRef.current = selectedQueueIndex;
212
217
 
213
- const tabConsumedRef = useRef(false);
214
- const handleTabConsumed = useCallback(() => {
215
- tabConsumedRef.current = true;
216
- }, []);
218
+ const slashCommandsRef = useRef<SlashCommand[]>([]);
219
+ const inputValueRef = useRef("");
217
220
 
218
221
  const stableAppHandler = useCallback(
219
222
  // biome-ignore lint/suspicious/noExplicitAny: Ink's Key type is not exported
@@ -224,11 +227,15 @@ export function App({
224
227
  return;
225
228
  }
226
229
 
227
- // Tab key cycles tabs — unless InputBar consumed it for completion
230
+ // Tab key cycles tabs — but on the Chat tab, let InputBar consume it
231
+ // whenever the slash autocomplete popup would be open.
228
232
  if (key.tab && !key.shift) {
229
- if (tabConsumedRef.current) {
230
- tabConsumedRef.current = false;
231
- return;
233
+ if (activeTabRef.current === 1) {
234
+ const popupOpen = getSlashMatches(
235
+ inputValueRef.current,
236
+ slashCommandsRef.current,
237
+ );
238
+ if (popupOpen) return;
232
239
  }
233
240
  setActiveTab((t) => ((t % 7) + 1) as TabId);
234
241
  return;
@@ -454,6 +461,10 @@ export function App({
454
461
  " Enter Send message",
455
462
  " ⌥+Enter Insert newline",
456
463
  " ↑/↓ Browse input history",
464
+ " / Open slash-command autocomplete",
465
+ " Tab/Enter Accept highlighted command (popup open)",
466
+ " ↑/↓ Move highlight (popup open)",
467
+ " Esc Close popup",
457
468
  "",
458
469
  "Tools (Tab 2):",
459
470
  " ↑/↓ Select tool call",
@@ -495,7 +506,7 @@ export function App({
495
506
  "Commands:",
496
507
  " /help Show this help",
497
508
  " /skills List available skills",
498
- " /quit, /exit End the chat session",
509
+ " /exit End the chat session",
499
510
  ...skillLines,
500
511
  ].join("\n"),
501
512
  timestamp: new Date(),
@@ -551,14 +562,19 @@ export function App({
551
562
  );
552
563
 
553
564
  const sessionSkills = ready ? sessionRef.current?.skills : undefined;
554
- const skillCompletions = useMemo(() => {
555
- const builtins = ["/help", "/quit", "/exit", "/skills"];
556
- const skillNames = Array.from(sessionSkills?.keys() ?? []).map(
557
- (name) => `/${name}`,
558
- );
559
- return [...builtins, ...skillNames];
565
+ const slashCommands = useMemo<SlashCommand[]>(() => {
566
+ const skillList = sessionSkills
567
+ ? Array.from(sessionSkills.values()).map((s) => ({
568
+ name: s.name,
569
+ description: s.description,
570
+ }))
571
+ : [];
572
+ return buildSlashCommands(BUILTIN_SLASH_COMMANDS, skillList);
560
573
  }, [sessionSkills]);
561
574
 
575
+ slashCommandsRef.current = slashCommands;
576
+ inputValueRef.current = inputValue;
577
+
562
578
  const allToolCalls = useMemo(
563
579
  () => messages.flatMap((m) => m.toolCalls ?? []),
564
580
  [messages],
@@ -681,8 +697,7 @@ export function App({
681
697
  disabled={activeTab !== 1}
682
698
  history={inputHistory}
683
699
  header={inputBarHeader}
684
- completions={skillCompletions}
685
- onTabConsumed={handleTabConsumed}
700
+ slashCommands={slashCommands}
686
701
  />
687
702
  <TabBar activeTab={activeTab} />
688
703
  </Box>
@@ -136,7 +136,7 @@ export const HelpPanel = memo(function HelpPanel({
136
136
  {" "}/help{" "}Show help in chat
137
137
  </Text>
138
138
  <Text>
139
- {" "}/quit, /exit{" "}End the chat session
139
+ {" "}/exit{" "}End the chat session
140
140
  </Text>
141
141
  </Box>
142
142
 
@@ -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
- completions?: string[];
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
- completions,
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 completionsRef = useRef(completions);
47
- const onTabConsumedRef = useRef(onTabConsumed);
48
- const tabCycleRef = useRef(-1);
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
- completionsRef.current = completions;
57
- onTabConsumedRef.current = onTabConsumed;
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
- // Enter: submit (shift+enter or opt+enter inserts newline)
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
- // History navigation
142
- if (key.upArrow && hist.length > 0) {
143
- const nextIndex = hIdx + 1;
144
- if (nextIndex < hist.length) {
145
- if (hIdx === -1) {
146
- savedInput.current = val;
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
- return;
159
- }
160
-
161
- if (key.downArrow && hist.length > 0) {
162
- if (hIdx > 0) {
163
- const nextIndex = hIdx - 1;
164
- historyIndexRef.current = nextIndex;
165
- setHistoryIndex(nextIndex);
166
- const entry = hist[hist.length - 1 - nextIndex];
167
- if (entry !== undefined) {
168
- valueRef.current = entry;
169
- cursorPosRef.current = entry.length;
170
- onChangeRef.current(entry);
171
- setCursorPos(entry.length);
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
- // Tab-completion for slash commands
186
- if (key.tab) {
187
- const comps = completionsRef.current;
188
- if (val.startsWith("/") && comps && comps.length > 0) {
189
- const matches = comps.filter((c) => c.startsWith(val));
190
- if (matches.length === 1) {
191
- const completed = `${matches[0] ?? ""} `;
192
- valueRef.current = completed;
193
- cursorPosRef.current = completed.length;
194
- onChangeRef.current(completed);
195
- setCursorPos(completed.length);
196
- tabCycleRef.current = -1;
197
- } else if (matches.length > 1) {
198
- const idx = (tabCycleRef.current + 1) % matches.length;
199
- tabCycleRef.current = idx;
200
- const completed = matches[idx] ?? "";
201
- valueRef.current = completed;
202
- cursorPosRef.current = completed.length;
203
- onChangeRef.current(completed);
204
- setCursorPos(completed.length);
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 || key.escape) {
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
- flexDirection="column"
246
- borderStyle="single"
247
- borderColor={disabled ? "gray" : "green"}
248
- paddingX={1}
249
- >
250
- {header}
251
- {!disabled && (
252
- <Box flexDirection="column">
253
- <Box>
254
- <Text color="green">{"› "}</Text>
255
- {placeholder ? (
256
- <Text dimColor>Type a message...</Text>
257
- ) : (
258
- <Text>
259
- {value.slice(0, cursorPos)}
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 dimColor> alt+return for newline, return to send</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
- </Box>
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
+ }