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
5 changes: 5 additions & 0 deletions .changeset/cli-followup-slash-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Fix slash commands being intercepted by followup suggestions during `ask_followup_question` prompts.
102 changes: 102 additions & 0 deletions cli/src/state/atoms/__tests__/keyboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
argumentSuggestionsAtom,
selectedIndexAtom,
fileMentionSuggestionsAtom,
setFollowupSuggestionsAtom,
followupSuggestionsAtom,
} from "../ui.js"
import { textBufferStringAtom, textBufferStateAtom } from "../textBuffer.js"
import {
Expand Down Expand Up @@ -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)
})
})
})
1 change: 1 addition & 0 deletions cli/src/state/atoms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ export {
// Followup suggestions state atoms
followupSuggestionsAtom,
showFollowupSuggestionsAtom,
followupSuggestionsMenuVisibleAtom,
selectedFollowupIndexAtom,

// Derived UI atoms
Expand Down
24 changes: 20 additions & 4 deletions cli/src/state/atoms/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
fileMentionContextAtom,
selectedIndexAtom,
followupSuggestionsAtom,
showFollowupSuggestionsAtom,
followupSuggestionsMenuVisibleAtom,
clearFollowupSuggestionsAtom,
inputModeAtom,
type InputMode,
Expand Down Expand Up @@ -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)
}
}
})

Expand Down Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions cli/src/state/atoms/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,23 @@ export const followupSuggestionsAtom = atom<FollowupSuggestion[]>([])
*/
export const showFollowupSuggestionsAtom = atom<boolean>(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<boolean>((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.
Expand Down
4 changes: 2 additions & 2 deletions cli/src/state/hooks/useFollowupSuggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useMemo, useCallback } from "react"
import type { FollowupSuggestion } from "../atoms/ui.js"
import {
followupSuggestionsAtom,
showFollowupSuggestionsAtom,
followupSuggestionsMenuVisibleAtom,
selectedIndexAtom,
setFollowupSuggestionsAtom,
clearFollowupSuggestionsAtom,
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions cli/src/state/hooks/useHotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions cli/src/ui/components/__tests__/StatusIndicator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(
<JotaiProvider store={store}>
Expand All @@ -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(
<JotaiProvider store={store}>
Expand Down