Browse Source

Merge remote-tracking branch 'origin/main' into temperature_control

Matt Rubens 11 months ago
parent
commit
2ccab1444b
46 changed files with 3266 additions and 178 deletions
  1. 0 5
      .changeset/cyan-insects-marry.md
  2. 0 5
      .changeset/dirty-coins-exist.md
  3. 8 1
      .clinerules
  4. 6 0
      .gitignore
  5. 19 0
      CHANGELOG.md
  6. 145 31
      package-lock.json
  7. 5 2
      package.json
  8. 21 2
      src/api/providers/__tests__/unbound.test.ts
  9. 14 3
      src/api/providers/openai.ts
  10. 7 7
      src/api/providers/unbound.ts
  11. 138 0
      src/api/transform/__tests__/simple-format.test.ts
  12. 67 0
      src/api/transform/simple-format.ts
  13. 218 11
      src/core/Cline.ts
  14. 35 6
      src/core/__tests__/Cline.test.ts
  15. 179 35
      src/core/webview/ClineProvider.ts
  16. 3 0
      src/core/webview/__tests__/ClineProvider.test.ts
  17. 350 0
      src/services/checkpoints/CheckpointService.ts
  18. 413 0
      src/services/checkpoints/__tests__/CheckpointService.test.ts
  19. 5 0
      src/services/mcp/McpHub.ts
  20. 10 2
      src/shared/ExtensionMessage.ts
  21. 25 1
      src/shared/WebviewMessage.ts
  22. 12 9
      src/shared/api.ts
  23. 150 0
      src/utils/logging/CompactLogger.ts
  24. 122 0
      src/utils/logging/CompactTransport.ts
  25. 338 0
      src/utils/logging/__tests__/CompactLogger.test.ts
  26. 220 0
      src/utils/logging/__tests__/CompactTransport.test.ts
  27. 34 0
      src/utils/logging/__tests__/MockTransport.ts
  28. 25 0
      src/utils/logging/index.ts
  29. 117 0
      src/utils/logging/types.ts
  30. 246 0
      webview-ui/package-lock.json
  31. 2 0
      webview-ui/package.json
  32. 10 1
      webview-ui/src/components/chat/ChatRow.tsx
  33. 5 1
      webview-ui/src/components/chat/ChatView.tsx
  34. 109 0
      webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx
  35. 22 0
      webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx
  36. 31 25
      webview-ui/src/components/settings/ApiOptions.tsx
  37. 1 6
      webview-ui/src/components/settings/ExperimentalFeature.tsx
  38. 4 4
      webview-ui/src/components/settings/ModelPicker.tsx
  39. 53 17
      webview-ui/src/components/settings/SettingsView.tsx
  40. 15 0
      webview-ui/src/components/settings/UnboundModelPicker.tsx
  41. 1 0
      webview-ui/src/components/ui/index.ts
  42. 5 3
      webview-ui/src/components/ui/popover.tsx
  43. 30 0
      webview-ui/src/components/ui/tooltip.tsx
  44. 22 0
      webview-ui/src/context/ExtensionStateContext.tsx
  45. 7 0
      webview-ui/src/index.css
  46. 17 1
      webview-ui/src/utils/validate.ts

+ 0 - 5
.changeset/cyan-insects-marry.md

@@ -1,5 +0,0 @@
----
-"roo-cline": patch
----
-
-Add a copy button to the recent tasks

+ 0 - 5
.changeset/dirty-coins-exist.md

@@ -1,5 +0,0 @@
----
-"roo-cline": patch
----
-
-Improve the user experience for adding a new configuration profile

+ 8 - 1
.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
+To add a new setting that persists its state, follow the steps in cline_docs/settings.md

+ 6 - 0
.gitignore

@@ -22,3 +22,9 @@ docs/_site/
 
 # Dotenv
 .env.integration
+
+#Local lint config
+.eslintrc.local.json
+
+#Logging
+logs

+ 19 - 0
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.

+ 145 - 31
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==",
+		"node_modules/rimraf/node_modules/glob": {
+			"version": "11.0.1",
+			"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz",
+			"integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==",
 			"dev": true,
 			"dependencies": {
-				"balanced-match": "^1.0.0",
-				"concat-map": "0.0.1"
+				"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": "20 || >=22"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/isaacs"
 			}
 		},
