From 27fb5500e320258c259f2e9d53c5a4f4d38954e2 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 25 Jun 2026 11:50:08 +0200 Subject: [PATCH] feat(vscode): add sandbox slash command --- .changeset/toggle-extension-sandbox.md | 5 ++ .../__tests__/AgentManagerProvider.spec.ts | 24 +++++ .../prompt-input-connection-guard.test.ts | 15 ++++ .../tests/unit/use-slash-command.test.ts | 90 +++++++++++++++++++ .../src/components/chat/PromptInput.tsx | 26 +++--- .../webview-ui/src/hooks/useSlashCommand.ts | 15 +++- 6 files changed, 163 insertions(+), 12 deletions(-) create mode 100644 .changeset/toggle-extension-sandbox.md create mode 100644 packages/kilo-vscode/tests/unit/use-slash-command.test.ts diff --git a/.changeset/toggle-extension-sandbox.md b/.changeset/toggle-extension-sandbox.md new file mode 100644 index 0000000000..86879752b9 --- /dev/null +++ b/.changeset/toggle-extension-sandbox.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Toggle the current session sandbox with `/sandbox` in the sidebar and Agent Manager. diff --git a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts index bf1f2dbd43..788db585a0 100644 --- a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts +++ b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts @@ -228,4 +228,28 @@ describe("AgentManagerProvider worktree creation", () => { contextDirectory: "/repo/.kilo/worktrees/wt-1", }) }) + + it("resolves new sandbox toggles to the selected worktree directory", async () => { + const manager = createHarness() + const state = { + getWorktree: vi.fn().mockReturnValue({ id: "wt-1", path: "/repo/.kilo/worktrees/wt-1" }), + } + manager.getStateManager.mockReturnValue(state) + manager.contextTarget.mockResolvedValue(undefined) + + const result = await manager.onMessage({ + type: "toggleSandbox", + agentManagerContext: "wt-1", + draftID: "draft-1", + requestID: "request-1", + }) + + expect(result).toEqual({ + type: "toggleSandbox", + agentManagerContext: "wt-1", + draftID: "draft-1", + requestID: "request-1", + contextDirectory: "/repo/.kilo/worktrees/wt-1", + }) + }) }) diff --git a/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts index 7b0bd33cbe..a124081340 100644 --- a/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts @@ -28,6 +28,7 @@ describe("PromptInput sandbox toggle", () => { expect(start).toBeGreaterThan(-1) expect(end).toBeGreaterThan(start) expect(toggle).toContain("const sessionID = sandboxID()") + expect(toggle).toContain("!sandboxVisible()") expect(toggle).toContain('type: "toggleSandbox"') expect(toggle).toContain("sessionID,") expect(toggle).toContain("draftID: props.pendingSessionID ?? session.draftSessionID()") @@ -50,6 +51,9 @@ describe("PromptInput sandbox toggle", () => { it("uses the internal flag for visibility and effective runtime state for the button", () => { expect(src).toContain("features().sandboxControls") expect(src).toContain("") + expect(src).toContain("{ action: toggleSandbox, enabled: () => sandboxVisible() && !sandboxDisabled() }") + expect(src).toContain('if (!sandboxVisible()) hidden.add("sandbox")') + expect(src).toContain("onClick={toggleSandbox}") expect(src).toContain('message.type === "sandboxStatus"') expect(src).toContain("message.sessionID !== sandboxID() && !matching") expect(src).toContain("setSandboxState(state)") @@ -62,4 +66,15 @@ describe("PromptInput sandbox toggle", () => { expect(src).toContain("if (sandboxRequest() && target === null) return") expect(src).not.toContain("if (state === current) return true") }) + + it("preserves the draft when the sandbox control is disabled", () => { + const start = src.indexOf("if (matched?.action)") + const guard = src.indexOf("if (matched.enabled && !matched.enabled()) return", start) + const clear = src.indexOf('setText("")', start) + + expect(start).toBeGreaterThan(-1) + expect(guard).toBeGreaterThan(start) + expect(clear).toBeGreaterThan(guard) + expect(src).toContain("disabled={sandboxDisabled()}") + }) }) diff --git a/packages/kilo-vscode/tests/unit/use-slash-command.test.ts b/packages/kilo-vscode/tests/unit/use-slash-command.test.ts new file mode 100644 index 0000000000..b243ab06fc --- /dev/null +++ b/packages/kilo-vscode/tests/unit/use-slash-command.test.ts @@ -0,0 +1,90 @@ +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 } = {}) { + 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, + ), + })) + const fire = (message: ExtensionMessage) => { + for (const handler of handlers) handler(message) + } + return { ...root, fire, sent } +} + +describe("useSlashCommand sandbox action", () => { + 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 } 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 } 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() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index f426620e8e..a0a7a56502 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -96,9 +96,6 @@ export const PromptInput: Component = (props) => { const mention = useFileMention(vscode, sid, hasGit) const terminal = useTerminalContext(vscode) const git = useGitChangesContext(vscode, ctx, hasGit) - const slash = useSlashCommand(vscode, () => - session.variantList(sid()).length > 0 ? new Set() : new Set(["variant"]), - ) const imageAttach = useImageAttachments() imageAttach.setFilePathDropHandler((paths) => { const cwd = server.workspaceDirectory() @@ -164,6 +161,8 @@ export const PromptInput: Component = (props) => { } const sandboxEnabled = () => sandbox()?.enabled ?? (!sandboxID() && config().experimental?.sandbox === true) const sandboxReady = () => !sandboxID() || sandbox() !== undefined + const sandboxDisabled = () => + !server.isConnected() || !sandboxReady() || sandbox()?.available === false || sandboxRequest() !== undefined const requestSandbox = () => { const sessionID = sandboxID() if (!sessionID || server.connectionState() !== "connected") return @@ -171,8 +170,7 @@ export const PromptInput: Component = (props) => { } const toggleSandbox = () => { const sessionID = sandboxID() - const state = sandbox() - if ((sessionID && !state) || state?.available === false || sandboxRequest() || !server.isConnected()) return + if (!sandboxVisible() || sandboxDisabled()) return const requestID = crypto.randomUUID() setSandboxRequest(requestID) setSandboxTarget(sessionID ?? null) @@ -184,6 +182,16 @@ export const PromptInput: Component = (props) => { agentManagerContext: ctx(), }) } + const slash = useSlashCommand( + vscode, + { action: toggleSandbox, enabled: () => sandboxVisible() && !sandboxDisabled() }, + () => { + const hidden = new Set() + if (session.variantList(sid()).length === 0) hidden.add("variant") + if (!sandboxVisible()) hidden.add("sandbox") + return hidden + }, + ) const clearSandboxRequest = () => { setSandboxRequest(undefined) setSandboxTarget(undefined) @@ -852,6 +860,7 @@ export const PromptInput: Component = (props) => { // Client-side slash command — runs locally without a backend round-trip if (matched?.action) { + if (matched.enabled && !matched.enabled()) return setText("") clearReviewComments() imageAttach.clear() @@ -1222,12 +1231,7 @@ export const PromptInput: Component = (props) => { variant="ghost" size="small" onClick={toggleSandbox} - disabled={ - !server.isConnected() || - !sandboxReady() || - sandbox()?.available === false || - sandboxRequest() !== undefined - } + disabled={sandboxDisabled()} aria-label={ sandboxEnabled() ? language.t("prompt.action.sandbox.disable") diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts index 31bfef0766..5468831a70 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts @@ -11,6 +11,7 @@ interface VSCodeContext { export interface SlashCommandEntry extends SlashCommandInfo { action?: () => void + enabled?: Accessor } export interface SlashCommand { @@ -35,7 +36,11 @@ export interface SlashCommand { close: () => void } -export function useSlashCommand(vscode: VSCodeContext, exclude?: Set | Accessor>): SlashCommand { +export function useSlashCommand( + vscode: VSCodeContext, + sandbox: { action: () => void; enabled: Accessor }, + exclude?: Set | Accessor>, +): SlashCommand { const [server, setServer] = createSignal([]) const [query, setQuery] = createSignal(null) const [index, setIndex] = createSignal(0) @@ -123,6 +128,13 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set | A vscode.postMessage({ type: "toggleRemote" }) }, }, + { + name: "sandbox", + description: "Toggle sandbox", + hints: [], + action: sandbox.action, + enabled: sandbox.enabled, + }, ] const excluded = () => { @@ -198,6 +210,7 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set | A onSelect?: () => void, ) => { if (cmd.action) { + if (cmd.enabled && !cmd.enabled()) return textarea.value = "" setText("") close()