mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
564 lines
18 KiB
TypeScript
564 lines
18 KiB
TypeScript
import { describe, expect, it } from "bun:test"
|
|
import { createRoot } from "solid-js"
|
|
import { useSlashCommand } from "../../webview-ui/src/hooks/useSlashCommand"
|
|
import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages"
|
|
|
|
function setup(
|
|
sandbox: () => void,
|
|
options: { enabled?: () => boolean; exclude?: () => Set<string>; include?: Set<string> } = {},
|
|
) {
|
|
const sent: WebviewMessage[] = []
|
|
const handlers = new Set<(message: ExtensionMessage) => void>()
|
|
const root = createRoot((dispose) => ({
|
|
dispose,
|
|
slash: useSlashCommand(
|
|
{
|
|
postMessage: (message) => sent.push(message),
|
|
onMessage: (handler) => {
|
|
handlers.add(handler)
|
|
return () => handlers.delete(handler)
|
|
},
|
|
},
|
|
{ action: sandbox, enabled: options.enabled ?? (() => true) },
|
|
options.exclude,
|
|
options.include,
|
|
),
|
|
}))
|
|
const fire = (message: ExtensionMessage) => {
|
|
for (const handler of handlers) handler(message)
|
|
}
|
|
return { ...root, fire, sent }
|
|
}
|
|
|
|
describe("useSlashCommand sandbox action", () => {
|
|
it("supports the singular model alias", () => {
|
|
const ctx = setup(() => {})
|
|
|
|
ctx.slash.onInput("/model", 6)
|
|
|
|
expect(ctx.slash.results()[0]).toEqual(expect.objectContaining({ name: "models", hints: ["model"] }))
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("can restrict the menu to worktree configuration commands", () => {
|
|
const ctx = setup(() => {}, { include: new Set(["models", "agents", "variant", "sandbox"]) })
|
|
|
|
ctx.fire({
|
|
type: "commandsLoaded",
|
|
commands: [
|
|
{ name: "merge", description: "Merge changes", hints: [] },
|
|
{ name: "models", description: "Server model command", hints: [] },
|
|
],
|
|
})
|
|
ctx.slash.onInput("/merge", 6)
|
|
expect(ctx.slash.results()).toEqual([])
|
|
|
|
ctx.slash.onInput("/models", 7)
|
|
expect(ctx.slash.results().map((command) => command.name)).toEqual(["models"])
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("opens project memory actions from the top-level command", () => {
|
|
const ctx = setup(() => {})
|
|
const state = { text: "/mem" }
|
|
const textarea = {
|
|
value: state.text,
|
|
setSelectionRange: () => {},
|
|
focus: () => {},
|
|
} as unknown as HTMLTextAreaElement
|
|
|
|
ctx.slash.onInput(state.text, state.text.length)
|
|
|
|
expect(ctx.slash.results()).toContainEqual(
|
|
expect.objectContaining({ name: "memory", description: "Manage project memory", hints: ["mem"] }),
|
|
)
|
|
ctx.slash.select(ctx.slash.results()[0]!, textarea, (text) => (state.text = text))
|
|
expect(state.text).toBe("/memory ")
|
|
expect(ctx.slash.results().map((command) => command.name)).toContain("memory inspect")
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("offers memory actions after the parent command", () => {
|
|
const ctx = setup(() => {})
|
|
|
|
ctx.slash.onInput("/memory ", 8)
|
|
|
|
expect(ctx.slash.results().map((command) => command.name)).toEqual([
|
|
"memory status",
|
|
"memory show",
|
|
"memory on",
|
|
"memory off",
|
|
"memory inspect",
|
|
"memory rebuild",
|
|
"memory remember",
|
|
"memory correct",
|
|
"memory forget",
|
|
"memory auto on",
|
|
"memory auto off",
|
|
"memory purge confirm",
|
|
])
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("keeps nested memory actions out of root hint matching", () => {
|
|
const ctx = setup(() => {})
|
|
const nested = ctx.slash.commands().filter((command) => command.name.startsWith("memory "))
|
|
|
|
expect(nested.length).toBeGreaterThan(0)
|
|
expect(nested.every((command) => command.hints.length === 0)).toBe(true)
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("completes nested memory actions and closes for free text", () => {
|
|
const ctx = setup(() => {})
|
|
const state = { text: "/mem rem" }
|
|
const textarea = {
|
|
value: state.text,
|
|
setSelectionRange: () => {},
|
|
focus: () => {},
|
|
} as unknown as HTMLTextAreaElement
|
|
|
|
ctx.slash.onInput(state.text, state.text.length)
|
|
expect(ctx.slash.results().map((command) => command.name)).toEqual(["memory remember"])
|
|
ctx.slash.select(ctx.slash.results()[0]!, textarea, (text) => (state.text = text))
|
|
expect(state.text).toBe("/memory remember ")
|
|
|
|
ctx.slash.onInput("/memory remember durable fact", 31)
|
|
expect(ctx.slash.show()).toBe(false)
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("runs the sandbox toggle as a client command", () => {
|
|
const state = { toggles: 0, text: "/sandbox", prevented: 0 }
|
|
const ctx = setup(() => state.toggles++)
|
|
const textarea = {
|
|
value: state.text,
|
|
selectionStart: state.text.length,
|
|
setSelectionRange: () => {},
|
|
} as unknown as HTMLTextAreaElement
|
|
const event = {
|
|
key: "Enter",
|
|
isComposing: false,
|
|
preventDefault: () => state.prevented++,
|
|
} as unknown as KeyboardEvent
|
|
|
|
ctx.slash.onInput(state.text, state.text.length)
|
|
const handled = ctx.slash.onKeyDown(event, textarea, (text) => (state.text = text))
|
|
|
|
expect(handled).toBe(true)
|
|
expect(state.toggles).toBe(1)
|
|
expect(state.prevented).toBe(1)
|
|
expect(state.text).toBe("")
|
|
expect(textarea.value).toBe("")
|
|
expect(ctx.sent).toEqual([{ type: "requestCommands" }])
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("keeps the command text when the sandbox control is disabled", () => {
|
|
const state = { toggles: 0, text: "/sandbox" }
|
|
const ctx = setup(() => state.toggles++, { enabled: () => false })
|
|
const textarea = {
|
|
value: state.text,
|
|
selectionStart: state.text.length,
|
|
setSelectionRange: () => {},
|
|
} as unknown as HTMLTextAreaElement
|
|
const event = {
|
|
key: "Enter",
|
|
isComposing: false,
|
|
preventDefault: () => {},
|
|
} as unknown as KeyboardEvent
|
|
|
|
ctx.slash.onInput(state.text, state.text.length)
|
|
const handled = ctx.slash.onKeyDown(event, textarea, (text) => (state.text = text))
|
|
|
|
expect(handled).toBe(true)
|
|
expect(state.toggles).toBe(0)
|
|
expect(state.text).toBe("/sandbox")
|
|
expect(textarea.value).toBe("/sandbox")
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("hides the client and server sandbox command when excluded", () => {
|
|
const state = { hidden: true }
|
|
const ctx = setup(() => {}, {
|
|
exclude: () => (state.hidden ? new Set(["sandbox"]) : new Set()),
|
|
})
|
|
|
|
ctx.slash.onInput("/sandbox", 8)
|
|
ctx.fire({
|
|
type: "commandsLoaded",
|
|
commands: [{ name: "sandbox", description: "Server sandbox command", hints: [] }],
|
|
})
|
|
expect(ctx.slash.results()).toEqual([])
|
|
|
|
state.hidden = false
|
|
expect(ctx.slash.results().map((command) => command.name)).toEqual(["sandbox"])
|
|
expect(ctx.slash.results()[0]?.description).toBe("Toggle sandbox")
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("opens review options from the top-level command", () => {
|
|
const ctx = setup(() => {})
|
|
const state = { text: "/review" }
|
|
const textarea = {
|
|
value: state.text,
|
|
setSelectionRange: () => {},
|
|
focus: () => {},
|
|
} as unknown as HTMLTextAreaElement
|
|
|
|
ctx.slash.onInput(state.text, state.text.length)
|
|
|
|
expect(ctx.slash.results()).toContainEqual(
|
|
expect.objectContaining({ name: "review", description: expect.stringContaining("Review code changes") }),
|
|
)
|
|
ctx.slash.select(ctx.slash.results().find((c) => c.name === "review")!, textarea, (text) => (state.text = text))
|
|
expect(state.text).toBe("/review ")
|
|
expect(ctx.slash.results().map((command) => command.name)).toEqual([
|
|
"review uncommitted",
|
|
"review staged",
|
|
"review unpushed",
|
|
"review branch",
|
|
"review quick",
|
|
])
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("completes nested review actions and closes for free text", () => {
|
|
const ctx = setup(() => {})
|
|
const state = { text: "/review unp" }
|
|
const textarea = {
|
|
value: state.text,
|
|
setSelectionRange: () => {},
|
|
focus: () => {},
|
|
} as unknown as HTMLTextAreaElement
|
|
|
|
ctx.slash.onInput(state.text, state.text.length)
|
|
expect(ctx.slash.results().map((command) => command.name)).toEqual(["review unpushed"])
|
|
ctx.slash.select(ctx.slash.results()[0]!, textarea, (text) => (state.text = text))
|
|
expect(state.text).toBe("/review unpushed ")
|
|
|
|
ctx.slash.onInput("/review focus on auth", 20)
|
|
expect(ctx.slash.show()).toBe(false)
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("preserves model, agent, and variant metadata on loaded server commands", () => {
|
|
const ctx = setup(() => {})
|
|
|
|
ctx.fire({
|
|
type: "commandsLoaded",
|
|
commands: [
|
|
{
|
|
name: "ship",
|
|
description: "Ship PR",
|
|
agent: "code",
|
|
model: "openai/gpt-5.6-luna-fast",
|
|
variant: "xhigh",
|
|
hints: ["deploy"],
|
|
},
|
|
],
|
|
})
|
|
|
|
ctx.slash.onInput("/ship", 5)
|
|
const matches = ctx.slash.results()
|
|
expect(matches).toHaveLength(1)
|
|
expect(matches[0]?.name).toBe("ship")
|
|
expect(matches[0]?.agent).toBe("code")
|
|
expect(matches[0]?.model).toBe("openai/gpt-5.6-luna-fast")
|
|
expect(matches[0]?.variant).toBe("xhigh")
|
|
ctx.dispose()
|
|
})
|
|
})
|
|
|
|
describe("slash command keyboard selection", () => {
|
|
it.each(["Enter", "Tab"] as const)("keeps %s selection aligned with the action-first menu", (key) => {
|
|
const state = { text: "/refresh", prevented: 0 }
|
|
const ctx = setup(() => {})
|
|
const textarea = {
|
|
value: state.text,
|
|
selectionStart: state.text.length,
|
|
setSelectionRange: () => {},
|
|
focus: () => {},
|
|
} as unknown as HTMLTextAreaElement
|
|
const event = {
|
|
key,
|
|
isComposing: false,
|
|
preventDefault: () => state.prevented++,
|
|
} as unknown as KeyboardEvent
|
|
|
|
ctx.slash.onInput(state.text, state.text.length)
|
|
ctx.fire({
|
|
type: "commandsLoaded",
|
|
commands: [{ name: "refresh", description: "Run the custom refresh command", hints: [] }],
|
|
})
|
|
|
|
expect(ctx.slash.results().map((command) => command.name)).toEqual(["reload", "refresh"])
|
|
const handled = ctx.slash.onKeyDown(event, textarea, (text) => (state.text = text))
|
|
|
|
expect(handled).toBe(true)
|
|
expect(state.prevented).toBe(1)
|
|
expect(state.text).toBe("")
|
|
expect(textarea.value).toBe("")
|
|
expect(ctx.sent).toEqual([{ type: "requestCommands" }, { type: "reload" }])
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("selects the second displayed result after ArrowDown", () => {
|
|
const state = { text: "/refresh", prevented: 0 }
|
|
const ctx = setup(() => {})
|
|
const textarea = {
|
|
value: state.text,
|
|
selectionStart: state.text.length,
|
|
setSelectionRange: () => {},
|
|
focus: () => {},
|
|
} as unknown as HTMLTextAreaElement
|
|
|
|
ctx.slash.onInput(state.text, state.text.length)
|
|
ctx.fire({
|
|
type: "commandsLoaded",
|
|
commands: [{ name: "refresh", description: "Run the custom refresh command", hints: [] }],
|
|
})
|
|
|
|
const down = {
|
|
key: "ArrowDown",
|
|
isComposing: false,
|
|
preventDefault: () => state.prevented++,
|
|
} as unknown as KeyboardEvent
|
|
const enter = {
|
|
key: "Enter",
|
|
isComposing: false,
|
|
preventDefault: () => state.prevented++,
|
|
} as unknown as KeyboardEvent
|
|
|
|
expect(ctx.slash.onKeyDown(down, textarea, (text) => (state.text = text))).toBe(true)
|
|
expect(ctx.slash.index()).toBe(1)
|
|
expect(ctx.slash.onKeyDown(enter, textarea, (text) => (state.text = text))).toBe(true)
|
|
|
|
expect(state.prevented).toBe(2)
|
|
expect(state.text).toBe("/refresh ")
|
|
expect(textarea.value).toBe("/refresh ")
|
|
expect(ctx.sent).toEqual([{ type: "requestCommands" }])
|
|
ctx.dispose()
|
|
})
|
|
})
|
|
|
|
describe("select", () => {
|
|
it("preserves trailing text for action commands", () => {
|
|
let actionCalls = 0
|
|
let currentText = "existing text"
|
|
const ctx = setup(() => {})
|
|
|
|
const textarea = {
|
|
value: "/newexisting text",
|
|
selectionStart: 4,
|
|
setSelectionRange: () => {},
|
|
} as unknown as HTMLTextAreaElement
|
|
const setText = (text: string) => {
|
|
currentText = text
|
|
}
|
|
|
|
ctx.slash.select(
|
|
{
|
|
name: "new",
|
|
description: "Start a new session",
|
|
hints: [],
|
|
action: () => {
|
|
actionCalls++
|
|
},
|
|
},
|
|
textarea,
|
|
setText,
|
|
)
|
|
|
|
expect(textarea.value).toBe("existing text")
|
|
expect(currentText).toBe("existing text")
|
|
expect(actionCalls).toBe(1)
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("preserves trailing text for server commands and sets cursor", () => {
|
|
const ctx = setup(() => {})
|
|
let currentText = ""
|
|
let selectionStart = 0
|
|
|
|
const textarea = {
|
|
value: "/docmdexisting text",
|
|
selectionStart: 6,
|
|
setSelectionRange: (start: number, end: number) => {
|
|
selectionStart = start
|
|
},
|
|
focus: () => {},
|
|
} as unknown as HTMLTextAreaElement
|
|
const setText = (text: string) => {
|
|
currentText = text
|
|
}
|
|
|
|
ctx.slash.select({ name: "docmd", description: "Run doc command", hints: [] }, textarea, setText)
|
|
|
|
expect(textarea.value).toBe("/docmd existing text")
|
|
expect(currentText).toBe("/docmd existing text")
|
|
expect(selectionStart).toBe("/docmd ".length)
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("uses slashEnd for server commands when onInput fired before select", () => {
|
|
const ctx = setup(() => {})
|
|
let currentText = ""
|
|
let selectionStart = 0
|
|
|
|
ctx.slash.onInput("/docmdexisting text", 6)
|
|
|
|
const textarea = {
|
|
value: "/docmdexisting text",
|
|
selectionStart: 2,
|
|
setSelectionRange: (start: number, end: number) => {
|
|
selectionStart = start
|
|
},
|
|
focus: () => {},
|
|
} as unknown as HTMLTextAreaElement
|
|
const setText = (text: string) => {
|
|
currentText = text
|
|
}
|
|
|
|
ctx.slash.select({ name: "docmd", description: "Run doc command", hints: [] }, textarea, setText)
|
|
|
|
expect(textarea.value).toBe("/docmd existing text")
|
|
expect(currentText).toBe("/docmd existing text")
|
|
expect(selectionStart).toBe("/docmd ".length)
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("preserves trailing text even when cursor moves after typing slash command", () => {
|
|
let actionCalls = 0
|
|
let currentText = "existing text"
|
|
const ctx = setup(() => {})
|
|
|
|
// Type slash command: cursor at 4, slashEnd stored as 4
|
|
ctx.slash.onInput("/newexisting text", 4)
|
|
|
|
// Simulate user moving cursor (e.g. ArrowLeft twice)
|
|
const textarea = {
|
|
value: "/newexisting text",
|
|
selectionStart: 2,
|
|
setSelectionRange: () => {},
|
|
} as unknown as HTMLTextAreaElement
|
|
const setText = (text: string) => {
|
|
currentText = text
|
|
}
|
|
|
|
ctx.slash.select(
|
|
{
|
|
name: "new",
|
|
description: "Start a new session",
|
|
hints: [],
|
|
action: () => {
|
|
actionCalls++
|
|
},
|
|
},
|
|
textarea,
|
|
setText,
|
|
)
|
|
|
|
// Should preserve trailing text from original slashEnd (4), not stale selectionStart (2)
|
|
expect(textarea.value).toBe("existing text")
|
|
expect(currentText).toBe("existing text")
|
|
expect(actionCalls).toBe(1)
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("preserves trailing text for memory commands when cursor moves", () => {
|
|
let currentText = ""
|
|
const ctx = setup(() => {})
|
|
|
|
// Type /memory rem: matches memory pattern, slashEnd set to end of text
|
|
const typed = "/memory rem"
|
|
ctx.slash.onInput(typed, typed.length)
|
|
|
|
// Simulate user moving cursor (no onInput fires for arrow keys)
|
|
const textarea = {
|
|
value: typed,
|
|
selectionStart: 3,
|
|
setSelectionRange: () => {},
|
|
focus: () => {},
|
|
} as unknown as HTMLTextAreaElement
|
|
const setText = (text: string) => {
|
|
currentText = text
|
|
}
|
|
|
|
// Find the memory remember command (nested, no action)
|
|
const remembered = ctx.slash.results().find((c) => c.name === "memory remember")
|
|
expect(remembered).toBeDefined()
|
|
|
|
ctx.slash.select(remembered!, textarea, setText)
|
|
|
|
// Trailing text from slashEnd (end of typed text) — empty — should be preserved correctly
|
|
expect(currentText).toBe("/memory remember ")
|
|
expect(textarea.value).toBe("/memory remember ")
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("preserves trailing text through the two-step nested command path", () => {
|
|
let currentText = ""
|
|
const ctx = setup(() => {})
|
|
|
|
ctx.slash.onInput("/mem", 4)
|
|
|
|
const textarea = {
|
|
value: "/mem",
|
|
selectionStart: 4,
|
|
setSelectionRange: () => {},
|
|
focus: () => {},
|
|
} as unknown as HTMLTextAreaElement
|
|
const setText = (text: string) => {
|
|
currentText = text
|
|
}
|
|
|
|
const memory = ctx.slash.results().find((c) => c.name === "memory")
|
|
expect(memory).toBeDefined()
|
|
ctx.slash.select(memory!, textarea, setText)
|
|
expect(currentText).toBe("/memory ")
|
|
expect(textarea.value).toBe("/memory ")
|
|
|
|
const remember = ctx.slash.results().find((c) => c.name === "memory remember")
|
|
expect(remember).toBeDefined()
|
|
ctx.slash.select(remember!, textarea, setText)
|
|
expect(currentText).toBe("/memory remember ")
|
|
expect(textarea.value).toBe("/memory remember ")
|
|
ctx.dispose()
|
|
})
|
|
|
|
it("keeps trailing text and cursor before it through nested selection", () => {
|
|
const ctx = setup(() => {})
|
|
let currentText = "hello"
|
|
const textarea = {
|
|
value: "hello",
|
|
selectionStart: 0,
|
|
setSelectionRange: (start: number, _end: number) => {
|
|
textarea.selectionStart = start
|
|
},
|
|
focus: () => {},
|
|
} as unknown as HTMLTextAreaElement
|
|
const setText = (text: string) => {
|
|
currentText = text
|
|
}
|
|
|
|
textarea.value = "/memhello"
|
|
textarea.selectionStart = 4
|
|
ctx.slash.onInput("/memhello", 4)
|
|
|
|
const memory = ctx.slash.results().find((c) => c.name === "memory")
|
|
expect(memory).toBeDefined()
|
|
ctx.slash.select(memory!, textarea, setText)
|
|
expect(textarea.value).toBe("/memory hello")
|
|
expect(textarea.selectionStart).toBe("/memory ".length)
|
|
|
|
const rebuild = ctx.slash.results().find((c) => c.name === "memory rebuild")
|
|
expect(rebuild).toBeDefined()
|
|
ctx.slash.select(rebuild!, textarea, setText)
|
|
|
|
expect(textarea.value).toBe("/memory rebuild hello")
|
|
expect(textarea.selectionStart).toBe("/memory rebuild ".length)
|
|
ctx.dispose()
|
|
})
|
|
})
|