-		"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",
+		"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": {
-				"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"
+				"@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": "3.1.2",
-			"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
-			"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+			"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": "^1.1.7"
+				"brace-expansion": "^2.0.1"
 			},
 			"engines": {
-				"node": "*"
+				"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": {

+ 5 - 2
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",

+ 21 - 2
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()
 		})
 	})

+ 14 - 3
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 },
 			}

+ 7 - 7
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,
 		}
 	}
 

+ 138 - 0
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<Anthropic.Messages.TextBlockParam | Anthropic.Messages.ImageBlockParam>
+			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<Anthropic.Messages.TextBlockParam | Anthropic.Messages.ToolUseBlockParam>
+			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<Anthropic.Messages.TextBlockParam | Anthropic.Messages.ToolResultBlockParam>
+			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]" },
+			])
+		})
+	})
+})

+ 67 - 0
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),
+	}))
+}

+ 218 - 11
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<string, number> = new Map()
 	private providerRef: WeakRef<ClineProvider>
 	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 `<environment_details>\n${details.trim()}\n</environment_details>`
 	}
+
+	// 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 {

+ 35 - 6
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}`)

+ 179 - 35
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
+					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.")
 							}
-							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
 						}
 
+						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<Record<string, ModelInfo> | undefined> {
-		const glamaModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.glamaModels)
-		const fileExists = await fileExistsAtPath(glamaModelsFilePath)
+	private async readModelsFromCache(filename: string): Promise<Record<string, ModelInfo> | 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<Record<string, ModelInfo> | 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<Record<string, ModelInfo> | 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<Record<string, ModelInfo> | undefined> {
+		return this.readModelsFromCache(GlobalFileNames.unboundModels)
+	}
+
+	async refreshUnboundModels() {
+		const unboundModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.unboundModels)
+
+		const models: Record<string, ModelInfo> = {}
+		try {
+			const response = await axios.get("https://api.getunbound.ai/models")
+
+			if (response.data) {
+				const rawModels: Record<string, any> = 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<ApiProvider | undefined>,
@@ -2307,6 +2441,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			this.getGlobalState("allowedCommands") as Promise<string[] | undefined>,
 			this.getGlobalState("soundEnabled") as Promise<boolean | undefined>,
 			this.getGlobalState("diffEnabled") as Promise<boolean | undefined>,
+			this.getGlobalState("checkpointsEnabled") as Promise<boolean | undefined>,
 			this.getGlobalState("soundVolume") as Promise<number | undefined>,
 			this.getGlobalState("browserViewportSize") as Promise<string | undefined>,
 			this.getGlobalState("fuzzyMatchThreshold") as Promise<number | undefined>,
@@ -2332,6 +2467,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			this.getGlobalState("experiments") as Promise<Record<ExperimentId, boolean> | undefined>,
 			this.getSecret("unboundApiKey") as Promise<string | undefined>,
 			this.getGlobalState("unboundModelId") as Promise<string | undefined>,
+			this.getGlobalState("unboundModelInfo") as Promise<ModelInfo | undefined>,
 			this.getGlobalState("modelTemperature") as Promise<number | undefined>,
 		])
 
@@ -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() {

+ 3 - 0
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,

+ 350 - 0
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 = "[email protected]"
+
+	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<CheckpointServiceOptions>) {
+		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 <hiddenBranch> <currentBranch>
+			await git.checkout(currentBranch) // git checkout <currentBranch>
+		}
+
+		return { currentBranch, currentSha, hiddenBranch }
+	}
+}

+ 413 - 0
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 = "[email protected]",
+		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 = "[email protected]"
+			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<string>,
+				) => {
+					if (scope === "global") {
+						if (key === "user.email") {
+							return Promise.resolve({ value: "[email protected]" }) 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("[email protected]")
+
+			await fs.rm(baseDir, { recursive: true, force: true })
+		})
+	})
+})

+ 5 - 0
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<string> {
 		const provider = this.providerRef.deref()
 		if (!provider) {

+ 10 - 2
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<string, ModelInfo>
 	openRouterModels?: Record<string, ModelInfo>
 	openAiModels?: string[]
+	unboundModels?: Record<string, ModelInfo>
 	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:

+ 25 - 1
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<typeof checkoutDiffPayloadSchema>
+
+export const checkoutRestorePayloadSchema = z.object({
+	ts: z.number(),
+	commitHash: z.string(),
+	mode: z.enum(["preview", "restore"]),
+})
+
+export type CheckpointRestorePayload = z.infer<typeof checkoutRestorePayloadSchema>
+
+export type WebViewMessagePayload = CheckpointDiffPayload | CheckpointRestorePayload

+ 12 - 9
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<string, ModelInfo>
 
 // 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<string, ModelInfo>
+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,
+}

+ 150 - 0
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)
+	}
+}

+ 122 - 0
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" })
+		}
+	}
+}

+ 338 - 0
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)
+		})
+	})
+})

+ 220 - 0
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
+		})
+	})
+})

+ 34 - 0
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<void> {
+		this.entries.push(entry)
+	}
+
+	override async close(): Promise<void> {
+		this.closed = true
+		await super.close()
+	}
+
+	clear(): void {
+		this.entries = []
+		this.closed = false
+	}
+}

+ 25 - 0
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

+ 117 - 0
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
+}

+ 246 - 0
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",

+ 2 - 0
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",

+ 10 - 1
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 = ({
 							</div>
 						</>
 					)
+				case "checkpoint_saved":
+					return (
+						<CheckpointSaved
+							ts={message.ts!}
+							commitHash={message.text!}
+							currentCheckpointHash={currentCheckpoint}
+						/>
+					)
 				default:
 					return (
 						<>

+ 5 - 1
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
 					)}
 				</>
 			)}
+
 			<ChatTextArea
 				ref={textAreaRef}
 				inputValue={inputValue}
@@ -1140,6 +1142,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 				mode={mode}
 				setMode={setMode}
 			/>
+
+			<div id="chat-view-portal" />
 		</div>
 	)
 }

+ 109 - 0
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<HTMLElement>()
+	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 (
+		<div className="flex flex-row gap-1">
+			<Button variant="ghost" size="icon" onClick={onCheckpointDiff} title="View Diff">
+				<span className="codicon codicon-diff-single" />
+			</Button>
+			<Popover
+				open={isOpen}
+				onOpenChange={(open) => {
+					setIsOpen(open)
+					setIsConfirming(false)
+				}}>
+				<PopoverTrigger asChild>
+					<Button variant="ghost" size="icon" title="Restore Checkpoint">
+						<span className="codicon codicon-history" />
+					</Button>
+				</PopoverTrigger>
+				<PopoverContent align="end" container={portalContainer}>
+					<div className="flex flex-col gap-2">
+						{!isCurrent && (
+							<div className="flex flex-col gap-1 group hover:text-foreground">
+								<Button variant="secondary" onClick={onPreview}>
+									Restore Files
+								</Button>
+								<div className="text-muted transition-colors group-hover:text-foreground">
+									Restores your project's files back to a snapshot taken at this point.
+								</div>
+							</div>
+						)}
+						<div className="flex flex-col gap-1 group hover:text-foreground">
+							<div className="flex flex-col gap-1 group hover:text-foreground">
+								{!isConfirming ? (
+									<Button variant="secondary" onClick={() => setIsConfirming(true)}>
+										Restore Files & Task
+									</Button>
+								) : (
+									<>
+										<Button variant="default" onClick={onRestore} className="grow">
+											<div className="flex flex-row gap-1">
+												<CheckIcon />
+												<div>Confirm</div>
+											</div>
+										</Button>
+										<Button variant="secondary" onClick={() => setIsConfirming(false)}>
+											<div className="flex flex-row gap-1">
+												<Cross2Icon />
+												<div>Cancel</div>
+											</div>
+										</Button>
+									</>
+								)}
+								{isConfirming ? (
+									<div className="text-destructive font-bold">This action cannot be undone.</div>
+								) : (
+									<div className="text-muted transition-colors group-hover:text-foreground">
+										Restores your project's files back to a snapshot taken at this point and deletes
+										all messages after this point.
+									</div>
+								)}
+							</div>
+						</div>
+					</div>
+				</PopoverContent>
+			</Popover>
+		</div>
+	)
+}

+ 22 - 0
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 (
+		<div className="flex items-center justify-between">
+			<div className="flex gap-2">
+				<span className="codicon codicon-git-commit text-blue-400" />
+				<span className="font-bold">Checkpoint</span>
+				{isCurrent && <span className="text-muted text-sm">Current</span>}
+			</div>
+			<CheckpointMenu {...props} />
+		</div>
+	)
+}

+ 31 - 25
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...">
 						<span style={{ fontWeight: 500 }}>Anthropic API Key</span>
 					</VSCodeTextField>
@@ -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...">
 						<span style={{ fontWeight: 500 }}>Glama API Key</span>
 					</VSCodeTextField>
@@ -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...">
 						<span style={{ fontWeight: 500 }}>OpenAI API Key</span>
 					</VSCodeTextField>
@@ -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...">
 						<span style={{ fontWeight: 500 }}>Mistral API Key</span>
 					</VSCodeTextField>
@@ -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...">
 						<span style={{ fontWeight: 500 }}>OpenRouter API Key</span>
 					</VSCodeTextField>
@@ -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) =
 						<VSCodeTextField
 							value={apiConfiguration?.awsProfile || ""}
 							style={{ width: "100%" }}
-							onInput={handleInputChange("awsProfile")}
+							onBlur={handleInputChange("awsProfile")}
 							placeholder="Enter profile name">
 							<span style={{ fontWeight: 500 }}>AWS Profile Name</span>
 						</VSCodeTextField>
@@ -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...">
 								<span style={{ fontWeight: 500 }}>AWS Access Key</span>
 							</VSCodeTextField>
@@ -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...">
 								<span style={{ fontWeight: 500 }}>AWS Secret Key</span>
 							</VSCodeTextField>
@@ -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...">
 								<span style={{ fontWeight: 500 }}>AWS Session Token</span>
 							</VSCodeTextField>
@@ -485,7 +486,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
 					<VSCodeTextField
 						value={apiConfiguration?.vertexProjectId || ""}
 						style={{ width: "100%" }}
-						onInput={handleInputChange("vertexProjectId")}
+						onBlur={handleInputChange("vertexProjectId")}
 						placeholder="Enter Project ID...">
 						<span style={{ fontWeight: 500 }}>Google Cloud Project ID</span>
 					</VSCodeTextField>
@@ -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...">
 						<span style={{ fontWeight: 500 }}>Gemini API Key</span>
 					</VSCodeTextField>
@@ -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..."}>
 						<span style={{ fontWeight: 500 }}>Base URL</span>
 					</VSCodeTextField>
@@ -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...">
 						<span style={{ fontWeight: 500 }}>API Key</span>
 					</VSCodeTextField>
@@ -622,7 +623,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
 						<VSCodeTextField
 							value={apiConfiguration?.azureApiVersion || ""}
 							style={{ width: "100%", marginTop: 3 }}
-							onInput={handleInputChange("azureApiVersion")}
+							onBlur={handleInputChange("azureApiVersion")}
 							placeholder={`Default: ${azureOpenAiDefaultApiVersion}`}
 						/>
 					)}
@@ -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"}>
 						<span style={{ fontWeight: 500 }}>Base URL (optional)</span>
 					</VSCodeTextField>
 					<VSCodeTextField
 						value={apiConfiguration?.lmStudioModelId || ""}
 						style={{ width: "100%" }}
-						onInput={handleInputChange("lmStudioModelId")}
+						onBlur={handleInputChange("lmStudioModelId")}
 						placeholder={"e.g. meta-llama-3.1-8b-instruct"}>
 						<span style={{ fontWeight: 500 }}>Model ID</span>
 					</VSCodeTextField>
@@ -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...">
 						<span style={{ fontWeight: 500 }}>DeepSeek API Key</span>
 					</VSCodeTextField>
@@ -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"}>
 						<span style={{ fontWeight: 500 }}>Base URL (optional)</span>
 					</VSCodeTextField>
 					<VSCodeTextField
 						value={apiConfiguration?.ollamaModelId || ""}
 						style={{ width: "100%" }}
-						onInput={handleInputChange("ollamaModelId")}
+						onBlur={handleInputChange("ollamaModelId")}
 						placeholder={"e.g. llama3.1"}>
 						<span style={{ fontWeight: 500 }}>Model ID</span>
 					</VSCodeTextField>
@@ -1315,6 +1316,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
 						}}>
 						This key is stored locally and only used to make API requests from this extension.
 					</p>
+					<UnboundModelPicker />
 				</div>
 			)}
 
@@ -1337,7 +1339,8 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
 				selectedProvider !== "openrouter" &&
 				selectedProvider !== "openai" &&
 				selectedProvider !== "ollama" &&
-				selectedProvider !== "lmstudio" && (
+				selectedProvider !== "lmstudio" &&
+				selectedProvider !== "unbound" && (
 					<>
 						<div className="dropdown-container">
 							<label htmlFor="model-id">
@@ -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)}
 						</div>
 
 						<ModelInfoView
@@ -1471,7 +1473,11 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
 				},
 			}
 		case "unbound":
-			return getProviderData(unboundModels, unboundDefaultModelId)
+			return {
+				selectedProvider: provider,
+				selectedModelId: apiConfiguration?.unboundModelId || unboundDefaultModelId,
+				selectedModelInfo: apiConfiguration?.unboundModelInfo || unboundDefaultModelInfo,
+			}
 		default:
 			return getProviderData(anthropicModels, anthropicDefaultModelId)
 	}

+ 1 - 6
webview-ui/src/components/settings/ExperimentalFeature.tsx

@@ -9,12 +9,7 @@ interface ExperimentalFeatureProps {
 
 const ExperimentalFeature = ({ name, description, enabled, onChange }: ExperimentalFeatureProps) => {
 	return (
-		<div
-			style={{
-				marginTop: 10,
-				paddingLeft: 10,
-				borderLeft: "2px solid var(--vscode-button-background)",
-			}}>
+		<div>
 			<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
 				<span style={{ color: "var(--vscode-errorForeground)" }}>⚠️</span>
 				<VSCodeCheckbox checked={enabled} onChange={(e: any) => onChange(e.target.checked)}>

+ 4 - 4
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

+ 53 - 17
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 && (
 							<div style={{ marginTop: 10 }}>
-								<ExperimentalFeature
-									key={EXPERIMENT_IDS.DIFF_STRATEGY}
-									{...experimentConfigsMap.DIFF_STRATEGY}
-									enabled={experiments[EXPERIMENT_IDS.DIFF_STRATEGY] ?? false}
-									onChange={(enabled) => setExperimentEnabled(EXPERIMENT_IDS.DIFF_STRATEGY, enabled)}
-								/>
 								<div
-									style={{ display: "flex", flexDirection: "column", gap: "5px", marginTop: "15px" }}>
+									style={{
+										display: "flex",
+										flexDirection: "column",
+										gap: "5px",
+										marginTop: "10px",
+										marginBottom: "10px",
+										paddingLeft: "10px",
+										borderLeft: "2px solid var(--vscode-button-background)",
+									}}>
 									<span style={{ fontWeight: "500" }}>Match precision</span>
 									<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
 										<input
@@ -668,19 +673,50 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 											{Math.round((fuzzyMatchThreshold || 1) * 100)}%
 										</span>
 									</div>
+									<p
+										style={{
+											fontSize: "12px",
+											marginTop: "5px",
+											color: "var(--vscode-descriptionForeground)",
+										}}>
+										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.
+									</p>
+									<ExperimentalFeature
+										key={EXPERIMENT_IDS.DIFF_STRATEGY}
+										{...experimentConfigsMap.DIFF_STRATEGY}
+										enabled={experiments[EXPERIMENT_IDS.DIFF_STRATEGY] ?? false}
+										onChange={(enabled) =>
+											setExperimentEnabled(EXPERIMENT_IDS.DIFF_STRATEGY, enabled)
+										}
+									/>
 								</div>
-								<p
-									style={{
-										fontSize: "12px",
-										marginTop: "5px",
-										color: "var(--vscode-descriptionForeground)",
-									}}>
-									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.
-								</p>
 							</div>
 						)}
+
+						<div style={{ marginBottom: 15 }}>
+							<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
+								<span style={{ color: "var(--vscode-errorForeground)" }}>⚠️</span>
+								<VSCodeCheckbox
+									checked={checkpointsEnabled}
+									onChange={(e: any) => {
+										setCheckpointsEnabled(e.target.checked)
+									}}>
+									<span style={{ fontWeight: "500" }}>Enable experimental checkpoints</span>
+								</VSCodeCheckbox>
+							</div>
+							<p
+								style={{
+									fontSize: "12px",
+									marginTop: "5px",
+									color: "var(--vscode-descriptionForeground)",
+								}}>
+								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.
+							</p>
+						</div>
+
 						{Object.entries(experimentConfigsMap)
 							.filter((config) => config[0] !== "DIFF_STRATEGY")
 							.map((config) => (

+ 15 - 0
webview-ui/src/components/settings/UnboundModelPicker.tsx

@@ -0,0 +1,15 @@
+import { ModelPicker } from "./ModelPicker"
+import { unboundDefaultModelId } from "../../../../src/shared/api"
+
+export const UnboundModelPicker = () => (
+	<ModelPicker
+		defaultModelId={unboundDefaultModelId}
+		modelsKey="unboundModels"
+		configKey="unboundModelId"
+		infoKey="unboundModelInfo"
+		refreshMessageType="refreshUnboundModels"
+		serviceName="Unbound"
+		serviceUrl="https://api.getunbound.ai/models"
+		recommendedModel={unboundDefaultModelId}
+	/>
+)

+ 1 - 0
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"

+ 5 - 3
webview-ui/src/components/ui/popover.tsx

@@ -11,9 +11,11 @@ const PopoverAnchor = PopoverPrimitive.Anchor
 
 const PopoverContent = React.forwardRef<
 	React.ElementRef<typeof PopoverPrimitive.Content>,
-	React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
->(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
-	<PopoverPrimitive.Portal>
+	React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
+		container?: HTMLElement
+	}
+>(({ className, align = "center", sideOffset = 4, container, ...props }, ref) => (
+	<PopoverPrimitive.Portal container={container}>
 		<PopoverPrimitive.Content
 			ref={ref}
 			align={align}

+ 30 - 0
webview-ui/src/components/ui/tooltip.tsx

@@ -0,0 +1,30 @@
+import * as React from "react"
+import * as TooltipPrimitive from "@radix-ui/react-tooltip"
+
+import { cn } from "@/lib/utils"
+
+const TooltipProvider = TooltipPrimitive.Provider
+
+const Tooltip = TooltipPrimitive.Root
+
+const TooltipTrigger = TooltipPrimitive.Trigger
+
+const TooltipContent = React.forwardRef<
+	React.ElementRef<typeof TooltipPrimitive.Content>,
+	React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
+>(({ className, sideOffset = 4, ...props }, ref) => (
+	<TooltipPrimitive.Portal>
+		<TooltipPrimitive.Content
+			ref={ref}
+			sideOffset={sideOffset}
+			className={cn(
+				"z-50 overflow-hidden rounded-xs bg-vscode-notifications-background border border-vscode-notifications-border px-3 py-1.5 text-xs text-vscode-notifications-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+				className,
+			)}
+			{...props}
+		/>
+	</TooltipPrimitive.Portal>
+))
+TooltipContent.displayName = TooltipPrimitive.Content.displayName
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

+ 22 - 0
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<string, ModelInfo>
 	openRouterModels: Record<string, ModelInfo>
+	unboundModels: Record<string, ModelInfo>
 	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<Record<string, ModelInfo>>({
 		[openRouterDefaultModelId]: openRouterDefaultModelInfo,
 	})
+	const [unboundModels, setUnboundModels] = useState<Record<string, ModelInfo>>({
+		[unboundDefaultModelId]: unboundDefaultModelInfo,
+	})
 
 	const [openAiModels, setOpenAiModels] = useState<string[]>([])
 	const [mcpServers, setMcpServers] = useState<McpServer[]>([])
+	const [currentCheckpoint, setCurrentCheckpoint] = useState<string>()
 
 	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 })),

+ 7 - 0
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 {

+ 17 - 1
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<string, ModelInfo>,
 	openRouterModels?: Record<string, ModelInfo>,
+	unboundModels?: Record<string, ModelInfo>,
 ): 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