diff --git a/.changeset/cli-followup-slash-commands.md b/.changeset/cli-followup-slash-commands.md new file mode 100644 index 0000000000..0805250ec3 --- /dev/null +++ b/.changeset/cli-followup-slash-commands.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix slash commands being intercepted by followup suggestions during `ask_followup_question` prompts. diff --git a/cli/src/state/atoms/__tests__/keyboard.test.ts b/cli/src/state/atoms/__tests__/keyboard.test.ts index b776426ba4..333bd44a6a 100644 --- a/cli/src/state/atoms/__tests__/keyboard.test.ts +++ b/cli/src/state/atoms/__tests__/keyboard.test.ts @@ -7,6 +7,8 @@ import { argumentSuggestionsAtom, selectedIndexAtom, fileMentionSuggestionsAtom, + setFollowupSuggestionsAtom, + followupSuggestionsAtom, } from "../ui.js" import { textBufferStringAtom, textBufferStateAtom } from "../textBuffer.js" import { @@ -1151,4 +1153,104 @@ describe("keypress atoms", () => { expect(store.get(exitPromptVisibleAtom)).toBe(true) }) }) + + describe("followup suggestions vs slash command input", () => { + it("should submit typed /command (not followup suggestion) when input starts with '/'", async () => { + const mockCallback = vi.fn() + store.set(submissionCallbackAtom, { callback: mockCallback }) + + // Followup suggestions are active (ask_followup_question), which normally takes priority over autocomplete. + store.set(setFollowupSuggestionsAtom, [{ answer: "Yes, continue" }, { answer: "No, stop" }]) + + // Type a slash command. + for (const char of ["/", "h", "e", "l", "p"]) { + const key: Key = { + name: char, + sequence: char, + ctrl: false, + meta: false, + shift: false, + paste: false, + } + store.set(keyboardHandlerAtom, key) + } + + // Simulate the "auto-select first item" behavior from autocomplete that can set selectedIndex to 0. + // In the buggy behavior, followup mode is still active and this causes Enter to submit the followup suggestion instead. + store.set(selectedIndexAtom, 0) + + // Press Enter to submit. + const enterKey: Key = { + name: "return", + sequence: "\r", + ctrl: false, + meta: false, + shift: false, + paste: false, + } + await store.set(keyboardHandlerAtom, enterKey) + + // Wait for async operations to complete + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockCallback).toHaveBeenCalledWith("/help") + // Followup should remain active after running a slash command. + expect(store.get(followupSuggestionsAtom)).toHaveLength(2) + // Followup should not auto-select after command execution. + expect(store.get(selectedIndexAtom)).toBe(-1) + }) + + it("should dismiss followup suggestions for /clear and /new commands", async () => { + const mockCallback = vi.fn() + store.set(submissionCallbackAtom, { callback: mockCallback }) + + store.set(setFollowupSuggestionsAtom, [{ answer: "Yes, continue" }, { answer: "No, stop" }]) + + // Type /clear + for (const char of ["/", "c", "l", "e", "a", "r"]) { + const key: Key = { + name: char, + sequence: char, + ctrl: false, + meta: false, + shift: false, + paste: false, + } + store.set(keyboardHandlerAtom, key) + } + + const enterKey: Key = { + name: "return", + sequence: "\r", + ctrl: false, + meta: false, + shift: false, + paste: false, + } + await store.set(keyboardHandlerAtom, enterKey) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockCallback).toHaveBeenCalledWith("/clear") + expect(store.get(followupSuggestionsAtom)).toHaveLength(0) + + // Re-seed followup and type /new + store.set(setFollowupSuggestionsAtom, [{ answer: "Yes, continue" }, { answer: "No, stop" }]) + for (const char of ["/", "n", "e", "w"]) { + const key: Key = { + name: char, + sequence: char, + ctrl: false, + meta: false, + shift: false, + paste: false, + } + store.set(keyboardHandlerAtom, key) + } + await store.set(keyboardHandlerAtom, enterKey) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockCallback).toHaveBeenCalledWith("/new") + expect(store.get(followupSuggestionsAtom)).toHaveLength(0) + }) + }) }) diff --git a/cli/src/state/atoms/index.ts b/cli/src/state/atoms/index.ts index 1e8cd0eeb9..fcea0ff1da 100644 --- a/cli/src/state/atoms/index.ts +++ b/cli/src/state/atoms/index.ts @@ -214,6 +214,7 @@ export { // Followup suggestions state atoms followupSuggestionsAtom, showFollowupSuggestionsAtom, + followupSuggestionsMenuVisibleAtom, selectedFollowupIndexAtom, // Derived UI atoms diff --git a/cli/src/state/atoms/keyboard.ts b/cli/src/state/atoms/keyboard.ts index 166e476758..de4a21db66 100644 --- a/cli/src/state/atoms/keyboard.ts +++ b/cli/src/state/atoms/keyboard.ts @@ -14,7 +14,7 @@ import { fileMentionContextAtom, selectedIndexAtom, followupSuggestionsAtom, - showFollowupSuggestionsAtom, + followupSuggestionsMenuVisibleAtom, clearFollowupSuggestionsAtom, inputModeAtom, type InputMode, @@ -408,14 +408,30 @@ export const submitInputAtom = atom(null, (get, set, text: string | Buffer) => { // Convert Buffer to string if needed const textStr = typeof text === "string" ? text : text.toString() + const trimmedText = textStr.trim() + const hasFollowupSuggestions = get(followupSuggestionsAtom).length > 0 + const isSlashCommand = trimmedText.startsWith("/") + const slashCommandName = isSlashCommand ? (trimmedText.match(/^\/([^\s]+)/)?.[1]?.toLowerCase() ?? "") : "" + const shouldDismissFollowupOnSlashCommand = new Set(["clear", "c", "cls", "new", "n", "start", "exit", "q", "quit"]) - if (callback && typeof callback === "function" && textStr && textStr.trim()) { + if (callback && typeof callback === "function" && trimmedText) { // Call the submission callback callback(textStr) // Clear input and related state set(clearTextBufferAtom) - set(clearFollowupSuggestionsAtom) + // If the user runs a slash command while a followup question is active, + // keep the followup question/suggestions so they can answer after the command runs. + if (hasFollowupSuggestions && isSlashCommand) { + if (slashCommandName && shouldDismissFollowupOnSlashCommand.has(slashCommandName)) { + set(clearFollowupSuggestionsAtom) + } else { + // Ensure followup stays in "no selection" mode after executing a slash command. + set(selectedIndexAtom, -1) + } + } else { + set(clearFollowupSuggestionsAtom) + } } }) @@ -1047,7 +1063,7 @@ export const keyboardHandlerAtom = atom(null, async (get, set, key: Key) => { // Priority 2: Determine current mode and route to mode-specific handler const isApprovalPending = get(isApprovalPendingAtom) - const isFollowupVisible = get(showFollowupSuggestionsAtom) + const isFollowupVisible = get(followupSuggestionsMenuVisibleAtom) const isAutocompleteVisible = get(showAutocompleteAtom) const fileMentionSuggestions = get(fileMentionSuggestionsAtom) const isInHistoryMode = get(historyModeAtom) diff --git a/cli/src/state/atoms/ui.ts b/cli/src/state/atoms/ui.ts index 3330b7611d..5bcf46c620 100644 --- a/cli/src/state/atoms/ui.ts +++ b/cli/src/state/atoms/ui.ts @@ -238,6 +238,23 @@ export const followupSuggestionsAtom = atom([]) */ export const showFollowupSuggestionsAtom = atom(false) +/** + * Derived atom that hides followup suggestions when slash-command autocomplete or file-mention autocomplete is active. + * This prevents the followup menu (and its selection index) from intercepting "/" commands. + */ +export const followupSuggestionsMenuVisibleAtom = atom((get) => { + if (!get(showFollowupSuggestionsAtom)) return false + if (get(followupSuggestionsAtom).length === 0) return false + + // If the user starts a "/" command, show command autocomplete instead of followups. + if (get(showAutocompleteAtom)) return false + + // If file-mention autocomplete is active, it should take precedence as well. + if (get(fileMentionSuggestionsAtom).length > 0) return false + + return true +}) + /** * @deprecated Use selectedIndexAtom instead - this is now shared across all selection contexts * This atom is kept for backward compatibility but will be removed in a future version. diff --git a/cli/src/state/hooks/useFollowupSuggestions.ts b/cli/src/state/hooks/useFollowupSuggestions.ts index dad6d19521..15a8e5baff 100644 --- a/cli/src/state/hooks/useFollowupSuggestions.ts +++ b/cli/src/state/hooks/useFollowupSuggestions.ts @@ -8,7 +8,7 @@ import { useMemo, useCallback } from "react" import type { FollowupSuggestion } from "../atoms/ui.js" import { followupSuggestionsAtom, - showFollowupSuggestionsAtom, + followupSuggestionsMenuVisibleAtom, selectedIndexAtom, setFollowupSuggestionsAtom, clearFollowupSuggestionsAtom, @@ -102,7 +102,7 @@ export interface UseFollowupSuggestionsReturn { export function useFollowupSuggestions(): UseFollowupSuggestionsReturn { // Read atoms const suggestions = useAtomValue(followupSuggestionsAtom) - const isVisible = useAtomValue(showFollowupSuggestionsAtom) + const isVisible = useAtomValue(followupSuggestionsMenuVisibleAtom) const selectedIndex = useAtomValue(selectedIndexAtom) const selectedSuggestion = useAtomValue(getSelectedFollowupAtom) const hasSuggestions = useAtomValue(hasFollowupSuggestionsAtom) diff --git a/cli/src/state/hooks/useHotkeys.ts b/cli/src/state/hooks/useHotkeys.ts index 863c543732..32d1a4b2d1 100644 --- a/cli/src/state/hooks/useHotkeys.ts +++ b/cli/src/state/hooks/useHotkeys.ts @@ -4,7 +4,7 @@ import { useAtomValue } from "jotai" import { useMemo } from "react" -import { isStreamingAtom, showFollowupSuggestionsAtom } from "../atoms/ui.js" +import { isStreamingAtom, followupSuggestionsMenuVisibleAtom } from "../atoms/ui.js" import { useApprovalHandler } from "./useApprovalHandler.js" import { hasResumeTaskAtom } from "../atoms/extension.js" import { shellModeActiveAtom } from "../atoms/keyboard.js" @@ -46,7 +46,7 @@ function getModifierKey(): string { */ export function useHotkeys(): UseHotkeysReturn { const isStreaming = useAtomValue(isStreamingAtom) - const isFollowupVisible = useAtomValue(showFollowupSuggestionsAtom) + const isFollowupVisible = useAtomValue(followupSuggestionsMenuVisibleAtom) const hasResumeTask = useAtomValue(hasResumeTaskAtom) const isShellModeActive = useAtomValue(shellModeActiveAtom) const { isApprovalPending } = useApprovalHandler() diff --git a/cli/src/ui/components/__tests__/StatusIndicator.test.tsx b/cli/src/ui/components/__tests__/StatusIndicator.test.tsx index 3d1942bc77..967edba32b 100644 --- a/cli/src/ui/components/__tests__/StatusIndicator.test.tsx +++ b/cli/src/ui/components/__tests__/StatusIndicator.test.tsx @@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { Provider as JotaiProvider } from "jotai" import { createStore } from "jotai" import { StatusIndicator } from "../StatusIndicator.js" -import { showFollowupSuggestionsAtom, isCancellingAtom } from "../../../state/atoms/ui.js" +import { setFollowupSuggestionsAtom, isCancellingAtom } from "../../../state/atoms/ui.js" import { chatMessagesAtom } from "../../../state/atoms/extension.js" import { exitPromptVisibleAtom } from "../../../state/atoms/keyboard.js" import type { ExtensionChatMessage } from "../../../types/messages.js" @@ -63,7 +63,7 @@ describe("StatusIndicator", () => { }) it("should show followup hotkeys when suggestions are visible", () => { - store.set(showFollowupSuggestionsAtom, true) + store.set(setFollowupSuggestionsAtom, [{ answer: "Yes, continue" }, { answer: "No, stop" }]) const { lastFrame } = render( @@ -80,7 +80,7 @@ describe("StatusIndicator", () => { it("should show general command hints when idle", () => { // No messages = not streaming store.set(chatMessagesAtom, []) - store.set(showFollowupSuggestionsAtom, false) + store.set(setFollowupSuggestionsAtom, []) const { lastFrame } = render(