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-slash-command-default-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Fix slash command suggestions to select first entry by default when typing `/`
328 changes: 328 additions & 0 deletions cli/src/state/atoms/__tests__/default-selection.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
Loading