From d1a09ce450bdc36145f5965c703f1506ff638d52 Mon Sep 17 00:00:00 2001 From: Dudka Date: Fri, 7 Aug 2026 16:37:01 +0300 Subject: [PATCH 1/2] feat(tui): add /tools so built-in capabilities are discoverable Closes #71. A user asked the agent to create a folder, searched /skills for "filesystem", found nothing, and concluded the agent could not touch files. Two things made that unavoidable: there was no command listing the tool surface at all, and the twelve filesystem tools are named os.fs.*, so the word "filesystem" matches nothing even in a working listing. - /tools lists all 63 built-in tools grouped by family, stating plainly that they are always available and separate from /skills - /tools filters by alias, family prefix or substring; aliases cover the words users actually type (filesystem, files, shell, terminal, web, network, images), and a miss says so and points at /tools and /skills instead of returning nothing - the Skills panel footer now carries "built-in tools: /tools" Co-Authored-By: Claude Fable 5 --- src/tui/commands/slash-command-handler.ts | 17 ++++ src/tui/commands/slash-commands.ts | 5 + src/tui/commands/tools-listing.test.ts | 82 +++++++++++++++ src/tui/commands/tools-listing.ts | 116 ++++++++++++++++++++++ src/tui/components/skills-panel.tsx | 8 ++ 5 files changed, 228 insertions(+) create mode 100644 src/tui/commands/tools-listing.test.ts create mode 100644 src/tui/commands/tools-listing.ts diff --git a/src/tui/commands/slash-command-handler.ts b/src/tui/commands/slash-command-handler.ts index b2ce8ed..cabf8a1 100644 --- a/src/tui/commands/slash-command-handler.ts +++ b/src/tui/commands/slash-command-handler.ts @@ -3,6 +3,7 @@ import { normalizeLocalLlmBaseUrl } from "../persist-user-local-models-config.js import { isThemeName, THEME_NAMES } from "../theme/theme.js"; import { parseSlashCommand } from "./slash-command-parser.js"; import { resolveSlashCommand, SLASH_COMMANDS } from "./slash-commands.js"; +import { renderToolsOverview, renderToolsSearch } from "./tools-listing.js"; export interface SlashDispatchCallbacks { onAbort(): void; @@ -196,6 +197,8 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult { return pureActions([], { triggerSessionPicker: true }); case "new": return pureActions([], { triggerSessionNew: true }); + case "tools": + return dispatchToolsSub(parsed.args); case "skills": return dispatchSkillsSub(parsed.args); case "skill": @@ -486,6 +489,20 @@ function dispatchLlmSub(rawArgs: string): SlashDispatchResult { }); } +/** + * `/tools` answers "what can this agent actually do" without touching a + * panel: built-in tools are always loaded, so a listing is pure text. + * Users used to search /skills for "filesystem", find nothing, and + * conclude the agent could not touch files (#71). + */ +function dispatchToolsSub(rawArgs: string): SlashDispatchResult { + const query = rawArgs.trim(); + return pureActions([], { + systemMessage: + query.length === 0 ? renderToolsOverview() : renderToolsSearch(query), + }); +} + function dispatchSkillsSub(rawArgs: string): SlashDispatchResult { const argPart = rawArgs.trim(); if (argPart.length === 0) { diff --git a/src/tui/commands/slash-commands.ts b/src/tui/commands/slash-commands.ts index d19193a..c481a64 100644 --- a/src/tui/commands/slash-commands.ts +++ b/src/tui/commands/slash-commands.ts @@ -22,6 +22,11 @@ export const SLASH_COMMANDS: readonly SlashCommandDef[] = [ "write debug zip (TUI snapshot + recent trace NDJSON) to ~/Documents/atomic-agent-debug", }, { name: "help", description: "list available slash commands" }, + { + name: "tools", + description: + "list built-in tools (fs, shell, browser, memory, vision): `/tools` | `/tools `", + }, { name: "theme", description: diff --git a/src/tui/commands/tools-listing.test.ts b/src/tui/commands/tools-listing.test.ts new file mode 100644 index 0000000..f9c7794 --- /dev/null +++ b/src/tui/commands/tools-listing.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { + listToolFamilies, + renderToolsOverview, + renderToolsSearch, + searchTools, +} from "./tools-listing.js"; + +describe("listToolFamilies", () => { + it("groups tools by namespace and sorts both levels", () => { + const families = listToolFamilies(); + const names = families.map((f) => f.family); + expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); + const fs = families.find((f) => f.family === "os.fs"); + expect(fs).toBeDefined(); + expect(fs!.tools).toContain("os.fs.read"); + expect(fs!.tools).toContain("os.fs.write"); + expect(fs!.tools).toEqual([...fs!.tools].sort((a, b) => a.localeCompare(b))); + }); + + it("covers the families users ask about", () => { + const names = listToolFamilies().map((f) => f.family); + for (const family of ["os.fs", "os.shell", "os.web", "browser"]) { + expect(names).toContain(family); + } + }); +}); + +describe("searchTools", () => { + it("resolves the alias that started this issue", () => { + // A user searched /skills for "filesystem", found nothing, and + // concluded the agent could not touch files (#71). No tool is + // literally named "filesystem", so the alias has to carry it. + const hits = searchTools("filesystem"); + expect(hits.length).toBeGreaterThan(0); + expect(hits).toContain("os.fs.write"); + }); + + it("matches a family prefix directly", () => { + expect(searchTools("browser")).toContain("browser.navigate"); + }); + + it("matches a substring of a tool name", () => { + expect(searchTools("grep")).toEqual(["os.fs.grep"]); + }); + + it("is case-insensitive", () => { + expect(searchTools("BROWSER")).toContain("browser.navigate"); + }); + + it("returns nothing for an unrelated query", () => { + expect(searchTools("kubernetes")).toEqual([]); + }); + + it("returns nothing for an empty query", () => { + expect(searchTools(" ")).toEqual([]); + }); +}); + +describe("renderToolsOverview", () => { + it("states the tools are built in and points at /skills", () => { + const out = renderToolsOverview(); + expect(out).toContain("always available"); + expect(out).toContain("/skills"); + expect(out).toContain("os.fs"); + }); +}); + +describe("renderToolsSearch", () => { + it("lists matches with their full names", () => { + const out = renderToolsSearch("filesystem"); + expect(out).toContain("os.fs.read"); + }); + + it("says so plainly on a miss and offers the next step", () => { + const out = renderToolsSearch("kubernetes"); + expect(out).toContain("no built-in tool matches"); + expect(out).toContain("/tools"); + expect(out).toContain("/skills"); + }); +}); diff --git a/src/tui/commands/tools-listing.ts b/src/tui/commands/tools-listing.ts new file mode 100644 index 0000000..da488be --- /dev/null +++ b/src/tui/commands/tools-listing.ts @@ -0,0 +1,116 @@ +import { DEFAULT_TOOL_DESCRIPTORS } from "../../prompt/tool-descriptors.js"; + +/** + * Human-readable grouping for `/tools`. Built-in tools are namespaced + * (`os.fs.read`, `browser.navigate`, …), so the family is everything up + * to the last dot; single-segment names fall back to themselves. + */ +function familyOf(name: string): string { + const lastDot = name.lastIndexOf("."); + return lastDot === -1 ? name : name.slice(0, lastDot); +} + +/** + * Names users search for, mapped to the family they actually live under. + * `/skills` returning nothing for "filesystem" is what made a user + * conclude the agent could not touch files at all (#71); the same + * aliases let `/tools filesystem` answer instead of drawing a blank. + */ +const FAMILY_ALIASES: ReadonlyMap = new Map([ + ["filesystem", "os.fs"], + ["file", "os.fs"], + ["files", "os.fs"], + ["fs", "os.fs"], + ["disk", "os.fs"], + ["shell", "os.shell"], + ["terminal", "os.shell"], + ["bash", "os.shell"], + ["web", "os.web"], + ["http", "os.http"], + ["net", "os.http"], + ["network", "os.http"], + ["browser", "browser"], + ["chrome", "browser"], + ["memory", "memory"], + ["notes", "memory.notes"], + ["vision", "vision"], + ["image", "vision"], + ["images", "vision"], + ["git", "os.shell"], +]); + +export interface ToolFamilyListing { + readonly family: string; + readonly tools: readonly string[]; +} + +/** All built-in tools grouped by family, families and tools sorted. */ +export function listToolFamilies(): readonly ToolFamilyListing[] { + const byFamily = new Map(); + for (const descriptor of DEFAULT_TOOL_DESCRIPTORS) { + const family = familyOf(descriptor.name); + const bucket = byFamily.get(family); + if (bucket) bucket.push(descriptor.name); + else byFamily.set(family, [descriptor.name]); + } + return [...byFamily.entries()] + .map(([family, tools]) => ({ + family, + tools: [...tools].sort((a, b) => a.localeCompare(b)), + })) + .sort((a, b) => a.family.localeCompare(b.family)); +} + +/** + * Resolve a user query to matching tools. Matches an alias + * ("filesystem"), a family prefix ("os.fs"), or any substring of a tool + * name. Returns an empty array when nothing matches. + */ +export function searchTools(query: string): readonly string[] { + const q = query.trim().toLowerCase(); + if (q.length === 0) return []; + const aliased = FAMILY_ALIASES.get(q); + const needle = aliased ?? q; + return DEFAULT_TOOL_DESCRIPTORS.map((d) => d.name) + .filter((name) => name.toLowerCase().includes(needle.toLowerCase())) + .sort((a, b) => a.localeCompare(b)); +} + +/** `/tools` with no argument: every family, one line each. */ +export function renderToolsOverview(): string { + const families = listToolFamilies(); + const total = families.reduce((sum, f) => sum + f.tools.length, 0); + const lines = [ + `built-in tools (${total}) — always available, no install needed:`, + "", + ...families.map( + (f) => ` ${f.family} ${f.tools.map(shortName).join(" ")}`, + ), + "", + "these are separate from /skills, which lists optional playbooks.", + "`/tools ` filters, e.g. `/tools filesystem` or `/tools browser`.", + ]; + return lines.join("\n"); +} + +/** `/tools `: matching tools, or a clear miss message. */ +export function renderToolsSearch(query: string): string { + const matches = searchTools(query); + if (matches.length === 0) { + return ( + `no built-in tool matches "${query.trim()}".\n` + + "run `/tools` for the full list, or `/skills` for optional skill packs." + ); + } + return [ + `built-in tools matching "${query.trim()}" (${matches.length}):`, + "", + ...matches.map((name) => ` ${name}`), + ].join("\n"); +} + +/** Drop the family prefix so the overview stays one line per family. */ +function shortName(name: string): string { + const lastDot = name.lastIndexOf("."); + return lastDot === -1 ? name : name.slice(lastDot + 1); +} diff --git a/src/tui/components/skills-panel.tsx b/src/tui/components/skills-panel.tsx index f8757c6..f79e965 100644 --- a/src/tui/components/skills-panel.tsx +++ b/src/tui/components/skills-panel.tsx @@ -128,6 +128,14 @@ function FilterBar({ {autoRefresh ? " · auto" : ""} {loading ? " · …" : ""} + {/* Skills are optional playbooks; the built-in tools (fs, shell, + browser, memory, vision) are always loaded and never appear + here. Users searched this list for "filesystem", found + nothing, and concluded the agent could not touch files (#71). */} + + {" "} + built-in tools: /tools + ); } From 3ef80295df0b4ac2c8de485dd108b24f31c59d0d Mon Sep 17 00:00:00 2001 From: Dudka Date: Fri, 7 Aug 2026 19:07:20 +0300 Subject: [PATCH 2/2] fix(tui): filter /tools through config gates and fix the git alias Review follow-ups for the /tools listing: - /tools now runs DEFAULT_TOOL_DESCRIPTORS through the same filterToolDescriptorsByConfig gates bootstrap.ts applies, so a user with browser.enabled=false no longer sees browser.* advertised as available. Vision uses vision.enabled alone (the mmproj probe is not visible from the TUI listing) and the MCP gate uses the configured server list instead of live connections; both approximations follow the user's stated config. - The "git" alias pointed at os.shell, which hid all six os.git.* tools; it now resolves to os.git. - A query that only matches config-disabled tools says they are turned off in config instead of pretending they do not exist. - Single-segment tools (reply, finish) no longer render as "reply reply" in the overview. - The overview header says "enabled under the current config" instead of "always available, no install needed", and drops the em-dash. - searchTools drops a redundant toLowerCase; the grep test asserts toContain instead of toEqual so new grep-like tools do not break it. Co-Authored-By: Claude Fable 5 --- src/tui/commands/slash-command-handler.ts | 3 +- src/tui/commands/tools-listing.test.ts | 118 +++++++++++++++++--- src/tui/commands/tools-listing.ts | 126 ++++++++++++++++++---- 3 files changed, 215 insertions(+), 32 deletions(-) diff --git a/src/tui/commands/slash-command-handler.ts b/src/tui/commands/slash-command-handler.ts index cabf8a1..0c61dea 100644 --- a/src/tui/commands/slash-command-handler.ts +++ b/src/tui/commands/slash-command-handler.ts @@ -491,7 +491,8 @@ function dispatchLlmSub(rawArgs: string): SlashDispatchResult { /** * `/tools` answers "what can this agent actually do" without touching a - * panel: built-in tools are always loaded, so a listing is pure text. + * panel: the listing is pure text, filtered through the same config + * gates the runtime applies so disabled families never show up. * Users used to search /skills for "filesystem", find nothing, and * conclude the agent could not touch files (#71). */ diff --git a/src/tui/commands/tools-listing.test.ts b/src/tui/commands/tools-listing.test.ts index f9c7794..83e44df 100644 --- a/src/tui/commands/tools-listing.test.ts +++ b/src/tui/commands/tools-listing.test.ts @@ -1,15 +1,36 @@ import { describe, expect, it } from "vitest"; +import { DEFAULT_TOOL_DESCRIPTORS } from "../../prompt/tool-descriptors.js"; import { + effectiveToolDescriptors, listToolFamilies, renderToolsOverview, renderToolsSearch, searchTools, + type ToolGateSourceConfig, } from "./tools-listing.js"; +/** + * Every gate open. Tests pass explicit descriptors / configs so results + * never depend on the config file of the machine running the suite. + */ +const ALL_ENABLED: ToolGateSourceConfig = { + browser: { enabled: true }, + web: { search: { enabled: true } }, + vision: { enabled: true }, + memory: { + profile: { enabled: true }, + notes: { enabled: true }, + lessons: { enabled: true }, + procedures: { enabled: true }, + }, + tasks: { enabled: true, agentToolsEnabled: true }, + mcp: { servers: [{}] }, +}; + describe("listToolFamilies", () => { it("groups tools by namespace and sorts both levels", () => { - const families = listToolFamilies(); + const families = listToolFamilies(DEFAULT_TOOL_DESCRIPTORS); const names = families.map((f) => f.family); expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); const fs = families.find((f) => f.family === "os.fs"); @@ -20,63 +41,134 @@ describe("listToolFamilies", () => { }); it("covers the families users ask about", () => { - const names = listToolFamilies().map((f) => f.family); + const names = listToolFamilies(DEFAULT_TOOL_DESCRIPTORS).map((f) => f.family); for (const family of ["os.fs", "os.shell", "os.web", "browser"]) { expect(names).toContain(family); } }); }); +describe("effectiveToolDescriptors", () => { + it("keeps the full catalog when every gate is open", () => { + const names = effectiveToolDescriptors(ALL_ENABLED).map((d) => d.name); + expect(names).toEqual(DEFAULT_TOOL_DESCRIPTORS.map((d) => d.name)); + }); + + it("drops browser.* when the browser is disabled in config", () => { + const names = effectiveToolDescriptors({ + ...ALL_ENABLED, + browser: { enabled: false }, + }).map((d) => d.name); + expect(names.some((n) => n.startsWith("browser."))).toBe(false); + expect(names).toContain("os.fs.read"); + }); + + it("drops mcp.* when no MCP servers are configured", () => { + const names = effectiveToolDescriptors({ + ...ALL_ENABLED, + mcp: { servers: [] }, + }).map((d) => d.name); + expect(names.some((n) => n.startsWith("mcp."))).toBe(false); + }); + + it("drops tasks.* when agent task tools are off", () => { + const names = effectiveToolDescriptors({ + ...ALL_ENABLED, + tasks: { enabled: true, agentToolsEnabled: false }, + }).map((d) => d.name); + expect(names.some((n) => n.startsWith("tasks."))).toBe(false); + }); +}); + describe("searchTools", () => { it("resolves the alias that started this issue", () => { // A user searched /skills for "filesystem", found nothing, and // concluded the agent could not touch files (#71). No tool is // literally named "filesystem", so the alias has to carry it. - const hits = searchTools("filesystem"); + const hits = searchTools("filesystem", DEFAULT_TOOL_DESCRIPTORS); expect(hits.length).toBeGreaterThan(0); expect(hits).toContain("os.fs.write"); }); + it("routes git to the os.git family, not the shell", () => { + const hits = searchTools("git", DEFAULT_TOOL_DESCRIPTORS); + expect(hits).toContain("os.git.status"); + expect(hits).toContain("os.git.log"); + expect(hits).not.toContain("os.shell.run"); + }); + it("matches a family prefix directly", () => { - expect(searchTools("browser")).toContain("browser.navigate"); + expect(searchTools("browser", DEFAULT_TOOL_DESCRIPTORS)).toContain( + "browser.navigate", + ); }); it("matches a substring of a tool name", () => { - expect(searchTools("grep")).toEqual(["os.fs.grep"]); + expect(searchTools("grep", DEFAULT_TOOL_DESCRIPTORS)).toContain("os.fs.grep"); }); it("is case-insensitive", () => { - expect(searchTools("BROWSER")).toContain("browser.navigate"); + expect(searchTools("BROWSER", DEFAULT_TOOL_DESCRIPTORS)).toContain( + "browser.navigate", + ); }); it("returns nothing for an unrelated query", () => { - expect(searchTools("kubernetes")).toEqual([]); + expect(searchTools("kubernetes", DEFAULT_TOOL_DESCRIPTORS)).toEqual([]); }); it("returns nothing for an empty query", () => { - expect(searchTools(" ")).toEqual([]); + expect(searchTools(" ", DEFAULT_TOOL_DESCRIPTORS)).toEqual([]); }); }); describe("renderToolsOverview", () => { - it("states the tools are built in and points at /skills", () => { - const out = renderToolsOverview(); - expect(out).toContain("always available"); + it("names the config-dependence and points at /skills", () => { + const out = renderToolsOverview(DEFAULT_TOOL_DESCRIPTORS); + expect(out).toContain("enabled under the current config"); expect(out).toContain("/skills"); expect(out).toContain("os.fs"); }); + + it("does not repeat single-segment tool names", () => { + const out = renderToolsOverview(DEFAULT_TOOL_DESCRIPTORS); + expect(out).toContain(" reply"); + expect(out).not.toContain("reply reply"); + expect(out).not.toContain("finish finish"); + }); + + it("omits families disabled by config", () => { + const descriptors = effectiveToolDescriptors({ + ...ALL_ENABLED, + browser: { enabled: false }, + }); + const out = renderToolsOverview(descriptors); + // The footer hint still says `/tools browser`; only the family + // lines (two-space indented) must lose the browser entry. + expect(out).not.toMatch(/^ {2}browser/m); + }); }); describe("renderToolsSearch", () => { it("lists matches with their full names", () => { - const out = renderToolsSearch("filesystem"); + const out = renderToolsSearch("filesystem", DEFAULT_TOOL_DESCRIPTORS); expect(out).toContain("os.fs.read"); }); it("says so plainly on a miss and offers the next step", () => { - const out = renderToolsSearch("kubernetes"); + const out = renderToolsSearch("kubernetes", DEFAULT_TOOL_DESCRIPTORS); expect(out).toContain("no built-in tool matches"); expect(out).toContain("/tools"); expect(out).toContain("/skills"); }); + + it("tells apart missing tools from config-disabled tools", () => { + const descriptors = effectiveToolDescriptors({ + ...ALL_ENABLED, + browser: { enabled: false }, + }); + const out = renderToolsSearch("browser", descriptors); + expect(out).toContain("turned off in your config"); + expect(out).toContain("browser.navigate"); + }); }); diff --git a/src/tui/commands/tools-listing.ts b/src/tui/commands/tools-listing.ts index da488be..e507141 100644 --- a/src/tui/commands/tools-listing.ts +++ b/src/tui/commands/tools-listing.ts @@ -1,4 +1,7 @@ +import { getConfig } from "../../config/index.js"; +import type { ToolDescriptor } from "../../prompt/stable-prefix.js"; import { DEFAULT_TOOL_DESCRIPTORS } from "../../prompt/tool-descriptors.js"; +import { filterToolDescriptorsByConfig } from "../../runtime/filter-disabled-tools.js"; /** * Human-readable grouping for `/tools`. Built-in tools are namespaced @@ -36,18 +39,79 @@ const FAMILY_ALIASES: ReadonlyMap = new Map([ ["vision", "vision"], ["image", "vision"], ["images", "vision"], - ["git", "os.shell"], + ["git", "os.git"], ]); +/** + * The slice of the app config that decides which built-in tools are + * actually registered at runtime. Structurally satisfied by + * `AtomicAgentConfig`, narrow enough for tests to construct by hand. + */ +export interface ToolGateSourceConfig { + readonly browser: { readonly enabled: boolean }; + readonly web: { readonly search: { readonly enabled: boolean } }; + readonly vision: { readonly enabled: boolean }; + readonly memory: { + readonly profile: { readonly enabled: boolean }; + readonly notes: { readonly enabled: boolean }; + readonly lessons: { readonly enabled: boolean }; + readonly procedures: { readonly enabled: boolean }; + }; + readonly tasks: { + readonly enabled: boolean; + readonly agentToolsEnabled: boolean; + }; + readonly mcp: { readonly servers: readonly unknown[] }; +} + +/** + * The catalog in `DEFAULT_TOOL_DESCRIPTORS` is static; the runtime + * drops config-gated families before the model ever sees them (see + * `bootstrap.ts` → `filterToolDescriptorsByConfig`). `/tools` must + * apply the same gates or it advertises tools the agent cannot call + * (e.g. `browser.*` under `browser.enabled=false`). + * + * Two gates are approximated because their runtime inputs are probed + * at bootstrap, not read from config: vision uses `vision.enabled` + * alone (the mmproj capability probe is not visible here), and the + * MCP gate uses the configured server list instead of live + * connections. Both approximations only ever err on the side of the + * user's stated config. + */ +export function effectiveToolDescriptors( + config: ToolGateSourceConfig = getConfig(), +): readonly ToolDescriptor[] { + return filterToolDescriptorsByConfig(DEFAULT_TOOL_DESCRIPTORS, { + browser: { enabled: config.browser.enabled }, + web: { search: { enabled: config.web.search.enabled } }, + vision: { + enabled: config.vision.enabled, + providerAvailable: config.vision.enabled, + }, + memory: { + profile: { enabled: config.memory.profile.enabled }, + notes: { enabled: config.memory.notes.enabled }, + lessons: { enabled: config.memory.lessons.enabled }, + procedures: { enabled: config.memory.procedures.enabled }, + }, + tasks: { + agentToolsEnabled: config.tasks.enabled && config.tasks.agentToolsEnabled, + }, + mcp: { enabled: config.mcp.servers.length > 0 }, + }); +} + export interface ToolFamilyListing { readonly family: string; readonly tools: readonly string[]; } -/** All built-in tools grouped by family, families and tools sorted. */ -export function listToolFamilies(): readonly ToolFamilyListing[] { +/** Enabled built-in tools grouped by family, families and tools sorted. */ +export function listToolFamilies( + descriptors: readonly ToolDescriptor[] = effectiveToolDescriptors(), +): readonly ToolFamilyListing[] { const byFamily = new Map(); - for (const descriptor of DEFAULT_TOOL_DESCRIPTORS) { + for (const descriptor of descriptors) { const family = familyOf(descriptor.name); const bucket = byFamily.get(family); if (bucket) bucket.push(descriptor.name); @@ -66,26 +130,29 @@ export function listToolFamilies(): readonly ToolFamilyListing[] { * ("filesystem"), a family prefix ("os.fs"), or any substring of a tool * name. Returns an empty array when nothing matches. */ -export function searchTools(query: string): readonly string[] { +export function searchTools( + query: string, + descriptors: readonly ToolDescriptor[] = effectiveToolDescriptors(), +): readonly string[] { const q = query.trim().toLowerCase(); if (q.length === 0) return []; - const aliased = FAMILY_ALIASES.get(q); - const needle = aliased ?? q; - return DEFAULT_TOOL_DESCRIPTORS.map((d) => d.name) - .filter((name) => name.toLowerCase().includes(needle.toLowerCase())) + const needle = FAMILY_ALIASES.get(q) ?? q; + return descriptors + .map((d) => d.name) + .filter((name) => name.toLowerCase().includes(needle)) .sort((a, b) => a.localeCompare(b)); } -/** `/tools` with no argument: every family, one line each. */ -export function renderToolsOverview(): string { - const families = listToolFamilies(); +/** `/tools` with no argument: every enabled family, one line each. */ +export function renderToolsOverview( + descriptors: readonly ToolDescriptor[] = effectiveToolDescriptors(), +): string { + const families = listToolFamilies(descriptors); const total = families.reduce((sum, f) => sum + f.tools.length, 0); const lines = [ - `built-in tools (${total}) — always available, no install needed:`, + `built-in tools (${total}) enabled under the current config:`, "", - ...families.map( - (f) => ` ${f.family} ${f.tools.map(shortName).join(" ")}`, - ), + ...families.map(renderFamilyLine), "", "these are separate from /skills, which lists optional playbooks.", "`/tools ` filters, e.g. `/tools filesystem` or `/tools browser`.", @@ -94,9 +161,20 @@ export function renderToolsOverview(): string { } /** `/tools `: matching tools, or a clear miss message. */ -export function renderToolsSearch(query: string): string { - const matches = searchTools(query); +export function renderToolsSearch( + query: string, + descriptors: readonly ToolDescriptor[] = effectiveToolDescriptors(), +): string { + const matches = searchTools(query, descriptors); if (matches.length === 0) { + const gatedMatches = searchTools(query, DEFAULT_TOOL_DESCRIPTORS); + if (gatedMatches.length > 0) { + return [ + `no enabled tool matches "${query.trim()}". these exist but are turned off in your config:`, + "", + ...gatedMatches.map((name) => ` ${name}`), + ].join("\n"); + } return ( `no built-in tool matches "${query.trim()}".\n` + "run `/tools` for the full list, or `/skills` for optional skill packs." @@ -109,6 +187,18 @@ export function renderToolsSearch(query: string): string { ].join("\n"); } +/** + * One overview line per family. Namespaced families show short member + * names after the prefix; a single-segment tool (`reply`, `finish`) + * IS its own family, so repeating the name would render "reply reply". + */ +function renderFamilyLine(f: ToolFamilyListing): string { + if (f.tools.length === 1 && f.tools[0] === f.family) { + return ` ${f.family}`; + } + return ` ${f.family} ${f.tools.map(shortName).join(" ")}`; +} + /** Drop the family prefix so the overview stays one line per family. */ function shortName(name: string): string { const lastDot = name.lastIndexOf(".");