diff --git a/packages/cli/src/tests/slash-commands.test.ts b/packages/cli/src/tests/slash-commands.test.ts index 7df38519..a2c7954b 100644 --- a/packages/cli/src/tests/slash-commands.test.ts +++ b/packages/cli/src/tests/slash-commands.test.ts @@ -6,6 +6,7 @@ import { findExactSlashCommand, formatSlashCommandDescription, formatSlashCommandLabel, + resolveSlashSubmission, } from "../ui"; import type { SkillInfo } from "@vegamo/deepcode-core"; @@ -175,6 +176,61 @@ test("findExactSlashCommand returns the matching skill", () => { assert.equal(item?.skill?.name, "code-review"); }); +test("resolveSlashSubmission treats a bare command as a command", () => { + const items = buildSlashCommands(skills); + assert.deepEqual(resolveSlashSubmission(items, "/plan"), { + kind: "command", + item: items.find((item) => item.kind === "plan"), + }); +}); + +test("resolveSlashSubmission sends /plan arguments as a one-shot prompt", () => { + const items = buildSlashCommands(skills); + const result = resolveSlashSubmission(items, "/plan 重构权限模块,先梳理现状 "); + assert.equal(result?.kind, "plan"); + assert.equal(result?.kind === "plan" ? result.text : "", "重构权限模块,先梳理现状"); +}); + +test("resolveSlashSubmission keeps newlines inside a /plan prompt", () => { + const items = buildSlashCommands(skills); + const result = resolveSlashSubmission(items, "/plan\nline one\n line two"); + assert.equal(result?.kind, "plan"); + assert.equal(result?.kind === "plan" ? result.text : "", "line one\n line two"); +}); + +test("resolveSlashSubmission applies a valid /raw argument", () => { + const items = buildSlashCommands(skills); + const result = resolveSlashSubmission(items, "/raw lite"); + assert.equal(result?.kind, "raw"); + assert.equal(result?.kind === "raw" ? result.mode : "", "lite"); +}); + +test("resolveSlashSubmission rejects an unknown /raw argument", () => { + const items = buildSlashCommands(skills); + const result = resolveSlashSubmission(items, "/raw verbose"); + assert.equal(result?.kind, "argument-error"); + assert.equal(result?.kind === "argument-error" ? result.argument : "", "verbose"); +}); + +test("resolveSlashSubmission keeps existing behavior for other commands", () => { + const items = buildSlashCommands(skills); + const result = resolveSlashSubmission(items, "/new keep this text"); + assert.equal(result?.kind, "command"); + assert.equal(result?.kind === "command" ? result.item.kind : "", "new"); +}); + +test("resolveSlashSubmission does not block /compact arguments", () => { + const items = buildSlashCommands(skills); + const result = resolveSlashSubmission(items, "/compact extra text"); + assert.equal(result?.kind, "command"); + assert.equal(result?.kind === "command" ? result.item.kind : "", "compact"); +}); + +test("resolveSlashSubmission leaves unknown slash text as a normal prompt", () => { + const items = buildSlashCommands(skills); + assert.equal(resolveSlashSubmission(items, "/planning is not a command"), null); +}); + test("formatSlashCommandDescription keeps descriptions on one line", () => { assert.equal(formatSlashCommandDescription("Line one\n line two"), "Line one line two"); }); diff --git a/packages/cli/src/ui/core/slash-commands.ts b/packages/cli/src/ui/core/slash-commands.ts index a7c3d65f..f9880e36 100644 --- a/packages/cli/src/ui/core/slash-commands.ts +++ b/packages/cli/src/ui/core/slash-commands.ts @@ -148,6 +148,48 @@ export function findExactSlashCommand(items: SlashCommandItem[], token: string): return matches.find((item) => item.kind !== "skill") ?? matches[0] ?? null; } +export type SlashSubmission = + | { kind: "command"; item: SlashCommandItem } + | { kind: "plan"; item: SlashCommandItem; text: string } + | { kind: "raw"; item: SlashCommandItem; mode: string } + | { kind: "argument-error"; item: SlashCommandItem; argument: string }; + +/** + * Classify a submitted slash input without touching UI state. A command alone + * runs as before; `/plan ` is a one-shot plan-mode prompt; `/raw ` + * selects that mode and reports an unknown mode. Other commands keep their + * existing behavior and ignore trailing text. + */ +export function resolveSlashSubmission(items: SlashCommandItem[], text: string): SlashSubmission | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("/")) { + return null; + } + const match = /^(\S+)([\s\S]*)$/.exec(trimmed); + if (!match) { + return null; + } + const item = findExactSlashCommand(items, match[1]); + if (!item) { + return null; + } + const rest = match[2].trim(); + if (!rest) { + return { kind: "command", item }; + } + if (item.kind === "plan") { + return { kind: "plan", item, text: rest }; + } + if (item.kind === "raw") { + if (item.args?.includes(rest)) { + return { kind: "raw", item, mode: rest }; + } + return { kind: "argument-error", item, argument: rest }; + } + // Other commands keep their existing behavior and ignore trailing text. + return { kind: "command", item }; +} + export function formatSlashCommandDescription(description: string): string { return (description || "(no description)").trim().replace(/\s+/g, " "); } diff --git a/packages/cli/src/ui/index.ts b/packages/cli/src/ui/index.ts index ce9c53bc..b9acdbff 100644 --- a/packages/cli/src/ui/index.ts +++ b/packages/cli/src/ui/index.ts @@ -85,8 +85,10 @@ export { findExactSlashCommand, formatSlashCommandDescription, formatSlashCommandLabel, + resolveSlashSubmission, type SlashCommandKind, type SlashCommandItem, + type SlashSubmission, } from "./core/slash-commands"; export { filterFileMentionItems, diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx index 00b87ad8..0c665bd5 100644 --- a/packages/cli/src/ui/views/PromptInput.tsx +++ b/packages/cli/src/ui/views/PromptInput.tsx @@ -34,7 +34,12 @@ import { redoPromptEdit, undoPromptEdit, } from "../core/prompt-undo-redo"; -import { buildSlashCommands, filterSlashCommands, findExactSlashCommand } from "../core/slash-commands"; +import { + buildSlashCommands, + filterSlashCommands, + findExactSlashCommand, + resolveSlashSubmission, +} from "../core/slash-commands"; import type { SlashCommandItem } from "../core/slash-commands"; import { filterFileMentionItems, @@ -759,12 +764,32 @@ export const PromptInput = React.memo(function PromptInput({ return; } - if (trimmed.startsWith("/")) { - const exactMatch = findExactSlashCommand(slashItems, trimmed.split(/\s+/, 1)[0]); - if (exactMatch) { - handleSlashSelection(exactMatch); + const slash = resolveSlashSubmission(slashItems, buffer.text); + if (slash) { + if (slash.kind === "command") { + handleSlashSelection(slash.item); + return; + } + if (slash.kind === "plan") { + onPlanModeChange(true); + onSubmit({ + text: expandPasteMarkers(slash.text, pastesRef.current), + imageUrls, + selectedSkills, + planMode: true, + }); + resetPromptInput(); return; } + if (slash.kind === "raw") { + onRawModeChange?.(slash.mode); + resetPromptInput(); + return; + } + setStatusMessage( + `Unknown ${slash.item.label} argument "${slash.argument}". Expected: ${slash.item.args?.join(", ") ?? "none"}.` + ); + return; } onSubmit({