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) => ( + + ))} +
+ ); +} + 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.