From 665f74e3bfa5a58b31180367c73c853c2e5ab91b Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 11 Mar 2026 17:28:47 -0700 Subject: [PATCH] feat(slash-commands): expose Claude Code CLI built-ins in command menu Adds /simplify, /loop, and /batch as CLI-sourced slash commands so they appear in the autocomplete menu with a 'CLI' badge and forward correctly to Claude Code instead of triggering 'Unknown command' errors. Introduces source?: 'cli' field to the SlashCommand interface to distinguish app-level commands from CLI pass-through entries. Resolves #208. --- src/lib/commands/slashCommands.test.ts | 154 ++++++++++++++++++++- src/lib/commands/slashCommands.ts | 46 ++++++ src/lib/components/SlashCommandMenu.svelte | 17 +++ 3 files changed, 215 insertions(+), 2 deletions(-) diff --git a/src/lib/commands/slashCommands.test.ts b/src/lib/commands/slashCommands.test.ts index af08a7b..86d8e95 100644 --- a/src/lib/commands/slashCommands.test.ts +++ b/src/lib/commands/slashCommands.test.ts @@ -87,10 +87,13 @@ describe("slashCommands", () => { expect(commandNames).toContain("search"); expect(commandNames).toContain("summarise"); expect(commandNames).toContain("skill"); + expect(commandNames).toContain("simplify"); + expect(commandNames).toContain("loop"); + expect(commandNames).toContain("batch"); }); - it("has 7 commands total", () => { - expect(slashCommands.length).toBe(7); + it("has 10 commands total", () => { + expect(slashCommands.length).toBe(10); }); it("each command has required properties", () => { @@ -160,6 +163,45 @@ describe("slashCommands", () => { expect(skillCmd!.description).toBe("Invoke a Claude Code skill from ~/.claude/skills/"); expect(skillCmd!.usage).toBe("/skill [name] [data]"); }); + + it("simplify command has correct metadata and source", () => { + const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify"); + expect(simplifyCmd).toBeDefined(); + expect(simplifyCmd!.source).toBe("cli"); + expect(simplifyCmd!.usage).toBe("/simplify"); + }); + + it("loop command has correct metadata and source", () => { + const loopCmd = slashCommands.find((cmd) => cmd.name === "loop"); + expect(loopCmd).toBeDefined(); + expect(loopCmd!.source).toBe("cli"); + expect(loopCmd!.usage).toBe("/loop [interval] [command]"); + }); + + it("batch command has correct metadata and source", () => { + const batchCmd = slashCommands.find((cmd) => cmd.name === "batch"); + expect(batchCmd).toBeDefined(); + expect(batchCmd!.source).toBe("cli"); + expect(batchCmd!.usage).toBe("/batch [tasks]"); + }); + + it("app commands do not have source set", () => { + const appCommandNames = ["cd", "clear", "new", "help", "search", "summarise", "skill"]; + appCommandNames.forEach((name) => { + const cmd = slashCommands.find((c) => c.name === name); + expect(cmd).toBeDefined(); + expect(cmd!.source).toBeUndefined(); + }); + }); + + it("cli commands have source set to 'cli'", () => { + const cliCommandNames = ["simplify", "loop", "batch"]; + cliCommandNames.forEach((name) => { + const cmd = slashCommands.find((c) => c.name === name); + expect(cmd).toBeDefined(); + expect(cmd!.source).toBe("cli"); + }); + }); }); describe("parseSlashCommand", () => { @@ -342,6 +384,19 @@ describe("slashCommands", () => { expect(names).toContain("search"); expect(names).toContain("summarise"); expect(names).toContain("skill"); + expect(names).toContain("simplify"); + }); + + it("returns /loop for /l prefix", () => { + const result = getMatchingCommands("/l"); + const names = result.map((cmd) => cmd.name); + expect(names).toContain("loop"); + }); + + it("returns /batch for /b prefix", () => { + const result = getMatchingCommands("/b"); + const names = result.map((cmd) => cmd.name); + expect(names).toContain("batch"); }); it("is case insensitive", () => { @@ -412,6 +467,19 @@ describe("slashCommands", () => { expect(testCommand.description).toBe("A test command"); expect(testCommand.usage).toBe("/test [arg]"); expect(typeof testCommand.execute).toBe("function"); + expect(testCommand.source).toBeUndefined(); + }); + + it("can create a cli-sourced slash command object", () => { + const cliCommand: SlashCommand = { + name: "cli-test", + description: "A CLI command", + usage: "/cli-test", + source: "cli", + execute: vi.fn(), + }; + + expect(cliCommand.source).toBe("cli"); }); it("execute can be async function", () => { @@ -715,6 +783,88 @@ describe("slashCommands", () => { }); }); + describe("/simplify execute", () => { + it("shows error when no active conversation", async () => { + getMock.mockReturnValue(null); + const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify")!; + await simplifyCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation"); + }); + + it("sends /simplify prompt to Claude when there is an active conversation", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(undefined); + const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify")!; + await simplifyCmd.execute(""); + expect(invokeMock).toHaveBeenCalledWith("send_prompt", { + conversationId: "conv-123", + message: "/simplify", + }); + }); + }); + + describe("/loop execute", () => { + it("shows error when no active conversation", async () => { + getMock.mockReturnValue(null); + const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!; + await loopCmd.execute("5m /help"); + expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation"); + }); + + it("sends /loop with args when args are provided", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(undefined); + const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!; + await loopCmd.execute("5m /help"); + expect(invokeMock).toHaveBeenCalledWith("send_prompt", { + conversationId: "conv-123", + message: "/loop 5m /help", + }); + }); + + it("sends /loop without args when no args provided", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(undefined); + const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!; + await loopCmd.execute(""); + expect(invokeMock).toHaveBeenCalledWith("send_prompt", { + conversationId: "conv-123", + message: "/loop", + }); + }); + }); + + describe("/batch execute", () => { + it("shows error when no active conversation", async () => { + getMock.mockReturnValue(null); + const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!; + await batchCmd.execute("task1, task2"); + expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation"); + }); + + it("sends /batch with args when args are provided", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(undefined); + const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!; + await batchCmd.execute("task1, task2"); + expect(invokeMock).toHaveBeenCalledWith("send_prompt", { + conversationId: "conv-123", + message: "/batch task1, task2", + }); + }); + + it("sends /batch without args when no args provided", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(undefined); + const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!; + await batchCmd.execute(""); + expect(invokeMock).toHaveBeenCalledWith("send_prompt", { + conversationId: "conv-123", + message: "/batch", + }); + }); + }); + describe("/cd success path", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts index 3e97bb2..6e65822 100644 --- a/src/lib/commands/slashCommands.ts +++ b/src/lib/commands/slashCommands.ts @@ -11,6 +11,8 @@ export interface SlashCommand { name: string; description: string; usage: string; + /** "cli" = built into Claude Code CLI; omitted = Hikari app command */ + source?: "cli"; execute: (args: string) => Promise | void; } @@ -231,6 +233,50 @@ export const slashCommands: SlashCommand[] = [ } }, }, + { + name: "simplify", + description: "Review changed code for reuse, quality, and efficiency (Claude Code built-in)", + usage: "/simplify", + source: "cli", + execute: async () => { + const conversationId = get(claudeStore.activeConversationId); + if (!conversationId) { + claudeStore.addLine("error", "No active conversation"); + return; + } + await invoke("send_prompt", { conversationId, message: "/simplify" }); + }, + }, + { + name: "loop", + description: "Run a prompt or slash command on a recurring interval (Claude Code built-in)", + usage: "/loop [interval] [command]", + source: "cli", + execute: async (args: string) => { + const conversationId = get(claudeStore.activeConversationId); + if (!conversationId) { + claudeStore.addLine("error", "No active conversation"); + return; + } + const message = args.trim() ? `/loop ${args.trim()}` : "/loop"; + await invoke("send_prompt", { conversationId, message }); + }, + }, + { + name: "batch", + description: "Process multiple tasks in a single Claude Code session (Claude Code built-in)", + usage: "/batch [tasks]", + source: "cli", + execute: async (args: string) => { + const conversationId = get(claudeStore.activeConversationId); + if (!conversationId) { + claudeStore.addLine("error", "No active conversation"); + return; + } + const message = args.trim() ? `/batch ${args.trim()}` : "/batch"; + await invoke("send_prompt", { conversationId, message }); + }, + }, { name: "skill", description: "Invoke a Claude Code skill from ~/.claude/skills/", diff --git a/src/lib/components/SlashCommandMenu.svelte b/src/lib/components/SlashCommandMenu.svelte index 159fb1a..1c56e1c 100644 --- a/src/lib/components/SlashCommandMenu.svelte +++ b/src/lib/components/SlashCommandMenu.svelte @@ -23,6 +23,9 @@ > /{command.name} {command.description} + {#if command.source === "cli"} + CLI + {/if} {/each} @@ -82,5 +85,19 @@ .command-description { color: var(--text-secondary); font-size: 13px; + flex: 1; + } + + .cli-badge { + font-size: 10px; + font-weight: 600; + padding: 1px 5px; + border-radius: 4px; + background: color-mix(in srgb, var(--accent-primary) 15%, transparent); + color: var(--accent-primary); + border: 1px solid color-mix(in srgb, var(--accent-primary) 30%, transparent); + letter-spacing: 0.5px; + text-transform: uppercase; + flex-shrink: 0; }