Skip to content
Open
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/vscode-slash-command-tab-completion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kimi-code": patch
---

Complete VS Code slash commands into the chat input so users can add arguments before sending them.
53 changes: 53 additions & 0 deletions apps/vscode/test/slash-command-completion.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
20 changes: 19 additions & 1 deletion apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -212,7 +225,12 @@ export function InputArea({ onAuthAction }: InputAreaProps) {
setSelectedIndex: setSlashSelectedIndex,
handleSlashMenuKey,
resetSlashMenu,
} = useSlashMenu(activeToken, handleSlashCommand, removeActiveToken);
} = useSlashMenu(
activeToken,
handleSlashCommand,
handleSlashCommandCompletion,
removeActiveToken,
);

const {
showFileMenu,
Expand Down
Original file line number Diff line number Diff line change
@@ -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: "/" | "@";
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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;
}
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
};
}
Original file line number Diff line number Diff line change
@@ -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);
}