diff --git a/src/cli/index.tsx b/src/cli/index.tsx index bf0f427..006c6f4 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -84,7 +84,7 @@ if (args.length > 0) { const commands: SlashCommand[] = await loadAvailableCommands(skillsDirs); render( - + , { patchConsole: false }, diff --git a/src/cli/tui/__tests__/command-registry.test.ts b/src/cli/tui/__tests__/command-registry.test.ts new file mode 100644 index 0000000..806ed0f --- /dev/null +++ b/src/cli/tui/__tests__/command-registry.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "bun:test"; + +import { BUILTIN_COMMANDS, formatHelp, resolveBuiltinCommand, type SlashCommand } from "../command-registry"; + +describe("resolveBuiltinCommand", () => { + it("resolves a bare builtin", () => { + expect(resolveBuiltinCommand("/clear")).toEqual({ name: "clear", args: "" }); + expect(resolveBuiltinCommand("/exit")).toEqual({ name: "exit", args: "" }); + expect(resolveBuiltinCommand("/help")).toEqual({ name: "help", args: "" }); + }); + + it("captures trailing args after a builtin", () => { + expect(resolveBuiltinCommand("/help clear")).toEqual({ name: "help", args: "clear" }); + expect(resolveBuiltinCommand("/help skill-creator")).toEqual({ + name: "help", + args: "skill-creator", + }); + }); + + it("treats input with no leading slash the same way", () => { + expect(resolveBuiltinCommand("clear")).toEqual({ name: "clear", args: "" }); + }); + + it("returns null for unknown commands and empty input", () => { + expect(resolveBuiltinCommand("/nope")).toBeNull(); + expect(resolveBuiltinCommand("")).toBeNull(); + expect(resolveBuiltinCommand(" ")).toBeNull(); + }); +}); + +describe("formatHelp", () => { + const commands: SlashCommand[] = [ + ...BUILTIN_COMMANDS, + { name: "skill-creator", description: "Create new skills", type: "skill" }, + ]; + + it("lists builtins and skills when called with no target", () => { + const text = formatHelp(commands); + expect(text).toContain("Available slash commands"); + expect(text).toContain("/clear"); + expect(text).toContain("/help"); + expect(text).toContain("/skill-creator"); + expect(text).toContain("Create new skills"); + }); + + it("renders details for a single command", () => { + const text = formatHelp(commands, "clear"); + expect(text).toContain("/clear"); + expect(text).toContain("Built-in command"); + expect(text).toContain("Clear the current conversation history"); + }); + + it("tolerates a leading slash and case in target", () => { + const text = formatHelp(commands, "/CLEAR"); + expect(text).toContain("/clear"); + }); + + it("returns an error message for unknown targets", () => { + const text = formatHelp(commands, "nope"); + expect(text).toContain("Unknown command"); + expect(text).toContain("/nope"); + }); +}); diff --git a/src/cli/tui/command-registry.ts b/src/cli/tui/command-registry.ts index 646c80c..514598a 100644 --- a/src/cli/tui/command-registry.ts +++ b/src/cli/tui/command-registry.ts @@ -23,6 +23,11 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [ description: "Exit the TUI session", type: "builtin", }, + { + name: "help", + description: "List available slash commands, or show details for one (`/help `)", + type: "builtin", + }, { name: "quit", description: "Exit the TUI session", @@ -30,6 +35,12 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [ }, ]; +/** Parsed builtin invocation: command name plus any trailing argument string. */ +export interface BuiltinInvocation { + name: SlashCommand["name"]; + args: string; +} + export async function loadAvailableCommands(skillsDirs?: string[]): Promise { const skills = await listSkills(skillsDirs); const skillCommands = skills.map(toSkillCommand).sort((left, right) => left.name.localeCompare(right.name)); @@ -69,12 +80,60 @@ export function getHighlightedCommandName(text: string, commands: SlashCommand[] return commands.some((command) => command.name.toLowerCase() === commandName) ? commandToken : null; } -export function resolveBuiltinCommand(text: string): SlashCommand["name"] | null { +export function resolveBuiltinCommand(text: string): BuiltinInvocation | null { const trimmed = text.trim(); - if (!trimmed || /\s/.test(trimmed)) return null; + if (!trimmed) return null; + + const match = trimmed.match(/^\/?([^\s]+)(?:\s+([\s\S]*))?$/); + if (!match) return null; + const token = match[1]; + if (!token) return null; + + const normalized = normalizeCommandName(token); + const builtin = BUILTIN_COMMANDS.find((command) => command.name === normalized); + if (!builtin) return null; + + return { name: builtin.name, args: (match[2] ?? "").trim() }; +} + +/** + * Renders a help string for slash commands. With no `target`, lists all + * commands grouped by type. With a `target`, prints the matched command's + * details, or an error message if not found. + */ +export function formatHelp(commands: SlashCommand[], target?: string): string { + if (target) { + const normalized = normalizeCommandName(target); + const match = commands.find((c) => c.name.toLowerCase() === normalized); + if (!match) { + return `Unknown command: \`/${target}\`. Run \`/help\` to see available commands.`; + } + const kind = match.type === "builtin" ? "Built-in command" : "Skill"; + return `**/${match.name}** — _${kind}_\n\n${match.description}`; + } + + const builtins = commands.filter((c) => c.type === "builtin"); + const skills = commands.filter((c) => c.type === "skill"); + + const lines: string[] = ["**Available slash commands**", ""]; + + if (builtins.length > 0) { + lines.push("_Built-in_"); + for (const c of builtins) { + lines.push(`- \`/${c.name}\` — ${c.description}`); + } + } + + if (skills.length > 0) { + if (builtins.length > 0) lines.push(""); + lines.push("_Skills_"); + for (const c of skills) { + lines.push(`- \`/${c.name}\` — ${c.description}`); + } + } - const normalized = normalizeCommandName(trimmed); - return BUILTIN_COMMANDS.find((command) => command.name === normalized)?.name ?? null; + lines.push("", "Run `/help ` for details on a single command."); + return lines.join("\n"); } export function buildPromptSubmission(text: string, commands: SlashCommand[]): PromptSubmission { diff --git a/src/cli/tui/hooks/use-agent-loop.ts b/src/cli/tui/hooks/use-agent-loop.ts index 3cdd55d..28dcddd 100644 --- a/src/cli/tui/hooks/use-agent-loop.ts +++ b/src/cli/tui/hooks/use-agent-loop.ts @@ -4,8 +4,8 @@ import type { ReactNode } from "react"; import type { Agent } from "@/agent"; import type { AssistantMessage, NonSystemMessage, UserMessage } from "@/foundation"; -import type { PromptSubmission } from "../command-registry"; -import { resolveBuiltinCommand } from "../command-registry"; +import type { PromptSubmission, SlashCommand } from "../command-registry"; +import { formatHelp, resolveBuiltinCommand } from "../command-registry"; type AgentLoopState = { agent: Agent; @@ -19,7 +19,15 @@ type AgentLoopState = { const AgentLoopContext = createContext(null); -export function AgentLoopProvider({ agent, children }: { agent: Agent; children: ReactNode }) { +export function AgentLoopProvider({ + agent, + commands = [], + children, +}: { + agent: Agent; + commands?: SlashCommand[]; + children: ReactNode; +}) { const [streaming, setStreaming] = useState(false); const [messages, setMessages] = useState([]); @@ -74,16 +82,16 @@ export function AgentLoopProvider({ agent, children }: { agent: Agent; children: const onSubmit = useCallback( async (submission: PromptSubmission) => { const { text, requestedSkillName } = submission; - const builtinCommand = resolveBuiltinCommand(text); + const invocation = resolveBuiltinCommand(text); - if (builtinCommand === "exit" || builtinCommand === "quit") { + if (invocation?.name === "exit" || invocation?.name === "quit") { process.exit(0); return; } if (streamingRef.current) return; - if (builtinCommand === "clear") { + if (invocation?.name === "clear") { agent.clearMessages(); flushPendingMessages(); setMessages([]); @@ -91,6 +99,22 @@ export function AgentLoopProvider({ agent, children }: { agent: Agent; children: return; } + if (invocation?.name === "help") { + flushPendingMessages(); + const userMessage: UserMessage = { role: "user", content: [{ type: "text", text }] }; + const helpMessage: AssistantMessage = { + role: "assistant", + content: [ + { + type: "text", + text: formatHelp(commands, invocation.args || undefined), + }, + ], + }; + setMessages((prev) => [...prev, userMessage, helpMessage]); + return; + } + setStreaming(true); try { @@ -116,7 +140,7 @@ export function AgentLoopProvider({ agent, children }: { agent: Agent; children: setStreaming(false); } }, - [agent, enqueueMessage, flushPendingMessages], + [agent, commands, enqueueMessage, flushPendingMessages], ); const value = useMemo(