diff --git a/.changeset/vscode-slash-command-tab-completion.md b/.changeset/vscode-slash-command-tab-completion.md new file mode 100644 index 0000000000..24186ade9c --- /dev/null +++ b/.changeset/vscode-slash-command-tab-completion.md @@ -0,0 +1,5 @@ +--- +"kimi-code": patch +--- + +Complete VS Code slash commands into the chat input so users can add arguments before sending them. diff --git a/apps/vscode/test/slash-command-completion.test.ts b/apps/vscode/test/slash-command-completion.test.ts new file mode 100644 index 0000000000..8daacf8b77 --- /dev/null +++ b/apps/vscode/test/slash-command-completion.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { computeSlashCommandInsert } from "../webview-ui/src/components/inputarea/slash-command-insert"; +import { dispatchSlashMenuCommand } from "../webview-ui/src/components/inputarea/slash-menu-action"; + +describe("slash command completion", () => { + it("replaces only the active token and leaves the command ready for arguments", () => { + expect( + computeSlashCommandInsert({ + text: "Please /rev later", + cursorPos: 11, + activeToken: { start: 7 }, + commandName: "review", + }), + ).toEqual({ text: "Please /review later", cursorPos: 15 }); + }); + + it("inserts one separator before an adjacent suffix", () => { + expect( + computeSlashCommandInsert({ + text: "/revLater", + cursorPos: 4, + activeToken: { start: 0 }, + commandName: "review", + }), + ).toEqual({ text: "/review Later", cursorPos: 8 }); + }); + + it("adds one separator after a completed command at the end of the input", () => { + expect( + computeSlashCommandInsert({ + text: "/ski", + cursorPos: 4, + activeToken: { start: 0 }, + commandName: "skill:review", + }), + ).toEqual({ text: "/skill:review ", cursorPos: 14 }); + }); +}); + +describe("slash menu key actions", () => { + it("completes on Tab but preserves selection on Enter", () => { + const select = vi.fn(); + const complete = vi.fn(); + + dispatchSlashMenuCommand("Tab", "review", { select, complete }); + expect(complete).toHaveBeenCalledWith("review"); + expect(select).not.toHaveBeenCalled(); + + dispatchSlashMenuCommand("Enter", "review", { select, complete }); + expect(select).toHaveBeenCalledWith("review"); + expect(complete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx index 3151005a77..0558f1a1f0 100644 --- a/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx +++ b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx @@ -13,6 +13,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { ActionMenu } from "../ActionMenu"; import { SlashCommandMenu } from "../SlashCommandMenu"; +import { computeSlashCommandInsert } from "./slash-command-insert"; import { FilePickerMenu } from "../FilePickerMenu"; import { MediaThumbnail } from "../MediaThumbnail"; import { MediaPreviewModal } from "../MediaPreviewModal"; @@ -187,6 +188,18 @@ export function InputArea({ onAuthAction }: InputAreaProps) { clearInput(); }); + const handleSlashCommandCompletion = useMemoizedFn((name: string) => { + if (activeToken?.trigger !== "/") return; + const completion = computeSlashCommandInsert({ text, cursorPos, activeToken, commandName: name }); + setText(completion.text); + setCursorPos(completion.cursorPos); + setTimeout(() => { + textareaRef.current?.setSelectionRange(completion.cursorPos, completion.cursorPos); + textareaRef.current?.focus(); + adjustHeight(); + }, 0); + }); + const applyMention = useMemoizedFn((filePath: string) => { const { newText, newCursorPos } = computeMentionInsert({ text, @@ -212,7 +225,12 @@ export function InputArea({ onAuthAction }: InputAreaProps) { setSelectedIndex: setSlashSelectedIndex, handleSlashMenuKey, resetSlashMenu, - } = useSlashMenu(activeToken, handleSlashCommand, removeActiveToken); + } = useSlashMenu( + activeToken, + handleSlashCommand, + handleSlashCommandCompletion, + removeActiveToken, + ); const { showFileMenu, diff --git a/apps/vscode/webview-ui/src/components/inputarea/hooks/useSlashMenu.ts b/apps/vscode/webview-ui/src/components/inputarea/hooks/useSlashMenu.ts index c56619747b..620cb778bc 100644 --- a/apps/vscode/webview-ui/src/components/inputarea/hooks/useSlashMenu.ts +++ b/apps/vscode/webview-ui/src/components/inputarea/hooks/useSlashMenu.ts @@ -1,6 +1,7 @@ import { useMemo, useState, useCallback } from "react"; import { useSettingsStore } from "@/stores"; import type { SlashCommandInfo } from "shared/legacy-sdk"; +import { dispatchSlashMenuCommand } from "../slash-menu-action"; interface ActiveToken { trigger: "/" | "@"; @@ -46,7 +47,12 @@ interface UseSlashMenuResult { resetSlashMenu: () => void; } -export function useSlashMenu(activeToken: ActiveToken | null, onSelectCommand: (name: string) => void, onCancel: () => void): UseSlashMenuResult { +export function useSlashMenu( + activeToken: ActiveToken | null, + onSelectCommand: (name: string) => void, + onCompleteCommand: (name: string) => void, + onCancel: () => void, +): UseSlashMenuResult { const [selectedIndex, setSelectedIndex] = useState(0); const { slashCommands } = useSettingsStore(); @@ -82,12 +88,25 @@ export function useSlashMenu(activeToken: ActiveToken | null, onSelectCommand: ( e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)); return true; - case "Tab": + case "Tab": { + e.preventDefault(); + const cmd = filteredCommands[selectedIndex]; + if (cmd) { + dispatchSlashMenuCommand("Tab", cmd.name, { + select: onSelectCommand, + complete: onCompleteCommand, + }); + } + return true; + } case "Enter": { e.preventDefault(); const cmd = filteredCommands[selectedIndex]; if (cmd) { - onSelectCommand(cmd.name); + dispatchSlashMenuCommand("Enter", cmd.name, { + select: onSelectCommand, + complete: onCompleteCommand, + }); } return true; } @@ -99,7 +118,7 @@ export function useSlashMenu(activeToken: ActiveToken | null, onSelectCommand: ( return false; } }, - [showSlashMenu, filteredCommands, selectedIndex, onSelectCommand, onCancel], + [showSlashMenu, filteredCommands, selectedIndex, onSelectCommand, onCompleteCommand, onCancel], ); return { diff --git a/apps/vscode/webview-ui/src/components/inputarea/slash-command-insert.ts b/apps/vscode/webview-ui/src/components/inputarea/slash-command-insert.ts new file mode 100644 index 0000000000..a5c1eefbbe --- /dev/null +++ b/apps/vscode/webview-ui/src/components/inputarea/slash-command-insert.ts @@ -0,0 +1,37 @@ +interface SlashToken { + start: number; +} + +interface SlashCommandInsertInput { + text: string; + cursorPos: number; + activeToken: SlashToken; + commandName: string; +} + +interface SlashCommandInsertResult { + text: string; + cursorPos: number; +} + +const COMMAND_PREFIX = "/"; +const ARGUMENT_SEPARATOR = " "; +const LEADING_WHITESPACE = /^\s/; + +export function computeSlashCommandInsert({ + text, + cursorPos, + activeToken, + commandName, +}: SlashCommandInsertInput): SlashCommandInsertResult { + const suffix = text.slice(cursorPos); + const existingSeparator = suffix.match(LEADING_WHITESPACE)?.[0]; + const command = `${COMMAND_PREFIX}${commandName}${existingSeparator ?? ARGUMENT_SEPARATOR}`; + return { + text: + text.slice(0, activeToken.start) + + command + + (existingSeparator === undefined ? suffix : suffix.slice(existingSeparator.length)), + cursorPos: activeToken.start + command.length, + }; +} diff --git a/apps/vscode/webview-ui/src/components/inputarea/slash-menu-action.ts b/apps/vscode/webview-ui/src/components/inputarea/slash-menu-action.ts new file mode 100644 index 0000000000..2b020b7e26 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/inputarea/slash-menu-action.ts @@ -0,0 +1,13 @@ +interface SlashMenuCallbacks { + select: (name: string) => void; + complete: (name: string) => void; +} + +export function dispatchSlashMenuCommand( + key: "Tab" | "Enter", + commandName: string, + callbacks: SlashMenuCallbacks, +): void { + if (key === "Tab") callbacks.complete(commandName); + else callbacks.select(commandName); +}