Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions packages/cli/src/tests/slash-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
findExactSlashCommand,
formatSlashCommandDescription,
formatSlashCommandLabel,
resolveSlashSubmission,
} from "../ui";
import type { SkillInfo } from "@vegamo/deepcode-core";

Expand Down Expand Up @@ -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");
});
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/ui/core/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text>` is a one-shot plan-mode prompt; `/raw <mode>`
* 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, " ");
}
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,10 @@ export {
findExactSlashCommand,
formatSlashCommandDescription,
formatSlashCommandLabel,
resolveSlashSubmission,
type SlashCommandKind,
type SlashCommandItem,
type SlashSubmission,
} from "./core/slash-commands";
export {
filterFileMentionItems,
Expand Down
35 changes: 30 additions & 5 deletions packages/cli/src/ui/views/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
Loading