From b84115304b09d7b5a27c97542753e49622f879d8 Mon Sep 17 00:00:00 2001 From: Sean McNamara Date: Fri, 15 May 2026 14:01:36 -0400 Subject: [PATCH] feat(chat): add compact slash commands --- .../__tests__/built-in-commands.spec.ts | 10 +-- src/services/command/built-in-commands.ts | 11 ++++ webview-ui/src/components/chat/ChatView.tsx | 66 ++++++++++++++++--- 3 files changed, 73 insertions(+), 14 deletions(-) diff --git a/src/services/command/__tests__/built-in-commands.spec.ts b/src/services/command/__tests__/built-in-commands.spec.ts index ecb2bdb0fb..5c00b1f359 100644 --- a/src/services/command/__tests__/built-in-commands.spec.ts +++ b/src/services/command/__tests__/built-in-commands.spec.ts @@ -5,8 +5,8 @@ describe("Built-in Commands", () => { it("should return all built-in commands", async () => { const commands = await getBuiltInCommands() - expect(commands).toHaveLength(1) - expect(commands.map((cmd) => cmd.name)).toEqual(expect.arrayContaining(["init"])) + expect(commands).toHaveLength(3) + expect(commands.map((cmd) => cmd.name)).toEqual(expect.arrayContaining(["init", "compact", "compact-and"])) // Verify all commands have required properties commands.forEach((command) => { @@ -63,10 +63,10 @@ describe("Built-in Commands", () => { it("should return all built-in command names", async () => { const names = await getBuiltInCommandNames() - expect(names).toHaveLength(1) - expect(names).toEqual(expect.arrayContaining(["init"])) + expect(names).toHaveLength(3) + expect(names).toEqual(expect.arrayContaining(["init", "compact", "compact-and"])) // Order doesn't matter since it's based on filesystem order - expect(names.sort()).toEqual(["init"]) + expect(names.sort()).toEqual(["compact", "compact-and", "init"]) }) it("should return array of strings", async () => { diff --git a/src/services/command/built-in-commands.ts b/src/services/command/built-in-commands.ts index db113c4895..d4b45ff99f 100644 --- a/src/services/command/built-in-commands.ts +++ b/src/services/command/built-in-commands.ts @@ -8,6 +8,17 @@ interface BuiltInCommandDefinition { } const BUILT_IN_COMMANDS: Record = { + compact: { + name: "compact", + description: "Manually condense the current task context to free up tokens", + content: "", + }, + "compact-and": { + name: "compact-and", + description: "Condense context, then send the following message", + argumentHint: "", + content: "", + }, init: { name: "init", description: "Analyze codebase and create concise AGENTS.md files for AI assistants", diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 7026d093f5..f84a1a3286 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -217,6 +217,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction>({}) + const pendingPostCompactRef = useRef<{ text: string; images: string[] } | null>(null) const playSound = useCallback( (audioType: AudioType) => { @@ -575,6 +576,18 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + if (isCondensing || sendingDisabled) { + return + } + setIsCondensing(true) + setSendingDisabled(true) + vscode.postMessage({ type: "condenseTaskContextRequest", text: taskId }) + }, + [isCondensing, sendingDisabled], + ) + /** * Handles sending messages to the extension * @param text - The message text to send @@ -585,6 +598,32 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0) { + // Intercept /compact and /compact-and before any other processing. + // /compact triggers the same context-condensing used by auto-condense. + // /compact-and condenses, then sends once condensing completes. + const compactMatch = /^\/compact(-and)?(?:\s+([\s\S]+))?$/.exec(text) + if (compactMatch) { + const taskId = currentTaskItem?.id + if (!taskId || messagesRef.current.length === 0 || isCondensing || sendingDisabled) { + // Nothing to condense, or a condense is already in flight. + setInputValue("") + setSelectedImages([]) + return + } + + const followUp = (compactMatch[2] ?? "").trim() + if (compactMatch[1] && followUp) { + pendingPostCompactRef.current = { text: followUp, images } + } + + setIsCondensing(true) + setSendingDisabled(true) + vscode.postMessage({ type: "condenseTaskContextRequest", text: taskId }) + setInputValue("") + setSelectedImages([]) + return + } + // Intercept when the active provider is retired — show a // WarningRow instead of sending anything to the backend. if (apiConfiguration?.apiProvider && isRetiredProvider(apiConfiguration.apiProvider)) { @@ -663,9 +702,27 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + if (isCondensing || sendingDisabled) { + return + } + + const pending = pendingPostCompactRef.current + if (!pending) { + return + } + + pendingPostCompactRef.current = null + handleSendMessage(pending.text, pending.images) + }, [isCondensing, sendingDisabled, handleSendMessage]) + const handleSetChatBoxMessage = useCallback( (text: string, images: string[]) => { // Avoid nested template literals by breaking down the logic @@ -1539,15 +1596,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - if (isCondensing || sendingDisabled) { - return - } - setIsCondensing(true) - setSendingDisabled(true) - vscode.postMessage({ type: "condenseTaskContextRequest", text: taskId }) - } - const areButtonsVisible = showScrollToBottom || primaryButtonText || secondaryButtonText return (