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.
@@ -0,0 +1,436 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import type {
3
+ Tool as AnthropicTool,
4
+ MessageParam,
5
+ ToolResultBlockParam,
6
+ ToolUseBlock,
7
+ } from "@anthropic-ai/sdk/resources/messages";
8
+ import type { McpxClient } from "@evantahler/mcpx";
9
+ import type { BotholomewConfig } from "../config/schemas.ts";
10
+ import type { DbConnection } from "../db/connection.ts";
11
+ import { mcpExecTool } from "../tools/mcp/exec.ts";
12
+ import { mcpInfoTool } from "../tools/mcp/info.ts";
13
+ import { mcpListToolsTool } from "../tools/mcp/list-tools.ts";
14
+ import { mcpSearchTool } from "../tools/mcp/search.ts";
15
+ import type { ToolContext } from "../tools/tool.ts";
16
+ import { type AnyToolDefinition, toAnthropicTool } from "../tools/tool.ts";
17
+ import { logger } from "../utils/logger.ts";
18
+ import { stripHtmlTags } from "./url-utils.ts";
19
+
20
+ const MAX_CONTENT_BYTES = 500_000;
21
+ const MAX_TURNS = 10;
22
+ const MAX_RESPONSE_TOKENS = 4_096;
23
+ const PREVIEW_CHARS = 2_000;
24
+ const HTTP_TIMEOUT_MS = 30_000;
25
+
26
+ export interface FetchedContent {
27
+ title: string;
28
+ content: string;
29
+ mimeType: string;
30
+ sourceUrl: string;
31
+ }
32
+
33
+ export class FetchFailureError extends Error {
34
+ readonly userMessage: string;
35
+ constructor(message: string) {
36
+ super(message);
37
+ this.name = "FetchFailureError";
38
+ this.userMessage = message;
39
+ }
40
+ }
41
+
42
+ const FETCHER_SYSTEM_PROMPT = `You are a content fetcher. Your job is to find the right MCP tool to retrieve the content at the given URL, run it, and tell the harness which result to save.
43
+
44
+ **Important: the harness captures the full result of every mcp_exec call automatically.** You only see a short preview of each result so you can verify it looks reasonable. You do NOT need to read or copy the full content — you just identify which exec call to save.
45
+
46
+ Strongly prefer markdown output. Most MCP tools support a markdown/format parameter — use it when available.
47
+
48
+ Workflow:
49
+ 1. Use mcp_search or mcp_list_tools to find the best tool for this URL (e.g., Google Docs tools for docs.google.com, Firecrawl for generic web pages, GitHub tools for github.com).
50
+ 2. Use mcp_info to inspect the tool's input schema.
51
+ 3. Call mcp_exec with the right arguments — request markdown format when supported.
52
+ 4. Look at the preview returned by mcp_exec. If it looks like the right content, call accept_content with the exec_call_id (the tool_use_id of the mcp_exec call) and a sensible title.
53
+
54
+ Terminal tools:
55
+ - accept_content(exec_call_id, title, mime_type?) — save the full content captured from a previous mcp_exec call. The harness has the full content; you just supply the id, title, and optional mime_type (defaults to text/markdown).
56
+ - request_http_fallback() — fall back to a basic HTTP fetch. Use only when no MCP tool can handle the URL after a genuine attempt. Tools like Firecrawl can handle most URLs, so don't give up on the first try.
57
+ - report_failure(message) — surface an actionable message to the user (e.g., "this Google Doc is private — share it with your service account", "Firecrawl is not authenticated"). Use only when there is a specific next step the user must take.`;
58
+
59
+ const acceptContentTool: AnthropicTool = {
60
+ name: "accept_content",
61
+ description:
62
+ "Save the full content captured by the harness from a previous mcp_exec call. You only need to supply the exec_call_id (the tool_use_id of that mcp_exec call) and a title — the harness already has the full content. Do NOT paste content here.",
63
+ input_schema: {
64
+ type: "object" as const,
65
+ properties: {
66
+ exec_call_id: {
67
+ type: "string",
68
+ description:
69
+ "The tool_use_id of the mcp_exec call whose result should be saved (the harness lists captured ids in mcp_exec previews).",
70
+ },
71
+ title: {
72
+ type: "string",
73
+ description:
74
+ "A human-readable title for the content (e.g., the document title, or derived from the URL).",
75
+ },
76
+ mime_type: {
77
+ type: "string",
78
+ description: "MIME type of the content (defaults to text/markdown).",
79
+ },
80
+ },
81
+ required: ["exec_call_id", "title"],
82
+ },
83
+ };
84
+
85
+ interface AcceptContentInput {
86
+ exec_call_id: string;
87
+ title: string;
88
+ mime_type?: string;
89
+ }
90
+
91
+ const requestHttpFallbackTool: AnthropicTool = {
92
+ name: "request_http_fallback",
93
+ description:
94
+ "Fall back to a basic HTTP fetch. Use only when no MCP tool can handle the URL after a genuine attempt.",
95
+ input_schema: {
96
+ type: "object" as const,
97
+ properties: {},
98
+ required: [],
99
+ },
100
+ };
101
+
102
+ const reportFailureTool: AnthropicTool = {
103
+ name: "report_failure",
104
+ description:
105
+ "Report a fetch failure with an actionable message for the user (e.g., 'this Google Doc is private — share it with your service account'). Use only when there is a clear next step the user must take.",
106
+ input_schema: {
107
+ type: "object" as const,
108
+ properties: {
109
+ message: {
110
+ type: "string",
111
+ description:
112
+ "A clear, actionable, user-facing message explaining what the user needs to do to make this URL fetchable.",
113
+ },
114
+ },
115
+ required: ["message"],
116
+ },
117
+ };
118
+
119
+ interface ReportFailureInput {
120
+ message: string;
121
+ }
122
+
123
+ const mcpTools: AnyToolDefinition[] = [
124
+ mcpListToolsTool as unknown as AnyToolDefinition,
125
+ mcpSearchTool as unknown as AnyToolDefinition,
126
+ mcpInfoTool as unknown as AnyToolDefinition,
127
+ mcpExecTool as unknown as AnyToolDefinition,
128
+ ];
129
+
130
+ export async function fetchUrl(
131
+ url: string,
132
+ config: Required<BotholomewConfig>,
133
+ mcpxClient: McpxClient | null,
134
+ promptAddition?: string,
135
+ ): Promise<FetchedContent> {
136
+ if (!config.anthropic_api_key) {
137
+ throw new Error(
138
+ "Anthropic API key is required for URL fetching. Set ANTHROPIC_API_KEY or configure it in .botholomew/config.json",
139
+ );
140
+ }
141
+
142
+ if (!mcpxClient) {
143
+ logger.dim(" no MCPX client — using HTTP fallback");
144
+ return httpFallback(url);
145
+ }
146
+
147
+ const result = await runFetcherLoop(url, config, mcpxClient, promptAddition);
148
+ if (result) return result;
149
+
150
+ logger.dim(" agent signaled fallback — using HTTP");
151
+ return httpFallback(url);
152
+ }
153
+
154
+ async function runFetcherLoop(
155
+ url: string,
156
+ config: Required<BotholomewConfig>,
157
+ mcpxClient: McpxClient,
158
+ promptAddition?: string,
159
+ ): Promise<FetchedContent | null> {
160
+ const client = new Anthropic({ apiKey: config.anthropic_api_key });
161
+
162
+ const toolCtx: ToolContext = {
163
+ conn: null as unknown as DbConnection,
164
+ projectDir: "",
165
+ config,
166
+ mcpxClient,
167
+ };
168
+
169
+ const tools: AnthropicTool[] = [
170
+ ...mcpTools.map(toAnthropicTool),
171
+ acceptContentTool,
172
+ requestHttpFallbackTool,
173
+ reportFailureTool,
174
+ ];
175
+
176
+ // Cache of full mcp_exec results keyed by tool_use_id.
177
+ // The LLM only sees a truncated preview; on accept_content it references
178
+ // the id and the harness saves the captured content.
179
+ const execResults = new Map<
180
+ string,
181
+ { server: string; tool: string; content: string; mimeType: string }
182
+ >();
183
+
184
+ const userPrompt = promptAddition
185
+ ? `Fetch the content at: ${url}\n\nAdditional guidance:\n${promptAddition}`
186
+ : `Fetch the content at: ${url}`;
187
+ const messages: MessageParam[] = [{ role: "user", content: userPrompt }];
188
+
189
+ for (let turn = 0; turn < MAX_TURNS; turn++) {
190
+ const response = await client.messages.create({
191
+ model: config.model,
192
+ max_tokens: MAX_RESPONSE_TOKENS,
193
+ system: FETCHER_SYSTEM_PROMPT,
194
+ messages,
195
+ tools,
196
+ });
197
+
198
+ // Log assistant text reasoning
199
+ for (const block of response.content) {
200
+ if (block.type === "text" && block.text.trim()) {
201
+ logger.dim(` turn ${turn + 1}: ${block.text.trim()}`);
202
+ }
203
+ }
204
+
205
+ if (response.stop_reason === "max_tokens") {
206
+ throw new FetchFailureError(
207
+ `The fetched document is too large to return in a single LLM response (hit max_tokens=${MAX_RESPONSE_TOKENS}). Try fetching a smaller section, a specific page, or a tool that supports pagination.`,
208
+ );
209
+ }
210
+
211
+ const toolUseBlocks = response.content.filter(
212
+ (block): block is ToolUseBlock => block.type === "tool_use",
213
+ );
214
+
215
+ if (toolUseBlocks.length === 0) {
216
+ logger.dim(` turn ${turn + 1}: no tool calls — signaling fallback`);
217
+ return null;
218
+ }
219
+
220
+ messages.push({ role: "assistant", content: response.content });
221
+
222
+ // Check for report_failure first (terminal — surfaces actionable user message)
223
+ const failureCall = toolUseBlocks.find((b) => b.name === "report_failure");
224
+ if (failureCall) {
225
+ const input = failureCall.input as Partial<ReportFailureInput>;
226
+ const message =
227
+ typeof input.message === "string" && input.message.trim()
228
+ ? input.message
229
+ : "Fetch failed but the agent did not provide a message.";
230
+ logger.dim(` turn ${turn + 1}: report_failure: ${message}`);
231
+ throw new FetchFailureError(message);
232
+ }
233
+
234
+ // Check for request_http_fallback (terminal)
235
+ const fallbackCall = toolUseBlocks.find(
236
+ (b) => b.name === "request_http_fallback",
237
+ );
238
+ if (fallbackCall) {
239
+ logger.dim(` turn ${turn + 1}: agent requested HTTP fallback`);
240
+ return null;
241
+ }
242
+
243
+ // Check for accept_content (terminal — looks up captured exec result)
244
+ const acceptCall = toolUseBlocks.find((b) => b.name === "accept_content");
245
+ if (acceptCall) {
246
+ const input = acceptCall.input as Partial<AcceptContentInput>;
247
+ if (
248
+ typeof input.exec_call_id !== "string" ||
249
+ typeof input.title !== "string"
250
+ ) {
251
+ logger.dim(
252
+ ` turn ${turn + 1}: accept_content missing required fields — asking agent to retry`,
253
+ );
254
+ messages.push({
255
+ role: "user",
256
+ content: [
257
+ {
258
+ type: "tool_result" as const,
259
+ tool_use_id: acceptCall.id,
260
+ content:
261
+ "Invalid accept_content call: both 'exec_call_id' and 'title' are required strings.",
262
+ is_error: true,
263
+ },
264
+ ],
265
+ });
266
+ continue;
267
+ }
268
+ const cached = execResults.get(input.exec_call_id);
269
+ if (!cached) {
270
+ const validIds = [...execResults.keys()];
271
+ logger.dim(
272
+ ` turn ${turn + 1}: accept_content: unknown exec_call_id "${input.exec_call_id}"`,
273
+ );
274
+ messages.push({
275
+ role: "user",
276
+ content: [
277
+ {
278
+ type: "tool_result" as const,
279
+ tool_use_id: acceptCall.id,
280
+ content: `No mcp_exec call with id "${input.exec_call_id}" was captured. Captured ids: ${validIds.length ? validIds.join(", ") : "(none yet — run mcp_exec first)"}.`,
281
+ is_error: true,
282
+ },
283
+ ],
284
+ });
285
+ continue;
286
+ }
287
+ const mimeType = input.mime_type || cached.mimeType;
288
+ logger.dim(
289
+ ` turn ${turn + 1}: accept_content: "${input.title}" (${cached.content.length} chars, ${mimeType}, from ${cached.server}/${cached.tool})`,
290
+ );
291
+ return {
292
+ title: input.title,
293
+ content: cached.content.slice(0, MAX_CONTENT_BYTES),
294
+ mimeType,
295
+ sourceUrl: url,
296
+ };
297
+ }
298
+
299
+ // Execute non-terminal MCP tools in parallel
300
+ const toolResults: ToolResultBlockParam[] = await Promise.all(
301
+ toolUseBlocks.map(async (toolUse) => {
302
+ // Log which tool the agent selected (and the underlying MCP server/tool for mcp_exec)
303
+ const toolInput = toolUse.input as Record<string, unknown>;
304
+ if (toolUse.name === "mcp_exec") {
305
+ logger.dim(
306
+ ` turn ${turn + 1}: mcp_exec → ${toolInput.server}/${toolInput.tool}`,
307
+ );
308
+ } else {
309
+ const args = JSON.stringify(toolInput).slice(0, 80);
310
+ logger.dim(` turn ${turn + 1}: ${toolUse.name}(${args})`);
311
+ }
312
+
313
+ const toolDef = mcpTools.find((t) => t.name === toolUse.name);
314
+ if (!toolDef) {
315
+ return {
316
+ type: "tool_result" as const,
317
+ tool_use_id: toolUse.id,
318
+ content: `Unknown tool: ${toolUse.name}`,
319
+ is_error: true,
320
+ };
321
+ }
322
+
323
+ try {
324
+ const parsed = toolDef.inputSchema.safeParse(toolUse.input);
325
+ if (!parsed.success) {
326
+ return {
327
+ type: "tool_result" as const,
328
+ tool_use_id: toolUse.id,
329
+ content: `Invalid input: ${parsed.error.message}`,
330
+ is_error: true,
331
+ };
332
+ }
333
+ const result = await toolDef.execute(parsed.data, toolCtx);
334
+ if (result.is_error) {
335
+ logger.dim(
336
+ ` → error: ${JSON.stringify(result).slice(0, 160)}`,
337
+ );
338
+ return {
339
+ type: "tool_result" as const,
340
+ tool_use_id: toolUse.id,
341
+ content: JSON.stringify(result),
342
+ is_error: true,
343
+ };
344
+ }
345
+
346
+ // For successful mcp_exec calls, capture the full content in the
347
+ // harness and send only a preview to the LLM. The LLM accepts the
348
+ // result by referring to its tool_use_id.
349
+ if (toolUse.name === "mcp_exec") {
350
+ const execResult = result as {
351
+ result: string;
352
+ is_error: boolean;
353
+ };
354
+ const content = execResult.result;
355
+ execResults.set(toolUse.id, {
356
+ server: String(toolInput.server),
357
+ tool: String(toolInput.tool),
358
+ content,
359
+ mimeType: "text/markdown",
360
+ });
361
+ const preview =
362
+ content.length > PREVIEW_CHARS
363
+ ? `${content.slice(0, PREVIEW_CHARS)}\n\n[... ${content.length - PREVIEW_CHARS} more chars truncated. Full content (${content.length} chars total) is captured by the harness with exec_call_id="${toolUse.id}". Call accept_content with this id to save it.]`
364
+ : `${content}\n\n[Full content (${content.length} chars) captured by the harness with exec_call_id="${toolUse.id}". Call accept_content with this id to save it.]`;
365
+ logger.dim(
366
+ ` → captured ${content.length} chars (id=${toolUse.id})`,
367
+ );
368
+ return {
369
+ type: "tool_result" as const,
370
+ tool_use_id: toolUse.id,
371
+ content: preview,
372
+ };
373
+ }
374
+
375
+ return {
376
+ type: "tool_result" as const,
377
+ tool_use_id: toolUse.id,
378
+ content: JSON.stringify(result),
379
+ };
380
+ } catch (err) {
381
+ logger.dim(` → exception: ${err}`);
382
+ return {
383
+ type: "tool_result" as const,
384
+ tool_use_id: toolUse.id,
385
+ content: `Error: ${err}`,
386
+ is_error: true,
387
+ };
388
+ }
389
+ }),
390
+ );
391
+
392
+ messages.push({ role: "user", content: toolResults });
393
+ }
394
+
395
+ logger.dim(` max turns (${MAX_TURNS}) exceeded — signaling fallback`);
396
+ return null;
397
+ }
398
+
399
+ export async function httpFallback(url: string): Promise<FetchedContent> {
400
+ const response = await fetch(url, {
401
+ headers: { "User-Agent": "Botholomew/1.0" },
402
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
403
+ });
404
+
405
+ if (!response.ok) {
406
+ throw new Error(`HTTP ${response.status} ${response.statusText}: ${url}`);
407
+ }
408
+
409
+ const contentType = response.headers.get("content-type") || "";
410
+ const isHtml = contentType.includes("text/html");
411
+ let text = await response.text();
412
+
413
+ let title = url;
414
+ if (isHtml) {
415
+ const titleMatch = text.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
416
+ if (titleMatch?.[1]) {
417
+ title = titleMatch[1].trim();
418
+ }
419
+ text = stripHtmlTags(text);
420
+ }
421
+
422
+ if (text.length > MAX_CONTENT_BYTES) {
423
+ text = text.slice(0, MAX_CONTENT_BYTES);
424
+ }
425
+
426
+ const mimeType = isHtml
427
+ ? "text/markdown"
428
+ : contentType.split(";")[0] || "text/plain";
429
+
430
+ return {
431
+ title,
432
+ content: text,
433
+ mimeType,
434
+ sourceUrl: url,
435
+ };
436
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Attempts to parse the input as a URL and returns true if the protocol is http or https.
3
+ */
4
+ export function isUrl(input: string): boolean {
5
+ try {
6
+ const url = new URL(input);
7
+ return url.protocol === "http:" || url.protocol === "https:";
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+
13
+ /**
14
+ * Derives a virtual context path from a URL.
15
+ * Example: `https://docs.google.com/document/d/abc123/edit` → `/{prefix}/docs.google.com/document-d-abc123.md`
16
+ */
17
+ export function urlToContextPath(url: string, prefix: string): string {
18
+ const parsed = new URL(url);
19
+ const hostname = parsed.hostname;
20
+ const pathname = parsed.pathname
21
+ .replace(/\/+$/, "") // strip trailing slashes
22
+ .replace(/^\/+/, "") // strip leading slashes
23
+ .replace(/[^a-zA-Z0-9\-_.]/g, "-") // slugify
24
+ .replace(/-{2,}/g, "-"); // collapse repeated dashes
25
+
26
+ const slug = pathname ? `${hostname}/${pathname}` : hostname;
27
+ const full = `${prefix.replace(/\/+$/, "")}/${slug}.md`;
28
+
29
+ if (full.length > 120) {
30
+ return `${full.slice(0, 117 - 3)}.md`;
31
+ }
32
+
33
+ return full;
34
+ }
35
+
36
+ /**
37
+ * Strips HTML tags from a string, removing script/style blocks first,
38
+ * then all remaining tags, and collapsing whitespace.
39
+ */
40
+ export function stripHtmlTags(html: string): string {
41
+ return html
42
+ .replace(/<script[\s\S]*?<\/script>/gi, "") // remove script blocks
43
+ .replace(/<style[\s\S]*?<\/style>/gi, "") // remove style blocks
44
+ .replace(/<[^>]*>/g, "") // remove all remaining tags
45
+ .replace(/[ \t]+/g, " ") // collapse horizontal whitespace
46
+ .replace(/\n{3,}/g, "\n\n") // collapse excessive newlines
47
+ .trim();
48
+ }
package/src/db/context.ts CHANGED
@@ -9,6 +9,7 @@ export interface ContextItem {
9
9
  content: string | null;
10
10
  mime_type: string;
11
11
  is_textual: boolean;
12
+ source_type: "file" | "url";
12
13
  source_path: string | null;
13
14
  context_path: string;
14
15
  indexed_at: Date | null;
@@ -30,6 +31,7 @@ interface ContextItemRow {
30
31
  content_blob: unknown;
31
32
  mime_type: string;
32
33
  is_textual: boolean;
34
+ source_type: string;
33
35
  source_path: string | null;
34
36
  context_path: string;
35
37
  indexed_at: string | null;
@@ -45,6 +47,7 @@ function rowToContextItem(row: ContextItemRow): ContextItem {
45
47
  content: row.content,
46
48
  mime_type: row.mime_type,
47
49
  is_textual: !!row.is_textual,
50
+ source_type: row.source_type as "file" | "url",
48
51
  source_path: row.source_path,
49
52
  context_path: row.context_path,
50
53
  indexed_at: row.indexed_at ? new Date(row.indexed_at) : null,
@@ -61,6 +64,7 @@ export async function createContextItem(
61
64
  title: string;
62
65
  content?: string;
63
66
  mimeType?: string;
67
+ sourceType?: "file" | "url";
64
68
  sourcePath?: string;
65
69
  contextPath: string;
66
70
  description?: string;
@@ -69,8 +73,8 @@ export async function createContextItem(
69
73
  ): Promise<ContextItem> {
70
74
  const id = uuidv7();
71
75
  const row = await db.queryGet<ContextItemRow>(
72
- `INSERT INTO context_items (id, title, description, content, mime_type, is_textual, source_path, context_path)
73
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
76
+ `INSERT INTO context_items (id, title, description, content, mime_type, is_textual, source_type, source_path, context_path)
77
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
74
78
  RETURNING *`,
75
79
  id,
76
80
  params.title,
@@ -78,6 +82,7 @@ export async function createContextItem(
78
82
  params.content ?? null,
79
83
  params.mimeType ?? "text/plain",
80
84
  params.isTextual !== false,
85
+ params.sourceType ?? "file",
81
86
  params.sourcePath ?? null,
82
87
  params.contextPath,
83
88
  );
@@ -99,6 +104,7 @@ export async function upsertContextItem(
99
104
  title: string;
100
105
  content?: string;
101
106
  mimeType?: string;
107
+ sourceType?: "file" | "url";
102
108
  sourcePath?: string;
103
109
  contextPath: string;
104
110
  description?: string;
@@ -0,0 +1 @@
1
+ ALTER TABLE context_items ADD COLUMN source_type TEXT DEFAULT 'file';
@@ -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