diff --git a/.changeset/vscode-queued-host-command.md b/.changeset/vscode-queued-host-command.md new file mode 100644 index 0000000000..f1420d8807 --- /dev/null +++ b/.changeset/vscode-queued-host-command.md @@ -0,0 +1,5 @@ +--- +"kimi-code": patch +--- + +Keep queued VS Code slash commands pending until the active response finishes instead of steering them into the model and discarding them. diff --git a/apps/vscode/shared/host-slash-command.ts b/apps/vscode/shared/host-slash-command.ts new file mode 100644 index 0000000000..684516889b --- /dev/null +++ b/apps/vscode/shared/host-slash-command.ts @@ -0,0 +1,29 @@ +const HOST_COMMANDS = new Set([ + "init", + "compact", + "clear", + "reset", + "yolo", + "auto", + "afk", + "plan", + "add-dir", + "export", + "import", +]); + +export interface HostSlashCommand { + readonly name: string; + readonly args: string; + readonly raw: string; +} + +export function parseHostSlashCommand(content: string | readonly unknown[]): HostSlashCommand | undefined { + if (typeof content !== "string") return undefined; + const raw = content.trim(); + const match = /^\/([^\s]+)(?:\s+(.*))?\s*$/s.exec(raw); + if (match === null) return undefined; + const name = match[1]!.toLowerCase(); + if (!HOST_COMMANDS.has(name) && !name.startsWith("skill:")) return undefined; + return { name, args: match[2]?.trim() ?? "", raw }; +} diff --git a/apps/vscode/src/handlers/slash-command.ts b/apps/vscode/src/handlers/slash-command.ts index 087c823fb0..ca1548d220 100644 --- a/apps/vscode/src/handlers/slash-command.ts +++ b/apps/vscode/src/handlers/slash-command.ts @@ -4,6 +4,11 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import * as vscode from "vscode"; +import { + parseHostSlashCommand, + type HostSlashCommand, +} from "../../shared/host-slash-command"; + import type { SessionRuntime } from "../runtime/session-runtime"; import { buildExportMarkdown, @@ -13,36 +18,9 @@ import { } from "../utils/session-context"; import type { HandlerContext } from "./types"; -const HOST_COMMANDS = new Set([ - "init", - "compact", - "clear", - "reset", - "yolo", - "auto", - "afk", - "plan", - "add-dir", - "export", - "import", -]); const MAX_IMPORT_BYTES = 10 * 1024 * 1024; -export interface HostSlashCommand { - readonly name: string; - readonly args: string; - readonly raw: string; -} - -export function parseHostSlashCommand(content: string | readonly unknown[]): HostSlashCommand | undefined { - if (typeof content !== "string") return undefined; - const raw = content.trim(); - const match = /^\/([^\s]+)(?:\s+(.*))?\s*$/s.exec(raw); - if (match === null) return undefined; - const name = match[1]!.toLowerCase(); - if (!HOST_COMMANDS.has(name) && !name.startsWith("skill:")) return undefined; - return { name, args: match[2]?.trim() ?? "", raw }; -} +export { parseHostSlashCommand, type HostSlashCommand } from "../../shared/host-slash-command"; export async function runHostSlashCommand( runtime: SessionRuntime, diff --git a/apps/vscode/test/settings-store.test.ts b/apps/vscode/test/settings-store.test.ts index 93d0decad8..01bb158509 100644 --- a/apps/vscode/test/settings-store.test.ts +++ b/apps/vscode/test/settings-store.test.ts @@ -11,6 +11,7 @@ import { MCP_SECRET_MASK } from "../shared/legacy-sdk"; const boundary = vi.hoisted(() => ({ saveConfig: vi.fn(), streamChat: vi.fn(), + steerChat: vi.fn(), abortChat: vi.fn(), trackFiles: vi.fn(), toastError: vi.fn(), @@ -21,6 +22,7 @@ vi.mock("@/services", () => ({ bridge: { saveConfig: boundary.saveConfig, streamChat: boundary.streamChat, + steerChat: boundary.steerChat, abortChat: boundary.abortChat, trackFiles: boundary.trackFiles, }, @@ -55,6 +57,8 @@ beforeEach(() => { boundary.saveConfig.mockReset(); boundary.streamChat.mockReset(); boundary.streamChat.mockResolvedValue({ done: false }); + boundary.steerChat.mockReset(); + boundary.steerChat.mockResolvedValue({ ok: true }); boundary.abortChat.mockReset(); boundary.abortChat.mockResolvedValue({ aborted: true }); boundary.trackFiles.mockReset(); @@ -449,3 +453,37 @@ describe("Webview mid-turn warnings", () => { }); }); }); + +describe("Webview queued messages", () => { + it("keeps host slash commands queued instead of steering them as model input", async () => { + useChatStore.setState({ + isStreaming: true, + queue: [{ id: "auto-command", content: "/auto", model: "plain" }], + }); + + await useChatStore.getState().steerQueued("auto-command"); + + expect(boundary.steerChat).not.toHaveBeenCalled(); + expect(useChatStore.getState().queue).toEqual([ + { id: "auto-command", content: "/auto", model: "plain" }, + ]); + + useChatStore.getState().processEvent({ type: "stream_complete", result: { status: "finished" } }); + await vi.waitFor(() => { + expect(boundary.streamChat).toHaveBeenCalledWith("/auto", "plain", "off", false, undefined); + }); + expect(useChatStore.getState().queue).toEqual([]); + }); + + it("still steers ordinary queued follow-ups", async () => { + useChatStore.setState({ + isStreaming: true, + queue: [{ id: "follow-up", content: "also update the tests", model: "plain" }], + }); + + await useChatStore.getState().steerQueued("follow-up"); + + expect(boundary.steerChat).toHaveBeenCalledWith("also update the tests"); + expect(useChatStore.getState().queue).toEqual([]); + }); +}); diff --git a/apps/vscode/webview-ui/src/components/QueuedMessagesPanel.tsx b/apps/vscode/webview-ui/src/components/QueuedMessagesPanel.tsx index dc6c982b50..8a0f3bcd39 100644 --- a/apps/vscode/webview-ui/src/components/QueuedMessagesPanel.tsx +++ b/apps/vscode/webview-ui/src/components/QueuedMessagesPanel.tsx @@ -2,23 +2,17 @@ import { useState } from "react"; import { IconTrash, IconArrowUp, IconPencil, IconCheck, IconX, IconBolt } from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; import { useChatStore } from "@/stores"; -import { bridge } from "@/services"; +import { canSteerQueuedContent } from "@/stores/chat.store"; import { Content } from "@/lib/content"; import type { ContentPart } from "shared/legacy-sdk"; function QueueItem({ id, content, isStreaming, onEdit }: { id: string; content: string | ContentPart[]; isStreaming: boolean; onEdit: (id: string) => void }) { - const { removeFromQueue, moveQueueItemUp, queue } = useChatStore(); + const { removeFromQueue, moveQueueItemUp, steerQueued, queue } = useChatStore(); const text = Content.getText(content); const hasMedia = Content.hasMedia(content); const isFirst = queue[0]?.id === id; - - const handleSteer = async () => { - const result = await bridge.steerChat(content); - if (result.ok) { - removeFromQueue(id); - } - }; + const canSteer = canSteerQueuedContent(content); return (