diff --git a/.changeset/cli-slash-command-default-selection.md b/.changeset/cli-slash-command-default-selection.md new file mode 100644 index 00000000000..bf2f965ff96 --- /dev/null +++ b/.changeset/cli-slash-command-default-selection.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix slash command suggestions to select first entry by default when typing `/` diff --git a/cli/src/state/atoms/__tests__/default-selection.test.ts b/cli/src/state/atoms/__tests__/default-selection.test.ts new file mode 100644 index 00000000000..e7442070c08 --- /dev/null +++ b/cli/src/state/atoms/__tests__/default-selection.test.ts @@ -0,0 +1,328 @@ +/** + * Tests for default selection behavior in dropdown menus + * + * This test file verifies that when dropdown menus appear (slash commands, + * argument suggestions, file mentions, approval menus), the first entry + * is selected by default. + */ + +import { describe, it, expect, vi } from "vitest" +import { createStore } from "jotai" +import { + selectedIndexAtom, + suggestionsAtom, + argumentSuggestionsAtom, + fileMentionSuggestionsAtom, + setSuggestionsAtom, + setArgumentSuggestionsAtom, + setFileMentionSuggestionsAtom, + setFollowupSuggestionsAtom, + followupSuggestionsAtom, +} from "../ui.js" +import { setPendingApprovalAtom, pendingApprovalAtom, approvalOptionsAtom } from "../approval.js" +import type { CommandSuggestion, ArgumentSuggestion, FileMentionSuggestion } from "../../../services/autocomplete.js" +import type { Command } from "../../../commands/core/types.js" +import type { ExtensionChatMessage } from "../../../types/messages.js" + +// Helper to create a mock command +const createMockCommand = (name: string): Command => ({ + name, + description: `${name} command`, + aliases: [], + usage: `/${name}`, + examples: [`/${name}`], + category: "test", + handler: vi.fn(), +}) + +// Helper to create a mock command suggestion +const createCommandSuggestion = (name: string): CommandSuggestion => ({ + command: createMockCommand(name), + matchScore: 90, + highlightedName: name, +}) + +// Helper to create a mock argument suggestion +const createArgumentSuggestion = (value: string): ArgumentSuggestion => ({ + value, + description: `${value} description`, + matchScore: 90, + highlightedValue: value, +}) + +// Helper to create a mock file mention suggestion +const createFileMentionSuggestion = (path: string): FileMentionSuggestion => ({ + value: path, + description: `in ${path.split("/").slice(0, -1).join("/")}`, + matchScore: 90, + highlightedValue: path, + type: "file", +}) + +// Helper to create a mock approval message +const createApprovalMessage = (ask: string, text: string = "{}"): ExtensionChatMessage => ({ + type: "ask", + ask, + text, + ts: Date.now(), + partial: false, + isAnswered: false, + say: "assistant", +}) + +describe("default selection behavior", () => { + describe("slash command suggestions", () => { + it("should set selectedIndex to 0 when command suggestions are set", () => { + const store = createStore() + + // Initially, selectedIndex should be 0 + expect(store.get(selectedIndexAtom)).toBe(0) + + // Set some command suggestions + const suggestions = [createCommandSuggestion("help"), createCommandSuggestion("mode")] + + store.set(setSuggestionsAtom, suggestions) + + // selectedIndex should be 0 (first entry selected) + expect(store.get(selectedIndexAtom)).toBe(0) + expect(store.get(suggestionsAtom)).toHaveLength(2) + }) + + it("should reset selectedIndex to -1 when command suggestions are cleared", () => { + const store = createStore() + + // Set some suggestions first + store.set(setSuggestionsAtom, [createCommandSuggestion("help")]) + expect(store.get(selectedIndexAtom)).toBe(0) + + // Navigate to a different index + store.set(selectedIndexAtom, 5) + + // Clear suggestions - should reset selectedIndex to -1 (no selection) + store.set(setSuggestionsAtom, []) + + // selectedIndex should be reset to -1 + expect(store.get(selectedIndexAtom)).toBe(-1) + }) + + it("should reset selectedIndex to 0 when new suggestions replace old ones", () => { + const store = createStore() + + // Set initial suggestions + store.set(setSuggestionsAtom, [createCommandSuggestion("help"), createCommandSuggestion("mode")]) + + // Manually change selectedIndex to simulate user navigation + store.set(selectedIndexAtom, 1) + expect(store.get(selectedIndexAtom)).toBe(1) + + // Set new suggestions + store.set(setSuggestionsAtom, [createCommandSuggestion("new"), createCommandSuggestion("task")]) + + // selectedIndex should be reset to 0 + expect(store.get(selectedIndexAtom)).toBe(0) + }) + }) + + describe("argument suggestions", () => { + it("should set selectedIndex to 0 when argument suggestions are set", () => { + const store = createStore() + + // Set some argument suggestions + const suggestions = [createArgumentSuggestion("code"), createArgumentSuggestion("architect")] + + store.set(setArgumentSuggestionsAtom, suggestions) + + // selectedIndex should be 0 (first entry selected) + expect(store.get(selectedIndexAtom)).toBe(0) + expect(store.get(argumentSuggestionsAtom)).toHaveLength(2) + }) + + it("should reset selectedIndex to -1 when argument suggestions are cleared", () => { + const store = createStore() + + // Set some suggestions first + store.set(setArgumentSuggestionsAtom, [createArgumentSuggestion("code")]) + expect(store.get(selectedIndexAtom)).toBe(0) + + // Navigate to a different index + store.set(selectedIndexAtom, 5) + + // Clear suggestions - should reset selectedIndex to -1 (no selection) + store.set(setArgumentSuggestionsAtom, []) + + // selectedIndex should be reset to -1 + expect(store.get(selectedIndexAtom)).toBe(-1) + }) + }) + + describe("file mention suggestions", () => { + it("should set selectedIndex to 0 when file mention suggestions are set", () => { + const store = createStore() + + // Set some file mention suggestions + const suggestions = [createFileMentionSuggestion("src/index.ts"), createFileMentionSuggestion("src/app.ts")] + + store.set(setFileMentionSuggestionsAtom, suggestions) + + // selectedIndex should be 0 (first entry selected) + expect(store.get(selectedIndexAtom)).toBe(0) + expect(store.get(fileMentionSuggestionsAtom)).toHaveLength(2) + }) + + it("should reset selectedIndex to -1 when file mention suggestions are cleared", () => { + const store = createStore() + + // Set some suggestions first + store.set(setFileMentionSuggestionsAtom, [createFileMentionSuggestion("src/index.ts")]) + expect(store.get(selectedIndexAtom)).toBe(0) + + // Navigate to a different index + store.set(selectedIndexAtom, 5) + + // Clear suggestions - should reset selectedIndex to -1 (no selection) + store.set(setFileMentionSuggestionsAtom, []) + + // selectedIndex should be reset to -1 + expect(store.get(selectedIndexAtom)).toBe(-1) + }) + }) + + describe("approval menu", () => { + it("should set selectedIndex to 0 when approval message is set", () => { + const store = createStore() + + // Set an approval message + const message = createApprovalMessage("tool", JSON.stringify({ tool: "readFile" })) + store.set(setPendingApprovalAtom, message) + + // selectedIndex should be 0 (first entry selected) + expect(store.get(selectedIndexAtom)).toBe(0) + expect(store.get(pendingApprovalAtom)).not.toBeNull() + expect(store.get(approvalOptionsAtom).length).toBeGreaterThan(0) + }) + + it("should reset selectedIndex to 0 when new approval message replaces old one", () => { + const store = createStore() + + // Set initial approval message with explicit timestamp + const message1: ExtensionChatMessage = { + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "readFile" }), + ts: 1000, // Fixed timestamp + partial: false, + isAnswered: false, + say: "assistant", + } + store.set(setPendingApprovalAtom, message1) + + // Manually change selectedIndex to simulate user navigation + store.set(selectedIndexAtom, 1) + expect(store.get(selectedIndexAtom)).toBe(1) + + // Set new approval message with different timestamp + const message2: ExtensionChatMessage = { + type: "ask", + ask: "command", + text: "git status", + ts: 2000, // Different timestamp + partial: false, + isAnswered: false, + say: "assistant", + } + store.set(setPendingApprovalAtom, message2) + + // selectedIndex should be reset to 0 + expect(store.get(selectedIndexAtom)).toBe(0) + }) + + it("should NOT reset selectedIndex for command_output updates (streaming)", () => { + const store = createStore() + + // Set initial command_output message + const message1: ExtensionChatMessage = { + type: "ask", + ask: "command_output", + text: "Initial output", + ts: 12345, // Fixed timestamp + partial: true, + isAnswered: false, + say: "assistant", + } + store.set(setPendingApprovalAtom, message1) + + // Manually change selectedIndex to simulate user navigation + store.set(selectedIndexAtom, 1) + expect(store.get(selectedIndexAtom)).toBe(1) + + // Update the same message (same timestamp, different content) + const message2: ExtensionChatMessage = { + ...message1, + text: "Updated output", + partial: false, + } + store.set(setPendingApprovalAtom, message2) + + // selectedIndex should NOT be reset (same message, just updated) + expect(store.get(selectedIndexAtom)).toBe(1) + }) + }) + + describe("followup suggestions", () => { + it("should set selectedIndex to -1 when followup suggestions are set (by design)", () => { + const store = createStore() + + // Set some followup suggestions + const suggestions = [{ answer: "Yes, continue" }, { answer: "No, stop" }] + + store.set(setFollowupSuggestionsAtom, suggestions) + + // selectedIndex should be -1 (no selection by design - allows custom typing) + expect(store.get(selectedIndexAtom)).toBe(-1) + expect(store.get(followupSuggestionsAtom)).toHaveLength(2) + }) + }) + + describe("cross-menu selection isolation", () => { + it("should reset selection when switching from commands to arguments", () => { + const store = createStore() + + // Set command suggestions + store.set(setSuggestionsAtom, [createCommandSuggestion("help"), createCommandSuggestion("mode")]) + expect(store.get(selectedIndexAtom)).toBe(0) + + // Navigate to second item + store.set(selectedIndexAtom, 1) + expect(store.get(selectedIndexAtom)).toBe(1) + + // Clear commands and set arguments + store.set(setSuggestionsAtom, []) + store.set(setArgumentSuggestionsAtom, [createArgumentSuggestion("code"), createArgumentSuggestion("ask")]) + + // selectedIndex should be 0 for the new argument suggestions + expect(store.get(selectedIndexAtom)).toBe(0) + }) + + it("should reset selection when switching from arguments to file mentions", () => { + const store = createStore() + + // Set argument suggestions + store.set(setArgumentSuggestionsAtom, [createArgumentSuggestion("code"), createArgumentSuggestion("ask")]) + expect(store.get(selectedIndexAtom)).toBe(0) + + // Navigate to second item + store.set(selectedIndexAtom, 1) + expect(store.get(selectedIndexAtom)).toBe(1) + + // Clear arguments and set file mentions + store.set(setArgumentSuggestionsAtom, []) + store.set(setFileMentionSuggestionsAtom, [ + createFileMentionSuggestion("src/index.ts"), + createFileMentionSuggestion("src/app.ts"), + ]) + + // selectedIndex should be 0 for the new file mention suggestions + expect(store.get(selectedIndexAtom)).toBe(0) + }) + }) +}) diff --git a/cli/src/state/atoms/ui.ts b/cli/src/state/atoms/ui.ts index 8318e319f77..3330b7611db 100644 --- a/cli/src/state/atoms/ui.ts +++ b/cli/src/state/atoms/ui.ts @@ -135,12 +135,12 @@ export const isStreamingAtom = atom((get) => { }) /** - * Atom to track when a cancellation is in progress - * This provides immediate feedback when user presses ESC to cancel - * The extension is the source of truth for streaming state, but this atom - * allows the CLI to show "Cancelling..." immediately without waiting for - * the extension to process the cancellation request - */ + * Atom to track when a cancellation is in progress + * This provides immediate feedback when user presses ESC to cancel + * The extension is the source of truth for streaming state, but this atom + * allows the CLI to show "Cancelling..." immediately without waiting for + * the extension to process the cancellation request + */ export const isCancellingAtom = atom(false) // ============================================================================ @@ -463,6 +463,56 @@ export const clearFileMentionAtom = atom(null, (get, set) => { set(fileMentionContextAtom, null) }) +/** + * Action atom to update all suggestion state atomically + * This ensures that selectedIndex is set after all suggestions are updated + */ +export const updateAllSuggestionsAtom = atom( + null, + ( + get, + set, + params: { + commandSuggestions?: CommandSuggestion[] + argumentSuggestions?: ArgumentSuggestion[] + fileMentionSuggestions?: FileMentionSuggestion[] + fileMentionContext?: FileMentionContext | null + }, + ) => { + const { commandSuggestions, argumentSuggestions, fileMentionSuggestions, fileMentionContext } = params + + // Set all suggestion arrays first + if (commandSuggestions !== undefined) { + set(suggestionsAtom, commandSuggestions) + } + if (argumentSuggestions !== undefined) { + set(argumentSuggestionsAtom, argumentSuggestions) + } + if (fileMentionSuggestions !== undefined) { + set(fileMentionSuggestionsAtom, fileMentionSuggestions) + } + if (fileMentionContext !== undefined) { + set(fileMentionContextAtom, fileMentionContext) + } + + // Determine which suggestions are active and set selectedIndex accordingly + let activeSuggestions: (CommandSuggestion | ArgumentSuggestion | FileMentionSuggestion)[] = [] + if (fileMentionSuggestions && fileMentionSuggestions.length > 0) { + activeSuggestions = fileMentionSuggestions + } else if (commandSuggestions && commandSuggestions.length > 0) { + activeSuggestions = commandSuggestions + } else if (argumentSuggestions && argumentSuggestions.length > 0) { + activeSuggestions = argumentSuggestions + } + + if (activeSuggestions.length > 0) { + set(selectedIndexAtom, 0) + } else { + set(selectedIndexAtom, -1) + } + }, +) + /** * Action atom to select the next suggestion */ diff --git a/cli/src/state/hooks/useCommandInput.ts b/cli/src/state/hooks/useCommandInput.ts index f6731f8c0ed..d258da50a20 100644 --- a/cli/src/state/hooks/useCommandInput.ts +++ b/cli/src/state/hooks/useCommandInput.ts @@ -32,11 +32,8 @@ import { commandQueryAtom, updateTextBufferAtom, clearTextBufferAtom, - setSuggestionsAtom, - setArgumentSuggestionsAtom, - setFileMentionSuggestionsAtom, - setFileMentionContextAtom, clearFileMentionAtom, + updateAllSuggestionsAtom, selectNextSuggestionAtom, selectPreviousSuggestionAtom, hideAutocompleteAtom, @@ -177,11 +174,8 @@ export function useCommandInput(): UseCommandInputReturn { // Write atoms const setInputAction = useSetAtom(updateTextBufferAtom) const clearInputAction = useSetAtom(clearTextBufferAtom) - const setSuggestionsAction = useSetAtom(setSuggestionsAtom) - const setArgumentSuggestionsAction = useSetAtom(setArgumentSuggestionsAtom) - const setFileMentionSuggestionsAction = useSetAtom(setFileMentionSuggestionsAtom) - const setFileMentionContextAction = useSetAtom(setFileMentionContextAtom) const clearFileMentionAction = useSetAtom(clearFileMentionAtom) + const updateAllSuggestionsAction = useSetAtom(updateAllSuggestionsAtom) const selectNextAction = useSetAtom(selectNextSuggestionAtom) const selectPreviousAction = useSetAtom(selectPreviousSuggestionAtom) const hideAutocompleteAction = useSetAtom(hideAutocompleteAtom) @@ -224,8 +218,12 @@ export function useCommandInput(): UseCommandInputReturn { if (isShellMode) { // Clear all suggestion state clearFileMentionAction() - setSuggestionsAction([]) - setArgumentSuggestionsAction([]) + updateAllSuggestionsAction({ + commandSuggestions: [], + argumentSuggestions: [], + fileMentionSuggestions: [], + fileMentionContext: null, + }) return } @@ -242,10 +240,12 @@ export function useCommandInput(): UseCommandInputReturn { if (fileMentionCtx?.isInMention) { // Get file suggestions const suggestions = await getFileMentionSuggestions(fileMentionCtx.query, cwd) - setFileMentionSuggestionsAction(suggestions) - setFileMentionContextAction(fileMentionCtx) - setSuggestionsAction([]) - setArgumentSuggestionsAction([]) + updateAllSuggestionsAction({ + commandSuggestions: [], + argumentSuggestions: [], + fileMentionSuggestions: suggestions, + fileMentionContext: fileMentionCtx, + }) return } @@ -253,8 +253,12 @@ export function useCommandInput(): UseCommandInputReturn { // Fall back to command/argument detection if (!checkIsCommandInput(inputValue)) { - setSuggestionsAction([]) - setArgumentSuggestionsAction([]) + updateAllSuggestionsAction({ + commandSuggestions: [], + argumentSuggestions: [], + fileMentionSuggestions: [], + fileMentionContext: null, + }) return } @@ -263,8 +267,12 @@ export function useCommandInput(): UseCommandInputReturn { if (state.type === "command") { // Get command suggestions const suggestions = getSuggestions(inputValue) - setSuggestionsAction(suggestions) - setArgumentSuggestionsAction([]) + updateAllSuggestionsAction({ + commandSuggestions: suggestions, + argumentSuggestions: [], + fileMentionSuggestions: [], + fileMentionContext: null, + }) } else if (state.type === "argument") { // Create command context for argument providers const customModes = extensionState?.customModes || [] @@ -291,25 +299,30 @@ export function useCommandInput(): UseCommandInputReturn { await refreshRouterModels() }, } - + // Get argument suggestions with command context const suggestions = await getArgumentSuggestions(inputValue, commandContext) - setArgumentSuggestionsAction(suggestions) - setSuggestionsAction([]) + updateAllSuggestionsAction({ + commandSuggestions: [], + argumentSuggestions: suggestions, + fileMentionSuggestions: [], + fileMentionContext: null, + }) } else { - setSuggestionsAction([]) - setArgumentSuggestionsAction([]) + updateAllSuggestionsAction({ + commandSuggestions: [], + argumentSuggestions: [], + fileMentionSuggestions: [], + fileMentionContext: null, + }) } }, [ inputValue, cursor, cwd, isShellMode, - setSuggestionsAction, - setArgumentSuggestionsAction, - setFileMentionSuggestionsAction, - setFileMentionContextAction, clearFileMentionAction, + updateAllSuggestionsAction, config, routerModels, currentProvider, @@ -319,6 +332,7 @@ export function useCommandInput(): UseCommandInputReturn { taskHistoryData, updateProvider, refreshRouterModels, + extensionState?.customModes, ]) const getInputState = useCallback(() => {