diff --git a/.changeset/checkpoint-enable-disable.md b/.changeset/checkpoint-enable-disable.md new file mode 100644 index 00000000000..03c3b8023a5 --- /dev/null +++ b/.changeset/checkpoint-enable-disable.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Add `/checkpoint enable` and `/checkpoint disable` subcommands to toggle checkpoint creation and save disk space diff --git a/cli/src/commands/__tests__/checkpoint.test.ts b/cli/src/commands/__tests__/checkpoint.test.ts new file mode 100644 index 00000000000..3b0fa3bb674 --- /dev/null +++ b/cli/src/commands/__tests__/checkpoint.test.ts @@ -0,0 +1,373 @@ +/** + * Tests for /checkpoint command + */ + +import { describe, it, expect, vi, beforeEach } from "vitest" +import { checkpointCommand } from "../checkpoint.js" +import type { CommandContext } from "../core/types.js" +import { createMockContext } from "./helpers/mockContext.js" + +// Mock the generateMessage utility +vi.mock("../../ui/utils/messages.js", () => ({ + generateMessage: vi.fn(() => ({ + id: "mock-id", + ts: Date.now(), + })), +})) + +// Mock the logs service +vi.mock("../../services/logs.js", () => ({ + logs: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +describe("/checkpoint command", () => { + let mockContext: CommandContext + let addMessageMock: ReturnType + let sendWebviewMessageMock: ReturnType + + beforeEach(() => { + addMessageMock = vi.fn() + sendWebviewMessageMock = vi.fn().mockResolvedValue(undefined) + + mockContext = createMockContext({ + input: "/checkpoint", + addMessage: addMessageMock, + sendWebviewMessage: sendWebviewMessageMock, + chatMessages: [], + }) + }) + + describe("Command metadata", () => { + it("should have correct name", () => { + expect(checkpointCommand.name).toBe("checkpoint") + }) + + it("should have correct aliases", () => { + expect(checkpointCommand.aliases).toEqual(["cp"]) + }) + + it("should have correct description", () => { + expect(checkpointCommand.description).toBe("Manage and revert to saved checkpoints") + }) + + it("should have correct category", () => { + expect(checkpointCommand.category).toBe("chat") + }) + + it("should have correct priority", () => { + expect(checkpointCommand.priority).toBe(7) + }) + + it("should have correct usage", () => { + expect(checkpointCommand.usage).toBe("/checkpoint [hash]") + }) + + it("should have examples including enable and disable", () => { + expect(checkpointCommand.examples).toContain("/checkpoint list") + expect(checkpointCommand.examples).toContain("/checkpoint restore 41db173a") + expect(checkpointCommand.examples).toContain("/checkpoint enable") + expect(checkpointCommand.examples).toContain("/checkpoint disable") + }) + + it("should have arguments defined", () => { + expect(checkpointCommand.arguments).toBeDefined() + expect(checkpointCommand.arguments).toHaveLength(2) + }) + + it("should have subcommand argument", () => { + const subcommandArg = checkpointCommand.arguments?.[0] + expect(subcommandArg?.name).toBe("subcommand") + expect(subcommandArg?.required).toBe(false) + expect(subcommandArg?.provider).toBeDefined() + }) + + it("should have hash argument", () => { + const hashArg = checkpointCommand.arguments?.[1] + expect(hashArg?.name).toBe("hash") + expect(hashArg?.required).toBe(false) + expect(hashArg?.provider).toBeDefined() + }) + }) + + describe("Display help (no args)", () => { + it("should display help when no subcommand provided", async () => { + await checkpointCommand.handler(mockContext) + + expect(addMessageMock).toHaveBeenCalledTimes(1) + const message = addMessageMock.mock.calls[0][0] + expect(message.type).toBe("system") + expect(message.content).toContain("Checkpoint Management") + expect(message.content).toContain("list") + expect(message.content).toContain("restore") + expect(message.content).toContain("enable") + expect(message.content).toContain("disable") + }) + + it("should include enable and disable in help text", async () => { + await checkpointCommand.handler(mockContext) + + const message = addMessageMock.mock.calls[0][0] + expect(message.content).toContain("enable") + expect(message.content).toContain("Enable checkpoint creation") + expect(message.content).toContain("disable") + expect(message.content).toContain("Disable checkpoint creation") + }) + }) + + describe("Enable subcommand", () => { + it("should enable checkpoints via updateSettings message", async () => { + mockContext.args = ["enable"] + + await checkpointCommand.handler(mockContext) + + expect(sendWebviewMessageMock).toHaveBeenCalledTimes(1) + expect(sendWebviewMessageMock).toHaveBeenCalledWith({ + type: "updateSettings", + updatedSettings: { enableCheckpoints: true }, + }) + }) + + it("should display success message when enabling checkpoints", async () => { + mockContext.args = ["enable"] + + await checkpointCommand.handler(mockContext) + + expect(addMessageMock).toHaveBeenCalledTimes(1) + const message = addMessageMock.mock.calls[0][0] + expect(message.type).toBe("system") + expect(message.content).toContain("Checkpoints **enabled**") + }) + + it("should handle errors when enabling checkpoints", async () => { + const error = new Error("Failed to update settings") + sendWebviewMessageMock.mockRejectedValue(error) + mockContext.args = ["enable"] + + await checkpointCommand.handler(mockContext) + + expect(addMessageMock).toHaveBeenCalledTimes(1) + const message = addMessageMock.mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain("Failed to enable checkpoints") + expect(message.content).toContain("Failed to update settings") + }) + }) + + describe("Disable subcommand", () => { + it("should disable checkpoints via updateSettings message", async () => { + mockContext.args = ["disable"] + + await checkpointCommand.handler(mockContext) + + expect(sendWebviewMessageMock).toHaveBeenCalledTimes(1) + expect(sendWebviewMessageMock).toHaveBeenCalledWith({ + type: "updateSettings", + updatedSettings: { enableCheckpoints: false }, + }) + }) + + it("should display success message when disabling checkpoints", async () => { + mockContext.args = ["disable"] + + await checkpointCommand.handler(mockContext) + + expect(addMessageMock).toHaveBeenCalledTimes(1) + const message = addMessageMock.mock.calls[0][0] + expect(message.type).toBe("system") + expect(message.content).toContain("Checkpoints **disabled**") + }) + + it("should include note about existing checkpoints when disabling", async () => { + mockContext.args = ["disable"] + + await checkpointCommand.handler(mockContext) + + const message = addMessageMock.mock.calls[0][0] + expect(message.content).toContain("Existing checkpoints") + expect(message.content).toContain("manually deleted") + // Should contain a path (either Unix or Windows style) + expect(message.content).toMatch(/checkpoints/) + }) + + it("should handle errors when disabling checkpoints", async () => { + const error = new Error("Failed to update settings") + sendWebviewMessageMock.mockRejectedValue(error) + mockContext.args = ["disable"] + + await checkpointCommand.handler(mockContext) + + expect(addMessageMock).toHaveBeenCalledTimes(1) + const message = addMessageMock.mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain("Failed to disable checkpoints") + expect(message.content).toContain("Failed to update settings") + }) + }) + + describe("List subcommand", () => { + it("should show no checkpoints message when empty", async () => { + mockContext.args = ["list"] + mockContext.chatMessages = [] + + await checkpointCommand.handler(mockContext) + + expect(addMessageMock).toHaveBeenCalledTimes(1) + const message = addMessageMock.mock.calls[0][0] + expect(message.type).toBe("system") + expect(message.content).toContain("No checkpoints available") + }) + + it("should list checkpoints when available", async () => { + mockContext.args = ["list"] + mockContext.chatMessages = [ + { + ts: Date.now() - 60000, + type: "say", + say: "checkpoint_saved", + text: "abc123def456789012345678901234567890abcd", + }, + ] + + await checkpointCommand.handler(mockContext) + + expect(addMessageMock).toHaveBeenCalledTimes(1) + const message = addMessageMock.mock.calls[0][0] + expect(message.type).toBe("system") + expect(message.content).toContain("Available checkpoints") + expect(message.content).toContain("abc123def456789012345678901234567890abcd") + }) + }) + + describe("Restore subcommand", () => { + it("should require hash for restore", async () => { + mockContext.args = ["restore"] + + await checkpointCommand.handler(mockContext) + + expect(addMessageMock).toHaveBeenCalledTimes(1) + const message = addMessageMock.mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain("Hash required") + }) + + it("should show error for non-existent checkpoint", async () => { + mockContext.args = ["restore", "nonexistent"] + mockContext.chatMessages = [] + + await checkpointCommand.handler(mockContext) + + expect(addMessageMock).toHaveBeenCalledTimes(1) + const message = addMessageMock.mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain('Checkpoint "nonexistent" not found') + }) + + it("should send restore approval request for valid checkpoint", async () => { + const checkpointHash = "abc123def456789012345678901234567890abcd" + mockContext.args = ["restore", checkpointHash] + mockContext.chatMessages = [ + { + ts: Date.now() - 60000, + type: "say", + say: "checkpoint_saved", + text: checkpointHash, + }, + ] + + await checkpointCommand.handler(mockContext) + + expect(sendWebviewMessageMock).toHaveBeenCalledTimes(1) + expect(sendWebviewMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: "requestCheckpointRestoreApproval", + payload: expect.objectContaining({ + commitHash: checkpointHash, + }), + }), + ) + }) + }) + + describe("Unknown subcommand", () => { + it("should show error for unknown subcommand", async () => { + mockContext.args = ["unknown"] + + await checkpointCommand.handler(mockContext) + + expect(addMessageMock).toHaveBeenCalledTimes(1) + const message = addMessageMock.mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain('Unknown command "unknown"') + expect(message.content).toContain("list, restore, enable, disable") + }) + }) + + describe("Subcommand autocomplete", () => { + it("should provide subcommand suggestions including enable and disable", async () => { + const providerFunc = checkpointCommand.arguments?.[0]?.provider + + if (providerFunc) { + const providerContext = { + commandName: "checkpoint", + argumentIndex: 0, + argumentName: "subcommand", + currentArgs: [], + currentOptions: {}, + partialInput: "", + getArgument: vi.fn(), + parsedValues: { + args: {}, + options: {}, + }, + command: checkpointCommand, + commandContext: mockContext, + } + + const suggestions = await providerFunc(providerContext) + + expect(Array.isArray(suggestions)).toBe(true) + expect(suggestions.length).toBe(4) + + const values = suggestions.map((s) => (typeof s === "string" ? s : s.value)) + expect(values).toContain("list") + expect(values).toContain("restore") + expect(values).toContain("enable") + expect(values).toContain("disable") + } + }) + + it("should filter subcommand suggestions based on partial input", async () => { + const providerFunc = checkpointCommand.arguments?.[0]?.provider + + if (providerFunc) { + const providerContext = { + commandName: "checkpoint", + argumentIndex: 0, + argumentName: "subcommand", + currentArgs: [], + currentOptions: {}, + partialInput: "en", + getArgument: vi.fn(), + parsedValues: { + args: {}, + options: {}, + }, + command: checkpointCommand, + commandContext: mockContext, + } + + const suggestions = await providerFunc(providerContext) + + expect(Array.isArray(suggestions)).toBe(true) + // Should only match "enable" since it contains "en" + const values = suggestions.map((s) => (typeof s === "string" ? s : s.value)) + expect(values).toContain("enable") + } + }) + }) +}) diff --git a/cli/src/commands/checkpoint.ts b/cli/src/commands/checkpoint.ts index 37a0ccd3e4c..7edaad02aec 100644 --- a/cli/src/commands/checkpoint.ts +++ b/cli/src/commands/checkpoint.ts @@ -5,6 +5,7 @@ import type { Command, CommandContext, ArgumentProviderContext, ArgumentSuggestion } from "./core/types.js" import { logs } from "../services/logs.js" import { ExtensionMessage } from "../types/messages.js" +import { generateMessage } from "../ui/utils/messages.js" /** * Interface for checkpoint message from chatMessages @@ -179,6 +180,79 @@ async function handleRestore(context: CommandContext, hash: string): Promise { + const { addMessage, sendWebviewMessage } = context + + logs.info("Enabling checkpoints", "checkpoint") + + try { + await sendWebviewMessage({ + type: "updateSettings", + updatedSettings: { enableCheckpoints: true }, + }) + + addMessage({ + ...generateMessage(), + type: "system", + content: "Checkpoints **enabled**. New checkpoints will be created during task execution.", + }) + + logs.info("Checkpoints enabled successfully", "checkpoint") + } catch (error) { + logs.error("Failed to enable checkpoints", "checkpoint", { error }) + addMessage({ + ...generateMessage(), + type: "error", + content: `Failed to enable checkpoints: ${error instanceof Error ? error.message : String(error)}`, + }) + } +} + +/** + * Get the checkpoints path based on the current platform + */ +function getCheckpointsPath(): string { + if (process.platform === "win32") { + return "%USERPROFILE%\\.kilocode\\cli\\global\\tasks\\*\\checkpoints\\" + } + return "~/.kilocode/cli/global/tasks/*/checkpoints/" +} + +/** + * Handle /checkpoint disable + */ +async function handleDisable(context: CommandContext): Promise { + const { addMessage, sendWebviewMessage } = context + + logs.info("Disabling checkpoints", "checkpoint") + + try { + await sendWebviewMessage({ + type: "updateSettings", + updatedSettings: { enableCheckpoints: false }, + }) + + const checkpointsPath = getCheckpointsPath() + addMessage({ + ...generateMessage(), + type: "system", + content: `Checkpoints **disabled**. No new checkpoints will be created.\n\n**Note:** Existing checkpoints in \`${checkpointsPath}\` can be manually deleted to free disk space.`, + }) + + logs.info("Checkpoints disabled successfully", "checkpoint") + } catch (error) { + logs.error("Failed to disable checkpoints", "checkpoint", { error }) + addMessage({ + ...generateMessage(), + type: "error", + content: `Failed to disable checkpoints: ${error instanceof Error ? error.message : String(error)}`, + }) + } +} + /** * Argument provider for checkpoint hashes */ @@ -224,6 +298,8 @@ async function provideSubcommands(context: ArgumentProviderContext): Promise [hash]", - examples: ["/checkpoint list", "/checkpoint restore 41db173a"], + usage: "/checkpoint [hash]", + examples: ["/checkpoint list", "/checkpoint restore 41db173a", "/checkpoint enable", "/checkpoint disable"], category: "chat", priority: 7, arguments: [ { name: "subcommand", - description: "The action to perform (list, restore)", + description: "The action to perform (list, restore, enable, disable)", required: false, provider: provideSubcommands, }, @@ -294,10 +370,14 @@ export const checkpointCommand: Command = { "**Commands:**", " list List all available checkpoints", " restore Revert to a checkpoint (destructive)", + " enable Enable checkpoint creation", + " disable Disable checkpoint creation (saves disk space)", "", "**Examples:**", " /checkpoint list", " /checkpoint restore 00d185d5020969752bc9ae40823b9d6a723696e2", + " /checkpoint enable", + " /checkpoint disable", "", "**Note:** Hash must be the full 40-character git commit hash.", ].join("\n"), @@ -332,12 +412,22 @@ export const checkpointCommand: Command = { await handleRestore(context, hash) break + case "enable": + logs.debug("Handling checkpoint enable command", "checkpoint") + await handleEnable(context) + break + + case "disable": + logs.debug("Handling checkpoint disable command", "checkpoint") + await handleDisable(context) + break + default: logs.warn("Unknown checkpoint subcommand", "checkpoint", { subcommand }) addMessage({ id: Date.now().toString(), type: "error", - content: `Unknown command "${subcommand}". Available: list, restore`, + content: `Unknown command "${subcommand}". Available: list, restore, enable, disable`, ts: Date.now(), }) }