feat(vscode): add sandbox slash command

This commit is contained in:
marius-kilocode
2026-06-25 11:50:08 +02:00
parent e3ad3bca8b
commit 27fb5500e3
6 changed files with 163 additions and 12 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Toggle the current session sandbox with `/sandbox` in the sidebar and Agent Manager.
@@ -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",
})
})
})
@@ -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("<Show when={sandboxVisible()}>")
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()}")
})
})
@@ -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<string> } = {}) {
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()
})
})
@@ -96,9 +96,6 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (props) => {
agentManagerContext: ctx(),
})
}
const slash = useSlashCommand(
vscode,
{ action: toggleSandbox, enabled: () => sandboxVisible() && !sandboxDisabled() },
() => {
const hidden = new Set<string>()
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<PromptInputProps> = (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<PromptInputProps> = (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")
@@ -11,6 +11,7 @@ interface VSCodeContext {
export interface SlashCommandEntry extends SlashCommandInfo {
action?: () => void
enabled?: Accessor<boolean>
}
export interface SlashCommand {
@@ -35,7 +36,11 @@ export interface SlashCommand {
close: () => void
}
export function useSlashCommand(vscode: VSCodeContext, exclude?: Set<string> | Accessor<Set<string>>): SlashCommand {
export function useSlashCommand(
vscode: VSCodeContext,
sandbox: { action: () => void; enabled: Accessor<boolean> },
exclude?: Set<string> | Accessor<Set<string>>,
): SlashCommand {
const [server, setServer] = createSignal<SlashCommandInfo[]>([])
const [query, setQuery] = createSignal<string | null>(null)
const [index, setIndex] = createSignal(0)
@@ -123,6 +128,13 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set<string> | 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<string> | A
onSelect?: () => void,
) => {
if (cmd.action) {
if (cmd.enabled && !cmd.enabled()) return
textarea.value = ""
setText("")
close()