mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
feat(vscode): add sandbox slash command
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user