diff --git a/.changeset/cyan-insects-marry.md b/.changeset/cyan-insects-marry.md
deleted file mode 100644
index 98dc450f7e..0000000000
--- a/.changeset/cyan-insects-marry.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"roo-cline": patch
----
-
-Add a copy button to the recent tasks
diff --git a/.changeset/dirty-coins-exist.md b/.changeset/dirty-coins-exist.md
deleted file mode 100644
index d01a3ba76e..0000000000
--- a/.changeset/dirty-coins-exist.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"roo-cline": patch
----
-
-Improve the user experience for adding a new configuration profile
diff --git a/.clinerules b/.clinerules
index bc39ae07ed..4f726299d6 100644
--- a/.clinerules
+++ b/.clinerules
@@ -10,6 +10,13 @@
- Prefer fixing the underlying issue over disabling the lint rule
- Document any approved lint rule disabling with a comment explaining the reason
+3. Logging Guidelines:
+ - Always instrument code changes using the logger exported from `src\utils\logging\index.ts`.
+ - This will facilitate efficient debugging without impacting production (as the logger no-ops outside of a test environment.)
+ - Logs can be found in `logs\app.log`
+ - Logfile is overwritten on each run to keep it to a manageable volume.
+
+
# Adding a New Setting
-To add a new setting that persists its state, follow the steps in cline_docs/settings.md
\ No newline at end of file
+To add a new setting that persists its state, follow the steps in cline_docs/settings.md
diff --git a/.gitignore b/.gitignore
index 9deb750e65..211d06aa19 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,9 @@ docs/_site/
# Dotenv
.env.integration
+
+#Local lint config
+.eslintrc.local.json
+
+#Logging
+logs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b3b6309797..97b1d998e1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,24 @@
# Roo Code Changelog
+## [3.3.17]
+
+- Fix the restore checkpoint popover
+- Unset git config that was previously set incorrectly by the checkpoints feature
+
+## [3.3.16]
+
+- Support Volcano Ark platform through the OpenAI-compatible provider
+- Fix jumpiness while entering API config by updating on blur instead of input
+- Add tooltips on checkpoint actions and fix an issue where checkpoints were overwriting existing git name/email settings - thanks for the feedback!
+
+## [3.3.15]
+
+- Improvements to MCP initialization and server restarts (thanks @MuriloFP and @hannesrudolph!)
+- Add a copy button to the recent tasks (thanks @hannesrudolph!)
+- Improve the user experience for adding a new API profile
+- Another significant fix to API profile switching on the settings screen
+- Opt-in experimental version of checkpoints in the advanced settings
+
## [3.3.14]
- Should have skipped floor 13 like an elevator. This fixes the broken 3.3.13 release by reverting some changes to the deployment scripts.
diff --git a/package-lock.json b/package-lock.json
index 3a6874a40e..5f6021f867 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "roo-cline",
- "version": "3.3.14",
+ "version": "3.3.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "roo-cline",
- "version": "3.3.14",
+ "version": "3.3.17",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",
"@anthropic-ai/sdk": "^0.26.0",
@@ -73,8 +73,10 @@
"jest": "^29.7.0",
"jest-simple-dot-reporter": "^1.0.5",
"lint-staged": "^15.2.11",
+ "mkdirp": "^3.0.1",
"npm-run-all": "^4.1.5",
"prettier": "^3.4.2",
+ "rimraf": "^6.0.1",
"ts-jest": "^29.2.5",
"typescript": "^5.4.5"
},
@@ -8831,6 +8833,65 @@
"node": "^10.12.0 || >=12.0.0"
}
},
+ "node_modules/flat-cache/node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/flat-cache/node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
+ "dev": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/flat-cache/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/flat-cache/node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/flatted": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz",
@@ -11847,6 +11908,21 @@
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
},
+ "node_modules/mkdirp": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
+ "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
+ "dev": true,
+ "bin": {
+ "mkdirp": "dist/cjs/src/bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/mocha": {
"version": "10.8.2",
"resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz",
@@ -13550,62 +13626,100 @@
"dev": true
},
"node_modules/rimraf": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
- "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
- "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz",
+ "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==",
"dev": true,
"dependencies": {
- "glob": "^7.1.3"
+ "glob": "^11.0.0",
+ "package-json-from-dist": "^1.0.0"
},
"bin": {
- "rimraf": "bin.js"
+ "rimraf": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/rimraf/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
- "dev": true,
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
"node_modules/rimraf/node_modules/glob": {
- "version": "7.2.3",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
- "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
- "deprecated": "Glob versions prior to v9 are no longer supported",
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz",
+ "integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==",
"dev": true,
"dependencies": {
- "fs.realpath": "^1.0.0",
- "inflight": "^1.0.4",
- "inherits": "2",
- "minimatch": "^3.1.1",
- "once": "^1.3.0",
- "path-is-absolute": "^1.0.0"
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^4.0.1",
+ "minimatch": "^10.0.0",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^2.0.0"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
},
"engines": {
- "node": "*"
+ "node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/rimraf/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "node_modules/rimraf/node_modules/jackspeak": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.2.tgz",
+ "integrity": "sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==",
"dev": true,
"dependencies": {
- "brace-expansion": "^1.1.7"
+ "@isaacs/cliui": "^8.0.2"
},
"engines": {
- "node": "*"
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/rimraf/node_modules/lru-cache": {
+ "version": "11.0.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz",
+ "integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==",
+ "dev": true,
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/rimraf/node_modules/minimatch": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz",
+ "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/rimraf/node_modules/path-scurry": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
+ "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/run-parallel": {
diff --git a/package.json b/package.json
index 9efe5b87dd..be833eba2a 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"displayName": "Roo Code (prev. Roo Cline)",
"description": "A VS Code plugin that enhances coding with AI-powered automation, multi-model support, and experimental features.",
"publisher": "RooVeterinaryInc",
- "version": "3.3.14",
+ "version": "3.3.17",
"icon": "assets/icons/rocket.png",
"galleryBanner": {
"color": "#617A91",
@@ -272,6 +272,7 @@
"compile:integration": "tsc -p tsconfig.integration.json",
"install:all": "npm install && cd webview-ui && npm install",
"lint": "eslint src --ext ts && npm run lint --prefix webview-ui",
+ "lint-fix": "eslint src --ext ts --fix && npm run lint-fix --prefix webview-ui",
"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
"pretest": "npm run compile && npm run compile:integration",
"dev": "cd webview-ui && npm run dev",
@@ -283,7 +284,7 @@
"publish": "npm run build && changeset publish && npm install --package-lock-only",
"version-packages": "changeset version && npm install --package-lock-only",
"vscode:prepublish": "npm run package",
- "vsix": "mkdir -p bin && npx vsce package --out bin",
+ "vsix": "rimraf bin && mkdirp bin && npx vsce package --out bin",
"watch": "npm-run-all -p watch:*",
"watch:esbuild": "node esbuild.js --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
@@ -350,6 +351,8 @@
"@vscode/test-cli": "^0.0.9",
"@vscode/test-electron": "^2.4.0",
"esbuild": "^0.24.0",
+ "mkdirp": "^3.0.1",
+ "rimraf": "^6.0.1",
"eslint": "^8.57.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
diff --git a/src/api/providers/__tests__/unbound.test.ts b/src/api/providers/__tests__/unbound.test.ts
index 3a0fe4868e..790c0f0129 100644
--- a/src/api/providers/__tests__/unbound.test.ts
+++ b/src/api/providers/__tests__/unbound.test.ts
@@ -73,6 +73,15 @@ describe("UnboundHandler", () => {
mockOptions = {
apiModelId: "anthropic/claude-3-5-sonnet-20241022",
unboundApiKey: "test-api-key",
+ unboundModelId: "anthropic/claude-3-5-sonnet-20241022",
+ unboundModelInfo: {
+ description: "Anthropic's Claude 3 Sonnet model",
+ maxTokens: 8192,
+ contextWindow: 200000,
+ supportsPromptCache: true,
+ inputPrice: 0.01,
+ outputPrice: 0.02,
+ },
}
handler = new UnboundHandler(mockOptions)
mockCreate.mockClear()
@@ -205,6 +214,15 @@ describe("UnboundHandler", () => {
const nonAnthropicOptions = {
apiModelId: "openai/gpt-4o",
unboundApiKey: "test-key",
+ unboundModelId: "openai/gpt-4o",
+ unboundModelInfo: {
+ description: "OpenAI's GPT-4",
+ maxTokens: undefined,
+ contextWindow: 128000,
+ supportsPromptCache: true,
+ inputPrice: 0.01,
+ outputPrice: 0.03,
+ },
}
const nonAnthropicHandler = new UnboundHandler(nonAnthropicOptions)
@@ -230,10 +248,11 @@ describe("UnboundHandler", () => {
it("should return default model when invalid model provided", () => {
const handlerWithInvalidModel = new UnboundHandler({
...mockOptions,
- apiModelId: "invalid/model",
+ unboundModelId: "invalid/model",
+ unboundModelInfo: undefined,
})
const modelInfo = handlerWithInvalidModel.getModel()
- expect(modelInfo.id).toBe("openai/gpt-4o") // Default model
+ expect(modelInfo.id).toBe("anthropic/claude-3-5-sonnet-20241022") // Default model
expect(modelInfo.info).toBeDefined()
})
})
diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts
index 98fdbc410b..8551d812a3 100644
--- a/src/api/providers/openai.ts
+++ b/src/api/providers/openai.ts
@@ -10,6 +10,7 @@ import {
import { ApiHandler, SingleCompletionHandler } from "../index"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToR1Format } from "../transform/r1-format"
+import { convertToSimpleMessages } from "../transform/simple-format"
import { ApiStream } from "../transform/stream"
export const DEEP_SEEK_DEFAULT_TEMPERATURE = 0.6
@@ -49,23 +50,33 @@ export class OpenAiHandler implements ApiHandler, SingleCompletionHandler {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const modelInfo = this.getModel().info
+ const modelUrl = this.options.openAiBaseUrl ?? ""
const modelId = this.options.openAiModelId ?? ""
const deepseekReasoner = modelId.includes("deepseek-reasoner")
+ const ark = modelUrl.includes(".volces.com")
if (this.options.openAiStreamingEnabled ?? true) {
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
role: "system",
content: systemPrompt,
}
+
+ let convertedMessages
+ if (deepseekReasoner) {
+ convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
+ } else if (ark) {
+ convertedMessages = [systemMessage, ...convertToSimpleMessages(messages)]
+ } else {
+ convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)]
+ }
+
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: modelId,
temperature:
this.options.modelTemperature ??
(deepseekReasoner ? DEEP_SEEK_DEFAULT_TEMPERATURE : OPENAI_DEFAULT_TEMPERATURE),
- messages: deepseekReasoner
- ? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
- : [systemMessage, ...convertToOpenAiMessages(messages)],
+ messages: convertedMessages,
stream: true as const,
stream_options: { include_usage: true },
}
diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts
index 809ae21889..0599ffa443 100644
--- a/src/api/providers/unbound.ts
+++ b/src/api/providers/unbound.ts
@@ -1,7 +1,7 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler, SingleCompletionHandler } from "../"
-import { ApiHandlerOptions, ModelInfo, UnboundModelId, unboundDefaultModelId, unboundModels } from "../../shared/api"
+import { ApiHandlerOptions, ModelInfo, unboundDefaultModelId, unboundDefaultModelInfo } from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
@@ -129,15 +129,15 @@ export class UnboundHandler implements ApiHandler, SingleCompletionHandler {
}
}
- getModel(): { id: UnboundModelId; info: ModelInfo } {
- const modelId = this.options.apiModelId
- if (modelId && modelId in unboundModels) {
- const id = modelId as UnboundModelId
- return { id, info: unboundModels[id] }
+ getModel(): { id: string; info: ModelInfo } {
+ const modelId = this.options.unboundModelId
+ const modelInfo = this.options.unboundModelInfo
+ if (modelId && modelInfo) {
+ return { id: modelId, info: modelInfo }
}
return {
id: unboundDefaultModelId,
- info: unboundModels[unboundDefaultModelId],
+ info: unboundDefaultModelInfo,
}
}
diff --git a/src/api/transform/__tests__/simple-format.test.ts b/src/api/transform/__tests__/simple-format.test.ts
new file mode 100644
index 0000000000..bf1de1927f
--- /dev/null
+++ b/src/api/transform/__tests__/simple-format.test.ts
@@ -0,0 +1,138 @@
+import { Anthropic } from "@anthropic-ai/sdk"
+import { convertToSimpleContent, convertToSimpleMessages } from "../simple-format"
+
+describe("simple-format", () => {
+ describe("convertToSimpleContent", () => {
+ it("returns string content as-is", () => {
+ const content = "Hello world"
+ expect(convertToSimpleContent(content)).toBe("Hello world")
+ })
+
+ it("extracts text from text blocks", () => {
+ const content = [
+ { type: "text", text: "Hello" },
+ { type: "text", text: "world" },
+ ] as Anthropic.Messages.TextBlockParam[]
+ expect(convertToSimpleContent(content)).toBe("Hello\nworld")
+ })
+
+ it("converts image blocks to descriptive text", () => {
+ const content = [
+ { type: "text", text: "Here's an image:" },
+ {
+ type: "image",
+ source: {
+ type: "base64",
+ media_type: "image/png",
+ data: "base64data",
+ },
+ },
+ ] as Array
+ expect(convertToSimpleContent(content)).toBe("Here's an image:\n[Image: image/png]")
+ })
+
+ it("converts tool use blocks to descriptive text", () => {
+ const content = [
+ { type: "text", text: "Using a tool:" },
+ {
+ type: "tool_use",
+ id: "tool-1",
+ name: "read_file",
+ input: { path: "test.txt" },
+ },
+ ] as Array
+ expect(convertToSimpleContent(content)).toBe("Using a tool:\n[Tool Use: read_file]")
+ })
+
+ it("handles string tool result content", () => {
+ const content = [
+ { type: "text", text: "Tool result:" },
+ {
+ type: "tool_result",
+ tool_use_id: "tool-1",
+ content: "Result text",
+ },
+ ] as Array
+ expect(convertToSimpleContent(content)).toBe("Tool result:\nResult text")
+ })
+
+ it("handles array tool result content with text and images", () => {
+ const content = [
+ {
+ type: "tool_result",
+ tool_use_id: "tool-1",
+ content: [
+ { type: "text", text: "Result 1" },
+ {
+ type: "image",
+ source: {
+ type: "base64",
+ media_type: "image/jpeg",
+ data: "base64data",
+ },
+ },
+ { type: "text", text: "Result 2" },
+ ],
+ },
+ ] as Anthropic.Messages.ToolResultBlockParam[]
+ expect(convertToSimpleContent(content)).toBe("Result 1\n[Image: image/jpeg]\nResult 2")
+ })
+
+ it("filters out empty strings", () => {
+ const content = [
+ { type: "text", text: "Hello" },
+ { type: "text", text: "" },
+ { type: "text", text: "world" },
+ ] as Anthropic.Messages.TextBlockParam[]
+ expect(convertToSimpleContent(content)).toBe("Hello\nworld")
+ })
+ })
+
+ describe("convertToSimpleMessages", () => {
+ it("converts messages with string content", () => {
+ const messages = [
+ { role: "user", content: "Hello" },
+ { role: "assistant", content: "Hi there" },
+ ] as Anthropic.Messages.MessageParam[]
+ expect(convertToSimpleMessages(messages)).toEqual([
+ { role: "user", content: "Hello" },
+ { role: "assistant", content: "Hi there" },
+ ])
+ })
+
+ it("converts messages with complex content", () => {
+ const messages = [
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "Look at this:" },
+ {
+ type: "image",
+ source: {
+ type: "base64",
+ media_type: "image/png",
+ data: "base64data",
+ },
+ },
+ ],
+ },
+ {
+ role: "assistant",
+ content: [
+ { type: "text", text: "I see the image" },
+ {
+ type: "tool_use",
+ id: "tool-1",
+ name: "analyze_image",
+ input: { data: "base64data" },
+ },
+ ],
+ },
+ ] as Anthropic.Messages.MessageParam[]
+ expect(convertToSimpleMessages(messages)).toEqual([
+ { role: "user", content: "Look at this:\n[Image: image/png]" },
+ { role: "assistant", content: "I see the image\n[Tool Use: analyze_image]" },
+ ])
+ })
+ })
+})
diff --git a/src/api/transform/simple-format.ts b/src/api/transform/simple-format.ts
new file mode 100644
index 0000000000..c1e4895bba
--- /dev/null
+++ b/src/api/transform/simple-format.ts
@@ -0,0 +1,67 @@
+import { Anthropic } from "@anthropic-ai/sdk"
+
+/**
+ * Convert complex content blocks to simple string content
+ */
+export function convertToSimpleContent(
+ content:
+ | string
+ | Array<
+ | Anthropic.Messages.TextBlockParam
+ | Anthropic.Messages.ImageBlockParam
+ | Anthropic.Messages.ToolUseBlockParam
+ | Anthropic.Messages.ToolResultBlockParam
+ >,
+): string {
+ if (typeof content === "string") {
+ return content
+ }
+
+ // Extract text from content blocks
+ return content
+ .map((block) => {
+ if (block.type === "text") {
+ return block.text
+ }
+ if (block.type === "image") {
+ return `[Image: ${block.source.media_type}]`
+ }
+ if (block.type === "tool_use") {
+ return `[Tool Use: ${block.name}]`
+ }
+ if (block.type === "tool_result") {
+ if (typeof block.content === "string") {
+ return block.content
+ }
+ if (Array.isArray(block.content)) {
+ return block.content
+ .map((part) => {
+ if (part.type === "text") {
+ return part.text
+ }
+ if (part.type === "image") {
+ return `[Image: ${part.source.media_type}]`
+ }
+ return ""
+ })
+ .join("\n")
+ }
+ return ""
+ }
+ return ""
+ })
+ .filter(Boolean)
+ .join("\n")
+}
+
+/**
+ * Convert Anthropic messages to simple format with string content
+ */
+export function convertToSimpleMessages(
+ messages: Anthropic.Messages.MessageParam[],
+): Array<{ role: "user" | "assistant"; content: string }> {
+ return messages.map((message) => ({
+ role: message.role,
+ content: convertToSimpleContent(message.content),
+ }))
+}
diff --git a/src/core/Cline.ts b/src/core/Cline.ts
index 0f2e796132..83cdabd466 100644
--- a/src/core/Cline.ts
+++ b/src/core/Cline.ts
@@ -11,7 +11,8 @@ import { serializeError } from "serialize-error"
import * as vscode from "vscode"
import { ApiHandler, SingleCompletionHandler, buildApiHandler } from "../api"
import { ApiStream } from "../api/transform/stream"
-import { DiffViewProvider } from "../integrations/editor/DiffViewProvider"
+import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
+import { CheckpointService } from "../services/checkpoints/CheckpointService"
import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
import {
extractTextFromFile,
@@ -93,12 +94,19 @@ export class Cline {
private consecutiveMistakeCountForApplyDiff: Map = new Map()
private providerRef: WeakRef
private abort: boolean = false
- didFinishAborting = false
+ didFinishAbortingStream = false
abandoned = false
private diffViewProvider: DiffViewProvider
private lastApiRequestTime?: number
+ isInitialized = false
+
+ // checkpoints
+ checkpointsEnabled: boolean = false
+ private checkpointService?: CheckpointService
// streaming
+ isWaitingForFirstChunk = false
+ isStreaming = false
private currentStreamingContentIndex = 0
private assistantMessageContent: AssistantMessageContent[] = []
private presentAssistantMessageLocked = false
@@ -114,6 +122,7 @@ export class Cline {
apiConfiguration: ApiConfiguration,
customInstructions?: string,
enableDiff?: boolean,
+ enableCheckpoints?: boolean,
fuzzyMatchThreshold?: number,
task?: string | undefined,
images?: string[] | undefined,
@@ -134,6 +143,7 @@ export class Cline {
this.fuzzyMatchThreshold = fuzzyMatchThreshold ?? 1.0
this.providerRef = new WeakRef(provider)
this.diffViewProvider = new DiffViewProvider(cwd)
+ this.checkpointsEnabled = enableCheckpoints ?? false
if (historyItem) {
this.taskId = historyItem.id
@@ -438,6 +448,7 @@ export class Cline {
await this.providerRef.deref()?.postStateToWebview()
await this.say("text", task, images)
+ this.isInitialized = true
let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
await this.initiateTaskLoop([
@@ -477,12 +488,13 @@ export class Cline {
await this.overwriteClineMessages(modifiedClineMessages)
this.clineMessages = await this.getSavedClineMessages()
- // need to make sure that the api conversation history can be resumed by the api, even if it goes out of sync with cline messages
-
- let existingApiConversationHistory: Anthropic.Messages.MessageParam[] =
- await this.getSavedApiConversationHistory()
-
- // Now present the cline messages to the user and ask if they want to resume
+ // Now present the cline messages to the user and ask if they want to
+ // resume (NOTE: we ran into a bug before where the
+ // apiConversationHistory wouldn't be initialized when opening a old
+ // task, and it was because we were waiting for resume).
+ // This is important in case the user deletes messages without resuming
+ // the task first.
+ this.apiConversationHistory = await this.getSavedApiConversationHistory()
const lastClineMessage = this.clineMessages
.slice()
@@ -506,6 +518,8 @@ export class Cline {
askType = "resume_task"
}
+ this.isInitialized = true
+
const { response, text, images } = await this.ask(askType) // calls poststatetowebview
let responseText: string | undefined
let responseImages: string[] | undefined
@@ -515,6 +529,11 @@ export class Cline {
responseImages = images
}
+ // Make sure that the api conversation history can be resumed by the API,
+ // even if it goes out of sync with cline messages.
+ let existingApiConversationHistory: Anthropic.Messages.MessageParam[] =
+ await this.getSavedApiConversationHistory()
+
// v2.0 xml tags refactor caveat: since we don't use tools anymore, we need to replace all tool use blocks with a text block since the API disallows conversations with tool uses and no tool schema
const conversationWithoutToolBlocks = existingApiConversationHistory.map((message) => {
if (Array.isArray(message.content)) {
@@ -706,11 +725,14 @@ export class Cline {
}
}
- abortTask() {
- this.abort = true // will stop any autonomously running promises
+ async abortTask() {
+ this.abort = true // Will stop any autonomously running promises.
this.terminalManager.disposeAll()
this.urlContentFetcher.closeBrowser()
this.browserSession.closeBrowser()
+ // Need to await for when we want to make sure directories/files are
+ // reverted before re-starting the task from a checkpoint.
+ await this.diffViewProvider.revertChanges()
}
// Tools
@@ -927,8 +949,10 @@ export class Cline {
try {
// awaiting first chunk to see if it will throw an error
+ this.isWaitingForFirstChunk = true
const firstChunk = await iterator.next()
yield firstChunk.value
+ this.isWaitingForFirstChunk = false
} catch (error) {
// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
if (alwaysApproveResubmit) {
@@ -1003,6 +1027,9 @@ export class Cline {
}
const block = cloneDeep(this.assistantMessageContent[this.currentStreamingContentIndex]) // need to create copy bc while stream is updating the array, it could be updating the reference block properties too
+
+ let isCheckpointPossible = false
+
switch (block.type) {
case "text": {
if (this.didRejectTool || this.didAlreadyUseTool) {
@@ -1134,6 +1161,10 @@ export class Cline {
}
// once a tool result has been collected, ignore all other tool uses since we should only ever present one tool result per message
this.didAlreadyUseTool = true
+
+ // Flag a checkpoint as possible since we've used a tool
+ // which may have changed the file system.
+ isCheckpointPossible = true
}
const askApproval = async (type: ClineAsk, partialMessage?: string) => {
@@ -2655,6 +2686,10 @@ export class Cline {
break
}
+ if (isCheckpointPossible) {
+ await this.checkpointSave()
+ }
+
/*
Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present.
When you see the UI inactive during this, it means that a tool is breaking without presenting any UI. For example the write_to_file tool was breaking when relpath was undefined, and for invalid relpath it never presented UI.
@@ -2811,7 +2846,7 @@ export class Cline {
await this.saveClineMessages()
// signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature
- this.didFinishAborting = true
+ this.didFinishAbortingStream = true
}
// reset streaming state
@@ -3197,6 +3232,178 @@ export class Cline {
return `\n${details.trim()}\n `
}
+
+ // Checkpoints
+
+ private async getCheckpointService() {
+ if (!this.checkpointService) {
+ this.checkpointService = await CheckpointService.create({
+ taskId: this.taskId,
+ baseDir: vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? "",
+ })
+ }
+
+ return this.checkpointService
+ }
+
+ public async checkpointDiff({
+ ts,
+ commitHash,
+ mode,
+ }: {
+ ts: number
+ commitHash: string
+ mode: "full" | "checkpoint"
+ }) {
+ if (!this.checkpointsEnabled) {
+ return
+ }
+
+ let previousCommitHash = undefined
+
+ if (mode === "checkpoint") {
+ const previousCheckpoint = this.clineMessages
+ .filter(({ say }) => say === "checkpoint_saved")
+ .sort((a, b) => b.ts - a.ts)
+ .find((message) => message.ts < ts)
+
+ previousCommitHash = previousCheckpoint?.text
+ }
+
+ try {
+ const service = await this.getCheckpointService()
+ const changes = await service.getDiff({ from: previousCommitHash, to: commitHash })
+
+ if (!changes?.length) {
+ vscode.window.showInformationMessage("No changes found.")
+ return
+ }
+
+ await vscode.commands.executeCommand(
+ "vscode.changes",
+ mode === "full" ? "Changes since task started" : "Changes since previous checkpoint",
+ changes.map((change) => [
+ vscode.Uri.file(change.paths.absolute),
+ vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${change.paths.relative}`).with({
+ query: Buffer.from(change.content.before ?? "").toString("base64"),
+ }),
+ vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${change.paths.relative}`).with({
+ query: Buffer.from(change.content.after ?? "").toString("base64"),
+ }),
+ ]),
+ )
+ } catch (err) {
+ this.providerRef
+ .deref()
+ ?.log(
+ `[checkpointDiff] Encountered unexpected error: $${err instanceof Error ? err.message : String(err)}`,
+ )
+
+ this.checkpointsEnabled = false
+ }
+ }
+
+ public async checkpointSave() {
+ if (!this.checkpointsEnabled) {
+ return
+ }
+
+ try {
+ const service = await this.getCheckpointService()
+ const commit = await service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`)
+
+ if (commit?.commit) {
+ await this.providerRef
+ .deref()
+ ?.postMessageToWebview({ type: "currentCheckpointUpdated", text: service.currentCheckpoint })
+
+ await this.say("checkpoint_saved", commit.commit)
+ }
+ } catch (err) {
+ this.providerRef
+ .deref()
+ ?.log(
+ `[checkpointSave] Encountered unexpected error: $${err instanceof Error ? err.message : String(err)}`,
+ )
+
+ this.checkpointsEnabled = false
+ }
+ }
+
+ public async checkpointRestore({
+ ts,
+ commitHash,
+ mode,
+ }: {
+ ts: number
+ commitHash: string
+ mode: "preview" | "restore"
+ }) {
+ if (!this.checkpointsEnabled) {
+ return
+ }
+
+ const index = this.clineMessages.findIndex((m) => m.ts === ts)
+
+ if (index === -1) {
+ return
+ }
+
+ try {
+ const service = await this.getCheckpointService()
+ await service.restoreCheckpoint(commitHash)
+
+ await this.providerRef
+ .deref()
+ ?.postMessageToWebview({ type: "currentCheckpointUpdated", text: service.currentCheckpoint })
+
+ if (mode === "restore") {
+ await this.overwriteApiConversationHistory(
+ this.apiConversationHistory.filter((m) => !m.ts || m.ts < ts),
+ )
+
+ const deletedMessages = this.clineMessages.slice(index + 1)
+
+ const { totalTokensIn, totalTokensOut, totalCacheWrites, totalCacheReads, totalCost } = getApiMetrics(
+ combineApiRequests(combineCommandSequences(deletedMessages)),
+ )
+
+ await this.overwriteClineMessages(this.clineMessages.slice(0, index + 1))
+
+ // TODO: Verify that this is working as expected.
+ await this.say(
+ "api_req_deleted",
+ JSON.stringify({
+ tokensIn: totalTokensIn,
+ tokensOut: totalTokensOut,
+ cacheWrites: totalCacheWrites,
+ cacheReads: totalCacheReads,
+ cost: totalCost,
+ } satisfies ClineApiReqInfo),
+ )
+ }
+
+ // The task is already cancelled by the provider beforehand, but we
+ // need to re-init to get the updated messages.
+ //
+ // This was take from Cline's implementation of the checkpoints
+ // feature. The cline instance will hang if we don't cancel twice,
+ // so this is currently necessary, but it seems like a complicated
+ // and hacky solution to a problem that I don't fully understand.
+ // I'd like to revisit this in the future and try to improve the
+ // task flow and the communication between the webview and the
+ // Cline instance.
+ this.providerRef.deref()?.cancelTask()
+ } catch (err) {
+ this.providerRef
+ .deref()
+ ?.log(
+ `[restoreCheckpoint] Encountered unexpected error: $${err instanceof Error ? err.message : String(err)}`,
+ )
+
+ this.checkpointsEnabled = false
+ }
+ }
}
function escapeRegExp(string: string): string {
diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts
index 4c1f697333..87fa034dec 100644
--- a/src/core/__tests__/Cline.test.ts
+++ b/src/core/__tests__/Cline.test.ts
@@ -306,6 +306,7 @@ describe("Cline", () => {
mockApiConfig,
"custom instructions",
false,
+ false,
0.95, // 95% threshold
"test task",
)
@@ -315,7 +316,15 @@ describe("Cline", () => {
})
it("should use default fuzzy match threshold when not provided", () => {
- const cline = new Cline(mockProvider, mockApiConfig, "custom instructions", true, undefined, "test task")
+ const cline = new Cline(
+ mockProvider,
+ mockApiConfig,
+ "custom instructions",
+ true,
+ false,
+ undefined,
+ "test task",
+ )
expect(cline.diffEnabled).toBe(true)
// The diff strategy should be created with default threshold (1.0)
@@ -330,6 +339,7 @@ describe("Cline", () => {
mockApiConfig,
"custom instructions",
true,
+ false,
0.9, // 90% threshold
"test task",
)
@@ -344,7 +354,15 @@ describe("Cline", () => {
it("should pass default threshold to diff strategy when not provided", () => {
const getDiffStrategySpy = jest.spyOn(require("../diff/DiffStrategy"), "getDiffStrategy")
- const cline = new Cline(mockProvider, mockApiConfig, "custom instructions", true, undefined, "test task")
+ const cline = new Cline(
+ mockProvider,
+ mockApiConfig,
+ "custom instructions",
+ true,
+ false,
+ undefined,
+ "test task",
+ )
expect(cline.diffEnabled).toBe(true)
expect(cline.diffStrategy).toBeDefined()
@@ -360,6 +378,7 @@ describe("Cline", () => {
mockApiConfig,
undefined, // customInstructions
false, // diffEnabled
+ false, // checkpointsEnabled
undefined, // fuzzyMatchThreshold
undefined, // task
)
@@ -412,7 +431,7 @@ describe("Cline", () => {
})
it("should include timezone information in environment details", async () => {
- const cline = new Cline(mockProvider, mockApiConfig, undefined, false, undefined, "test task")
+ const cline = new Cline(mockProvider, mockApiConfig, undefined, false, false, undefined, "test task")
const details = await cline["getEnvironmentDetails"](false)
@@ -425,7 +444,7 @@ describe("Cline", () => {
describe("API conversation handling", () => {
it("should clean conversation history before sending to API", async () => {
- const cline = new Cline(mockProvider, mockApiConfig, undefined, false, undefined, "test task")
+ const cline = new Cline(mockProvider, mockApiConfig, undefined, false, false, undefined, "test task")
// Mock the API's createMessage method to capture the conversation history
const createMessageSpy = jest.fn()
@@ -537,6 +556,7 @@ describe("Cline", () => {
configWithImages,
undefined,
false,
+ false,
undefined,
"test task",
)
@@ -561,6 +581,7 @@ describe("Cline", () => {
configWithoutImages,
undefined,
false,
+ false,
undefined,
"test task",
)
@@ -647,7 +668,7 @@ describe("Cline", () => {
})
it("should handle API retry with countdown", async () => {
- const cline = new Cline(mockProvider, mockApiConfig, undefined, false, undefined, "test task")
+ const cline = new Cline(mockProvider, mockApiConfig, undefined, false, false, undefined, "test task")
// Mock delay to track countdown timing
const mockDelay = jest.fn().mockResolvedValue(undefined)
@@ -767,7 +788,15 @@ describe("Cline", () => {
describe("loadContext", () => {
it("should process mentions in task and feedback tags", async () => {
- const cline = new Cline(mockProvider, mockApiConfig, undefined, false, undefined, "test task")
+ const cline = new Cline(
+ mockProvider,
+ mockApiConfig,
+ undefined,
+ false,
+ false,
+ undefined,
+ "test task",
+ )
// Mock parseMentions to track calls
const mockParseMentions = jest.fn().mockImplementation((text) => `processed: ${text}`)
diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts
index 221c89a9ad..1051267c43 100644
--- a/src/core/webview/ClineProvider.ts
+++ b/src/core/webview/ClineProvider.ts
@@ -6,6 +6,8 @@ import os from "os"
import pWaitFor from "p-wait-for"
import * as path from "path"
import * as vscode from "vscode"
+import simpleGit from "simple-git"
+
import { buildApiHandler } from "../../api"
import { downloadTask } from "../../integrations/misc/export-markdown"
import { openFile, openImage } from "../../integrations/misc/open-file"
@@ -18,7 +20,7 @@ import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
import { findLast } from "../../shared/array"
import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
import { HistoryItem } from "../../shared/HistoryItem"
-import { WebviewMessage } from "../../shared/WebviewMessage"
+import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug } from "../../shared/modes"
import { SYSTEM_PROMPT } from "../prompts/system"
import { fileExistsAtPath } from "../../utils/fs"
@@ -96,6 +98,7 @@ type GlobalStateKey =
| "soundEnabled"
| "soundVolume"
| "diffEnabled"
+ | "checkpointsEnabled"
| "browserViewportSize"
| "screenshotQuality"
| "fuzzyMatchThreshold"
@@ -119,6 +122,7 @@ type GlobalStateKey =
| "autoApprovalEnabled"
| "customModes" // Array of custom modes
| "unboundModelId"
+ | "unboundModelInfo"
| "modelTemperature"
export const GlobalFileNames = {
@@ -127,6 +131,7 @@ export const GlobalFileNames = {
glamaModels: "glama_models.json",
openRouterModels: "openrouter_models.json",
mcpSettings: "cline_mcp_settings.json",
+ unboundModels: "unbound_models.json",
}
export class ClineProvider implements vscode.WebviewViewProvider {
@@ -392,6 +397,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiConfiguration,
customModePrompts,
diffEnabled,
+ checkpointsEnabled,
fuzzyMatchThreshold,
mode,
customInstructions: globalInstructions,
@@ -406,6 +412,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiConfiguration,
effectiveInstructions,
diffEnabled,
+ checkpointsEnabled,
fuzzyMatchThreshold,
task,
images,
@@ -416,10 +423,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
public async initClineWithHistoryItem(historyItem: HistoryItem) {
await this.clearTask()
+
const {
apiConfiguration,
customModePrompts,
diffEnabled,
+ checkpointsEnabled,
fuzzyMatchThreshold,
mode,
customInstructions: globalInstructions,
@@ -434,6 +443,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiConfiguration,
effectiveInstructions,
diffEnabled,
+ checkpointsEnabled,
fuzzyMatchThreshold,
undefined,
undefined,
@@ -619,7 +629,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
if (this.mcpHub) {
this.postMessageToWebview({
type: "mcpServers",
- mcpServers: this.mcpHub.getServers(),
+ mcpServers: this.mcpHub.getAllServers(),
})
}
@@ -658,6 +668,24 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
})
+ this.readUnboundModels().then((unboundModels) => {
+ if (unboundModels) {
+ this.postMessageToWebview({ type: "unboundModels", unboundModels })
+ }
+ })
+ this.refreshUnboundModels().then(async (unboundModels) => {
+ if (unboundModels) {
+ const { apiConfiguration } = await this.getState()
+ if (apiConfiguration?.unboundModelId) {
+ await this.updateGlobalState(
+ "unboundModelInfo",
+ unboundModels[apiConfiguration.unboundModelId],
+ )
+ await this.postStateToWebview()
+ }
+ }
+ })
+
this.configManager
.listConfig()
.then(async (listApiConfig) => {
@@ -817,6 +845,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.postMessageToWebview({ type: "openAiModels", openAiModels })
}
break
+ case "refreshUnboundModels":
+ await this.refreshUnboundModels()
+ break
case "openImage":
openImage(message.text!)
break
@@ -826,25 +857,37 @@ export class ClineProvider implements vscode.WebviewViewProvider {
case "openMention":
openMention(message.text)
break
- case "cancelTask":
- if (this.cline) {
- const { historyItem } = await this.getTaskWithId(this.cline.taskId)
- this.cline.abortTask()
- await pWaitFor(() => this.cline === undefined || this.cline.didFinishAborting, {
- timeout: 3_000,
- }).catch((error) => {
- this.outputChannel.appendLine(
- `Failed to abort task ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
- )
- })
- if (this.cline) {
- // 'abandoned' will prevent this cline instance from affecting future cline instance gui. this may happen if its hanging on a streaming request
- this.cline.abandoned = true
- }
- await this.initClineWithHistoryItem(historyItem) // clears task again, so we need to abortTask manually above
- // await this.postStateToWebview() // new Cline instance will post state when it's ready. having this here sent an empty messages array to webview leading to virtuoso having to reload the entire list
+ case "checkpointDiff":
+ const result = checkoutDiffPayloadSchema.safeParse(message.payload)
+
+ if (result.success) {
+ await this.cline?.checkpointDiff(result.data)
}
+ break
+ case "checkpointRestore": {
+ const result = checkoutRestorePayloadSchema.safeParse(message.payload)
+
+ if (result.success) {
+ await this.cancelTask()
+
+ try {
+ await pWaitFor(() => this.cline?.isInitialized === true, { timeout: 3_000 })
+ } catch (error) {
+ vscode.window.showErrorMessage("Timed out when attempting to restore checkpoint.")
+ }
+
+ try {
+ await this.cline?.checkpointRestore(result.data)
+ } catch (error) {
+ vscode.window.showErrorMessage("Failed to restore checkpoint.")
+ }
+ }
+
+ break
+ }
+ case "cancelTask":
+ await this.cancelTask()
break
case "allowedCommands":
await this.context.globalState.update("allowedCommands", message.commands)
@@ -933,6 +976,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("diffEnabled", diffEnabled)
await this.postStateToWebview()
break
+ case "checkpointsEnabled":
+ const checkpointsEnabled = message.bool ?? false
+ await this.updateGlobalState("checkpointsEnabled", checkpointsEnabled)
+ await this.postStateToWebview()
+ break
case "browserViewportSize":
const browserViewportSize = message.text ?? "900x600"
await this.updateGlobalState("browserViewportSize", browserViewportSize)
@@ -1539,6 +1587,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
mistralApiKey,
unboundApiKey,
unboundModelId,
+ unboundModelInfo,
modelTemperature,
} = apiConfiguration
await this.updateGlobalState("apiProvider", apiProvider)
@@ -1580,12 +1629,46 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.storeSecret("mistralApiKey", mistralApiKey)
await this.storeSecret("unboundApiKey", unboundApiKey)
await this.updateGlobalState("unboundModelId", unboundModelId)
+ await this.updateGlobalState("unboundModelInfo", unboundModelInfo)
await this.updateGlobalState("modelTemperature", modelTemperature)
if (this.cline) {
this.cline.api = buildApiHandler(apiConfiguration)
}
}
+ async cancelTask() {
+ if (this.cline) {
+ const { historyItem } = await this.getTaskWithId(this.cline.taskId)
+ this.cline.abortTask()
+
+ await pWaitFor(
+ () =>
+ this.cline === undefined ||
+ this.cline.isStreaming === false ||
+ this.cline.didFinishAbortingStream ||
+ // If only the first chunk is processed, then there's no
+ // need to wait for graceful abort (closes edits, browser,
+ // etc).
+ this.cline.isWaitingForFirstChunk,
+ {
+ timeout: 3_000,
+ },
+ ).catch(() => {
+ console.error("Failed to abort task")
+ })
+
+ if (this.cline) {
+ // 'abandoned' will prevent this Cline instance from affecting
+ // future Cline instances. This may happen if its hanging on a
+ // streaming request.
+ this.cline.abandoned = true
+ }
+
+ // Clears task again, so we need to abortTask manually above.
+ await this.initClineWithHistoryItem(historyItem)
+ }
+ }
+
async updateCustomInstructions(instructions?: string) {
// User may be clearing the field
await this.updateGlobalState("customInstructions", instructions || undefined)
@@ -1753,16 +1836,20 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome
}
- async readGlamaModels(): Promise | undefined> {
- const glamaModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.glamaModels)
- const fileExists = await fileExistsAtPath(glamaModelsFilePath)
+ private async readModelsFromCache(filename: string): Promise | undefined> {
+ const filePath = path.join(await this.ensureCacheDirectoryExists(), filename)
+ const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
- const fileContents = await fs.readFile(glamaModelsFilePath, "utf8")
+ const fileContents = await fs.readFile(filePath, "utf8")
return JSON.parse(fileContents)
}
return undefined
}
+ async readGlamaModels(): Promise | undefined> {
+ return this.readModelsFromCache(GlobalFileNames.glamaModels)
+ }
+
async refreshGlamaModels() {
const glamaModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.glamaModels)
@@ -1826,7 +1913,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.outputChannel.appendLine("Invalid response from Glama API")
}
await fs.writeFile(glamaModelsFilePath, JSON.stringify(models))
- this.outputChannel.appendLine(`Glama models fetched and saved: ${JSON.stringify(models, null, 2)}`)
} catch (error) {
this.outputChannel.appendLine(
`Error fetching Glama models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
@@ -1838,16 +1924,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
async readOpenRouterModels(): Promise | undefined> {
- const openRouterModelsFilePath = path.join(
- await this.ensureCacheDirectoryExists(),
- GlobalFileNames.openRouterModels,
- )
- const fileExists = await fileExistsAtPath(openRouterModelsFilePath)
- if (fileExists) {
- const fileContents = await fs.readFile(openRouterModelsFilePath, "utf8")
- return JSON.parse(fileContents)
- }
- return undefined
+ return this.readModelsFromCache(GlobalFileNames.openRouterModels)
}
async refreshOpenRouterModels() {
@@ -1951,7 +2028,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.outputChannel.appendLine("Invalid response from OpenRouter API")
}
await fs.writeFile(openRouterModelsFilePath, JSON.stringify(models))
- this.outputChannel.appendLine(`OpenRouter models fetched and saved: ${JSON.stringify(models, null, 2)}`)
} catch (error) {
this.outputChannel.appendLine(
`Error fetching OpenRouter models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
@@ -1962,6 +2038,45 @@ export class ClineProvider implements vscode.WebviewViewProvider {
return models
}
+ async readUnboundModels(): Promise | undefined> {
+ return this.readModelsFromCache(GlobalFileNames.unboundModels)
+ }
+
+ async refreshUnboundModels() {
+ const unboundModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.unboundModels)
+
+ const models: Record = {}
+ try {
+ const response = await axios.get("https://api.getunbound.ai/models")
+
+ if (response.data) {
+ const rawModels: Record = response.data
+
+ for (const [modelId, model] of Object.entries(rawModels)) {
+ models[modelId] = {
+ maxTokens: model.maxTokens ? parseInt(model.maxTokens) : undefined,
+ contextWindow: model.contextWindow ? parseInt(model.contextWindow) : 0,
+ supportsImages: model.supportsImages ?? false,
+ supportsPromptCache: model.supportsPromptCaching ?? false,
+ supportsComputerUse: model.supportsComputerUse ?? false,
+ inputPrice: model.inputTokenPrice ? parseFloat(model.inputTokenPrice) : undefined,
+ outputPrice: model.outputTokenPrice ? parseFloat(model.outputTokenPrice) : undefined,
+ cacheWritesPrice: model.cacheWritePrice ? parseFloat(model.cacheWritePrice) : undefined,
+ cacheReadsPrice: model.cacheReadPrice ? parseFloat(model.cacheReadPrice) : undefined,
+ }
+ }
+ }
+ await fs.writeFile(unboundModelsFilePath, JSON.stringify(models))
+ } catch (error) {
+ this.outputChannel.appendLine(
+ `Error fetching Unbound models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
+ )
+ }
+
+ await this.postMessageToWebview({ type: "unboundModels", unboundModels: models })
+ return models
+ }
+
// Task history
async getTaskWithId(id: string): Promise<{
@@ -2032,6 +2147,21 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await fs.unlink(legacyMessagesFilePath)
}
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
+
+ const { checkpointsEnabled } = await this.getState()
+ const baseDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
+ const branch = `roo-code-checkpoints-${id}`
+
+ if (checkpointsEnabled && baseDir) {
+ try {
+ await simpleGit(baseDir).branch(["-D", branch])
+ console.log(`[deleteTaskWithId] Deleted branch ${branch}`)
+ } catch (err) {
+ console.error(
+ `[deleteTaskWithId] Error deleting branch ${branch}: ${err instanceof Error ? err.message : String(err)}`,
+ )
+ }
+ }
}
async deleteTaskFromState(id: string) {
@@ -2062,6 +2192,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowModeSwitch,
soundEnabled,
diffEnabled,
+ checkpointsEnabled,
taskHistory,
soundVolume,
browserViewportSize,
@@ -2104,6 +2235,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
.sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts),
soundEnabled: soundEnabled ?? false,
diffEnabled: diffEnabled ?? true,
+ checkpointsEnabled: checkpointsEnabled ?? false,
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
allowedCommands,
soundVolume: soundVolume ?? 0.5,
@@ -2127,7 +2259,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
autoApprovalEnabled: autoApprovalEnabled ?? false,
customModes: await this.customModesManager.getCustomModes(),
experiments: experiments ?? experimentDefault,
- mcpServers: this.mcpHub?.getServers() ?? [],
+ mcpServers: this.mcpHub?.getAllServers() ?? [],
}
}
@@ -2232,6 +2364,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
allowedCommands,
soundEnabled,
diffEnabled,
+ checkpointsEnabled,
soundVolume,
browserViewportSize,
fuzzyMatchThreshold,
@@ -2257,6 +2390,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
experiments,
unboundApiKey,
unboundModelId,
+ unboundModelInfo,
modelTemperature,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise,
@@ -2307,6 +2441,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("allowedCommands") as Promise,
this.getGlobalState("soundEnabled") as Promise,
this.getGlobalState("diffEnabled") as Promise,
+ this.getGlobalState("checkpointsEnabled") as Promise,
this.getGlobalState("soundVolume") as Promise,
this.getGlobalState("browserViewportSize") as Promise,
this.getGlobalState("fuzzyMatchThreshold") as Promise,
@@ -2332,6 +2467,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("experiments") as Promise | undefined>,
this.getSecret("unboundApiKey") as Promise,
this.getGlobalState("unboundModelId") as Promise,
+ this.getGlobalState("unboundModelInfo") as Promise,
this.getGlobalState("modelTemperature") as Promise,
])
@@ -2390,6 +2526,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
vsCodeLmModelSelector,
unboundApiKey,
unboundModelId,
+ unboundModelInfo,
modelTemperature,
},
lastShownAnnouncementId,
@@ -2404,6 +2541,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
allowedCommands,
soundEnabled: soundEnabled ?? false,
diffEnabled: diffEnabled ?? true,
+ checkpointsEnabled: checkpointsEnabled ?? false,
soundVolume,
browserViewportSize: browserViewportSize ?? "900x600",
screenshotQuality: screenshotQuality ?? 75,
@@ -2557,6 +2695,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
}
+ // logging
+
+ public log(message: string) {
+ this.outputChannel.appendLine(message)
+ }
+
// integration tests
get viewLaunched() {
diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts
index 8ca919b27e..25a4f0bd16 100644
--- a/src/core/webview/__tests__/ClineProvider.test.ts
+++ b/src/core/webview/__tests__/ClineProvider.test.ts
@@ -340,6 +340,7 @@ describe("ClineProvider", () => {
uriScheme: "vscode",
soundEnabled: false,
diffEnabled: false,
+ checkpointsEnabled: false,
writeDelayMs: 1000,
browserViewportSize: "900x600",
fuzzyMatchThreshold: 1.0,
@@ -646,6 +647,7 @@ describe("ClineProvider", () => {
},
mode: "code",
diffEnabled: true,
+ checkpointsEnabled: false,
fuzzyMatchThreshold: 1.0,
experiments: experimentDefault,
} as any)
@@ -663,6 +665,7 @@ describe("ClineProvider", () => {
mockApiConfig,
modeCustomInstructions,
true,
+ false,
1.0,
"Test task",
undefined,
diff --git a/src/services/checkpoints/CheckpointService.ts b/src/services/checkpoints/CheckpointService.ts
new file mode 100644
index 0000000000..cf27b5a33a
--- /dev/null
+++ b/src/services/checkpoints/CheckpointService.ts
@@ -0,0 +1,350 @@
+import fs from "fs/promises"
+import { existsSync } from "fs"
+import path from "path"
+
+import debug from "debug"
+import simpleGit, { SimpleGit, CleanOptions } from "simple-git"
+
+export type CheckpointServiceOptions = {
+ taskId: string
+ git?: SimpleGit
+ baseDir: string
+ log?: (message: string) => void
+}
+
+/**
+ * The CheckpointService provides a mechanism for storing a snapshot of the
+ * current VSCode workspace each time a Roo Code tool is executed. It uses Git
+ * under the hood.
+ *
+ * HOW IT WORKS
+ *
+ * Two branches are used:
+ * - A main branch for normal operation (the branch you are currently on).
+ * - A hidden branch for storing checkpoints.
+ *
+ * Saving a checkpoint:
+ * - Current changes are stashed (including untracked files).
+ * - The hidden branch is reset to match main.
+ * - Stashed changes are applied and committed as a checkpoint on the hidden
+ * branch.
+ * - We return to the main branch with the original state restored.
+ *
+ * Restoring a checkpoint:
+ * - The workspace is restored to the state of the specified checkpoint using
+ * `git restore` and `git clean`.
+ *
+ * This approach allows for:
+ * - Non-destructive version control (main branch remains untouched).
+ * - Preservation of the full history of checkpoints.
+ * - Safe restoration to any previous checkpoint.
+ *
+ * NOTES
+ *
+ * - Git must be installed.
+ * - If the current working directory is not a Git repository, we will
+ * initialize a new one with a .gitkeep file.
+ * - If you manually edit files and then restore a checkpoint, the changes
+ * will be lost. Addressing this adds some complexity to the implementation
+ * and it's not clear whether it's worth it.
+ */
+
+export class CheckpointService {
+ private static readonly USER_NAME = "Roo Code"
+ private static readonly USER_EMAIL = "support@roocode.com"
+
+ private _currentCheckpoint?: string
+
+ public get currentCheckpoint() {
+ return this._currentCheckpoint
+ }
+
+ private set currentCheckpoint(value: string | undefined) {
+ this._currentCheckpoint = value
+ }
+
+ constructor(
+ public readonly taskId: string,
+ private readonly git: SimpleGit,
+ public readonly baseDir: string,
+ public readonly mainBranch: string,
+ public readonly baseCommitHash: string,
+ public readonly hiddenBranch: string,
+ private readonly log: (message: string) => void,
+ ) {}
+
+ private async pushStash() {
+ const status = await this.git.status()
+
+ if (status.files.length > 0) {
+ await this.git.stash(["-u"]) // Includes tracked and untracked files.
+ return true
+ }
+
+ return false
+ }
+
+ private async applyStash() {
+ const stashList = await this.git.stashList()
+
+ if (stashList.all.length > 0) {
+ await this.git.stash(["apply"]) // Applies the most recent stash only.
+ return true
+ }
+
+ return false
+ }
+
+ private async popStash() {
+ const stashList = await this.git.stashList()
+
+ if (stashList.all.length > 0) {
+ await this.git.stash(["pop", "--index"]) // Pops the most recent stash only.
+ return true
+ }
+
+ return false
+ }
+
+ private async ensureBranch(expectedBranch: string) {
+ const branch = await this.git.revparse(["--abbrev-ref", "HEAD"])
+
+ if (branch.trim() !== expectedBranch) {
+ throw new Error(`Git branch mismatch: expected '${expectedBranch}' but found '${branch}'`)
+ }
+ }
+
+ public async getDiff({ from, to }: { from?: string; to: string }) {
+ const result = []
+
+ if (!from) {
+ from = this.baseCommitHash
+ }
+
+ const { files } = await this.git.diffSummary([`${from}..${to}`])
+
+ for (const file of files.filter((f) => !f.binary)) {
+ const relPath = file.file
+ const absPath = path.join(this.baseDir, relPath)
+
+ // If modified both before and after will generate content.
+ // If added only after will generate content.
+ // If deleted only before will generate content.
+ let beforeContent = ""
+ let afterContent = ""
+
+ try {
+ beforeContent = await this.git.show([`${from}:${relPath}`])
+ } catch (err) {
+ // File doesn't exist in older commit.
+ }
+
+ try {
+ afterContent = await this.git.show([`${to}:${relPath}`])
+ } catch (err) {
+ // File doesn't exist in newer commit.
+ }
+
+ result.push({
+ paths: { relative: relPath, absolute: absPath },
+ content: { before: beforeContent, after: afterContent },
+ })
+ }
+
+ return result
+ }
+
+ public async saveCheckpoint(message: string) {
+ await this.ensureBranch(this.mainBranch)
+
+ // Attempt to stash pending changes (including untracked files).
+ const pendingChanges = await this.pushStash()
+
+ // Get the latest commit on the hidden branch before we reset it.
+ const latestHash = await this.git.revparse([this.hiddenBranch])
+
+ // Check if there is any diff relative to the latest commit.
+ if (!pendingChanges) {
+ const diff = await this.git.diff([latestHash])
+
+ if (!diff) {
+ this.log(`[saveCheckpoint] No changes detected, giving up`)
+ return undefined
+ }
+ }
+
+ await this.git.checkout(this.hiddenBranch)
+
+ const reset = async () => {
+ await this.git.reset(["HEAD", "."])
+ await this.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE])
+ await this.git.reset(["--hard", latestHash])
+ await this.git.checkout(this.mainBranch)
+ await this.popStash()
+ }
+
+ try {
+ // Reset hidden branch to match main and apply the pending changes.
+ await this.git.reset(["--hard", this.mainBranch])
+
+ if (pendingChanges) {
+ await this.applyStash()
+ }
+
+ // Using "-A" ensures that deletions are staged as well.
+ await this.git.add(["-A"])
+ const diff = await this.git.diff([latestHash])
+
+ if (!diff) {
+ this.log(`[saveCheckpoint] No changes detected, resetting and giving up`)
+ await reset()
+ return undefined
+ }
+
+ // Otherwise, commit the changes.
+ const status = await this.git.status()
+ this.log(`[saveCheckpoint] Changes detected, committing ${JSON.stringify(status)}`)
+
+ // Allow empty commits in order to correctly handle deletion of
+ // untracked files (see unit tests for an example of this).
+ // Additionally, skip pre-commit hooks so that they don't slow
+ // things down or tamper with the contents of the commit.
+ const commit = await this.git.commit(message, undefined, {
+ "--allow-empty": null,
+ "--no-verify": null,
+ })
+
+ await this.git.checkout(this.mainBranch)
+
+ if (pendingChanges) {
+ await this.popStash()
+ }
+
+ this.currentCheckpoint = commit.commit
+
+ return commit
+ } catch (err) {
+ this.log(`[saveCheckpoint] Failed to save checkpoint: ${err instanceof Error ? err.message : String(err)}`)
+
+ // If we're not on the main branch then we need to trigger a reset
+ // to return to the main branch and restore it's previous state.
+ const currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
+
+ if (currentBranch.trim() !== this.mainBranch) {
+ await reset()
+ }
+
+ throw err
+ }
+ }
+
+ public async restoreCheckpoint(commitHash: string) {
+ await this.ensureBranch(this.mainBranch)
+ await this.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE])
+ await this.git.raw(["restore", "--source", commitHash, "--worktree", "--", "."])
+ this.currentCheckpoint = commitHash
+ }
+
+ public static async create({ taskId, git, baseDir, log = console.log }: CheckpointServiceOptions) {
+ git =
+ git ||
+ simpleGit({
+ baseDir,
+ binary: "git",
+ maxConcurrentProcesses: 1,
+ config: [],
+ trimmed: true,
+ })
+
+ const version = await git.version()
+
+ if (!version?.installed) {
+ throw new Error(`Git is not installed. Please install Git if you wish to use checkpoints.`)
+ }
+
+ if (!baseDir || !existsSync(baseDir)) {
+ throw new Error(`Base directory is not set or does not exist.`)
+ }
+
+ const { currentBranch, currentSha, hiddenBranch } = await CheckpointService.initRepo({
+ taskId,
+ git,
+ baseDir,
+ log,
+ })
+
+ log(
+ `[CheckpointService] taskId = ${taskId}, baseDir = ${baseDir}, currentBranch = ${currentBranch}, currentSha = ${currentSha}, hiddenBranch = ${hiddenBranch}`,
+ )
+
+ return new CheckpointService(taskId, git, baseDir, currentBranch, currentSha, hiddenBranch, log)
+ }
+
+ private static async initRepo({ taskId, git, baseDir, log }: Required) {
+ const isExistingRepo = existsSync(path.join(baseDir, ".git"))
+
+ if (!isExistingRepo) {
+ await git.init()
+ log(`[initRepo] Initialized new Git repository at ${baseDir}`)
+ }
+
+ const globalUserName = await git.getConfig("user.name", "global")
+ const localUserName = await git.getConfig("user.name", "local")
+ const userName = localUserName.value || globalUserName.value
+
+ const globalUserEmail = await git.getConfig("user.email", "global")
+ const localUserEmail = await git.getConfig("user.email", "local")
+ const userEmail = localUserEmail.value || globalUserEmail.value
+
+ // Prior versions of this service indiscriminately set the local user
+ // config, and it should not override the global config. To address
+ // this we remove the local user config if it matches the default
+ // user name and email and there's a global config.
+ if (globalUserName.value && localUserName.value === CheckpointService.USER_NAME) {
+ await git.raw(["config", "--unset", "--local", "user.name"])
+ }
+
+ if (globalUserEmail.value && localUserEmail.value === CheckpointService.USER_EMAIL) {
+ await git.raw(["config", "--unset", "--local", "user.email"])
+ }
+
+ // Only set user config if not already configured.
+ if (!userName) {
+ await git.addConfig("user.name", CheckpointService.USER_NAME)
+ }
+
+ if (!userEmail) {
+ await git.addConfig("user.email", CheckpointService.USER_EMAIL)
+ }
+
+ if (!isExistingRepo) {
+ // We need at least one file to commit, otherwise the initial
+ // commit will fail, unless we use the `--allow-empty` flag.
+ // However, using an empty commit causes problems when restoring
+ // the checkpoint (i.e. the `git restore` command doesn't work
+ // for empty commits).
+ await fs.writeFile(path.join(baseDir, ".gitkeep"), "")
+ await git.add(".gitkeep")
+ const commit = await git.commit("Initial commit")
+
+ if (!commit.commit) {
+ throw new Error("Failed to create initial commit")
+ }
+
+ log(`[initRepo] Initial commit: ${commit.commit}`)
+ }
+
+ const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
+ const currentSha = await git.revparse(["HEAD"])
+
+ const hiddenBranch = `roo-code-checkpoints-${taskId}`
+ const branchSummary = await git.branch()
+
+ if (!branchSummary.all.includes(hiddenBranch)) {
+ await git.checkoutBranch(hiddenBranch, currentBranch) // git checkout -b
+ await git.checkout(currentBranch) // git checkout
+ }
+
+ return { currentBranch, currentSha, hiddenBranch }
+ }
+}
diff --git a/src/services/checkpoints/__tests__/CheckpointService.test.ts b/src/services/checkpoints/__tests__/CheckpointService.test.ts
new file mode 100644
index 0000000000..9165da332a
--- /dev/null
+++ b/src/services/checkpoints/__tests__/CheckpointService.test.ts
@@ -0,0 +1,413 @@
+// npx jest src/services/checkpoints/__tests__/CheckpointService.test.ts
+
+import fs from "fs/promises"
+import path from "path"
+import os from "os"
+
+import { simpleGit, SimpleGit, SimpleGitTaskCallback } from "simple-git"
+
+import { CheckpointService } from "../CheckpointService"
+
+describe("CheckpointService", () => {
+ const taskId = "test-task"
+
+ let git: SimpleGit
+ let testFile: string
+ let service: CheckpointService
+
+ const initRepo = async ({
+ baseDir,
+ userName = "Roo Code",
+ userEmail = "support@roocode.com",
+ testFileName = "test.txt",
+ textFileContent = "Hello, world!",
+ }: {
+ baseDir: string
+ userName?: string
+ userEmail?: string
+ testFileName?: string
+ textFileContent?: string
+ }) => {
+ // Create a temporary directory for testing.
+ await fs.mkdir(baseDir)
+
+ // Initialize git repo.
+ const git = simpleGit(baseDir)
+ await git.init()
+ await git.addConfig("user.name", userName)
+ await git.addConfig("user.email", userEmail)
+
+ // Create test file.
+ const testFile = path.join(baseDir, testFileName)
+ await fs.writeFile(testFile, textFileContent)
+
+ // Create initial commit.
+ await git.add(".")
+ await git.commit("Initial commit")!
+
+ return { git, testFile }
+ }
+
+ beforeEach(async () => {
+ const baseDir = path.join(os.tmpdir(), `checkpoint-service-test-${Date.now()}`)
+ const repo = await initRepo({ baseDir })
+
+ git = repo.git
+ testFile = repo.testFile
+ service = await CheckpointService.create({ taskId, git, baseDir, log: () => {} })
+ })
+
+ afterEach(async () => {
+ await fs.rm(service.baseDir, { recursive: true, force: true })
+ jest.restoreAllMocks()
+ })
+
+ describe("getDiff", () => {
+ it("returns the correct diff between commits", async () => {
+ await fs.writeFile(testFile, "Ahoy, world!")
+ const commit1 = await service.saveCheckpoint("First checkpoint")
+ expect(commit1?.commit).toBeTruthy()
+
+ await fs.writeFile(testFile, "Goodbye, world!")
+ const commit2 = await service.saveCheckpoint("Second checkpoint")
+ expect(commit2?.commit).toBeTruthy()
+
+ const diff1 = await service.getDiff({ to: commit1!.commit })
+ expect(diff1).toHaveLength(1)
+ expect(diff1[0].paths.relative).toBe("test.txt")
+ expect(diff1[0].paths.absolute).toBe(testFile)
+ expect(diff1[0].content.before).toBe("Hello, world!")
+ expect(diff1[0].content.after).toBe("Ahoy, world!")
+
+ const diff2 = await service.getDiff({ to: commit2!.commit })
+ expect(diff2).toHaveLength(1)
+ expect(diff2[0].paths.relative).toBe("test.txt")
+ expect(diff2[0].paths.absolute).toBe(testFile)
+ expect(diff2[0].content.before).toBe("Hello, world!")
+ expect(diff2[0].content.after).toBe("Goodbye, world!")
+
+ const diff12 = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
+ expect(diff12).toHaveLength(1)
+ expect(diff12[0].paths.relative).toBe("test.txt")
+ expect(diff12[0].paths.absolute).toBe(testFile)
+ expect(diff12[0].content.before).toBe("Ahoy, world!")
+ expect(diff12[0].content.after).toBe("Goodbye, world!")
+ })
+
+ it("handles new files in diff", async () => {
+ const newFile = path.join(service.baseDir, "new.txt")
+ await fs.writeFile(newFile, "New file content")
+ const commit = await service.saveCheckpoint("Add new file")
+ expect(commit?.commit).toBeTruthy()
+
+ const changes = await service.getDiff({ to: commit!.commit })
+ const change = changes.find((c) => c.paths.relative === "new.txt")
+ expect(change).toBeDefined()
+ expect(change?.content.before).toBe("")
+ expect(change?.content.after).toBe("New file content")
+ })
+
+ it("handles deleted files in diff", async () => {
+ const fileToDelete = path.join(service.baseDir, "new.txt")
+ await fs.writeFile(fileToDelete, "New file content")
+ const commit1 = await service.saveCheckpoint("Add file")
+ expect(commit1?.commit).toBeTruthy()
+
+ await fs.unlink(fileToDelete)
+ const commit2 = await service.saveCheckpoint("Delete file")
+ expect(commit2?.commit).toBeTruthy()
+
+ const changes = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
+ const change = changes.find((c) => c.paths.relative === "new.txt")
+ expect(change).toBeDefined()
+ expect(change!.content.before).toBe("New file content")
+ expect(change!.content.after).toBe("")
+ })
+ })
+
+ describe("saveCheckpoint", () => {
+ it("creates a checkpoint if there are pending changes", async () => {
+ await fs.writeFile(testFile, "Ahoy, world!")
+ const commit1 = await service.saveCheckpoint("First checkpoint")
+ expect(commit1?.commit).toBeTruthy()
+ const details1 = await git.show([commit1!.commit])
+ expect(details1).toContain("-Hello, world!")
+ expect(details1).toContain("+Ahoy, world!")
+
+ await fs.writeFile(testFile, "Hola, world!")
+ const commit2 = await service.saveCheckpoint("Second checkpoint")
+ expect(commit2?.commit).toBeTruthy()
+ const details2 = await git.show([commit2!.commit])
+ expect(details2).toContain("-Hello, world!")
+ expect(details2).toContain("+Hola, world!")
+
+ // Switch to checkpoint 1.
+ await service.restoreCheckpoint(commit1!.commit)
+ expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!")
+
+ // Switch to checkpoint 2.
+ await service.restoreCheckpoint(commit2!.commit)
+ expect(await fs.readFile(testFile, "utf-8")).toBe("Hola, world!")
+
+ // Switch back to initial commit.
+ await service.restoreCheckpoint(service.baseCommitHash)
+ expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
+ })
+
+ it("preserves workspace and index state after saving checkpoint", async () => {
+ // Create three files with different states: staged, unstaged, and mixed.
+ const unstagedFile = path.join(service.baseDir, "unstaged.txt")
+ const stagedFile = path.join(service.baseDir, "staged.txt")
+ const mixedFile = path.join(service.baseDir, "mixed.txt")
+
+ await fs.writeFile(unstagedFile, "Initial unstaged")
+ await fs.writeFile(stagedFile, "Initial staged")
+ await fs.writeFile(mixedFile, "Initial mixed")
+ await git.add(["."])
+ const result = await git.commit("Add initial files")
+ expect(result?.commit).toBeTruthy()
+
+ await fs.writeFile(unstagedFile, "Modified unstaged")
+
+ await fs.writeFile(stagedFile, "Modified staged")
+ await git.add([stagedFile])
+
+ await fs.writeFile(mixedFile, "Modified mixed - staged")
+ await git.add([mixedFile])
+ await fs.writeFile(mixedFile, "Modified mixed - unstaged")
+
+ // Save checkpoint.
+ const commit = await service.saveCheckpoint("Test checkpoint")
+ expect(commit?.commit).toBeTruthy()
+
+ // Verify workspace state is preserved.
+ const status = await git.status()
+
+ // All files should be modified.
+ expect(status.modified).toContain("unstaged.txt")
+ expect(status.modified).toContain("staged.txt")
+ expect(status.modified).toContain("mixed.txt")
+
+ // Only staged and mixed files should be staged.
+ expect(status.staged).not.toContain("unstaged.txt")
+ expect(status.staged).toContain("staged.txt")
+ expect(status.staged).toContain("mixed.txt")
+
+ // Verify file contents.
+ expect(await fs.readFile(unstagedFile, "utf-8")).toBe("Modified unstaged")
+ expect(await fs.readFile(stagedFile, "utf-8")).toBe("Modified staged")
+ expect(await fs.readFile(mixedFile, "utf-8")).toBe("Modified mixed - unstaged")
+
+ // Verify staged changes (--cached shows only staged changes).
+ const stagedDiff = await git.diff(["--cached", "mixed.txt"])
+ expect(stagedDiff).toContain("-Initial mixed")
+ expect(stagedDiff).toContain("+Modified mixed - staged")
+
+ // Verify unstaged changes (shows working directory changes).
+ const unstagedDiff = await git.diff(["mixed.txt"])
+ expect(unstagedDiff).toContain("-Modified mixed - staged")
+ expect(unstagedDiff).toContain("+Modified mixed - unstaged")
+ })
+
+ it("does not create a checkpoint if there are no pending changes", async () => {
+ await fs.writeFile(testFile, "Ahoy, world!")
+ const commit = await service.saveCheckpoint("First checkpoint")
+ expect(commit?.commit).toBeTruthy()
+
+ const commit2 = await service.saveCheckpoint("Second checkpoint")
+ expect(commit2?.commit).toBeFalsy()
+ })
+
+ it("includes untracked files in checkpoints", async () => {
+ // Create an untracked file.
+ const untrackedFile = path.join(service.baseDir, "untracked.txt")
+ await fs.writeFile(untrackedFile, "I am untracked!")
+
+ // Save a checkpoint with the untracked file.
+ const commit1 = await service.saveCheckpoint("Checkpoint with untracked file")
+ expect(commit1?.commit).toBeTruthy()
+
+ // Verify the untracked file was included in the checkpoint.
+ const details = await git.show([commit1!.commit])
+ expect(details).toContain("+I am untracked!")
+
+ // Create another checkpoint with a different state.
+ await fs.writeFile(testFile, "Changed tracked file")
+ const commit2 = await service.saveCheckpoint("Second checkpoint")
+ expect(commit2?.commit).toBeTruthy()
+
+ // Restore first checkpoint and verify untracked file is preserved.
+ await service.restoreCheckpoint(commit1!.commit)
+ expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
+ expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
+
+ // Restore second checkpoint and verify untracked file remains (since
+ // restore preserves untracked files)
+ await service.restoreCheckpoint(commit2!.commit)
+ expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
+ expect(await fs.readFile(testFile, "utf-8")).toBe("Changed tracked file")
+ })
+
+ it("throws if we're on the wrong branch", async () => {
+ // Create and switch to a feature branch.
+ await git.checkoutBranch("feature", service.mainBranch)
+
+ // Attempt to save checkpoint from feature branch.
+ await expect(service.saveCheckpoint("test")).rejects.toThrow(
+ `Git branch mismatch: expected '${service.mainBranch}' but found 'feature'`,
+ )
+
+ // Attempt to restore checkpoint from feature branch.
+ await expect(service.restoreCheckpoint(service.baseCommitHash)).rejects.toThrow(
+ `Git branch mismatch: expected '${service.mainBranch}' but found 'feature'`,
+ )
+ })
+
+ it("cleans up staged files if a commit fails", async () => {
+ await fs.writeFile(testFile, "Changed content")
+
+ // Mock git commit to simulate failure.
+ jest.spyOn(git, "commit").mockRejectedValue(new Error("Simulated commit failure"))
+
+ // Attempt to save checkpoint.
+ await expect(service.saveCheckpoint("test")).rejects.toThrow("Simulated commit failure")
+
+ // Verify files are unstaged.
+ const status = await git.status()
+ expect(status.staged).toHaveLength(0)
+ })
+
+ it("handles file deletions correctly", async () => {
+ await fs.writeFile(testFile, "I am tracked!")
+ const untrackedFile = path.join(service.baseDir, "new.txt")
+ await fs.writeFile(untrackedFile, "I am untracked!")
+ const commit1 = await service.saveCheckpoint("First checkpoint")
+ expect(commit1?.commit).toBeTruthy()
+
+ await fs.unlink(testFile)
+ await fs.unlink(untrackedFile)
+ const commit2 = await service.saveCheckpoint("Second checkpoint")
+ expect(commit2?.commit).toBeTruthy()
+
+ // Verify files are gone.
+ await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow()
+ await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow()
+
+ // Restore first checkpoint.
+ await service.restoreCheckpoint(commit1!.commit)
+ expect(await fs.readFile(testFile, "utf-8")).toBe("I am tracked!")
+ expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
+
+ // Restore second checkpoint.
+ await service.restoreCheckpoint(commit2!.commit)
+ await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow()
+ await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow()
+ })
+ })
+
+ describe("create", () => {
+ it("initializes a git repository if one does not already exist", async () => {
+ const baseDir = path.join(os.tmpdir(), `checkpoint-service-test2-${Date.now()}`)
+ await fs.mkdir(baseDir)
+ const newTestFile = path.join(baseDir, "test.txt")
+ await fs.writeFile(newTestFile, "Hello, world!")
+
+ const newGit = simpleGit(baseDir)
+ const initSpy = jest.spyOn(newGit, "init")
+ const newService = await CheckpointService.create({ taskId, git: newGit, baseDir, log: () => {} })
+
+ // Ensure the git repository was initialized.
+ expect(initSpy).toHaveBeenCalled()
+
+ // Save a checkpoint: Hello, world!
+ const commit1 = await newService.saveCheckpoint("Hello, world!")
+ expect(commit1?.commit).toBeTruthy()
+ expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
+
+ // Restore initial commit; the file should no longer exist.
+ await newService.restoreCheckpoint(newService.baseCommitHash)
+ await expect(fs.access(newTestFile)).rejects.toThrow()
+
+ // Restore to checkpoint 1; the file should now exist.
+ await newService.restoreCheckpoint(commit1!.commit)
+ expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
+
+ // Save a new checkpoint: Ahoy, world!
+ await fs.writeFile(newTestFile, "Ahoy, world!")
+ const commit2 = await newService.saveCheckpoint("Ahoy, world!")
+ expect(commit2?.commit).toBeTruthy()
+ expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
+
+ // Restore "Hello, world!"
+ await newService.restoreCheckpoint(commit1!.commit)
+ expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
+
+ // Restore "Ahoy, world!"
+ await newService.restoreCheckpoint(commit2!.commit)
+ expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
+
+ // Restore initial commit.
+ await newService.restoreCheckpoint(newService.baseCommitHash)
+ await expect(fs.access(newTestFile)).rejects.toThrow()
+
+ await fs.rm(newService.baseDir, { recursive: true, force: true })
+ })
+
+ it("respects existing git user configuration", async () => {
+ const baseDir = path.join(os.tmpdir(), `checkpoint-service-test-config2-${Date.now()}`)
+ const userName = "Custom User"
+ const userEmail = "custom@example.com"
+ const repo = await initRepo({ baseDir, userName, userEmail })
+ const newGit = repo.git
+
+ await CheckpointService.create({ taskId, git: newGit, baseDir, log: () => {} })
+
+ expect((await newGit.getConfig("user.name")).value).toBe(userName)
+ expect((await newGit.getConfig("user.email")).value).toBe(userEmail)
+
+ await fs.rm(baseDir, { recursive: true, force: true })
+ })
+
+ it("removes local git config if it matches default and global exists", async () => {
+ const baseDir = path.join(os.tmpdir(), `checkpoint-service-test-config2-${Date.now()}`)
+ const repo = await initRepo({ baseDir })
+ const newGit = repo.git
+
+ const originalGetConfig = newGit.getConfig.bind(newGit)
+
+ jest.spyOn(newGit, "getConfig").mockImplementation(
+ (
+ key: string,
+ scope?: "system" | "global" | "local" | "worktree",
+ callback?: SimpleGitTaskCallback,
+ ) => {
+ if (scope === "global") {
+ if (key === "user.email") {
+ return Promise.resolve({ value: "global@example.com" }) as any
+ }
+ if (key === "user.name") {
+ return Promise.resolve({ value: "Global User" }) as any
+ }
+ }
+
+ return originalGetConfig(key, scope, callback)
+ },
+ )
+
+ await CheckpointService.create({ taskId, git: newGit, baseDir, log: () => {} })
+
+ // Verify local config was removed and global config is used.
+ const localName = await newGit.getConfig("user.name", "local")
+ const localEmail = await newGit.getConfig("user.email", "local")
+ const globalName = await newGit.getConfig("user.name", "global")
+ const globalEmail = await newGit.getConfig("user.email", "global")
+
+ expect(localName.value).toBeNull() // Local config should be removed.
+ expect(localEmail.value).toBeNull()
+ expect(globalName.value).toBe("Global User") // Global config should remain.
+ expect(globalEmail.value).toBe("global@example.com")
+
+ await fs.rm(baseDir, { recursive: true, force: true })
+ })
+ })
+})
diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts
index dcbe4a94d0..209df3dbe6 100644
--- a/src/services/mcp/McpHub.ts
+++ b/src/services/mcp/McpHub.ts
@@ -67,6 +67,11 @@ export class McpHub {
return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server)
}
+ getAllServers(): McpServer[] {
+ // Return all servers regardless of state
+ return this.connections.map((conn) => conn.server)
+ }
+
async getMcpServersPath(): Promise {
const provider = this.providerRef.deref()
if (!provider) {
diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts
index bee51f6d09..f22ad958a0 100644
--- a/src/shared/ExtensionMessage.ts
+++ b/src/shared/ExtensionMessage.ts
@@ -42,6 +42,9 @@ export interface ExtensionMessage {
| "autoApprovalEnabled"
| "updateCustomMode"
| "deleteCustomMode"
+ | "unboundModels"
+ | "refreshUnboundModels"
+ | "currentCheckpointUpdated"
text?: string
action?:
| "chatButtonClicked"
@@ -66,6 +69,7 @@ export interface ExtensionMessage {
glamaModels?: Record
openRouterModels?: Record
openAiModels?: string[]
+ unboundModels?: Record
mcpServers?: McpServer[]
commits?: GitCommit[]
listApiConfig?: ApiConfigMeta[]
@@ -105,6 +109,7 @@ export interface ExtensionState {
soundEnabled?: boolean
soundVolume?: number
diffEnabled?: boolean
+ checkpointsEnabled: boolean
browserViewportSize?: string
screenshotQuality?: number
fuzzyMatchThreshold?: number
@@ -131,6 +136,7 @@ export interface ClineMessage {
images?: string[]
partial?: boolean
reasoning?: string
+ conversationHistoryIndex?: number
}
export type ClineAsk =
@@ -151,13 +157,14 @@ export type ClineSay =
| "error"
| "api_req_started"
| "api_req_finished"
+ | "api_req_retried"
+ | "api_req_retry_delayed"
+ | "api_req_deleted"
| "text"
| "reasoning"
| "completion_result"
| "user_feedback"
| "user_feedback_diff"
- | "api_req_retried"
- | "api_req_retry_delayed"
| "command_output"
| "tool"
| "shell_integration_warning"
@@ -168,6 +175,7 @@ export type ClineSay =
| "mcp_server_response"
| "new_task_started"
| "new_task"
+ | "checkpoint_saved"
export interface ClineSayTool {
tool:
diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts
index 5fde75c4bb..9d9c00872c 100644
--- a/src/shared/WebviewMessage.ts
+++ b/src/shared/WebviewMessage.ts
@@ -1,6 +1,9 @@
+import { z } from "zod"
import { ApiConfiguration, ApiProvider } from "./api"
import { Mode, PromptComponent, ModeConfig } from "./modes"
+export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
+
export type PromptMode = Mode | "enhance"
export type AudioType = "notification" | "celebration" | "progress_loop"
@@ -39,6 +42,7 @@ export interface WebviewMessage {
| "refreshGlamaModels"
| "refreshOpenRouterModels"
| "refreshOpenAiModels"
+ | "refreshUnboundModels"
| "alwaysAllowBrowser"
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
@@ -46,6 +50,7 @@ export interface WebviewMessage {
| "soundEnabled"
| "soundVolume"
| "diffEnabled"
+ | "checkpointsEnabled"
| "browserViewportSize"
| "screenshotQuality"
| "openMcpSettings"
@@ -83,6 +88,8 @@ export interface WebviewMessage {
| "deleteCustomMode"
| "setopenAiCustomModelInfo"
| "openCustomModesSettings"
+ | "checkpointDiff"
+ | "checkpointRestore"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse
@@ -104,6 +111,23 @@ export interface WebviewMessage {
slug?: string
modeConfig?: ModeConfig
timeout?: number
+ payload?: WebViewMessagePayload
}
-export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
+export const checkoutDiffPayloadSchema = z.object({
+ ts: z.number(),
+ commitHash: z.string(),
+ mode: z.enum(["full", "checkpoint"]),
+})
+
+export type CheckpointDiffPayload = z.infer
+
+export const checkoutRestorePayloadSchema = z.object({
+ ts: z.number(),
+ commitHash: z.string(),
+ mode: z.enum(["preview", "restore"]),
+})
+
+export type CheckpointRestorePayload = z.infer
+
+export type WebViewMessagePayload = CheckpointDiffPayload | CheckpointRestorePayload
diff --git a/src/shared/api.ts b/src/shared/api.ts
index c4f62259a7..5f41130990 100644
--- a/src/shared/api.ts
+++ b/src/shared/api.ts
@@ -60,6 +60,7 @@ export interface ApiHandlerOptions {
includeMaxTokens?: boolean
unboundApiKey?: string
unboundModelId?: string
+ unboundModelInfo?: ModelInfo
modelTemperature?: number
}
@@ -651,12 +652,14 @@ export const mistralModels = {
} as const satisfies Record
// Unbound Security
-export type UnboundModelId = keyof typeof unboundModels
-export const unboundDefaultModelId = "openai/gpt-4o"
-export const unboundModels = {
- "anthropic/claude-3-5-sonnet-20241022": anthropicModels["claude-3-5-sonnet-20241022"],
- "openai/gpt-4o": openAiNativeModels["gpt-4o"],
- "deepseek/deepseek-chat": deepSeekModels["deepseek-chat"],
- "deepseek/deepseek-reasoner": deepSeekModels["deepseek-reasoner"],
- "mistral/codestral-latest": mistralModels["codestral-latest"],
-} as const satisfies Record
+export const unboundDefaultModelId = "anthropic/claude-3-5-sonnet-20241022"
+export const unboundDefaultModelInfo: ModelInfo = {
+ maxTokens: 8192,
+ contextWindow: 200_000,
+ supportsImages: true,
+ supportsPromptCache: true,
+ inputPrice: 3.0,
+ outputPrice: 15.0,
+ cacheWritesPrice: 3.75,
+ cacheReadsPrice: 0.3,
+}
diff --git a/src/utils/logging/CompactLogger.ts b/src/utils/logging/CompactLogger.ts
new file mode 100644
index 0000000000..4fc27ed58a
--- /dev/null
+++ b/src/utils/logging/CompactLogger.ts
@@ -0,0 +1,150 @@
+/**
+ * @fileoverview Implementation of the compact logging system's main logger class
+ */
+
+import { ILogger, LogMeta, CompactLogEntry, LogLevel } from "./types"
+import { CompactTransport } from "./CompactTransport"
+
+/**
+ * Main logger implementation providing compact, efficient logging capabilities
+ * @implements {ILogger}
+ */
+export class CompactLogger implements ILogger {
+ private transport: CompactTransport
+ private parentMeta: LogMeta | undefined
+
+ /**
+ * Creates a new CompactLogger instance
+ * @param transport - Optional custom transport instance
+ * @param parentMeta - Optional parent metadata for hierarchical logging
+ */
+ constructor(transport?: CompactTransport, parentMeta?: LogMeta) {
+ this.transport = transport ?? new CompactTransport()
+ this.parentMeta = parentMeta
+ }
+
+ /**
+ * Logs a debug level message
+ * @param message - The message to log
+ * @param meta - Optional metadata to include
+ */
+ debug(message: string, meta?: LogMeta): void {
+ this.log("debug", message, this.combineMeta(meta))
+ }
+
+ /**
+ * Logs an info level message
+ * @param message - The message to log
+ * @param meta - Optional metadata to include
+ */
+ info(message: string, meta?: LogMeta): void {
+ this.log("info", message, this.combineMeta(meta))
+ }
+
+ /**
+ * Logs a warning level message
+ * @param message - The message to log
+ * @param meta - Optional metadata to include
+ */
+ warn(message: string, meta?: LogMeta): void {
+ this.log("warn", message, this.combineMeta(meta))
+ }
+
+ /**
+ * Logs an error level message
+ * @param message - The error message or Error object
+ * @param meta - Optional metadata to include
+ */
+ error(message: string | Error, meta?: LogMeta): void {
+ this.handleErrorLog("error", message, meta)
+ }
+
+ /**
+ * Logs a fatal level message
+ * @param message - The error message or Error object
+ * @param meta - Optional metadata to include
+ */
+ fatal(message: string | Error, meta?: LogMeta): void {
+ this.handleErrorLog("fatal", message, meta)
+ }
+
+ /**
+ * Creates a child logger inheriting this logger's metadata
+ * @param meta - Additional metadata for the child logger
+ * @returns A new logger instance with combined metadata
+ */
+ child(meta: LogMeta): ILogger {
+ const combinedMeta = this.parentMeta ? { ...this.parentMeta, ...meta } : meta
+ return new CompactLogger(this.transport, combinedMeta)
+ }
+
+ /**
+ * Closes the logger and its transport
+ */
+ close(): void {
+ this.transport.close()
+ }
+
+ /**
+ * Handles logging of error and fatal messages with special error object processing
+ * @private
+ * @param level - The log level (error or fatal)
+ * @param message - The message or Error object to log
+ * @param meta - Optional metadata to include
+ */
+ private handleErrorLog(level: "error" | "fatal", message: string | Error, meta?: LogMeta): void {
+ if (message instanceof Error) {
+ const errorMeta: LogMeta = {
+ ...meta,
+ ctx: meta?.ctx ?? level,
+ error: {
+ name: message.name,
+ message: message.message,
+ stack: message.stack,
+ },
+ }
+ this.log(level, message.message, this.combineMeta(errorMeta))
+ } else {
+ this.log(level, message, this.combineMeta(meta))
+ }
+ }
+
+ /**
+ * Combines parent and current metadata with proper context handling
+ * @private
+ * @param meta - The current metadata to combine with parent metadata
+ * @returns Combined metadata or undefined if no metadata exists
+ */
+ private combineMeta(meta?: LogMeta): LogMeta | undefined {
+ if (!this.parentMeta) {
+ return meta
+ }
+ if (!meta) {
+ return this.parentMeta
+ }
+ return {
+ ...this.parentMeta,
+ ...meta,
+ ctx: meta.ctx || this.parentMeta.ctx,
+ }
+ }
+
+ /**
+ * Core logging function that processes and writes log entries
+ * @private
+ * @param level - The log level
+ * @param message - The message to log
+ * @param meta - Optional metadata to include
+ */
+ private log(level: LogLevel, message: string, meta?: LogMeta): void {
+ const entry: CompactLogEntry = {
+ t: Date.now(),
+ l: level,
+ m: message,
+ c: meta?.ctx,
+ d: meta ? (({ ctx, ...rest }) => (Object.keys(rest).length > 0 ? rest : undefined))(meta) : undefined,
+ }
+
+ this.transport.write(entry)
+ }
+}
diff --git a/src/utils/logging/CompactTransport.ts b/src/utils/logging/CompactTransport.ts
new file mode 100644
index 0000000000..144f27c472
--- /dev/null
+++ b/src/utils/logging/CompactTransport.ts
@@ -0,0 +1,122 @@
+/**
+ * @fileoverview Implementation of the compact logging transport system with file output capabilities
+ */
+
+import { writeFileSync, mkdirSync } from "fs"
+import { dirname } from "path"
+import { CompactTransportConfig, ICompactTransport, CompactLogEntry, LogLevel, LOG_LEVELS } from "./types"
+
+/**
+ * Default configuration for the transport
+ */
+const DEFAULT_CONFIG: CompactTransportConfig = {
+ level: "debug",
+ fileOutput: {
+ enabled: true,
+ path: "./logs/app.log",
+ },
+}
+
+/**
+ * Determines if a log entry should be processed based on configured minimum level
+ * @param configLevel - The minimum log level from configuration
+ * @param entryLevel - The level of the current log entry
+ * @returns Whether the entry should be processed
+ */
+function isLevelEnabled(configLevel: LogLevel, entryLevel: string): boolean {
+ const configIdx = LOG_LEVELS.indexOf(configLevel)
+ const entryIdx = LOG_LEVELS.indexOf(entryLevel as LogLevel)
+ return entryIdx >= configIdx
+}
+
+/**
+ * Implements the compact logging transport with file output support
+ * @implements {ICompactTransport}
+ */
+export class CompactTransport implements ICompactTransport {
+ private sessionStart: number
+ private lastTimestamp: number
+ private filePath?: string
+ private initialized: boolean = false
+
+ /**
+ * Creates a new CompactTransport instance
+ * @param config - Optional transport configuration
+ */
+ constructor(readonly config: CompactTransportConfig = DEFAULT_CONFIG) {
+ this.sessionStart = Date.now()
+ this.lastTimestamp = this.sessionStart
+
+ if (config.fileOutput?.enabled) {
+ this.filePath = config.fileOutput.path
+ }
+ }
+
+ /**
+ * Ensures the log file is initialized with proper directory structure and session start marker
+ * @private
+ * @throws {Error} If file initialization fails
+ */
+ private ensureInitialized(): void {
+ if (this.initialized || !this.filePath) return
+
+ try {
+ mkdirSync(dirname(this.filePath), { recursive: true })
+ writeFileSync(this.filePath, "", { flag: "w" })
+
+ const sessionStart = {
+ t: 0,
+ l: "info",
+ m: "Log session started",
+ d: { timestamp: new Date(this.sessionStart).toISOString() },
+ }
+ writeFileSync(this.filePath, JSON.stringify(sessionStart) + "\n", { flag: "w" })
+
+ this.initialized = true
+ } catch (err) {
+ throw new Error(`Failed to initialize log file: ${(err as Error).message}`)
+ }
+ }
+
+ /**
+ * Writes a log entry to configured outputs (console and/or file)
+ * @param entry - The log entry to write
+ */
+ write(entry: CompactLogEntry): void {
+ const deltaT = entry.t - this.lastTimestamp
+ this.lastTimestamp = entry.t
+
+ const compact = {
+ ...entry,
+ t: deltaT,
+ }
+
+ const output = JSON.stringify(compact) + "\n"
+
+ // Write to console if level is enabled
+ if (this.config.level && isLevelEnabled(this.config.level, entry.l)) {
+ process.stdout.write(output)
+ }
+
+ // Write to file if enabled
+ if (this.filePath) {
+ this.ensureInitialized()
+ writeFileSync(this.filePath, output, { flag: "a" })
+ }
+ }
+
+ /**
+ * Closes the transport and writes session end marker
+ */
+ close(): void {
+ if (this.filePath && this.initialized) {
+ const sessionEnd = {
+ t: Date.now() - this.lastTimestamp,
+ l: "info",
+ m: "Log session ended",
+ d: { timestamp: new Date().toISOString() },
+ }
+ writeFileSync(this.filePath, JSON.stringify(sessionEnd) + "\n", { flag: "a" })
+ }
+ }
+}
diff --git a/src/utils/logging/__tests__/CompactLogger.test.ts b/src/utils/logging/__tests__/CompactLogger.test.ts
new file mode 100644
index 0000000000..0a098b778a
--- /dev/null
+++ b/src/utils/logging/__tests__/CompactLogger.test.ts
@@ -0,0 +1,338 @@
+// __tests__/CompactLogger.test.ts
+import { describe, expect, test, beforeEach, afterEach } from "@jest/globals"
+import { CompactLogger } from "../CompactLogger"
+import { MockTransport } from "./MockTransport"
+import { LogLevel } from "../types"
+
+describe("CompactLogger", () => {
+ let transport: MockTransport
+ let logger: CompactLogger
+
+ beforeEach(() => {
+ transport = new MockTransport()
+ logger = new CompactLogger(transport)
+ })
+
+ afterEach(() => {
+ transport.clear()
+ })
+
+ describe("Log Levels", () => {
+ const levels: LogLevel[] = ["debug", "info", "warn", "error", "fatal"]
+
+ levels.forEach((level) => {
+ test(`${level} level logs correctly`, () => {
+ const message = `test ${level} message`
+ ;(logger[level] as (message: string) => void)(message)
+
+ expect(transport.entries.length).toBe(1)
+ expect(transport.entries[0]).toMatchObject({
+ l: level,
+ m: message,
+ })
+ expect(transport.entries[0].t).toBeGreaterThan(0)
+ })
+ })
+ })
+
+ describe("Metadata Handling", () => {
+ test("logs with simple metadata", () => {
+ const meta = { ctx: "test", userId: "123" }
+ logger.info("test message", meta)
+
+ expect(transport.entries[0]).toMatchObject({
+ m: "test message",
+ c: "test",
+ d: { userId: "123" },
+ })
+ })
+
+ test("handles undefined metadata", () => {
+ logger.info("test message")
+
+ expect(transport.entries[0]).toMatchObject({
+ m: "test message",
+ })
+ expect(transport.entries[0].d).toBeUndefined()
+ })
+
+ test("strips empty metadata", () => {
+ logger.info("test message", { ctx: "test" })
+
+ expect(transport.entries[0]).toMatchObject({
+ m: "test message",
+ c: "test",
+ })
+ expect(transport.entries[0].d).toBeUndefined()
+ })
+ })
+
+ describe("Error Handling", () => {
+ test("handles Error objects in error level", () => {
+ const error = new Error("test error")
+ logger.error(error)
+
+ expect(transport.entries[0]).toMatchObject({
+ l: "error",
+ m: "test error",
+ c: "error",
+ d: {
+ error: {
+ name: "Error",
+ message: "test error",
+ stack: error.stack,
+ },
+ },
+ })
+ })
+
+ test("handles Error objects in fatal level", () => {
+ const error = new Error("test fatal")
+ logger.fatal(error)
+
+ expect(transport.entries[0]).toMatchObject({
+ l: "fatal",
+ m: "test fatal",
+ c: "fatal",
+ d: {
+ error: {
+ name: "Error",
+ message: "test fatal",
+ stack: error.stack,
+ },
+ },
+ })
+ })
+
+ test("handles Error objects with custom metadata", () => {
+ const error = new Error("test error")
+ const meta = { ctx: "custom", userId: "123" }
+ logger.error(error, meta)
+
+ expect(transport.entries[0]).toMatchObject({
+ l: "error",
+ m: "test error",
+ c: "custom",
+ d: {
+ userId: "123",
+ error: {
+ name: "Error",
+ message: "test error",
+ stack: error.stack,
+ },
+ },
+ })
+ })
+ })
+
+ describe("Child Loggers", () => {
+ test("creates child logger with inherited metadata", () => {
+ const parentMeta = { ctx: "parent", traceId: "123" }
+ const childMeta = { ctx: "child", userId: "456" }
+
+ const parentLogger = new CompactLogger(transport, parentMeta)
+ const childLogger = parentLogger.child(childMeta)
+
+ childLogger.info("test message")
+
+ expect(transport.entries[0]).toMatchObject({
+ m: "test message",
+ c: "child",
+ d: {
+ traceId: "123",
+ userId: "456",
+ },
+ })
+ })
+
+ test("child logger respects parent context when not overridden", () => {
+ const parentLogger = new CompactLogger(transport, { ctx: "parent" })
+ const childLogger = parentLogger.child({ userId: "123" })
+
+ childLogger.info("test message")
+
+ expect(transport.entries[0]).toMatchObject({
+ m: "test message",
+ c: "parent",
+ d: { userId: "123" },
+ })
+ })
+ })
+
+ describe("Lifecycle", () => {
+ test("closes transport on logger close", () => {
+ logger.close()
+ expect(transport.closed).toBe(true)
+ })
+ })
+
+ describe("Timestamp Handling", () => {
+ beforeEach(() => {
+ jest.useFakeTimers()
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ test("generates increasing timestamps", () => {
+ const now = Date.now()
+ jest.setSystemTime(now)
+
+ logger.info("first")
+ jest.setSystemTime(now + 10)
+ logger.info("second")
+
+ expect(transport.entries[0].t).toBeLessThan(transport.entries[1].t)
+ })
+ })
+
+ describe("Message Handling", () => {
+ test("handles empty string messages", () => {
+ logger.info("")
+ expect(transport.entries[0]).toMatchObject({
+ m: "",
+ l: "info",
+ })
+ })
+ })
+
+ describe("Metadata Edge Cases", () => {
+ test("handles metadata with undefined values", () => {
+ const meta = {
+ ctx: "test",
+ someField: undefined,
+ validField: "value",
+ }
+ logger.info("test", meta)
+
+ expect(transport.entries[0].d).toMatchObject({
+ someField: undefined,
+ validField: "value",
+ })
+ })
+
+ test("handles metadata with null values", () => {
+ logger.info("test", { ctx: "test", nullField: null })
+ expect(transport.entries[0].d).toMatchObject({ nullField: null })
+ })
+
+ test("maintains metadata value types", () => {
+ const meta = {
+ str: "string",
+ num: 123,
+ bool: true,
+ arr: [1, 2, 3],
+ obj: { nested: true },
+ }
+ logger.info("test", meta)
+ expect(transport.entries[0].d).toStrictEqual(meta)
+ })
+ })
+
+ describe("Child Logger Edge Cases", () => {
+ test("deeply nested child loggers maintain correct metadata inheritance", () => {
+ const root = new CompactLogger(transport, { ctx: "root", rootVal: 1 })
+ const child1 = root.child({ level1: "a" })
+ const child2 = child1.child({ level2: "b" })
+ const child3 = child2.child({ ctx: "leaf" })
+
+ child3.info("test")
+
+ expect(transport.entries[0]).toMatchObject({
+ c: "leaf",
+ d: {
+ rootVal: 1,
+ level1: "a",
+ level2: "b",
+ },
+ })
+ })
+
+ test("child logger with empty metadata inherits parent metadata unchanged", () => {
+ const parent = new CompactLogger(transport, { ctx: "parent", data: "value" })
+ const child = parent.child({})
+
+ child.info("test")
+
+ expect(transport.entries[0]).toMatchObject({
+ c: "parent",
+ d: { data: "value" },
+ })
+ })
+ })
+
+ describe("Error Handling Edge Cases", () => {
+ test("handles custom error types", () => {
+ class CustomError extends Error {
+ constructor(
+ message: string,
+ public code: string,
+ ) {
+ super(message)
+ this.name = "CustomError"
+ }
+ }
+
+ const error = new CustomError("custom error", "ERR_CUSTOM")
+ logger.error(error)
+
+ expect(transport.entries[0]).toMatchObject({
+ m: "custom error",
+ d: {
+ error: {
+ name: "CustomError",
+ message: "custom error",
+ stack: error.stack,
+ },
+ },
+ })
+ })
+
+ test("handles errors without stack traces", () => {
+ const error = new Error("test")
+ delete error.stack
+
+ logger.error(error)
+
+ expect(transport.entries[0].d).toMatchObject({
+ error: {
+ name: "Error",
+ message: "test",
+ stack: undefined,
+ },
+ })
+ })
+ })
+
+ describe("Timestamp Generation", () => {
+ beforeEach(() => {
+ jest.useFakeTimers()
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ test("uses current timestamp for entries", () => {
+ const baseTime = 1000000000000
+ jest.setSystemTime(baseTime)
+
+ logger.info("test")
+ expect(transport.entries[0].t).toBe(baseTime)
+ })
+
+ test("timestamps reflect time progression", () => {
+ const baseTime = 1000000000000
+ jest.setSystemTime(baseTime)
+
+ logger.info("first")
+ jest.setSystemTime(baseTime + 100)
+ logger.info("second")
+
+ expect(transport.entries).toHaveLength(2)
+ expect(transport.entries[0].t).toBe(baseTime)
+ expect(transport.entries[1].t).toBe(baseTime + 100)
+ })
+ })
+})
diff --git a/src/utils/logging/__tests__/CompactTransport.test.ts b/src/utils/logging/__tests__/CompactTransport.test.ts
new file mode 100644
index 0000000000..1a372069e2
--- /dev/null
+++ b/src/utils/logging/__tests__/CompactTransport.test.ts
@@ -0,0 +1,220 @@
+// __tests__/CompactTransport.test.ts
+import { describe, expect, test, beforeEach, afterEach } from "@jest/globals"
+import { CompactTransport } from "../CompactTransport"
+import fs from "fs"
+import path from "path"
+
+describe("CompactTransport", () => {
+ const testDir = "./test-logs"
+ const testLogPath = path.join(testDir, "test.log")
+ let transport: CompactTransport
+ const originalWrite = process.stdout.write
+
+ const cleanupTestLogs = () => {
+ const rmDirRecursive = (dirPath: string) => {
+ if (fs.existsSync(dirPath)) {
+ fs.readdirSync(dirPath).forEach((file) => {
+ const curPath = path.join(dirPath, file)
+ if (fs.lstatSync(curPath).isDirectory()) {
+ // Recursive call for directories
+ rmDirRecursive(curPath)
+ } else {
+ // Delete file
+ fs.unlinkSync(curPath)
+ }
+ })
+ // Remove directory after it's empty
+ fs.rmdirSync(dirPath)
+ }
+ }
+
+ try {
+ rmDirRecursive(testDir)
+ } catch (err) {
+ console.error("Cleanup error:", err)
+ }
+ }
+
+ beforeEach(() => {
+ process.stdout.write = () => true
+ cleanupTestLogs()
+ fs.mkdirSync(testDir, { recursive: true })
+
+ transport = new CompactTransport({
+ level: "fatal",
+ fileOutput: {
+ enabled: true,
+ path: testLogPath,
+ },
+ })
+ })
+
+ afterEach(() => {
+ process.stdout.write = originalWrite
+ transport.close()
+ cleanupTestLogs()
+ })
+
+ describe("File Handling", () => {
+ test("creates new log file on initialization", () => {
+ const entry = {
+ t: Date.now(),
+ l: "info",
+ m: "test message",
+ }
+
+ transport.write(entry)
+
+ const fileContent = fs.readFileSync(testLogPath, "utf-8")
+ const lines = fileContent.trim().split("\n")
+
+ expect(lines.length).toBe(2)
+ expect(JSON.parse(lines[0])).toMatchObject({
+ l: "info",
+ m: "Log session started",
+ })
+ expect(JSON.parse(lines[1])).toMatchObject({
+ l: "info",
+ m: "test message",
+ })
+ })
+
+ test("appends entries after initialization", () => {
+ transport.write({
+ t: Date.now(),
+ l: "info",
+ m: "first",
+ })
+
+ transport.write({
+ t: Date.now(),
+ l: "info",
+ m: "second",
+ })
+
+ const fileContent = fs.readFileSync(testLogPath, "utf-8")
+ const lines = fileContent.trim().split("\n")
+
+ expect(lines.length).toBe(3)
+ expect(JSON.parse(lines[1])).toMatchObject({ m: "first" })
+ expect(JSON.parse(lines[2])).toMatchObject({ m: "second" })
+ })
+
+ test("writes session end marker on close", () => {
+ transport.write({
+ t: Date.now(),
+ l: "info",
+ m: "test",
+ })
+
+ transport.close()
+
+ const fileContent = fs.readFileSync(testLogPath, "utf-8")
+ const lines = fileContent.trim().split("\n")
+ const lastLine = JSON.parse(lines[lines.length - 1])
+
+ expect(lastLine).toMatchObject({
+ l: "info",
+ m: "Log session ended",
+ })
+ })
+ })
+
+ describe("File System Edge Cases", () => {
+ test("handles file path with deep directories", () => {
+ const deepDir = path.join(testDir, "deep/nested/path")
+ const deepPath = path.join(deepDir, "test.log")
+ const deepTransport = new CompactTransport({
+ fileOutput: { enabled: true, path: deepPath },
+ })
+
+ try {
+ deepTransport.write({
+ t: Date.now(),
+ l: "info",
+ m: "test",
+ })
+
+ expect(fs.existsSync(deepPath)).toBeTruthy()
+ } finally {
+ deepTransport.close()
+ // Clean up the deep directory structure
+ const rmDirRecursive = (dirPath: string) => {
+ if (fs.existsSync(dirPath)) {
+ fs.readdirSync(dirPath).forEach((file) => {
+ const curPath = path.join(dirPath, file)
+ if (fs.lstatSync(curPath).isDirectory()) {
+ rmDirRecursive(curPath)
+ } else {
+ fs.unlinkSync(curPath)
+ }
+ })
+ fs.rmdirSync(dirPath)
+ }
+ }
+ rmDirRecursive(path.join(testDir, "deep"))
+ }
+ })
+
+ test("handles concurrent writes", async () => {
+ const entries = Array(100)
+ .fill(null)
+ .map((_, i) => ({
+ t: Date.now(),
+ l: "info",
+ m: `test ${i}`,
+ }))
+
+ await Promise.all(entries.map((entry) => Promise.resolve(transport.write(entry))))
+
+ const fileContent = fs.readFileSync(testLogPath, "utf-8")
+ const lines = fileContent.trim().split("\n")
+ // +1 for session start line
+ expect(lines.length).toBe(entries.length + 1)
+ })
+ })
+
+ describe("Delta Timestamp Conversion", () => {
+ let output: string[] = []
+
+ beforeEach(() => {
+ output = []
+ jest.useFakeTimers()
+ const baseTime = 1000000000000
+ jest.setSystemTime(baseTime) // Set time before transport creation
+
+ process.stdout.write = (str: string): boolean => {
+ output.push(str)
+ return true
+ }
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ test("converts absolute timestamps to deltas", () => {
+ const baseTime = Date.now() // Use current fake time
+ const transport = new CompactTransport({
+ level: "info",
+ fileOutput: { enabled: false, path: "null" },
+ })
+
+ transport.write({
+ t: baseTime,
+ l: "info",
+ m: "first",
+ })
+
+ transport.write({
+ t: baseTime + 100,
+ l: "info",
+ m: "second",
+ })
+
+ const entries = output.map((str) => JSON.parse(str))
+ expect(entries[0].t).toBe(0) // First entry should have 0 delta from transport creation
+ expect(entries[1].t).toBe(100) // Delta from previous entry
+ })
+ })
+})
diff --git a/src/utils/logging/__tests__/MockTransport.ts b/src/utils/logging/__tests__/MockTransport.ts
new file mode 100644
index 0000000000..c552a09056
--- /dev/null
+++ b/src/utils/logging/__tests__/MockTransport.ts
@@ -0,0 +1,34 @@
+// __tests__/MockTransport.ts
+import { CompactTransport } from "../CompactTransport"
+import type { CompactLogEntry, CompactTransportConfig } from "../types"
+
+const TEST_CONFIG: CompactTransportConfig = {
+ level: "fatal",
+ fileOutput: {
+ enabled: false,
+ path: "",
+ },
+}
+
+export class MockTransport extends CompactTransport {
+ public entries: CompactLogEntry[] = []
+ public closed = false
+
+ constructor() {
+ super(TEST_CONFIG)
+ }
+
+ override async write(entry: CompactLogEntry): Promise {
+ this.entries.push(entry)
+ }
+
+ override async close(): Promise {
+ this.closed = true
+ await super.close()
+ }
+
+ clear(): void {
+ this.entries = []
+ this.closed = false
+ }
+}
diff --git a/src/utils/logging/index.ts b/src/utils/logging/index.ts
new file mode 100644
index 0000000000..6eb80e3798
--- /dev/null
+++ b/src/utils/logging/index.ts
@@ -0,0 +1,25 @@
+/**
+ * @fileoverview Main entry point for the compact logging system
+ * Provides a default logger instance with Jest environment detection
+ */
+
+import { CompactLogger } from "./CompactLogger"
+
+/**
+ * No-operation logger implementation for production environments
+ */
+const noopLogger = {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ fatal: () => {},
+ child: () => noopLogger,
+ close: () => {},
+}
+
+/**
+ * Default logger instance
+ * Uses CompactLogger for normal operation, switches to noop logger in Jest test environment
+ */
+export const logger = process.env.JEST_WORKER_ID !== undefined ? new CompactLogger() : noopLogger
diff --git a/src/utils/logging/types.ts b/src/utils/logging/types.ts
new file mode 100644
index 0000000000..b8e789b61a
--- /dev/null
+++ b/src/utils/logging/types.ts
@@ -0,0 +1,117 @@
+/**
+ * @fileoverview Core type definitions for the compact logging system
+ */
+
+/**
+ * Represents a compact log entry format optimized for storage and transmission
+ */
+export interface CompactLogEntry {
+ /** Delta timestamp from last entry in milliseconds */
+ t: number
+ /** Log level identifier */
+ l: string
+ /** Log message content */
+ m: string
+ /** Optional context identifier */
+ c?: string
+ /** Optional structured data payload */
+ d?: unknown
+}
+
+/** Available log levels in ascending order of severity */
+export const LOG_LEVELS = ["debug", "info", "warn", "error", "fatal"] as const
+/** Type representing valid log levels */
+export type LogLevel = (typeof LOG_LEVELS)[number]
+
+/**
+ * Metadata structure for log entries
+ */
+export interface LogMeta {
+ /** Optional context identifier */
+ ctx?: string
+ /** Additional arbitrary metadata fields */
+ [key: string]: unknown
+}
+
+/**
+ * Configuration options for CompactTransport
+ */
+export interface CompactTransportConfig {
+ /** Minimum log level to process */
+ level?: LogLevel
+ /** File output configuration */
+ fileOutput?: {
+ /** Whether file output is enabled */
+ enabled: boolean
+ /** Path to the log file */
+ path: string
+ }
+}
+
+/**
+ * Interface for log transport implementations
+ */
+export interface ICompactTransport {
+ /**
+ * Writes a log entry to the transport
+ * @param entry - The log entry to write
+ */
+ write(entry: CompactLogEntry): void
+
+ /**
+ * Closes the transport and performs cleanup
+ */
+ close(): void
+}
+
+/**
+ * Interface for logger implementations
+ */
+export interface ILogger {
+ /**
+ * Logs a debug message
+ * @param message - The message to log
+ * @param meta - Optional metadata
+ */
+ debug(message: string, meta?: LogMeta): void
+
+ /**
+ * Logs an info message
+ * @param message - The message to log
+ * @param meta - Optional metadata
+ */
+ info(message: string, meta?: LogMeta): void
+
+ /**
+ * Logs a warning message
+ * @param message - The message to log
+ * @param meta - Optional metadata
+ */
+ warn(message: string, meta?: LogMeta): void
+
+ /**
+ * Logs an error message
+ * @param message - The message or error to log
+ * @param meta - Optional metadata
+ */
+ error(message: string | Error, meta?: LogMeta): void
+
+ /**
+ * Logs a fatal error message
+ * @param message - The message or error to log
+ * @param meta - Optional metadata
+ */
+ fatal(message: string | Error, meta?: LogMeta): void
+
+ /**
+ * Creates a child logger with inherited metadata
+ * @param meta - Metadata to merge with parent's metadata
+ * @returns A new logger instance with combined metadata
+ */
+ child(meta: LogMeta): ILogger
+
+ /**
+ * Closes the logger and its transport
+ */
+ close(): void
+}
diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json
index b44ade2851..b3dc15147f 100644
--- a/webview-ui/package-lock.json
+++ b/webview-ui/package-lock.json
@@ -13,6 +13,7 @@
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-slot": "^1.1.1",
+ "@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/vite": "^4.0.0",
"@vscode/webview-ui-toolkit": "^1.4.0",
"class-variance-authority": "^0.7.1",
@@ -4185,6 +4186,187 @@
}
}
},
+ "node_modules/@radix-ui/react-tooltip": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz",
+ "integrity": "sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.1",
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.5",
+ "@radix-ui/react-id": "1.1.0",
+ "@radix-ui/react-popper": "1.2.2",
+ "@radix-ui/react-portal": "1.1.4",
+ "@radix-ui/react-presence": "1.1.2",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-slot": "1.1.2",
+ "@radix-ui/react-use-controllable-state": "1.1.0",
+ "@radix-ui/react-visually-hidden": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-arrow": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz",
+ "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.0.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz",
+ "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.1",
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-use-callback-ref": "1.1.0",
+ "@radix-ui/react-use-escape-keydown": "1.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz",
+ "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.0.0",
+ "@radix-ui/react-arrow": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-use-callback-ref": "1.1.0",
+ "@radix-ui/react-use-layout-effect": "1.1.0",
+ "@radix-ui/react-use-rect": "1.1.0",
+ "@radix-ui/react-use-size": "1.1.0",
+ "@radix-ui/rect": "1.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-portal": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz",
+ "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
+ "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
+ "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
@@ -4287,6 +4469,70 @@
}
}
},
+ "node_modules/@radix-ui/react-visually-hidden": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz",
+ "integrity": "sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.0.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
+ "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
+ "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/rect": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz",
diff --git a/webview-ui/package.json b/webview-ui/package.json
index d7a5765690..59ea5693b6 100644
--- a/webview-ui/package.json
+++ b/webview-ui/package.json
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"lint": "eslint src --ext ts,tsx",
+ "lint-fix": "eslint src --ext ts,tsx --fix",
"check-types": "tsc --noEmit",
"test": "jest",
"dev": "vite",
@@ -19,6 +20,7 @@
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-slot": "^1.1.1",
+ "@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/vite": "^4.0.0",
"@vscode/webview-ui-toolkit": "^1.4.0",
"class-variance-authority": "^0.7.1",
diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx
index 8df6f596c5..c18fa84889 100644
--- a/webview-ui/src/components/chat/ChatRow.tsx
+++ b/webview-ui/src/components/chat/ChatRow.tsx
@@ -21,6 +21,7 @@ import Thumbnails from "../common/Thumbnails"
import McpResourceRow from "../mcp/McpResourceRow"
import McpToolRow from "../mcp/McpToolRow"
import { highlightMentions } from "./TaskHeader"
+import { CheckpointSaved } from "./checkpoints/CheckpointSaved"
interface ChatRowProps {
message: ClineMessage
@@ -80,7 +81,7 @@ export const ChatRowContent = ({
isLast,
isStreaming,
}: ChatRowContentProps) => {
- const { mcpServers, alwaysAllowMcp } = useExtensionState()
+ const { mcpServers, alwaysAllowMcp, currentCheckpoint } = useExtensionState()
const [reasoningCollapsed, setReasoningCollapsed] = useState(false)
// Auto-collapse reasoning when new messages arrive
@@ -755,6 +756,14 @@ export const ChatRowContent = ({
>
)
+ case "checkpoint_saved":
+ return (
+
+ )
default:
return (
<>
diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx
index a102ca1fc2..b9fc215a1c 100644
--- a/webview-ui/src/components/chat/ChatView.tsx
+++ b/webview-ui/src/components/chat/ChatView.tsx
@@ -223,9 +223,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setEnableButtons(false)
}
break
+ case "api_req_finished":
case "task":
case "error":
- case "api_req_finished":
case "text":
case "browser_action":
case "browser_action_result":
@@ -547,6 +547,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
switch (message.say) {
case "api_req_finished": // combineApiRequests removes this from modifiedMessages anyways
case "api_req_retried": // this message is used to update the latest api_req_started that the request was retried
+ case "api_req_deleted": // aggregated api_req metrics from deleted messages
return false
case "api_req_retry_delayed":
// Only show the retry message if it's the last message
@@ -1121,6 +1122,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
)}
>
)}
+
+
+
)
}
diff --git a/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx b/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx
new file mode 100644
index 0000000000..5eba795b73
--- /dev/null
+++ b/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx
@@ -0,0 +1,109 @@
+import { useState, useEffect, useCallback } from "react"
+import { CheckIcon, Cross2Icon } from "@radix-ui/react-icons"
+
+import { vscode } from "../../../utils/vscode"
+
+import { Button, Popover, PopoverContent, PopoverTrigger } from "@/components/ui"
+
+type CheckpointMenuProps = {
+ ts: number
+ commitHash: string
+ currentCheckpointHash?: string
+}
+
+export const CheckpointMenu = ({ ts, commitHash, currentCheckpointHash }: CheckpointMenuProps) => {
+ const [portalContainer, setPortalContainer] = useState()
+ const [isOpen, setIsOpen] = useState(false)
+ const [isConfirming, setIsConfirming] = useState(false)
+
+ const isCurrent = currentCheckpointHash === commitHash
+
+ const onCheckpointDiff = useCallback(() => {
+ vscode.postMessage({ type: "checkpointDiff", payload: { ts, commitHash, mode: "checkpoint" } })
+ }, [ts, commitHash])
+
+ const onPreview = useCallback(() => {
+ vscode.postMessage({ type: "checkpointRestore", payload: { ts, commitHash, mode: "preview" } })
+ setIsOpen(false)
+ }, [ts, commitHash])
+
+ const onRestore = useCallback(() => {
+ vscode.postMessage({ type: "checkpointRestore", payload: { ts, commitHash, mode: "restore" } })
+ setIsOpen(false)
+ }, [ts, commitHash])
+
+ useEffect(() => {
+ // The dropdown menu uses a portal from @shadcn/ui which by default renders
+ // at the document root. This causes the menu to remain visible even when
+ // the parent ChatView component is hidden (during settings/history view).
+ // By moving the portal inside ChatView, the menu will properly hide when
+ // its parent is hidden.
+ setPortalContainer(document.getElementById("chat-view-portal") || undefined)
+ }, [])
+
+ return (
+
+
+
+
+
{
+ setIsOpen(open)
+ setIsConfirming(false)
+ }}>
+
+
+
+
+
+
+
+ {!isCurrent && (
+
+
+ Restore Files
+
+
+ Restores your project's files back to a snapshot taken at this point.
+
+
+ )}
+
+
+ {!isConfirming ? (
+
setIsConfirming(true)}>
+ Restore Files & Task
+
+ ) : (
+ <>
+
+
+
+
setIsConfirming(false)}>
+
+
+ >
+ )}
+ {isConfirming ? (
+
This action cannot be undone.
+ ) : (
+
+ Restores your project's files back to a snapshot taken at this point and deletes
+ all messages after this point.
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx b/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx
new file mode 100644
index 0000000000..d5c0050e53
--- /dev/null
+++ b/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx
@@ -0,0 +1,22 @@
+import { CheckpointMenu } from "./CheckpointMenu"
+
+type CheckpointSavedProps = {
+ ts: number
+ commitHash: string
+ currentCheckpointHash?: string
+}
+
+export const CheckpointSaved = (props: CheckpointSavedProps) => {
+ const isCurrent = props.currentCheckpointHash === props.commitHash
+
+ return (
+
+
+
+ Checkpoint
+ {isCurrent && Current }
+
+
+
+ )
+}
diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx
index 7e51347c0b..d6ebc6b86f 100644
--- a/webview-ui/src/components/settings/ApiOptions.tsx
+++ b/webview-ui/src/components/settings/ApiOptions.tsx
@@ -29,7 +29,7 @@ import {
vertexDefaultModelId,
vertexModels,
unboundDefaultModelId,
- unboundModels,
+ unboundDefaultModelInfo,
} from "../../../../src/shared/api"
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
import { useExtensionState } from "../../context/ExtensionStateContext"
@@ -38,6 +38,7 @@ import VSCodeButtonLink from "../common/VSCodeButtonLink"
import { OpenRouterModelPicker } from "./OpenRouterModelPicker"
import OpenAiModelPicker from "./OpenAiModelPicker"
import { GlamaModelPicker } from "./GlamaModelPicker"
+import { UnboundModelPicker } from "./UnboundModelPicker"
import { ModelInfoView } from "./ModelInfoView"
import { DROPDOWN_Z_INDEX } from "./styles"
@@ -163,7 +164,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
value={apiConfiguration?.apiKey || ""}
style={{ width: "100%" }}
type="password"
- onInput={handleInputChange("apiKey")}
+ onBlur={handleInputChange("apiKey")}
placeholder="Enter API Key...">
Anthropic API Key
@@ -188,7 +189,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
value={apiConfiguration?.anthropicBaseUrl || ""}
style={{ width: "100%", marginTop: 3 }}
type="url"
- onInput={handleInputChange("anthropicBaseUrl")}
+ onBlur={handleInputChange("anthropicBaseUrl")}
placeholder="Default: https://api.anthropic.com"
/>
)}
@@ -217,7 +218,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
value={apiConfiguration?.glamaApiKey || ""}
style={{ width: "100%" }}
type="password"
- onInput={handleInputChange("glamaApiKey")}
+ onBlur={handleInputChange("glamaApiKey")}
placeholder="Enter API Key...">
Glama API Key
@@ -246,7 +247,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
value={apiConfiguration?.openAiNativeApiKey || ""}
style={{ width: "100%" }}
type="password"
- onInput={handleInputChange("openAiNativeApiKey")}
+ onBlur={handleInputChange("openAiNativeApiKey")}
placeholder="Enter API Key...">
OpenAI API Key
@@ -274,7 +275,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
value={apiConfiguration?.mistralApiKey || ""}
style={{ width: "100%" }}
type="password"
- onInput={handleInputChange("mistralApiKey")}
+ onBlur={handleInputChange("mistralApiKey")}
placeholder="Enter API Key...">
Mistral API Key
@@ -305,7 +306,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
value={apiConfiguration?.openRouterApiKey || ""}
style={{ width: "100%" }}
type="password"
- onInput={handleInputChange("openRouterApiKey")}
+ onBlur={handleInputChange("openRouterApiKey")}
placeholder="Enter API Key...">
OpenRouter API Key
@@ -339,7 +340,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
value={apiConfiguration?.openRouterBaseUrl || ""}
style={{ width: "100%", marginTop: 3 }}
type="url"
- onInput={handleInputChange("openRouterBaseUrl")}
+ onBlur={handleInputChange("openRouterBaseUrl")}
placeholder="Default: https://openrouter.ai/api/v1"
/>
)}
@@ -390,7 +391,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
AWS Profile Name
@@ -401,7 +402,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
value={apiConfiguration?.awsAccessKey || ""}
style={{ width: "100%" }}
type="password"
- onInput={handleInputChange("awsAccessKey")}
+ onBlur={handleInputChange("awsAccessKey")}
placeholder="Enter Access Key...">
AWS Access Key
@@ -409,7 +410,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
value={apiConfiguration?.awsSecretKey || ""}
style={{ width: "100%" }}
type="password"
- onInput={handleInputChange("awsSecretKey")}
+ onBlur={handleInputChange("awsSecretKey")}
placeholder="Enter Secret Key...">
AWS Secret Key
@@ -417,7 +418,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
value={apiConfiguration?.awsSessionToken || ""}
style={{ width: "100%" }}
type="password"
- onInput={handleInputChange("awsSessionToken")}
+ onBlur={handleInputChange("awsSessionToken")}
placeholder="Enter Session Token...">
AWS Session Token
@@ -485,7 +486,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
Google Cloud Project ID
@@ -543,7 +544,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
value={apiConfiguration?.geminiApiKey || ""}
style={{ width: "100%" }}
type="password"
- onInput={handleInputChange("geminiApiKey")}
+ onBlur={handleInputChange("geminiApiKey")}
placeholder="Enter API Key...">
Gemini API Key
@@ -571,7 +572,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
value={apiConfiguration?.openAiBaseUrl || ""}
style={{ width: "100%" }}
type="url"
- onInput={handleInputChange("openAiBaseUrl")}
+ onBlur={handleInputChange("openAiBaseUrl")}
placeholder={"Enter base URL..."}>
Base URL
@@ -579,7 +580,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
value={apiConfiguration?.openAiApiKey || ""}
style={{ width: "100%" }}
type="password"
- onInput={handleInputChange("openAiApiKey")}
+ onBlur={handleInputChange("openAiApiKey")}
placeholder="Enter API Key...">
API Key
@@ -622,7 +623,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
)}
@@ -1072,14 +1073,14 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
value={apiConfiguration?.lmStudioBaseUrl || ""}
style={{ width: "100%" }}
type="url"
- onInput={handleInputChange("lmStudioBaseUrl")}
+ onBlur={handleInputChange("lmStudioBaseUrl")}
placeholder={"Default: http://localhost:1234"}>
Base URL (optional)
Model ID
@@ -1141,7 +1142,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
value={apiConfiguration?.deepSeekApiKey || ""}
style={{ width: "100%" }}
type="password"
- onInput={handleInputChange("deepSeekApiKey")}
+ onBlur={handleInputChange("deepSeekApiKey")}
placeholder="Enter API Key...">
DeepSeek API Key
@@ -1231,14 +1232,14 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
value={apiConfiguration?.ollamaBaseUrl || ""}
style={{ width: "100%" }}
type="url"
- onInput={handleInputChange("ollamaBaseUrl")}
+ onBlur={handleInputChange("ollamaBaseUrl")}
placeholder={"Default: http://localhost:11434"}>
Base URL (optional)
Model ID
@@ -1315,6 +1316,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
}}>
This key is stored locally and only used to make API requests from this extension.
+
)}
@@ -1337,7 +1339,8 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
selectedProvider !== "openrouter" &&
selectedProvider !== "openai" &&
selectedProvider !== "ollama" &&
- selectedProvider !== "lmstudio" && (
+ selectedProvider !== "lmstudio" &&
+ selectedProvider !== "unbound" && (
<>
@@ -1350,7 +1353,6 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
{selectedProvider === "openai-native" && createDropdown(openAiNativeModels)}
{selectedProvider === "deepseek" && createDropdown(deepSeekModels)}
{selectedProvider === "mistral" && createDropdown(mistralModels)}
- {selectedProvider === "unbound" && createDropdown(unboundModels)}
{
return (
-
+
⚠️
onChange(e.target.checked)}>
diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx
index db306ac7ce..0b609466bb 100644
--- a/webview-ui/src/components/settings/ModelPicker.tsx
+++ b/webview-ui/src/components/settings/ModelPicker.tsx
@@ -25,10 +25,10 @@ import { ModelInfoView } from "./ModelInfoView"
interface ModelPickerProps {
defaultModelId: string
- modelsKey: "glamaModels" | "openRouterModels"
- configKey: "glamaModelId" | "openRouterModelId"
- infoKey: "glamaModelInfo" | "openRouterModelInfo"
- refreshMessageType: "refreshGlamaModels" | "refreshOpenRouterModels"
+ modelsKey: "glamaModels" | "openRouterModels" | "unboundModels"
+ configKey: "glamaModelId" | "openRouterModelId" | "unboundModelId"
+ infoKey: "glamaModelInfo" | "openRouterModelInfo" | "unboundModelInfo"
+ refreshMessageType: "refreshGlamaModels" | "refreshOpenRouterModels" | "refreshUnboundModels"
serviceName: string
serviceUrl: string
recommendedModel: string
diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx
index c81c372ce1..bd2a244afb 100644
--- a/webview-ui/src/components/settings/SettingsView.tsx
+++ b/webview-ui/src/components/settings/SettingsView.tsx
@@ -34,6 +34,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setSoundVolume,
diffEnabled,
setDiffEnabled,
+ checkpointsEnabled,
+ setCheckpointsEnabled,
browserViewportSize,
setBrowserViewportSize,
openRouterModels,
@@ -86,6 +88,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
vscode.postMessage({ type: "soundEnabled", bool: soundEnabled })
vscode.postMessage({ type: "soundVolume", value: soundVolume })
vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
+ vscode.postMessage({ type: "checkpointsEnabled", bool: checkpointsEnabled })
vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize })
vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs })
@@ -641,14 +644,16 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
{diffEnabled && (
-
setExperimentEnabled(EXPERIMENT_IDS.DIFF_STRATEGY, enabled)}
- />
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ gap: "5px",
+ marginTop: "10px",
+ marginBottom: "10px",
+ paddingLeft: "10px",
+ borderLeft: "2px solid var(--vscode-button-background)",
+ }}>
Match precision
{
{Math.round((fuzzyMatchThreshold || 1) * 100)}%
+
+ This slider controls how precisely code sections must match when applying diffs.
+ Lower values allow more flexible matching but increase the risk of incorrect
+ replacements. Use values below 100% with extreme caution.
+
+
+ setExperimentEnabled(EXPERIMENT_IDS.DIFF_STRATEGY, enabled)
+ }
+ />
-
- This slider controls how precisely code sections must match when applying diffs.
- Lower values allow more flexible matching but increase the risk of incorrect
- replacements. Use values below 100% with extreme caution.
-
)}
+
+
+
+ ⚠️
+ {
+ setCheckpointsEnabled(e.target.checked)
+ }}>
+ Enable experimental checkpoints
+
+
+
+ When enabled, Roo will save a checkpoint whenever a file in the workspace is modified,
+ added or deleted, letting you easily revert to a previous state.
+
+
+
{Object.entries(experimentConfigsMap)
.filter((config) => config[0] !== "DIFF_STRATEGY")
.map((config) => (
diff --git a/webview-ui/src/components/settings/UnboundModelPicker.tsx b/webview-ui/src/components/settings/UnboundModelPicker.tsx
new file mode 100644
index 0000000000..4901884f1e
--- /dev/null
+++ b/webview-ui/src/components/settings/UnboundModelPicker.tsx
@@ -0,0 +1,15 @@
+import { ModelPicker } from "./ModelPicker"
+import { unboundDefaultModelId } from "../../../../src/shared/api"
+
+export const UnboundModelPicker = () => (
+
+)
diff --git a/webview-ui/src/components/ui/index.ts b/webview-ui/src/components/ui/index.ts
index 24843c1211..0de5e7eab9 100644
--- a/webview-ui/src/components/ui/index.ts
+++ b/webview-ui/src/components/ui/index.ts
@@ -3,3 +3,4 @@ export * from "./command"
export * from "./dialog"
export * from "./dropdown-menu"
export * from "./popover"
+export * from "./tooltip"
diff --git a/webview-ui/src/components/ui/popover.tsx b/webview-ui/src/components/ui/popover.tsx
index 09c6ba659a..3ab3344689 100644
--- a/webview-ui/src/components/ui/popover.tsx
+++ b/webview-ui/src/components/ui/popover.tsx
@@ -11,9 +11,11 @@ const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
-
+ React.ComponentPropsWithoutRef & {
+ container?: HTMLElement
+ }
+>(({ className, align = "center", sideOffset = 4, container, ...props }, ref) => (
+
,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+TooltipContent.displayName = TooltipPrimitive.Content.displayName
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx
index ac9243d572..79edcc6a35 100644
--- a/webview-ui/src/context/ExtensionStateContext.tsx
+++ b/webview-ui/src/context/ExtensionStateContext.tsx
@@ -8,6 +8,8 @@ import {
glamaDefaultModelInfo,
openRouterDefaultModelId,
openRouterDefaultModelInfo,
+ unboundDefaultModelId,
+ unboundDefaultModelInfo,
} from "../../../src/shared/api"
import { vscode } from "../utils/vscode"
import { convertTextMateToHljs } from "../utils/textMateToHljs"
@@ -24,8 +26,10 @@ export interface ExtensionStateContextType extends ExtensionState {
theme: any
glamaModels: Record
openRouterModels: Record
+ unboundModels: Record
openAiModels: string[]
mcpServers: McpServer[]
+ currentCheckpoint?: string
filePaths: string[]
openedTabs: Array<{ label: string; isActive: boolean; path?: string }>
setApiConfiguration: (config: ApiConfiguration) => void
@@ -41,6 +45,7 @@ export interface ExtensionStateContextType extends ExtensionState {
setSoundEnabled: (value: boolean) => void
setSoundVolume: (value: number) => void
setDiffEnabled: (value: boolean) => void
+ setCheckpointsEnabled: (value: boolean) => void
setBrowserViewportSize: (value: string) => void
setFuzzyMatchThreshold: (value: number) => void
preferredLanguage: string
@@ -88,6 +93,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
soundEnabled: false,
soundVolume: 0.5,
diffEnabled: false,
+ checkpointsEnabled: false,
fuzzyMatchThreshold: 1.0,
preferredLanguage: "English",
writeDelayMs: 1000,
@@ -121,9 +127,13 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const [openRouterModels, setOpenRouterModels] = useState>({
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
})
+ const [unboundModels, setUnboundModels] = useState>({
+ [unboundDefaultModelId]: unboundDefaultModelInfo,
+ })
const [openAiModels, setOpenAiModels] = useState([])
const [mcpServers, setMcpServers] = useState([])
+ const [currentCheckpoint, setCurrentCheckpoint] = useState()
const setListApiConfigMeta = useCallback(
(value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })),
@@ -235,10 +245,19 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setOpenAiModels(updatedModels)
break
}
+ case "unboundModels": {
+ const updatedModels = message.unboundModels ?? {}
+ setUnboundModels(updatedModels)
+ break
+ }
case "mcpServers": {
setMcpServers(message.mcpServers ?? [])
break
}
+ case "currentCheckpointUpdated": {
+ setCurrentCheckpoint(message.text)
+ break
+ }
case "listApiConfig": {
setListApiConfigMeta(message.listApiConfig ?? [])
break
@@ -262,7 +281,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
glamaModels,
openRouterModels,
openAiModels,
+ unboundModels,
mcpServers,
+ currentCheckpoint,
filePaths,
openedTabs,
soundVolume: state.soundVolume,
@@ -288,6 +309,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })),
setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })),
setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })),
+ setCheckpointsEnabled: (value) => setState((prevState) => ({ ...prevState, checkpointsEnabled: value })),
setBrowserViewportSize: (value: string) =>
setState((prevState) => ({ ...prevState, browserViewportSize: value })),
setFuzzyMatchThreshold: (value) => setState((prevState) => ({ ...prevState, fuzzyMatchThreshold: value })),
diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css
index 026d27c5f3..facc2db084 100644
--- a/webview-ui/src/index.css
+++ b/webview-ui/src/index.css
@@ -76,6 +76,13 @@
--color-vscode-input-background: var(--vscode-input-background);
--color-vscode-input-border: var(--vscode-input-border);
+
+ --color-vscode-badge-foreground: var(--vscode-badge-foreground);
+ --color-vscode-badge-background: var(--vscode-badge-background);
+
+ --color-vscode-notifications-foreground: var(--vscode-notifications-foreground);
+ --color-vscode-notifications-background: var(--vscode-notifications-background);
+ --color-vscode-notifications-border: var(--vscode-notifications-border);
}
@layer base {
diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts
index 58c34f5c5a..19b13e2c6c 100644
--- a/webview-ui/src/utils/validate.ts
+++ b/webview-ui/src/utils/validate.ts
@@ -1,4 +1,9 @@
-import { ApiConfiguration, glamaDefaultModelId, openRouterDefaultModelId } from "../../../src/shared/api"
+import {
+ ApiConfiguration,
+ glamaDefaultModelId,
+ openRouterDefaultModelId,
+ unboundDefaultModelId,
+} from "../../../src/shared/api"
import { ModelInfo } from "../../../src/shared/api"
export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined {
if (apiConfiguration) {
@@ -76,6 +81,7 @@ export function validateModelId(
apiConfiguration?: ApiConfiguration,
glamaModels?: Record,
openRouterModels?: Record,
+ unboundModels?: Record,
): string | undefined {
if (apiConfiguration) {
switch (apiConfiguration.apiProvider) {
@@ -99,6 +105,16 @@ export function validateModelId(
return "The model ID you provided is not available. Please choose a different model."
}
break
+ case "unbound":
+ const unboundModelId = apiConfiguration.unboundModelId || unboundDefaultModelId
+ if (!unboundModelId) {
+ return "You must provide a model ID."
+ }
+ if (unboundModels && !Object.keys(unboundModels).includes(unboundModelId)) {
+ // even if the model list endpoint failed, extensionstatecontext will always have the default model info
+ return "The model ID you provided is not available. Please choose a different model."
+ }
+ break
}
}
return undefined