diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx
index 4007def3..a43ed49a 100644
--- a/frontend/src/components/Chat.test.tsx
+++ b/frontend/src/components/Chat.test.tsx
@@ -1481,6 +1481,10 @@ describe("Slash command handling (Story 374)", () => {
await act(async () => {
fireEvent.change(input, { target: { value: "/status" } });
});
+ // First Enter selects the command from the picker; second Enter submits it
+ await act(async () => {
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
+ });
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
@@ -1551,6 +1555,10 @@ describe("Slash command handling (Story 374)", () => {
await act(async () => {
fireEvent.change(input, { target: { value: "/git" } });
});
+ // First Enter selects the command from the picker; second Enter submits it
+ await act(async () => {
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
+ });
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
@@ -1569,6 +1577,10 @@ describe("Slash command handling (Story 374)", () => {
await act(async () => {
fireEvent.change(input, { target: { value: "/cost" } });
});
+ // First Enter selects the command from the picker; second Enter submits it
+ await act(async () => {
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
+ });
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
@@ -1595,6 +1607,10 @@ describe("Slash command handling (Story 374)", () => {
await act(async () => {
fireEvent.change(input, { target: { value: "/reset" } });
});
+ // First Enter selects the command from the picker; second Enter submits it
+ await act(async () => {
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
+ });
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
@@ -1634,6 +1650,10 @@ describe("Slash command handling (Story 374)", () => {
await act(async () => {
fireEvent.change(input, { target: { value: "/help" } });
});
+ // First Enter selects the command from the picker; second Enter submits it
+ await act(async () => {
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
+ });
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
@@ -1652,6 +1672,10 @@ describe("Slash command handling (Story 374)", () => {
await act(async () => {
fireEvent.change(input, { target: { value: "/git" } });
});
+ // First Enter selects the command from the picker; second Enter submits it
+ await act(async () => {
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
+ });
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx
index bc10e367..133f1660 100644
--- a/frontend/src/components/Chat.tsx
+++ b/frontend/src/components/Chat.tsx
@@ -1059,6 +1059,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
)}
{messages.map((msg: Message, idx: number) => (
diff --git a/frontend/src/components/ChatInput.tsx b/frontend/src/components/ChatInput.tsx
index c560881b..e0d3c0d6 100644
--- a/frontend/src/components/ChatInput.tsx
+++ b/frontend/src/components/ChatInput.tsx
@@ -1,5 +1,6 @@
import * as React from "react";
import { api } from "../api/client";
+import { SLASH_COMMANDS, type SlashCommand } from "../slashCommands";
const {
forwardRef,
@@ -113,6 +114,83 @@ function FilePickerOverlay({
);
}
+interface SlashCommandPickerOverlayProps {
+ query: string;
+ selectedIndex: number;
+ onSelect: (cmd: SlashCommand) => void;
+}
+
+function SlashCommandPickerOverlay({
+ query,
+ selectedIndex,
+ onSelect,
+}: SlashCommandPickerOverlayProps) {
+ const filtered = SLASH_COMMANDS.filter((cmd) =>
+ fuzzyMatch(cmd.name, query),
+ ).sort((a, b) => fuzzyScore(a.name, query) - fuzzyScore(b.name, query));
+
+ if (filtered.length === 0) return null;
+
+ return (
+
+ {filtered.map((cmd, idx) => (
+ onSelect(cmd)}
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ width: "100%",
+ textAlign: "left",
+ padding: "10px 14px",
+ background: idx === selectedIndex ? "#2d4a6e" : "transparent",
+ border: "none",
+ cursor: "pointer",
+ gap: "2px",
+ }}
+ >
+
+ {cmd.name}
+
+
+ {cmd.description}
+
+
+ ))}
+
+ );
+}
+
export const ChatInput = forwardRef(
function ChatInput(
{ loading, queuedMessages, onSubmit, onCancel, onRemoveQueuedMessage },
@@ -127,6 +205,10 @@ export const ChatInput = forwardRef(
const [pickerSelectedIndex, setPickerSelectedIndex] = useState(0);
const [pickerAtStart, setPickerAtStart] = useState(0);
+ // Slash command picker state
+ const [slashQuery, setSlashQuery] = useState(null);
+ const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
+
useImperativeHandle(ref, () => ({
appendToInput(text: string) {
setInput((prev) => (prev ? `${prev}\n${text}` : text));
@@ -153,6 +235,31 @@ export const ChatInput = forwardRef(
setPickerSelectedIndex(0);
}, []);
+ // Compute filtered slash commands for current query
+ const filteredCommands =
+ slashQuery !== null
+ ? SLASH_COMMANDS.filter((cmd) => fuzzyMatch(cmd.name, slashQuery)).sort(
+ (a, b) =>
+ fuzzyScore(a.name, slashQuery) - fuzzyScore(b.name, slashQuery),
+ )
+ : [];
+
+ const dismissSlashPicker = useCallback(() => {
+ setSlashQuery(null);
+ setSlashSelectedIndex(0);
+ }, []);
+
+ const selectCommand = useCallback(
+ (cmd: SlashCommand) => {
+ // Extract base command (first word, e.g. "/assign" from "/assign ")
+ const baseCommand = cmd.name.split(" ")[0];
+ setInput(`${baseCommand} `);
+ dismissSlashPicker();
+ setTimeout(() => inputRef.current?.focus(), 0);
+ },
+ [dismissSlashPicker],
+ );
+
const selectFile = useCallback(
(file: string) => {
// Replace the @query portion with @file
@@ -173,11 +280,20 @@ export const ChatInput = forwardRef(
setInput(val);
const cursor = e.target.selectionStart ?? val.length;
- // Find the last @ before the cursor that starts a reference token
const textUpToCursor = val.slice(0, cursor);
- // Match @ not preceded by non-whitespace (i.e. @ at start or after space/newline)
- const atMatch = textUpToCursor.match(/(^|[\s\n])@([^\s@]*)$/);
+ // Slash command picker: triggered when input starts with / and no space yet
+ const slashMatch = textUpToCursor.match(/^\/(\S*)$/);
+ if (slashMatch) {
+ setSlashQuery(slashMatch[1]);
+ setSlashSelectedIndex(0);
+ if (pickerQuery !== null) dismissPicker();
+ return;
+ }
+ if (slashQuery !== null) dismissSlashPicker();
+
+ // File picker: triggered by @ at start or after whitespace
+ const atMatch = textUpToCursor.match(/(^|[\s\n])@([^\s@]*)$/);
if (atMatch) {
const query = atMatch[2];
const atPos = textUpToCursor.lastIndexOf("@");
@@ -196,11 +312,50 @@ export const ChatInput = forwardRef(
if (pickerQuery !== null) dismissPicker();
}
},
- [projectFiles.length, pickerQuery, dismissPicker],
+ [
+ projectFiles.length,
+ pickerQuery,
+ dismissPicker,
+ slashQuery,
+ dismissSlashPicker,
+ ],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
+ // Slash command picker navigation
+ if (slashQuery !== null && filteredCommands.length > 0) {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setSlashSelectedIndex((i) =>
+ Math.min(i + 1, filteredCommands.length - 1),
+ );
+ return;
+ }
+ if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setSlashSelectedIndex((i) => Math.max(i - 1, 0));
+ return;
+ }
+ if (e.key === "Tab" || e.key === "Enter") {
+ e.preventDefault();
+ selectCommand(
+ filteredCommands[slashSelectedIndex] ?? filteredCommands[0],
+ );
+ return;
+ }
+ if (e.key === "Escape") {
+ e.preventDefault();
+ dismissSlashPicker();
+ return;
+ }
+ } else if (e.key === "Escape" && slashQuery !== null) {
+ e.preventDefault();
+ dismissSlashPicker();
+ return;
+ }
+
+ // File picker navigation
if (pickerQuery !== null && filteredFiles.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
@@ -236,6 +391,11 @@ export const ChatInput = forwardRef(
}
},
[
+ slashQuery,
+ filteredCommands,
+ slashSelectedIndex,
+ selectCommand,
+ dismissSlashPicker,
pickerQuery,
filteredFiles,
pickerSelectedIndex,
@@ -249,6 +409,7 @@ export const ChatInput = forwardRef(
onSubmit(input);
setInput("");
dismissPicker();
+ dismissSlashPicker();
};
return (
@@ -357,6 +518,13 @@ export const ChatInput = forwardRef(
position: "relative",
}}
>
+ {slashQuery !== null && (
+
+ )}
{pickerQuery !== null && (
({
+ api: {
+ listProjectFiles: vi.fn().mockResolvedValue([]),
+ },
+}));
+
+const defaultProps = {
+ loading: false,
+ queuedMessages: [],
+ onSubmit: vi.fn(),
+ onCancel: vi.fn(),
+ onRemoveQueuedMessage: vi.fn(),
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("Slash command picker overlay (Story 438 AC1)", () => {
+ it("shows slash command picker when / is typed at position 0", async () => {
+ render( );
+ const textarea = screen.getByPlaceholderText("Send a message...");
+
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: "/" } });
+ });
+
+ expect(screen.getByTestId("slash-command-picker")).toBeInTheDocument();
+ });
+
+ it("does not show slash command picker for plain text", async () => {
+ render( );
+ const textarea = screen.getByPlaceholderText("Send a message...");
+
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: "hello" } });
+ });
+
+ expect(
+ screen.queryByTestId("slash-command-picker"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("does not show slash command picker when / is not at position 0", async () => {
+ render( );
+ const textarea = screen.getByPlaceholderText("Send a message...");
+
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: "hello /world" } });
+ });
+
+ expect(
+ screen.queryByTestId("slash-command-picker"),
+ ).not.toBeInTheDocument();
+ });
+});
+
+describe("Slash command list (Story 438 AC2)", () => {
+ it("lists slash commands with name and description", async () => {
+ render( );
+ const textarea = screen.getByPlaceholderText("Send a message...");
+
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: "/" } });
+ });
+
+ expect(screen.getByTestId("slash-command-picker")).toBeInTheDocument();
+ // First command should be /help
+ expect(screen.getByTestId("slash-command-item-0")).toBeInTheDocument();
+ expect(screen.getByTestId("slash-command-item-0")).toHaveTextContent(
+ "/help",
+ );
+ });
+});
+
+describe("Slash command fuzzy filter (Story 438 AC3)", () => {
+ it("filters commands when typing after /", async () => {
+ render( );
+ const textarea = screen.getByPlaceholderText("Send a message...");
+
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: "/hel" } });
+ });
+
+ expect(screen.getByTestId("slash-command-picker")).toBeInTheDocument();
+ // /help should match "hel"
+ expect(screen.getByTestId("slash-command-item-0")).toHaveTextContent(
+ "/help",
+ );
+ // /rebuild should not be visible (no match for "hel")
+ const items = screen.queryAllByTestId(/^slash-command-item-/);
+ const texts = items.map((el) => el.textContent ?? "");
+ expect(texts.some((t) => t.includes("/rebuild"))).toBe(false);
+ });
+
+ it("shows no picker when query matches nothing", async () => {
+ render( );
+ const textarea = screen.getByPlaceholderText("Send a message...");
+
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: "/zzzzz" } });
+ });
+
+ expect(
+ screen.queryByTestId("slash-command-picker"),
+ ).not.toBeInTheDocument();
+ });
+});
+
+describe("Slash command keyboard navigation (Story 438 AC4)", () => {
+ it("ArrowDown navigates to next item", async () => {
+ render( );
+ const textarea = screen.getByPlaceholderText("Send a message...");
+
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: "/" } });
+ });
+
+ const item0 = screen.getByTestId("slash-command-item-0");
+ expect(item0).toHaveStyle({ background: "#2d4a6e" });
+
+ await act(async () => {
+ fireEvent.keyDown(textarea, { key: "ArrowDown" });
+ });
+
+ const item1 = screen.getByTestId("slash-command-item-1");
+ expect(item1).toHaveStyle({ background: "#2d4a6e" });
+ });
+
+ it("ArrowUp stays at 0 when already at top", async () => {
+ render( );
+ const textarea = screen.getByPlaceholderText("Send a message...");
+
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: "/" } });
+ });
+
+ await act(async () => {
+ fireEvent.keyDown(textarea, { key: "ArrowUp" });
+ });
+
+ const item0 = screen.getByTestId("slash-command-item-0");
+ expect(item0).toHaveStyle({ background: "#2d4a6e" });
+ });
+
+ it("Enter selects the highlighted command and inserts it", async () => {
+ render( );
+ const textarea = screen.getByPlaceholderText("Send a message...");
+
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: "/hel" } });
+ });
+
+ await act(async () => {
+ fireEvent.keyDown(textarea, { key: "Enter" });
+ });
+
+ expect(
+ screen.queryByTestId("slash-command-picker"),
+ ).not.toBeInTheDocument();
+ expect((textarea as HTMLTextAreaElement).value).toBe("/help ");
+ });
+
+ it("Tab selects the highlighted command and inserts it", async () => {
+ render( );
+ const textarea = screen.getByPlaceholderText("Send a message...");
+
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: "/hel" } });
+ });
+
+ await act(async () => {
+ fireEvent.keyDown(textarea, { key: "Tab" });
+ });
+
+ expect(
+ screen.queryByTestId("slash-command-picker"),
+ ).not.toBeInTheDocument();
+ expect((textarea as HTMLTextAreaElement).value).toBe("/help ");
+ });
+
+ it("Escape dismisses the picker", async () => {
+ render( );
+ const textarea = screen.getByPlaceholderText("Send a message...");
+
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: "/" } });
+ });
+
+ expect(screen.getByTestId("slash-command-picker")).toBeInTheDocument();
+
+ await act(async () => {
+ fireEvent.keyDown(textarea, { key: "Escape" });
+ });
+
+ expect(
+ screen.queryByTestId("slash-command-picker"),
+ ).not.toBeInTheDocument();
+ });
+});
+
+describe("Slash command selection inserts with trailing space (Story 438 AC5)", () => {
+ it("clicking a command inserts / with trailing space", async () => {
+ render( );
+ const textarea = screen.getByPlaceholderText("Send a message...");
+
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: "/" } });
+ });
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId("slash-command-item-0"));
+ });
+
+ expect(
+ screen.queryByTestId("slash-command-picker"),
+ ).not.toBeInTheDocument();
+ const val = (textarea as HTMLTextAreaElement).value;
+ expect(val).toMatch(/^\/\w+ $/);
+ });
+
+ it("selection inserts only the base command (no argument placeholders)", async () => {
+ render( );
+ const textarea = screen.getByPlaceholderText("Send a message...");
+
+ await act(async () => {
+ fireEvent.change(textarea, { target: { value: "/ass" } });
+ });
+
+ await act(async () => {
+ fireEvent.keyDown(textarea, { key: "Enter" });
+ });
+
+ expect((textarea as HTMLTextAreaElement).value).toBe("/assign ");
+ });
+});
diff --git a/frontend/src/components/HelpOverlay.tsx b/frontend/src/components/HelpOverlay.tsx
index e542465f..808fbab6 100644
--- a/frontend/src/components/HelpOverlay.tsx
+++ b/frontend/src/components/HelpOverlay.tsx
@@ -1,75 +1,8 @@
import * as React from "react";
+import { SLASH_COMMANDS } from "../slashCommands";
const { useEffect, useRef } = React;
-interface SlashCommand {
- name: string;
- description: string;
-}
-
-const SLASH_COMMANDS: SlashCommand[] = [
- {
- name: "/help",
- description: "Show this list of available slash commands.",
- },
- {
- name: "/status",
- description:
- "Show pipeline status and agent availability. `/status ` shows a story triage dump.",
- },
- {
- name: "/assign ",
- description: "Pre-assign a model to a story (e.g. `/assign 42 opus`).",
- },
- {
- name: "/start ",
- description:
- "Start a coder on a story. Optionally specify a model: `/start opus`.",
- },
- {
- name: "/show ",
- description: "Display the full text of a work item.",
- },
- {
- name: "/move ",
- description:
- "Move a work item to a pipeline stage (backlog, current, qa, merge, done).",
- },
- {
- name: "/delete ",
- description:
- "Remove a work item from the pipeline and stop any running agent.",
- },
- {
- name: "/cost",
- description:
- "Show token spend: 24h total, top stories, breakdown by agent type, and all-time total.",
- },
- {
- name: "/git",
- description:
- "Show git status: branch, uncommitted changes, and ahead/behind remote.",
- },
- {
- name: "/overview ",
- description: "Show the implementation summary for a merged story.",
- },
- {
- name: "/rebuild",
- description: "Rebuild the server binary and restart.",
- },
- {
- name: "/reset",
- description:
- "Clear the current Claude Code session and start fresh (messages and session ID are cleared locally).",
- },
- {
- name: "/btw ",
- description:
- "Ask a side question using the current conversation as context. The question and answer are not added to the conversation history.",
- },
-];
-
interface HelpOverlayProps {
onDismiss: () => void;
}
diff --git a/frontend/src/components/MessageItem.tsx b/frontend/src/components/MessageItem.tsx
index 818d9fee..6f9171e5 100644
--- a/frontend/src/components/MessageItem.tsx
+++ b/frontend/src/components/MessageItem.tsx
@@ -136,6 +136,7 @@ function MessageItemInner({ msg }: MessageItemProps) {
return (
(
` shows a story triage dump.",
+ },
+ {
+ name: "/assign ",
+ description: "Pre-assign a model to a story (e.g. `/assign 42 opus`).",
+ },
+ {
+ name: "/start ",
+ description:
+ "Start a coder on a story. Optionally specify a model: `/start opus`.",
+ },
+ {
+ name: "/show ",
+ description: "Display the full text of a work item.",
+ },
+ {
+ name: "/move ",
+ description:
+ "Move a work item to a pipeline stage (backlog, current, qa, merge, done).",
+ },
+ {
+ name: "/delete ",
+ description:
+ "Remove a work item from the pipeline and stop any running agent.",
+ },
+ {
+ name: "/cost",
+ description:
+ "Show token spend: 24h total, top stories, breakdown by agent type, and all-time total.",
+ },
+ {
+ name: "/git",
+ description:
+ "Show git status: branch, uncommitted changes, and ahead/behind remote.",
+ },
+ {
+ name: "/overview ",
+ description: "Show the implementation summary for a merged story.",
+ },
+ {
+ name: "/rebuild",
+ description: "Rebuild the server binary and restart.",
+ },
+ {
+ name: "/reset",
+ description:
+ "Clear the current Claude Code session and start fresh (messages and session ID are cleared locally).",
+ },
+ {
+ name: "/btw ",
+ description:
+ "Ask a side question using the current conversation as context. The question and answer are not added to the conversation history.",
+ },
+];
diff --git a/server/src/chat/commands/unblock.rs b/server/src/chat/commands/unblock.rs
index 1fa2019d..d3d40fa8 100644
--- a/server/src/chat/commands/unblock.rs
+++ b/server/src/chat/commands/unblock.rs
@@ -108,17 +108,13 @@ pub(crate) fn unblock_by_path(path: &Path, story_id: &str) -> String {
}
// Clear the blocked flag if present.
- if has_blocked {
- if let Err(e) = clear_front_matter_field(path, "blocked") {
- return format!("Failed to clear blocked flag on **{story_id}**: {e}");
- }
+ if has_blocked && let Err(e) = clear_front_matter_field(path, "blocked") {
+ return format!("Failed to clear blocked flag on **{story_id}**: {e}");
}
// Clear merge_failure if present.
- if has_merge_failure {
- if let Err(e) = clear_front_matter_field(path, "merge_failure") {
- return format!("Failed to clear merge_failure on **{story_id}**: {e}");
- }
+ if has_merge_failure && let Err(e) = clear_front_matter_field(path, "merge_failure") {
+ return format!("Failed to clear merge_failure on **{story_id}**: {e}");
}
// Reset retry_count to 0 (re-read the updated file, modify, write).
diff --git a/server/src/http/mcp/wizard_tools.rs b/server/src/http/mcp/wizard_tools.rs
index da5319c0..f1c24f5e 100644
--- a/server/src/http/mcp/wizard_tools.rs
+++ b/server/src/http/mcp/wizard_tools.rs
@@ -154,7 +154,7 @@ pub(super) fn tool_wizard_generate(args: &Value, ctx: &AppContext) -> Result bool {
- let dominated_by_storkit = std::fs::read_dir(project_root)
+ std::fs::read_dir(project_root)
.ok()
.map(|entries| {
let names: Vec = entries
@@ -171,8 +171,7 @@ fn is_bare_project(project_root: &Path) -> bool {
|| n == "store.json"
})
})
- .unwrap_or(true);
- dominated_by_storkit
+ .unwrap_or(true)
}
/// Return a generation hint for a step based on the project root.