Browse Source

Merge branch 'main' into mistral

Matt Rubens 10 months ago
parent
commit
a9d8a1d00d
47 changed files with 2517 additions and 1184 deletions
  1. 5 0
      .changeset/clever-news-arrive.md
  2. 0 5
      .changeset/orange-geese-provide.md
  3. 5 0
      .clinerules
  4. 8 0
      CHANGELOG.md
  5. 28 2
      package-lock.json
  6. 17 3
      package.json
  7. 13 0
      src/__mocks__/get-folder-size.js
  8. 3 0
      src/activate/registerCommands.ts
  9. 57 25
      src/core/Cline.ts
  10. 3 0
      src/core/__tests__/Cline.test.ts
  11. 196 45
      src/core/config/CustomModesManager.ts
  12. 13 16
      src/core/config/CustomModesSchema.ts
  13. 315 151
      src/core/config/__tests__/CustomModesManager.test.ts
  14. 0 22
      src/core/config/__tests__/CustomModesSchema.test.ts
  15. 0 9
      src/core/config/__tests__/GroupConfigSchema.test.ts
  16. 1 38
      src/core/prompts/__tests__/__snapshots__/system.test.ts.snap
  17. 10 4
      src/core/prompts/sections/modes.ts
  18. 41 7
      src/core/webview/ClineProvider.ts
  19. 26 21
      src/core/webview/__tests__/ClineProvider.test.ts
  20. 29 0
      src/services/checkpoints/CheckpointServiceFactory.ts
  21. 42 62
      src/services/checkpoints/LocalCheckpointService.ts
  22. 249 0
      src/services/checkpoints/ShadowCheckpointService.ts
  23. 65 101
      src/services/checkpoints/__tests__/LocalCheckpointService.test.ts
  24. 334 0
      src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts
  25. 89 0
      src/services/checkpoints/constants.ts
  26. 2 0
      src/services/checkpoints/index.ts
  27. 32 0
      src/services/checkpoints/types.ts
  28. 2 0
      src/shared/ExtensionMessage.ts
  29. 1 0
      src/shared/HistoryItem.ts
  30. 2 0
      src/shared/WebviewMessage.ts
  31. 10 10
      src/shared/__tests__/modes.test.ts
  32. 3 2
      src/shared/modes.ts
  33. 156 0
      src/test/VSCODE_INTEGRATION_TESTS.md
  34. 44 42
      src/test/suite/modes.test.ts
  35. 6 3
      src/test/suite/task.test.ts
  36. 12 0
      webview-ui/src/__mocks__/pretty-bytes.js
  37. 1 1
      webview-ui/src/components/chat/ChatRow.tsx
  38. 66 72
      webview-ui/src/components/chat/TaskHeader.tsx
  39. 68 58
      webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx
  40. 14 5
      webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx
  41. 2 0
      webview-ui/src/components/chat/checkpoints/schema.ts
  42. 271 297
      webview-ui/src/components/history/HistoryView.tsx
  43. 1 4
      webview-ui/src/components/history/__tests__/HistoryView.test.tsx
  44. 245 179
      webview-ui/src/components/prompts/PromptsView.tsx
  45. 25 0
      webview-ui/src/components/settings/SettingsView.tsx
  46. 3 0
      webview-ui/src/context/ExtensionStateContext.tsx
  47. 2 0
      webview-ui/src/index.css

+ 5 - 0
.changeset/clever-news-arrive.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Disable writing in ask mode

+ 0 - 5
.changeset/orange-geese-provide.md

@@ -1,5 +0,0 @@
----
-"roo-cline": patch
----
-
-Allow users to clear out custom instructions for the built-in modes

+ 5 - 0
.clinerules

@@ -16,6 +16,11 @@
    - Logs can be found in `logs\app.log`
      - Logfile is overwritten on each run to keep it to a manageable volume.
 
+4. Styling Guidelines:
+   - Use Tailwind CSS classes instead of inline style objects for new markup
+   - VSCode CSS variables must be added to webview-ui/src/index.css before using them in Tailwind classes
+   - Example: `<div className="text-md text-vscode-descriptionForeground mb-2" />` instead of style objects
+
 
 # Adding a New Setting
 

+ 8 - 0
CHANGELOG.md

@@ -1,5 +1,13 @@
 # Roo Code Changelog
 
+## [3.3.19]
+
+- Fix a bug where aborting in the middle of file writes would not revert the write
+- Honor the VS Code theme for dialog backgrounds
+- Make it possible to clear out the default custom instructions for built-in modes
+- Add a help button that links to our new documentation site (which we would love help from the community to improve!)
+- Switch checkpoints logic to use a shadow git repository to work around issues with hot reloads and polluting existing repositories (thanks Cline for the inspiration!)
+
 ## [3.3.18]
 
 - Add a per-API-configuration model temperature setting (thanks @joemanley201!)

+ 28 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
 	"name": "roo-cline",
-	"version": "3.3.18",
+	"version": "3.3.19",
 	"lockfileVersion": 3,
 	"requires": true,
 	"packages": {
 		"": {
 			"name": "roo-cline",
-			"version": "3.3.18",
+			"version": "3.3.19",
 			"dependencies": {
 				"@anthropic-ai/bedrock-sdk": "^0.10.2",
 				"@anthropic-ai/sdk": "^0.26.0",
@@ -31,6 +31,7 @@
 				"diff-match-patch": "^1.0.5",
 				"fast-deep-equal": "^3.1.3",
 				"fastest-levenshtein": "^1.0.16",
+				"get-folder-size": "^5.0.0",
 				"globby": "^14.0.2",
 				"isbinaryfile": "^5.0.2",
 				"mammoth": "^1.8.0",
@@ -39,6 +40,7 @@
 				"os-name": "^6.0.0",
 				"p-wait-for": "^5.0.2",
 				"pdf-parse": "^1.1.1",
+				"pretty-bytes": "^6.1.1",
 				"puppeteer-chromium-resolver": "^23.0.0",
 				"puppeteer-core": "^23.4.0",
 				"serialize-error": "^11.0.3",
@@ -9429,6 +9431,18 @@
 				"url": "https://github.com/sponsors/sindresorhus"
 			}
 		},
+		"node_modules/get-folder-size": {
+			"version": "5.0.0",
+			"resolved": "https://registry.npmjs.org/get-folder-size/-/get-folder-size-5.0.0.tgz",
+			"integrity": "sha512-+fgtvbL83tSDypEK+T411GDBQVQtxv+qtQgbV+HVa/TYubqDhNd5ghH/D6cOHY9iC5/88GtOZB7WI8PXy2A3bg==",
+			"license": "MIT",
+			"bin": {
+				"get-folder-size": "bin/get-folder-size.js"
+			},
+			"engines": {
+				"node": ">=18.11.0"
+			}
+		},
 		"node_modules/get-intrinsic": {
 			"version": "1.2.4",
 			"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
@@ -13428,6 +13442,18 @@
 				"url": "https://github.com/prettier/prettier?sponsor=1"
 			}
 		},
+		"node_modules/pretty-bytes": {
+			"version": "6.1.1",
+			"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
+			"integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
+			"license": "MIT",
+			"engines": {
+				"node": "^14.13.1 || >=16.0.0"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/sindresorhus"
+			}
+		},
 		"node_modules/pretty-format": {
 			"version": "29.7.0",
 			"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",

+ 17 - 3
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.18",
+	"version": "3.3.19",
 	"icon": "assets/icons/rocket.png",
 	"galleryBanner": {
 		"color": "#617A91",
@@ -99,6 +99,11 @@
 				"title": "Settings",
 				"icon": "$(settings-gear)"
 			},
+			{
+				"command": "roo-cline.helpButtonClicked",
+				"title": "Documentation",
+				"icon": "$(question)"
+			},
 			{
 				"command": "roo-cline.openInNewTab",
 				"title": "Open In New Tab",
@@ -225,6 +230,11 @@
 					"command": "roo-cline.settingsButtonClicked",
 					"group": "navigation@6",
 					"when": "view == roo-cline.SidebarProvider"
+				},
+				{
+					"command": "roo-cline.helpButtonClicked",
+					"group": "navigation@7",
+					"when": "view == roo-cline.SidebarProvider"
 				}
 			]
 		},
@@ -272,7 +282,9 @@
 		"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-local": "eslint -c .eslintrc.local.json src --ext ts && npm run lint --prefix webview-ui",
 		"lint-fix": "eslint src --ext ts --fix && npm run lint-fix --prefix webview-ui",
+		"lint-fix-local": "eslint -c .eslintrc.local.json 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",
@@ -314,6 +326,7 @@
 		"diff-match-patch": "^1.0.5",
 		"fast-deep-equal": "^3.1.3",
 		"fastest-levenshtein": "^1.0.16",
+		"get-folder-size": "^5.0.0",
 		"globby": "^14.0.2",
 		"isbinaryfile": "^5.0.2",
 		"mammoth": "^1.8.0",
@@ -322,6 +335,7 @@
 		"os-name": "^6.0.0",
 		"p-wait-for": "^5.0.2",
 		"pdf-parse": "^1.1.1",
+		"pretty-bytes": "^6.1.1",
 		"puppeteer-chromium-resolver": "^23.0.0",
 		"puppeteer-core": "^23.4.0",
 		"serialize-error": "^11.0.3",
@@ -352,17 +366,17 @@
 		"@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",
 		"glob": "^11.0.1",
 		"husky": "^9.1.7",
 		"jest": "^29.7.0",
 		"jest-simple-dot-reporter": "^1.0.5",
 		"lint-staged": "^15.2.11",
+		"mkdirp": "^3.0.1",
 		"mocha": "^11.1.0",
 		"npm-run-all": "^4.1.5",
 		"prettier": "^3.4.2",
+		"rimraf": "^6.0.1",
 		"ts-jest": "^29.2.5",
 		"typescript": "^5.4.5"
 	},

+ 13 - 0
src/__mocks__/get-folder-size.js

@@ -0,0 +1,13 @@
+module.exports = async function getFolderSize() {
+	return {
+		size: 1000,
+		errors: [],
+	}
+}
+
+module.exports.loose = async function getFolderSizeLoose() {
+	return {
+		size: 1000,
+		errors: [],
+	}
+}

+ 3 - 0
src/activate/registerCommands.ts

@@ -38,6 +38,9 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
 		"roo-cline.historyButtonClicked": () => {
 			provider.postMessageToWebview({ type: "action", action: "historyButtonClicked" })
 		},
+		"roo-cline.helpButtonClicked": () => {
+			vscode.env.openExternal(vscode.Uri.parse("https://docs.roocode.com"))
+		},
 	}
 }
 

+ 57 - 25
src/core/Cline.ts

@@ -6,13 +6,14 @@ import delay from "delay"
 import fs from "fs/promises"
 import os from "os"
 import pWaitFor from "p-wait-for"
+import getFolderSize from "get-folder-size"
 import * as path from "path"
 import { serializeError } from "serialize-error"
 import * as vscode from "vscode"
 import { ApiHandler, SingleCompletionHandler, buildApiHandler } from "../api"
 import { ApiStream } from "../api/transform/stream"
 import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
-import { CheckpointService } from "../services/checkpoints/CheckpointService"
+import { CheckpointService, CheckpointServiceFactory } from "../services/checkpoints"
 import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
 import {
 	extractTextFromFile,
@@ -239,7 +240,8 @@ export class Cline {
 
 	private async saveClineMessages() {
 		try {
-			const filePath = path.join(await this.ensureTaskDirectoryExists(), GlobalFileNames.uiMessages)
+			const taskDir = await this.ensureTaskDirectoryExists()
+			const filePath = path.join(taskDir, GlobalFileNames.uiMessages)
 			await fs.writeFile(filePath, JSON.stringify(this.clineMessages))
 			// combined as they are in ChatView
 			const apiMetrics = getApiMetrics(combineApiRequests(combineCommandSequences(this.clineMessages.slice(1))))
@@ -251,6 +253,17 @@ export class Cline {
 						(m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"),
 					)
 				]
+
+			let taskDirSize = 0
+
+			try {
+				taskDirSize = await getFolderSize.loose(taskDir)
+			} catch (err) {
+				console.error(
+					`[saveClineMessages] failed to get task directory size (${taskDir}): ${err instanceof Error ? err.message : String(err)}`,
+				)
+			}
+
 			await this.providerRef.deref()?.updateTaskHistory({
 				id: this.taskId,
 				ts: lastRelevantMessage.ts,
@@ -260,6 +273,7 @@ export class Cline {
 				cacheWrites: apiMetrics.totalCacheWrites,
 				cacheReads: apiMetrics.totalCacheReads,
 				totalCost: apiMetrics.totalCost,
+				size: taskDirSize,
 			})
 		} catch (error) {
 			console.error("Failed to save cline messages:", error)
@@ -2692,7 +2706,7 @@ export class Cline {
 		}
 
 		if (isCheckpointPossible) {
-			await this.checkpointSave()
+			await this.checkpointSave({ isFirst: false })
 		}
 
 		/*
@@ -2762,7 +2776,7 @@ export class Cline {
 		const isFirstRequest = this.clineMessages.filter((m) => m.say === "api_req_started").length === 0
 
 		if (isFirstRequest) {
-			await this.checkpointSave()
+			await this.checkpointSave({ isFirst: true })
 		}
 
 		// getting verbose details is an expensive operation, it uses globby to top-down build file structure of project which for large projects can take a few seconds
@@ -3098,11 +3112,14 @@ export class Cline {
 		}
 
 		details += "\n\n# VSCode Open Tabs"
+		const { maxOpenTabsContext } = (await this.providerRef.deref()?.getState()) ?? {}
+		const maxTabs = maxOpenTabsContext ?? 20
 		const openTabs = vscode.window.tabGroups.all
 			.flatMap((group) => group.tabs)
 			.map((tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath)
 			.filter(Boolean)
 			.map((absolutePath) => path.relative(cwd, absolutePath).toPosix())
+			.slice(0, maxTabs)
 			.join("\n")
 		if (openTabs) {
 			details += `\n${openTabs}`
@@ -3255,11 +3272,32 @@ export class Cline {
 	// Checkpoints
 
 	private async getCheckpointService() {
+		if (!this.checkpointsEnabled) {
+			throw new Error("Checkpoints are disabled")
+		}
+
 		if (!this.checkpointService) {
-			this.checkpointService = await CheckpointService.create({
-				taskId: this.taskId,
-				baseDir: vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? "",
-				log: (message) => this.providerRef.deref()?.log(message),
+			const workspaceDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
+			const shadowDir = this.providerRef.deref()?.context.globalStorageUri.fsPath
+
+			if (!workspaceDir) {
+				this.providerRef.deref()?.log("[getCheckpointService] workspace folder not found")
+				throw new Error("Workspace directory not found")
+			}
+
+			if (!shadowDir) {
+				this.providerRef.deref()?.log("[getCheckpointService] shadowDir not found")
+				throw new Error("Global storage directory not found")
+			}
+
+			this.checkpointService = await CheckpointServiceFactory.create({
+				strategy: "shadow",
+				options: {
+					taskId: this.taskId,
+					workspaceDir,
+					shadowDir,
+					log: (message) => this.providerRef.deref()?.log(message),
+				},
 			})
 		}
 
@@ -3318,29 +3356,25 @@ export class Cline {
 		}
 	}
 
-	public async checkpointSave() {
+	public async checkpointSave({ isFirst }: { isFirst: boolean }) {
 		if (!this.checkpointsEnabled) {
 			return
 		}
 
 		try {
-			const isFirst = !this.checkpointService
 			const service = await this.getCheckpointService()
-			const commit = await service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`)
+			const strategy = service.strategy
+			const version = service.version
 
-			if (commit?.commit) {
-				await this.providerRef
-					.deref()
-					?.postMessageToWebview({ type: "currentCheckpointUpdated", text: service.currentCheckpoint })
+			const commit = await service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`)
+			const fromHash = service.baseHash
+			const toHash = isFirst ? commit?.commit || fromHash : commit?.commit
 
-				// Checkpoint metadata required by the UI.
-				const checkpoint = {
-					isFirst,
-					from: service.baseCommitHash,
-					to: commit.commit,
-				}
+			if (toHash) {
+				await this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: toHash })
 
-				await this.say("checkpoint_saved", commit.commit, undefined, undefined, checkpoint)
+				const checkpoint = { isFirst, from: fromHash, to: toHash, strategy, version }
+				await this.say("checkpoint_saved", toHash, undefined, undefined, checkpoint)
 			}
 		} catch (err) {
 			this.providerRef.deref()?.log("[checkpointSave] disabling checkpoints for this task")
@@ -3371,9 +3405,7 @@ export class Cline {
 			const service = await this.getCheckpointService()
 			await service.restoreCheckpoint(commitHash)
 
-			await this.providerRef
-				.deref()
-				?.postMessageToWebview({ type: "currentCheckpointUpdated", text: service.currentCheckpoint })
+			await this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: commitHash })
 
 			if (mode === "restore") {
 				await this.overwriteApiConversationHistory(

+ 3 - 0
src/core/__tests__/Cline.test.ts

@@ -475,6 +475,7 @@ describe("Cline", () => {
 				// Mock abort state
 				Object.defineProperty(cline, "abort", {
 					get: () => false,
+					set: () => {},
 					configurable: true,
 				})
 
@@ -603,10 +604,12 @@ describe("Cline", () => {
 				// Mock abort state for both instances
 				Object.defineProperty(clineWithImages, "abort", {
 					get: () => false,
+					set: () => {},
 					configurable: true,
 				})
 				Object.defineProperty(clineWithoutImages, "abort", {
 					get: () => false,
+					set: () => {},
 					configurable: true,
 				})
 

+ 196 - 45
src/core/config/CustomModesManager.ts

@@ -6,6 +6,8 @@ import { ModeConfig } from "../../shared/modes"
 import { fileExistsAtPath } from "../../utils/fs"
 import { arePathsEqual } from "../../utils/path"
 
+const ROOMODES_FILENAME = ".roomodes"
+
 export class CustomModesManager {
 	private disposables: vscode.Disposable[] = []
 	private isWriting = false
@@ -15,7 +17,7 @@ export class CustomModesManager {
 		private readonly context: vscode.ExtensionContext,
 		private readonly onUpdate: () => Promise<void>,
 	) {
-		this.watchCustomModesFile()
+		this.watchCustomModesFiles()
 	}
 
 	private async queueWrite(operation: () => Promise<void>): Promise<void> {
@@ -43,6 +45,71 @@ export class CustomModesManager {
 		}
 	}
 
+	private async getWorkspaceRoomodes(): Promise<string | undefined> {
+		const workspaceFolders = vscode.workspace.workspaceFolders
+		if (!workspaceFolders || workspaceFolders.length === 0) {
+			return undefined
+		}
+		const workspaceRoot = workspaceFolders[0].uri.fsPath
+		const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME)
+		const exists = await fileExistsAtPath(roomodesPath)
+		return exists ? roomodesPath : undefined
+	}
+
+	private async loadModesFromFile(filePath: string): Promise<ModeConfig[]> {
+		try {
+			const content = await fs.readFile(filePath, "utf-8")
+			const settings = JSON.parse(content)
+			const result = CustomModesSettingsSchema.safeParse(settings)
+			if (!result.success) {
+				return []
+			}
+
+			// Determine source based on file path
+			const isRoomodes = filePath.endsWith(ROOMODES_FILENAME)
+			const source = isRoomodes ? ("project" as const) : ("global" as const)
+
+			// Add source to each mode
+			return result.data.customModes.map((mode) => ({
+				...mode,
+				source,
+			}))
+		} catch (error) {
+			const errorMsg = `Failed to load modes from ${filePath}: ${error instanceof Error ? error.message : String(error)}`
+			console.error(`[CustomModesManager] ${errorMsg}`)
+			return []
+		}
+	}
+
+	private async mergeCustomModes(projectModes: ModeConfig[], globalModes: ModeConfig[]): Promise<ModeConfig[]> {
+		const slugs = new Set<string>()
+		const merged: ModeConfig[] = []
+
+		// Add project mode (takes precedence)
+		for (const mode of projectModes) {
+			if (!slugs.has(mode.slug)) {
+				slugs.add(mode.slug)
+				merged.push({
+					...mode,
+					source: "project",
+				})
+			}
+		}
+
+		// Add non-duplicate global modes
+		for (const mode of globalModes) {
+			if (!slugs.has(mode.slug)) {
+				slugs.add(mode.slug)
+				merged.push({
+					...mode,
+					source: "global",
+				})
+			}
+		}
+
+		return merged
+	}
+
 	async getCustomModesFilePath(): Promise<string> {
 		const settingsDir = await this.ensureSettingsDirectoryExists()
 		const filePath = path.join(settingsDir, "cline_custom_modes.json")
@@ -55,14 +122,17 @@ export class CustomModesManager {
 		return filePath
 	}
 
-	private async watchCustomModesFile(): Promise<void> {
+	private async watchCustomModesFiles(): Promise<void> {
 		const settingsPath = await this.getCustomModesFilePath()
+
+		// Watch settings file
 		this.disposables.push(
 			vscode.workspace.onDidSaveTextDocument(async (document) => {
 				if (arePathsEqual(document.uri.fsPath, settingsPath)) {
 					const content = await fs.readFile(settingsPath, "utf-8")
 					const errorMessage =
 						"Invalid custom modes format. Please ensure your settings follow the correct JSON format."
+
 					let config: any
 					try {
 						config = JSON.parse(content)
@@ -71,86 +141,170 @@ export class CustomModesManager {
 						vscode.window.showErrorMessage(errorMessage)
 						return
 					}
+
 					const result = CustomModesSettingsSchema.safeParse(config)
 					if (!result.success) {
 						vscode.window.showErrorMessage(errorMessage)
 						return
 					}
-					await this.context.globalState.update("customModes", result.data.customModes)
+
+					// Get modes from .roomodes if it exists (takes precedence)
+					const roomodesPath = await this.getWorkspaceRoomodes()
+					const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
+
+					// Merge modes from both sources (.roomodes takes precedence)
+					const mergedModes = await this.mergeCustomModes(roomodesModes, result.data.customModes)
+					await this.context.globalState.update("customModes", mergedModes)
 					await this.onUpdate()
 				}
 			}),
 		)
+
+		// Watch .roomodes file if it exists
+		const roomodesPath = await this.getWorkspaceRoomodes()
+		if (roomodesPath) {
+			this.disposables.push(
+				vscode.workspace.onDidSaveTextDocument(async (document) => {
+					if (arePathsEqual(document.uri.fsPath, roomodesPath)) {
+						const settingsModes = await this.loadModesFromFile(settingsPath)
+						const roomodesModes = await this.loadModesFromFile(roomodesPath)
+						// .roomodes takes precedence
+						const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
+						await this.context.globalState.update("customModes", mergedModes)
+						await this.onUpdate()
+					}
+				}),
+			)
+		}
 	}
 
 	async getCustomModes(): Promise<ModeConfig[]> {
-		const modes = await this.context.globalState.get<ModeConfig[]>("customModes")
+		// Get modes from settings file
+		const settingsPath = await this.getCustomModesFilePath()
+		const settingsModes = await this.loadModesFromFile(settingsPath)
 
-		// Always read from file to ensure we have the latest
-		try {
-			const settingsPath = await this.getCustomModesFilePath()
-			const content = await fs.readFile(settingsPath, "utf-8")
+		// Get modes from .roomodes if it exists
+		const roomodesPath = await this.getWorkspaceRoomodes()
+		const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
 
-			const settings = JSON.parse(content)
-			const result = CustomModesSettingsSchema.safeParse(settings)
-			if (result.success) {
-				await this.context.globalState.update("customModes", result.data.customModes)
-				return result.data.customModes
+		// Create maps to store modes by source
+		const projectModes = new Map<string, ModeConfig>()
+		const globalModes = new Map<string, ModeConfig>()
+
+		// Add project modes (they take precedence)
+		for (const mode of roomodesModes) {
+			projectModes.set(mode.slug, { ...mode, source: "project" as const })
+		}
+
+		// Add global modes
+		for (const mode of settingsModes) {
+			if (!projectModes.has(mode.slug)) {
+				globalModes.set(mode.slug, { ...mode, source: "global" as const })
 			}
-			return modes ?? []
-		} catch (error) {
-			// Return empty array if there's an error reading the file
 		}
 
-		return modes ?? []
+		// Combine modes in the correct order: project modes first, then global modes
+		const mergedModes = [
+			...roomodesModes.map((mode) => ({ ...mode, source: "project" as const })),
+			...settingsModes
+				.filter((mode) => !projectModes.has(mode.slug))
+				.map((mode) => ({ ...mode, source: "global" as const })),
+		]
+
+		await this.context.globalState.update("customModes", mergedModes)
+		return mergedModes
 	}
 
 	async updateCustomMode(slug: string, config: ModeConfig): Promise<void> {
 		try {
-			const settingsPath = await this.getCustomModesFilePath()
+			const isProjectMode = config.source === "project"
+			const targetPath = isProjectMode ? await this.getWorkspaceRoomodes() : await this.getCustomModesFilePath()
 
-			await this.queueWrite(async () => {
-				// Read and update file
-				const content = await fs.readFile(settingsPath, "utf-8")
-				const settings = JSON.parse(content)
-				const currentModes = settings.customModes || []
-				const updatedModes = currentModes.filter((m: ModeConfig) => m.slug !== slug)
-				updatedModes.push(config)
-				settings.customModes = updatedModes
-
-				const newContent = JSON.stringify(settings, null, 2)
+			if (isProjectMode && !targetPath) {
+				throw new Error("No workspace folder found for project-specific mode")
+			}
 
-				// Write to file
-				await fs.writeFile(settingsPath, newContent)
+			await this.queueWrite(async () => {
+				// Ensure source is set correctly based on target file
+				const modeWithSource = {
+					...config,
+					source: isProjectMode ? ("project" as const) : ("global" as const),
+				}
 
-				// Update global state
-				await this.context.globalState.update("customModes", updatedModes)
+				await this.updateModesInFile(targetPath!, (modes) => {
+					const updatedModes = modes.filter((m) => m.slug !== slug)
+					updatedModes.push(modeWithSource)
+					return updatedModes
+				})
 
-				// Notify about the update
-				await this.onUpdate()
+				await this.refreshMergedState()
 			})
-
-			// Success, no need for message
 		} catch (error) {
 			vscode.window.showErrorMessage(
 				`Failed to update custom mode: ${error instanceof Error ? error.message : String(error)}`,
 			)
 		}
 	}
+	private async updateModesInFile(filePath: string, operation: (modes: ModeConfig[]) => ModeConfig[]): Promise<void> {
+		let content = "{}"
+		try {
+			content = await fs.readFile(filePath, "utf-8")
+		} catch (error) {
+			// File might not exist yet
+			content = JSON.stringify({ customModes: [] })
+		}
+
+		let settings
+		try {
+			settings = JSON.parse(content)
+		} catch (error) {
+			console.error(`[CustomModesManager] Failed to parse JSON from ${filePath}:`, error)
+			settings = { customModes: [] }
+		}
+		settings.customModes = operation(settings.customModes || [])
+		await fs.writeFile(filePath, JSON.stringify(settings, null, 2), "utf-8")
+	}
+
+	private async refreshMergedState(): Promise<void> {
+		const settingsPath = await this.getCustomModesFilePath()
+		const roomodesPath = await this.getWorkspaceRoomodes()
+
+		const settingsModes = await this.loadModesFromFile(settingsPath)
+		const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
+		const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
+
+		await this.context.globalState.update("customModes", mergedModes)
+		await this.onUpdate()
+	}
 
 	async deleteCustomMode(slug: string): Promise<void> {
 		try {
 			const settingsPath = await this.getCustomModesFilePath()
+			const roomodesPath = await this.getWorkspaceRoomodes()
+
+			const settingsModes = await this.loadModesFromFile(settingsPath)
+			const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
+
+			// Find the mode in either file
+			const projectMode = roomodesModes.find((m) => m.slug === slug)
+			const globalMode = settingsModes.find((m) => m.slug === slug)
+
+			if (!projectMode && !globalMode) {
+				throw new Error("Write error: Mode not found")
+			}
 
 			await this.queueWrite(async () => {
-				const content = await fs.readFile(settingsPath, "utf-8")
-				const settings = JSON.parse(content)
+				// Delete from project first if it exists there
+				if (projectMode && roomodesPath) {
+					await this.updateModesInFile(roomodesPath, (modes) => modes.filter((m) => m.slug !== slug))
+				}
 
-				settings.customModes = (settings.customModes || []).filter((m: ModeConfig) => m.slug !== slug)
-				await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2))
+				// Delete from global settings if it exists there
+				if (globalMode) {
+					await this.updateModesInFile(settingsPath, (modes) => modes.filter((m) => m.slug !== slug))
+				}
 
-				await this.context.globalState.update("customModes", settings.customModes)
-				await this.onUpdate()
+				await this.refreshMergedState()
 			})
 		} catch (error) {
 			vscode.window.showErrorMessage(
@@ -165,9 +319,6 @@ export class CustomModesManager {
 		return settingsDir
 	}
 
-	/**
-	 * Delete the custom modes file and reset to default state
-	 */
 	async resetCustomModes(): Promise<void> {
 		try {
 			const filePath = await this.getCustomModesFilePath()

+ 13 - 16
src/core/config/CustomModesSchema.ts

@@ -29,22 +29,19 @@ const GroupOptionsSchema = z.object({
 const GroupEntrySchema = z.union([ToolGroupSchema, z.tuple([ToolGroupSchema, GroupOptionsSchema])])
 
 // Schema for array of groups
-const GroupsArraySchema = z
-	.array(GroupEntrySchema)
-	.min(1, "At least one tool group is required")
-	.refine(
-		(groups) => {
-			const seen = new Set()
-			return groups.every((group) => {
-				// For tuples, check the group name (first element)
-				const groupName = Array.isArray(group) ? group[0] : group
-				if (seen.has(groupName)) return false
-				seen.add(groupName)
-				return true
-			})
-		},
-		{ message: "Duplicate groups are not allowed" },
-	)
+const GroupsArraySchema = z.array(GroupEntrySchema).refine(
+	(groups) => {
+		const seen = new Set()
+		return groups.every((group) => {
+			// For tuples, check the group name (first element)
+			const groupName = Array.isArray(group) ? group[0] : group
+			if (seen.has(groupName)) return false
+			seen.add(groupName)
+			return true
+		})
+	},
+	{ message: "Duplicate groups are not allowed" },
+)
 
 // Schema for mode configuration
 export const CustomModeSchema = z.object({

+ 315 - 151
src/core/config/__tests__/CustomModesManager.test.ts

@@ -1,134 +1,307 @@
-import { ModeConfig } from "../../../shared/modes"
-import { CustomModesManager } from "../CustomModesManager"
 import * as vscode from "vscode"
-import * as fs from "fs/promises"
 import * as path from "path"
+import * as fs from "fs/promises"
+import { CustomModesManager } from "../CustomModesManager"
+import { ModeConfig } from "../../../shared/modes"
+import { fileExistsAtPath } from "../../../utils/fs"
 
-// Mock dependencies
 jest.mock("vscode")
 jest.mock("fs/promises")
-jest.mock("../../../utils/fs", () => ({
-	fileExistsAtPath: jest.fn().mockResolvedValue(false),
-}))
+jest.mock("../../../utils/fs")
 
 describe("CustomModesManager", () => {
 	let manager: CustomModesManager
 	let mockContext: vscode.ExtensionContext
 	let mockOnUpdate: jest.Mock
-	let mockStoragePath: string
-
-	beforeEach(() => {
-		// Reset mocks
-		jest.clearAllMocks()
+	let mockWorkspaceFolders: { uri: { fsPath: string } }[]
 
-		// Mock storage path
-		mockStoragePath = "/test/storage/path"
+	const mockStoragePath = "/mock/settings"
+	const mockSettingsPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
+	const mockRoomodes = "/mock/workspace/.roomodes"
 
-		// Mock context
+	beforeEach(() => {
+		mockOnUpdate = jest.fn()
 		mockContext = {
-			globalStorageUri: { fsPath: mockStoragePath },
 			globalState: {
-				get: jest.fn().mockResolvedValue([]),
-				update: jest.fn().mockResolvedValue(undefined),
+				get: jest.fn(),
+				update: jest.fn(),
+			},
+			globalStorageUri: {
+				fsPath: mockStoragePath,
 			},
 		} as unknown as vscode.ExtensionContext
 
-		// Mock onUpdate callback
-		mockOnUpdate = jest.fn().mockResolvedValue(undefined)
-
-		// Mock fs.mkdir to do nothing
+		mockWorkspaceFolders = [{ uri: { fsPath: "/mock/workspace" } }]
+		;(vscode.workspace as any).workspaceFolders = mockWorkspaceFolders
+		;(vscode.workspace.onDidSaveTextDocument as jest.Mock).mockReturnValue({ dispose: jest.fn() })
+		;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
+			return path === mockSettingsPath || path === mockRoomodes
+		})
 		;(fs.mkdir as jest.Mock).mockResolvedValue(undefined)
+		;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
+			if (path === mockSettingsPath) {
+				return JSON.stringify({ customModes: [] })
+			}
+			throw new Error("File not found")
+		})
 
-		// Create manager instance
 		manager = new CustomModesManager(mockContext, mockOnUpdate)
 	})
 
-	describe("Mode Configuration Validation", () => {
-		test("validates valid custom mode configuration", async () => {
-			const validMode = {
-				slug: "test-mode",
-				name: "Test Mode",
-				roleDefinition: "Test role definition",
-				groups: ["read"] as const,
-			} satisfies ModeConfig
+	afterEach(() => {
+		jest.clearAllMocks()
+	})
 
-			// Mock file read/write operations
-			;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
-			;(fs.writeFile as jest.Mock).mockResolvedValue(undefined)
+	describe("getCustomModes", () => {
+		it("should merge modes with .roomodes taking precedence", async () => {
+			const settingsModes = [
+				{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] },
+				{ slug: "mode2", name: "Mode 2", roleDefinition: "Role 2", groups: ["read"] },
+			]
+
+			const roomodesModes = [
+				{ slug: "mode2", name: "Mode 2 Override", roleDefinition: "Role 2 Override", groups: ["read"] },
+				{ slug: "mode3", name: "Mode 3", roleDefinition: "Role 3", groups: ["read"] },
+			]
+
+			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
+				if (path === mockSettingsPath) {
+					return JSON.stringify({ customModes: settingsModes })
+				}
+				if (path === mockRoomodes) {
+					return JSON.stringify({ customModes: roomodesModes })
+				}
+				throw new Error("File not found")
+			})
 
-			await manager.updateCustomMode(validMode.slug, validMode)
+			const modes = await manager.getCustomModes()
 
-			// Verify file was written with the new mode
-			expect(fs.writeFile).toHaveBeenCalledWith(
-				expect.stringContaining("cline_custom_modes.json"),
-				expect.stringContaining(validMode.name),
+			// Should contain 3 modes (mode1 from settings, mode2 and mode3 from roomodes)
+			expect(modes).toHaveLength(3)
+			expect(modes.map((m) => m.slug)).toEqual(["mode2", "mode3", "mode1"])
+
+			// mode2 should come from .roomodes since it takes precedence
+			const mode2 = modes.find((m) => m.slug === "mode2")
+			expect(mode2?.name).toBe("Mode 2 Override")
+			expect(mode2?.roleDefinition).toBe("Role 2 Override")
+		})
+
+		it("should handle missing .roomodes file", async () => {
+			const settingsModes = [{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] }]
+
+			;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
+				return path === mockSettingsPath
+			})
+			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
+				if (path === mockSettingsPath) {
+					return JSON.stringify({ customModes: settingsModes })
+				}
+				throw new Error("File not found")
+			})
+
+			const modes = await manager.getCustomModes()
+
+			expect(modes).toHaveLength(1)
+			expect(modes[0].slug).toBe("mode1")
+		})
+
+		it("should handle invalid JSON in .roomodes", async () => {
+			const settingsModes = [{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] }]
+
+			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
+				if (path === mockSettingsPath) {
+					return JSON.stringify({ customModes: settingsModes })
+				}
+				if (path === mockRoomodes) {
+					return "invalid json"
+				}
+				throw new Error("File not found")
+			})
+
+			const modes = await manager.getCustomModes()
+
+			// Should fall back to settings modes when .roomodes is invalid
+			expect(modes).toHaveLength(1)
+			expect(modes[0].slug).toBe("mode1")
+		})
+	})
+
+	describe("updateCustomMode", () => {
+		it("should update mode in settings file while preserving .roomodes precedence", async () => {
+			const newMode: ModeConfig = {
+				slug: "mode1",
+				name: "Updated Mode 1",
+				roleDefinition: "Updated Role 1",
+				groups: ["read"],
+				source: "global",
+			}
+
+			const roomodesModes = [
+				{
+					slug: "mode1",
+					name: "Roomodes Mode 1",
+					roleDefinition: "Role 1",
+					groups: ["read"],
+					source: "project",
+				},
+			]
+
+			const existingModes = [
+				{ slug: "mode2", name: "Mode 2", roleDefinition: "Role 2", groups: ["read"], source: "global" },
+			]
+
+			let settingsContent = { customModes: existingModes }
+			let roomodesContent = { customModes: roomodesModes }
+
+			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
+				if (path === mockRoomodes) {
+					return JSON.stringify(roomodesContent)
+				}
+				if (path === mockSettingsPath) {
+					return JSON.stringify(settingsContent)
+				}
+				throw new Error("File not found")
+			})
+			;(fs.writeFile as jest.Mock).mockImplementation(
+				async (path: string, content: string, encoding?: string) => {
+					if (path === mockSettingsPath) {
+						settingsContent = JSON.parse(content)
+					}
+					if (path === mockRoomodes) {
+						roomodesContent = JSON.parse(content)
+					}
+					return Promise.resolve()
+				},
 			)
 
-			// Verify global state was updated
+			await manager.updateCustomMode("mode1", newMode)
+
+			// Should write to settings file
+			expect(fs.writeFile).toHaveBeenCalledWith(mockSettingsPath, expect.any(String), "utf-8")
+
+			// Verify the content of the write
+			const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
+			const content = JSON.parse(writeCall[1])
+			expect(content.customModes).toContainEqual(
+				expect.objectContaining({
+					slug: "mode1",
+					name: "Updated Mode 1",
+					roleDefinition: "Updated Role 1",
+					source: "global",
+				}),
+			)
+
+			// Should update global state with merged modes where .roomodes takes precedence
 			expect(mockContext.globalState.update).toHaveBeenCalledWith(
 				"customModes",
-				expect.arrayContaining([validMode]),
+				expect.arrayContaining([
+					expect.objectContaining({
+						slug: "mode1",
+						name: "Roomodes Mode 1", // .roomodes version should take precedence
+						source: "project",
+					}),
+				]),
 			)
 
-			// Verify onUpdate was called
+			// Should trigger onUpdate
 			expect(mockOnUpdate).toHaveBeenCalled()
 		})
 
-		test("handles file read errors gracefully", async () => {
-			// Mock fs.readFile to throw error
-			;(fs.readFile as jest.Mock).mockRejectedValueOnce(new Error("Test error"))
-
-			const modes = await manager.getCustomModes()
-
-			// Should return empty array on error
-			expect(modes).toEqual([])
-		})
+		it("queues write operations", async () => {
+			const mode1: ModeConfig = {
+				slug: "mode1",
+				name: "Mode 1",
+				roleDefinition: "Role 1",
+				groups: ["read"],
+				source: "global",
+			}
+			const mode2: ModeConfig = {
+				slug: "mode2",
+				name: "Mode 2",
+				roleDefinition: "Role 2",
+				groups: ["read"],
+				source: "global",
+			}
 
-		test("handles file write errors gracefully", async () => {
-			const validMode = {
-				slug: "123e4567-e89b-12d3-a456-426614174000",
-				name: "Test Mode",
-				roleDefinition: "Test role definition",
-				groups: ["read"] as const,
-			} satisfies ModeConfig
+			let settingsContent = { customModes: [] }
+			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
+				if (path === mockSettingsPath) {
+					return JSON.stringify(settingsContent)
+				}
+				throw new Error("File not found")
+			})
+			;(fs.writeFile as jest.Mock).mockImplementation(
+				async (path: string, content: string, encoding?: string) => {
+					if (path === mockSettingsPath) {
+						settingsContent = JSON.parse(content)
+					}
+					return Promise.resolve()
+				},
+			)
 
-			// Mock fs.writeFile to throw error
-			;(fs.writeFile as jest.Mock).mockRejectedValueOnce(new Error("Write error"))
+			// Start both updates simultaneously
+			await Promise.all([manager.updateCustomMode("mode1", mode1), manager.updateCustomMode("mode2", mode2)])
 
-			const mockShowError = jest.fn()
-			;(vscode.window.showErrorMessage as jest.Mock) = mockShowError
+			// Verify final state in settings file
+			expect(settingsContent.customModes).toHaveLength(2)
+			expect(settingsContent.customModes.map((m: ModeConfig) => m.name)).toContain("Mode 1")
+			expect(settingsContent.customModes.map((m: ModeConfig) => m.name)).toContain("Mode 2")
 
-			await manager.updateCustomMode(validMode.slug, validMode)
+			// Verify global state was updated
+			expect(mockContext.globalState.update).toHaveBeenCalledWith(
+				"customModes",
+				expect.arrayContaining([
+					expect.objectContaining({
+						slug: "mode1",
+						name: "Mode 1",
+						source: "global",
+					}),
+					expect.objectContaining({
+						slug: "mode2",
+						name: "Mode 2",
+						source: "global",
+					}),
+				]),
+			)
 
-			// Should show error message
-			expect(mockShowError).toHaveBeenCalledWith(expect.stringContaining("Write error"))
+			// Should trigger onUpdate
+			expect(mockOnUpdate).toHaveBeenCalled()
 		})
 	})
 
 	describe("File Operations", () => {
-		test("creates settings directory if it doesn't exist", async () => {
+		it("creates settings directory if it doesn't exist", async () => {
 			const configPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
 			await manager.getCustomModesFilePath()
 
 			expect(fs.mkdir).toHaveBeenCalledWith(path.dirname(configPath), { recursive: true })
 		})
 
-		test("creates default config if file doesn't exist", async () => {
+		it("creates default config if file doesn't exist", async () => {
 			const configPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
+
+			// Mock fileExists to return false first time, then true
+			let firstCall = true
+			;(fileExistsAtPath as jest.Mock).mockImplementation(async () => {
+				if (firstCall) {
+					firstCall = false
+					return false
+				}
+				return true
+			})
+
 			await manager.getCustomModesFilePath()
 
-			expect(fs.writeFile).toHaveBeenCalledWith(configPath, JSON.stringify({ customModes: [] }, null, 2))
+			expect(fs.writeFile).toHaveBeenCalledWith(
+				configPath,
+				expect.stringMatching(/^\{\s+"customModes":\s+\[\s*\]\s*\}$/),
+			)
 		})
 
-		test("watches file for changes", async () => {
-			// Mock file path resolution
+		it("watches file for changes", async () => {
 			const configPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
 			;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
 
-			// Create manager and wait for initialization
-			const manager = new CustomModesManager(mockContext, mockOnUpdate)
-			await manager.getCustomModesFilePath() // This ensures watchCustomModesFile has completed
-
 			// Get the registered callback
 			const registerCall = (vscode.workspace.onDidSaveTextDocument as jest.Mock).mock.calls[0]
 			expect(registerCall).toBeDefined()
@@ -144,102 +317,93 @@ describe("CustomModesManager", () => {
 			expect(fs.readFile).toHaveBeenCalledWith(configPath, "utf-8")
 			expect(mockContext.globalState.update).toHaveBeenCalled()
 			expect(mockOnUpdate).toHaveBeenCalled()
-
-			// Verify file content was processed
-			expect(fs.readFile).toHaveBeenCalled()
 		})
 	})
 
-	describe("Mode Operations", () => {
-		const validMode = {
-			slug: "123e4567-e89b-12d3-a456-426614174000",
-			name: "Test Mode",
-			roleDefinition: "Test role definition",
-			groups: ["read"] as const,
-		} satisfies ModeConfig
-
-		beforeEach(() => {
-			// Mock fs.readFile to return empty config
-			;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
-		})
+	describe("deleteCustomMode", () => {
+		it("deletes mode from settings file", async () => {
+			const existingMode = {
+				slug: "mode-to-delete",
+				name: "Mode To Delete",
+				roleDefinition: "Test role",
+				groups: ["read"],
+				source: "global",
+			}
 
-		test("adds new custom mode", async () => {
-			await manager.updateCustomMode(validMode.slug, validMode)
+			let settingsContent = { customModes: [existingMode] }
+			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
+				if (path === mockSettingsPath) {
+					return JSON.stringify(settingsContent)
+				}
+				throw new Error("File not found")
+			})
+			;(fs.writeFile as jest.Mock).mockImplementation(
+				async (path: string, content: string, encoding?: string) => {
+					if (path === mockSettingsPath && encoding === "utf-8") {
+						settingsContent = JSON.parse(content)
+					}
+					return Promise.resolve()
+				},
+			)
 
-			expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), expect.stringContaining(validMode.name))
-			expect(mockOnUpdate).toHaveBeenCalled()
-		})
+			// Mock the global state update to actually update the settingsContent
+			;(mockContext.globalState.update as jest.Mock).mockImplementation((key: string, value: any) => {
+				if (key === "customModes") {
+					settingsContent.customModes = value
+				}
+				return Promise.resolve()
+			})
 
-		test("updates existing custom mode", async () => {
-			// Mock existing mode
-			;(fs.readFile as jest.Mock).mockResolvedValue(
-				JSON.stringify({
-					customModes: [validMode],
-				}),
-			)
+			await manager.deleteCustomMode("mode-to-delete")
 
-			const updatedMode = {
-				...validMode,
-				name: "Updated Name",
-			}
+			// Verify mode was removed from settings file
+			expect(settingsContent.customModes).toHaveLength(0)
 
-			await manager.updateCustomMode(validMode.slug, updatedMode)
+			// Verify global state was updated
+			expect(mockContext.globalState.update).toHaveBeenCalledWith("customModes", [])
 
-			expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), expect.stringContaining("Updated Name"))
+			// Should trigger onUpdate
 			expect(mockOnUpdate).toHaveBeenCalled()
 		})
 
-		test("deletes custom mode", async () => {
-			// Mock existing mode
-			;(fs.readFile as jest.Mock).mockResolvedValue(
-				JSON.stringify({
-					customModes: [validMode],
-				}),
-			)
+		it("handles errors gracefully", async () => {
+			const mockShowError = jest.fn()
+			;(vscode.window.showErrorMessage as jest.Mock) = mockShowError
+			;(fs.writeFile as jest.Mock).mockRejectedValue(new Error("Write error"))
 
-			await manager.deleteCustomMode(validMode.slug)
+			await manager.deleteCustomMode("non-existent-mode")
 
-			expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), expect.not.stringContaining(validMode.name))
-			expect(mockOnUpdate).toHaveBeenCalled()
+			expect(mockShowError).toHaveBeenCalledWith(expect.stringContaining("Write error"))
 		})
+	})
 
-		test("queues write operations", async () => {
-			const mode1 = {
-				...validMode,
-				name: "Mode 1",
-			}
-			const mode2 = {
-				...validMode,
-				slug: "mode-2",
-				name: "Mode 2",
+	describe("updateModesInFile", () => {
+		it("handles corrupted JSON content gracefully", async () => {
+			const corruptedJson = "{ invalid json content"
+			;(fs.readFile as jest.Mock).mockResolvedValue(corruptedJson)
+
+			const newMode: ModeConfig = {
+				slug: "test-mode",
+				name: "Test Mode",
+				roleDefinition: "Test Role",
+				groups: ["read"],
+				source: "global",
 			}
 
-			// Mock initial empty state and track writes
-			let currentModes: ModeConfig[] = []
-			;(fs.readFile as jest.Mock).mockImplementation(() => JSON.stringify({ customModes: currentModes }))
-			;(fs.writeFile as jest.Mock).mockImplementation(async (path, content) => {
-				const data = JSON.parse(content)
-				currentModes = data.customModes
-				return Promise.resolve()
+			await manager.updateCustomMode("test-mode", newMode)
+
+			// Verify that a valid JSON structure was written
+			const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
+			const writtenContent = JSON.parse(writeCall[1])
+			expect(writtenContent).toEqual({
+				customModes: [
+					expect.objectContaining({
+						slug: "test-mode",
+						name: "Test Mode",
+						roleDefinition: "Test Role",
+					}),
+				],
 			})
-
-			// Start both updates simultaneously
-			await Promise.all([
-				manager.updateCustomMode(mode1.slug, mode1),
-				manager.updateCustomMode(mode2.slug, mode2),
-			])
-
-			// Verify final state
-			expect(currentModes).toHaveLength(2)
-			expect(currentModes.map((m) => m.name)).toContain("Mode 1")
-			expect(currentModes.map((m) => m.name)).toContain("Mode 2")
-
-			// Verify write was called with both modes
-			const lastWriteCall = (fs.writeFile as jest.Mock).mock.calls.pop()
-			const finalContent = JSON.parse(lastWriteCall[1])
-			expect(finalContent.customModes).toHaveLength(2)
-			expect(finalContent.customModes.map((m: ModeConfig) => m.name)).toContain("Mode 1")
-			expect(finalContent.customModes.map((m: ModeConfig) => m.name)).toContain("Mode 2")
 		})
 	})
 })

+ 0 - 22
src/core/config/__tests__/CustomModesSchema.test.ts

@@ -95,17 +95,6 @@ describe("CustomModeSchema", () => {
 			expect(() => validateCustomMode(invalidGroupMode)).toThrow(ZodError)
 		})
 
-		test("rejects empty groups array", () => {
-			const invalidMode = {
-				slug: "123e4567-e89b-12d3-a456-426614174000",
-				name: "Test Mode",
-				roleDefinition: "Test role definition",
-				groups: [] as const,
-			} satisfies ModeConfig
-
-			expect(() => validateCustomMode(invalidMode)).toThrow("At least one tool group is required")
-		})
-
 		test("handles null and undefined gracefully", () => {
 			expect(() => validateCustomMode(null)).toThrow(ZodError)
 			expect(() => validateCustomMode(undefined)).toThrow(ZodError)
@@ -179,16 +168,5 @@ describe("CustomModeSchema", () => {
 
 			expect(() => CustomModeSchema.parse(modeWithDuplicates)).toThrow(/Duplicate groups/)
 		})
-
-		it("requires at least one group", () => {
-			const modeWithNoGroups = {
-				slug: "test",
-				name: "Test",
-				roleDefinition: "Test",
-				groups: [],
-			}
-
-			expect(() => CustomModeSchema.parse(modeWithNoGroups)).toThrow(/At least one tool group is required/)
-		})
 	})
 })

+ 0 - 9
src/core/config/__tests__/GroupConfigSchema.test.ts

@@ -45,15 +45,6 @@ describe("GroupConfigSchema", () => {
 			expect(() => CustomModeSchema.parse(mode)).toThrow()
 		})
 
-		test("rejects empty groups array", () => {
-			const mode = {
-				...validBaseMode,
-				groups: [] as const,
-			} satisfies ModeConfig
-
-			expect(() => CustomModeSchema.parse(mode)).toThrow("At least one tool group is required")
-		})
-
 		test("rejects invalid group names", () => {
 			const mode = {
 				...validBaseMode,

+ 1 - 38
src/core/prompts/__tests__/__snapshots__/system.test.ts.snap

@@ -4004,43 +4004,6 @@ Example: Requesting to list all top level source code definitions in the current
 <path>.</path>
 </list_code_definition_names>
 
-## write_to_file
-Description: Request to write full content to a file at the specified path. If the file exists, it will be overwritten with the provided content. If the file doesn't exist, it will be created. This tool will automatically create any directories needed to write the file.
-Parameters:
-- path: (required) The path of the file to write to (relative to the current working directory /test/path)
-- content: (required) The content to write to the file. ALWAYS provide the COMPLETE intended content of the file, without any truncation or omissions. You MUST include ALL parts of the file, even if they haven't been modified. Do NOT include the line numbers in the content though, just the actual content of the file.
-- line_count: (required) The number of lines in the file. Make sure to compute this based on the actual content of the file, not the number of lines in the content you're providing.
-Usage:
-<write_to_file>
-<path>File path here</path>
-<content>
-Your file content here
-</content>
-<line_count>total number of lines in the file, including empty lines</line_count>
-</write_to_file>
-
-Example: Requesting to write to frontend-config.json
-<write_to_file>
-<path>frontend-config.json</path>
-<content>
-{
-  "apiEndpoint": "https://api.example.com",
-  "theme": {
-    "primaryColor": "#007bff",
-    "secondaryColor": "#6c757d",
-    "fontFamily": "Arial, sans-serif"
-  },
-  "features": {
-    "darkMode": true,
-    "notifications": true,
-    "analytics": false
-  },
-  "version": "1.0.0"
-}
-</content>
-<line_count>14</line_count>
-</write_to_file>
-
 ## ask_followup_question
 Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
 Parameters:
@@ -4213,7 +4176,7 @@ USER'S CUSTOM INSTRUCTIONS
 The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines.
 
 Mode-specific Instructions:
-You can analyze code, explain concepts, and access external resources. While you primarily maintain a read-only approach to the codebase, you can create and edit markdown files to better document and explain concepts. Make sure to answer the user's questions and don't rush to switch to implementing code.
+You can analyze code, explain concepts, and access external resources. Make sure to answer the user's questions and don't rush to switch to implementing code.
 
 Rules:
 # Rules from .clinerules-ask:

+ 10 - 4
src/core/prompts/sections/modes.ts

@@ -16,7 +16,13 @@ MODES
 ${modes.map((mode: ModeConfig) => `  * "${mode.name}" mode - ${mode.roleDefinition.split(".")[0]}`).join("\n")}
   Custom modes will be referred to by their configured name property.
 
-- Custom modes can be configured by editing the custom modes file at '${customModesPath}'. The file gets created automatically on startup and should always exist. Make sure to read the latest contents before writing to it to avoid overwriting existing modes.
+- Custom modes can be configured in two ways:
+  1. Globally via '${customModesPath}' (created automatically on startup)
+  2. Per-workspace via '.roomodes' in the workspace root directory
+
+  When modes with the same slug exist in both files, the workspace-specific .roomodes version takes precedence. This allows projects to override global modes or define project-specific modes.
+
+  If asked to create a project mode, create it in .roomodes in the workspace root. If asked to create a global mode, use the global custom modes file.
 
 - The following fields are required and must not be empty:
   * slug: A valid slug (lowercase letters, numbers, and hyphens). Must be unique, and shorter is better.
@@ -26,15 +32,15 @@ ${modes.map((mode: ModeConfig) => `  * "${mode.name}" mode - ${mode.roleDefiniti
 
 - The customInstructions field is optional.
 
-- For multi-line text, include newline characters in the string like "This is the first line.\nThis is the next line.\n\nThis is a double line break."
+- For multi-line text, include newline characters in the string like "This is the first line.\\nThis is the next line.\\n\\nThis is a double line break."
 
-The file should follow this structure:
+Both files should follow this structure:
 {
  "customModes": [
    {
      "slug": "designer", // Required: unique slug with lowercase letters, numbers, and hyphens
      "name": "Designer", // Required: mode display name
-     "roleDefinition": "You are Roo, a UI/UX expert specializing in design systems and frontend development. Your expertise includes:\n- Creating and maintaining design systems\n- Implementing responsive and accessible web interfaces\n- Working with CSS, HTML, and modern frontend frameworks\n- Ensuring consistent user experiences across platforms", // Required: non-empty
+     "roleDefinition": "You are Roo, a UI/UX expert specializing in design systems and frontend development. Your expertise includes:\\n- Creating and maintaining design systems\\n- Implementing responsive and accessible web interfaces\\n- Working with CSS, HTML, and modern frontend frameworks\\n- Ensuring consistent user experiences across platforms", // Required: non-empty
      "groups": [ // Required: array of tool groups (can be empty)
        "read",    // Read files group (read_file, search_files, list_files, list_code_definition_names)
        "edit",    // Edit files group (write_to_file, apply_diff) - allows editing any file

+ 41 - 7
src/core/webview/ClineProvider.ts

@@ -128,6 +128,7 @@ type GlobalStateKey =
 	| "unboundModelInfo"
 	| "modelTemperature"
 	| "mistralCodestralUrl"
+	| "maxOpenTabsContext"
 
 export const GlobalFileNames = {
 	apiConversationHistory: "api_conversation_history.json",
@@ -1208,6 +1209,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 						await this.updateGlobalState("screenshotQuality", message.value)
 						await this.postStateToWebview()
 						break
+					case "maxOpenTabsContext":
+						const tabCount = Math.min(Math.max(0, message.value ?? 20), 500)
+						await this.updateGlobalState("maxOpenTabsContext", tabCount)
+						await this.postStateToWebview()
+						break
 					case "enhancementApiConfigId":
 						await this.updateGlobalState("enhancementApiConfigId", message.text)
 						await this.postStateToWebview()
@@ -2280,35 +2286,55 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 
 		await this.deleteTaskFromState(id)
 
-		// Delete the task files
+		// Delete the task files.
 		const apiConversationHistoryFileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
+
 		if (apiConversationHistoryFileExists) {
 			await fs.unlink(apiConversationHistoryFilePath)
 		}
+
 		const uiMessagesFileExists = await fileExistsAtPath(uiMessagesFilePath)
+
 		if (uiMessagesFileExists) {
 			await fs.unlink(uiMessagesFilePath)
 		}
+
 		const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
+
 		if (await fileExistsAtPath(legacyMessagesFilePath)) {
 			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}`
 
+		// Delete checkpoints branch.
 		if (checkpointsEnabled && baseDir) {
+			const branchSummary = await simpleGit(baseDir)
+				.branch(["-D", `roo-code-checkpoints-${id}`])
+				.catch(() => undefined)
+
+			if (branchSummary) {
+				console.log(`[deleteTaskWithId${id}] deleted checkpoints branch`)
+			}
+		}
+
+		// Delete checkpoints directory
+		const checkpointsDir = path.join(taskDirPath, "checkpoints")
+
+		if (await fileExistsAtPath(checkpointsDir)) {
 			try {
-				await simpleGit(baseDir).branch(["-D", branch])
-				console.log(`[deleteTaskWithId] Deleted branch ${branch}`)
-			} catch (err) {
+				await fs.rm(checkpointsDir, { recursive: true, force: true })
+				console.log(`[deleteTaskWithId${id}] removed checkpoints repo`)
+			} catch (error) {
 				console.error(
-					`[deleteTaskWithId] Error deleting branch ${branch}: ${err instanceof Error ? err.message : String(err)}`,
+					`[deleteTaskWithId${id}] failed to remove checkpoints repo: ${error instanceof Error ? error.message : String(error)}`,
 				)
 			}
 		}
+
+		// Succeeds if the dir is empty.
+		await fs.rmdir(taskDirPath)
 	}
 
 	async deleteTaskFromState(id: string) {
@@ -2361,6 +2387,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			enhancementApiConfigId,
 			autoApprovalEnabled,
 			experiments,
+			maxOpenTabsContext,
 		} = await this.getState()
 
 		const allowedCommands = vscode.workspace.getConfiguration("roo-cline").get<string[]>("allowedCommands") || []
@@ -2376,6 +2403,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			alwaysAllowMcp: alwaysAllowMcp ?? false,
 			alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false,
 			uriScheme: vscode.env.uriScheme,
+			currentTaskItem: this.cline?.taskId
+				? (taskHistory || []).find((item) => item.id === this.cline?.taskId)
+				: undefined,
 			clineMessages: this.cline?.clineMessages || [],
 			taskHistory: (taskHistory || [])
 				.filter((item: HistoryItem) => item.ts && item.task)
@@ -2407,6 +2437,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			customModes: await this.customModesManager.getCustomModes(),
 			experiments: experiments ?? experimentDefault,
 			mcpServers: this.mcpHub?.getAllServers() ?? [],
+			maxOpenTabsContext: maxOpenTabsContext ?? 20,
 		}
 	}
 
@@ -2543,6 +2574,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			requestyModelId,
 			requestyModelInfo,
 			modelTemperature,
+			maxOpenTabsContext,
 		] = await Promise.all([
 			this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
 			this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -2624,6 +2656,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			this.getGlobalState("requestyModelId") as Promise<string | undefined>,
 			this.getGlobalState("requestyModelInfo") as Promise<ModelInfo | undefined>,
 			this.getGlobalState("modelTemperature") as Promise<number | undefined>,
+			this.getGlobalState("maxOpenTabsContext") as Promise<number | undefined>,
 		])
 
 		let apiProvider: ApiProvider
@@ -2751,6 +2784,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			experiments: experiments ?? experimentDefault,
 			autoApprovalEnabled: autoApprovalEnabled ?? false,
 			customModes,
+			maxOpenTabsContext: maxOpenTabsContext ?? 20,
 		}
 	}
 

+ 26 - 21
src/core/webview/__tests__/ClineProvider.test.ts

@@ -246,7 +246,7 @@ describe("ClineProvider", () => {
 		// Mock CustomModesManager
 		const mockCustomModesManager = {
 			updateCustomMode: jest.fn().mockResolvedValue(undefined),
-			getCustomModes: jest.fn().mockResolvedValue({}),
+			getCustomModes: jest.fn().mockResolvedValue({ customModes: [] }),
 			dispose: jest.fn(),
 		}
 
@@ -351,6 +351,7 @@ describe("ClineProvider", () => {
 			mode: defaultModeSlug,
 			customModes: [],
 			experiments: experimentDefault,
+			maxOpenTabsContext: 20,
 		}
 
 		const message: ExtensionMessage = {
@@ -1048,7 +1049,7 @@ describe("ClineProvider", () => {
 				"900x600", // browserViewportSize
 				"code", // mode
 				{}, // customModePrompts
-				{}, // customModes
+				{ customModes: [] }, // customModes
 				undefined, // effectiveInstructions
 				undefined, // preferredLanguage
 				true, // diffEnabled
@@ -1101,7 +1102,7 @@ describe("ClineProvider", () => {
 				"900x600", // browserViewportSize
 				"code", // mode
 				{}, // customModePrompts
-				{}, // customModes
+				{ customModes: [] }, // customModes
 				undefined, // effectiveInstructions
 				undefined, // preferredLanguage
 				false, // diffEnabled
@@ -1219,12 +1220,14 @@ describe("ClineProvider", () => {
 			provider.customModesManager = {
 				updateCustomMode: jest.fn().mockResolvedValue(undefined),
 				getCustomModes: jest.fn().mockResolvedValue({
-					"test-mode": {
-						slug: "test-mode",
-						name: "Test Mode",
-						roleDefinition: "Updated role definition",
-						groups: ["read"] as const,
-					},
+					customModes: [
+						{
+							slug: "test-mode",
+							name: "Test Mode",
+							roleDefinition: "Updated role definition",
+							groups: ["read"] as const,
+						},
+					],
 				}),
 				dispose: jest.fn(),
 			} as any
@@ -1250,27 +1253,29 @@ describe("ClineProvider", () => {
 			)
 
 			// Verify state was updated
-			expect(mockContext.globalState.update).toHaveBeenCalledWith(
-				"customModes",
-				expect.objectContaining({
-					"test-mode": expect.objectContaining({
+			expect(mockContext.globalState.update).toHaveBeenCalledWith("customModes", {
+				customModes: [
+					expect.objectContaining({
 						slug: "test-mode",
 						roleDefinition: "Updated role definition",
 					}),
-				}),
-			)
+				],
+			})
 
 			// Verify state was posted to webview
+			// Verify state was posted to webview with correct format
 			expect(mockPostMessage).toHaveBeenCalledWith(
 				expect.objectContaining({
 					type: "state",
 					state: expect.objectContaining({
-						customModes: expect.objectContaining({
-							"test-mode": expect.objectContaining({
-								slug: "test-mode",
-								roleDefinition: "Updated role definition",
-							}),
-						}),
+						customModes: {
+							customModes: [
+								expect.objectContaining({
+									slug: "test-mode",
+									roleDefinition: "Updated role definition",
+								}),
+							],
+						},
 					}),
 				}),
 			)

+ 29 - 0
src/services/checkpoints/CheckpointServiceFactory.ts

@@ -0,0 +1,29 @@
+import { LocalCheckpointService, LocalCheckpointServiceOptions } from "./LocalCheckpointService"
+import { ShadowCheckpointService, ShadowCheckpointServiceOptions } from "./ShadowCheckpointService"
+
+export type CreateCheckpointServiceFactoryOptions =
+	| {
+			strategy: "local"
+			options: LocalCheckpointServiceOptions
+	  }
+	| {
+			strategy: "shadow"
+			options: ShadowCheckpointServiceOptions
+	  }
+
+type CheckpointServiceType<T extends CreateCheckpointServiceFactoryOptions> = T extends { strategy: "local" }
+	? LocalCheckpointService
+	: T extends { strategy: "shadow" }
+		? ShadowCheckpointService
+		: never
+
+export class CheckpointServiceFactory {
+	public static create<T extends CreateCheckpointServiceFactoryOptions>(options: T): CheckpointServiceType<T> {
+		switch (options.strategy) {
+			case "local":
+				return LocalCheckpointService.create(options.options) as any
+			case "shadow":
+				return ShadowCheckpointService.create(options.options) as any
+		}
+	}
+}

+ 42 - 62
src/services/checkpoints/CheckpointService.ts → src/services/checkpoints/LocalCheckpointService.ts

@@ -4,12 +4,9 @@ import path from "path"
 
 import simpleGit, { SimpleGit, CleanOptions } from "simple-git"
 
-export type CheckpointServiceOptions = {
-	taskId: string
-	git?: SimpleGit
-	baseDir: string
-	log?: (message: string) => void
-}
+import { CheckpointStrategy, CheckpointService, CheckpointServiceOptions } from "./types"
+
+export interface LocalCheckpointServiceOptions extends CheckpointServiceOptions {}
 
 /**
  * The CheckpointService provides a mechanism for storing a snapshot of the
@@ -49,29 +46,26 @@ export type CheckpointServiceOptions = {
  *    and it's not clear whether it's worth it.
  */
 
-export class CheckpointService {
+export class LocalCheckpointService implements CheckpointService {
 	private static readonly USER_NAME = "Roo Code"
 	private static readonly USER_EMAIL = "[email protected]"
 	private static readonly CHECKPOINT_BRANCH = "roo-code-checkpoints"
 	private static readonly STASH_BRANCH = "roo-code-stash"
 
-	private _currentCheckpoint?: string
-
-	public get currentCheckpoint() {
-		return this._currentCheckpoint
-	}
+	public readonly strategy: CheckpointStrategy = "local"
+	public readonly version = 1
 
-	private set currentCheckpoint(value: string | undefined) {
-		this._currentCheckpoint = value
+	public get baseHash() {
+		return this._baseHash
 	}
 
 	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,
+		public readonly git: SimpleGit,
+		public readonly workspaceDir: string,
+		private readonly mainBranch: string,
+		private _baseHash: string,
+		private readonly hiddenBranch: string,
 		private readonly log: (message: string) => void,
 	) {}
 
@@ -83,40 +77,27 @@ export class CheckpointService {
 		}
 	}
 
-	public async getDiff({ from, to }: { from?: string; to: string }) {
+	public async getDiff({ from, to }: { from?: string; to?: string }) {
 		const result = []
 
 		if (!from) {
-			from = this.baseCommitHash
+			from = this.baseHash
 		}
 
 		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)
+			const absPath = path.join(this.workspaceDir, relPath)
+			const before = await this.git.show([`${from}:${relPath}`]).catch(() => "")
 
-			// 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.
-			}
+			const after = to
+				? await this.git.show([`${to}:${relPath}`]).catch(() => "")
+				: await fs.readFile(absPath, "utf8").catch(() => "")
 
 			result.push({
 				paths: { relative: relPath, absolute: absPath },
-				content: { before: beforeContent, after: afterContent },
+				content: { before, after },
 			})
 		}
 
@@ -201,7 +182,7 @@ export class CheckpointService {
 		 *   - Create branch
 		 *   - Change branch
 		 */
-		const stashBranch = `${CheckpointService.STASH_BRANCH}-${Date.now()}`
+		const stashBranch = `${LocalCheckpointService.STASH_BRANCH}-${Date.now()}`
 		await this.git.checkout(["-b", stashBranch])
 		this.log(`[saveCheckpoint] created and checked out ${stashBranch}`)
 
@@ -322,7 +303,7 @@ export class CheckpointService {
 				// If the cherry-pick resulted in an empty commit (e.g., only
 				// deletions) then complete it with --allow-empty.
 				// Otherwise, rethrow the error.
-				if (existsSync(path.join(this.baseDir, ".git/CHERRY_PICK_HEAD"))) {
+				if (existsSync(path.join(this.workspaceDir, ".git/CHERRY_PICK_HEAD"))) {
 					await this.git.raw(["commit", "--allow-empty", "--no-edit"])
 				} else {
 					throw err
@@ -330,7 +311,6 @@ export class CheckpointService {
 			}
 
 			commit = await this.git.revparse(["HEAD"])
-			this.currentCheckpoint = commit
 			this.log(`[saveCheckpoint] cherry-pick commit = ${commit}`)
 		} catch (err) {
 			this.log(
@@ -360,42 +340,42 @@ export class CheckpointService {
 		await this.git.raw(["restore", "--source", commitHash, "--worktree", "--", "."])
 		const duration = Date.now() - startTime
 		this.log(`[restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
-		this.currentCheckpoint = commitHash
 	}
 
-	public static async create({ taskId, git, baseDir, log = console.log }: CheckpointServiceOptions) {
-		git = git || simpleGit({ baseDir })
-
+	public static async create({ taskId, workspaceDir, log = console.log }: LocalCheckpointServiceOptions) {
+		const git = simpleGit(workspaceDir)
 		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)) {
+		if (!workspaceDir || !existsSync(workspaceDir)) {
 			throw new Error(`Base directory is not set or does not exist.`)
 		}
 
-		const { currentBranch, currentSha, hiddenBranch } = await CheckpointService.initRepo({
+		const { currentBranch, currentSha, hiddenBranch } = await LocalCheckpointService.initRepo(git, {
 			taskId,
-			git,
-			baseDir,
+			workspaceDir,
 			log,
 		})
 
 		log(
-			`[CheckpointService] taskId = ${taskId}, baseDir = ${baseDir}, currentBranch = ${currentBranch}, currentSha = ${currentSha}, hiddenBranch = ${hiddenBranch}`,
+			`[create] taskId = ${taskId}, workspaceDir = ${workspaceDir}, currentBranch = ${currentBranch}, currentSha = ${currentSha}, hiddenBranch = ${hiddenBranch}`,
 		)
 
-		return new CheckpointService(taskId, git, baseDir, currentBranch, currentSha, hiddenBranch, log)
+		return new LocalCheckpointService(taskId, git, workspaceDir, currentBranch, currentSha, hiddenBranch, log)
 	}
 
-	private static async initRepo({ taskId, git, baseDir, log }: Required<CheckpointServiceOptions>) {
-		const isExistingRepo = existsSync(path.join(baseDir, ".git"))
+	private static async initRepo(
+		git: SimpleGit,
+		{ taskId, workspaceDir, log }: Required<LocalCheckpointServiceOptions>,
+	) {
+		const isExistingRepo = existsSync(path.join(workspaceDir, ".git"))
 
 		if (!isExistingRepo) {
 			await git.init()
-			log(`[initRepo] Initialized new Git repository at ${baseDir}`)
+			log(`[initRepo] Initialized new Git repository at ${workspaceDir}`)
 		}
 
 		const globalUserName = await git.getConfig("user.name", "global")
@@ -410,21 +390,21 @@ export class CheckpointService {
 		// 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) {
+		if (globalUserName.value && localUserName.value === LocalCheckpointService.USER_NAME) {
 			await git.raw(["config", "--unset", "--local", "user.name"])
 		}
 
-		if (globalUserEmail.value && localUserEmail.value === CheckpointService.USER_EMAIL) {
+		if (globalUserEmail.value && localUserEmail.value === LocalCheckpointService.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)
+			await git.addConfig("user.name", LocalCheckpointService.USER_NAME)
 		}
 
 		if (!userEmail) {
-			await git.addConfig("user.email", CheckpointService.USER_EMAIL)
+			await git.addConfig("user.email", LocalCheckpointService.USER_EMAIL)
 		}
 
 		if (!isExistingRepo) {
@@ -433,7 +413,7 @@ export class CheckpointService {
 			// 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 fs.writeFile(path.join(workspaceDir, ".gitkeep"), "")
 			await git.add(".gitkeep")
 			const commit = await git.commit("Initial commit")
 
@@ -447,7 +427,7 @@ export class CheckpointService {
 		const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
 		const currentSha = await git.revparse(["HEAD"])
 
-		const hiddenBranch = `${CheckpointService.CHECKPOINT_BRANCH}-${taskId}`
+		const hiddenBranch = `${LocalCheckpointService.CHECKPOINT_BRANCH}-${taskId}`
 		const branchSummary = await git.branch()
 
 		if (!branchSummary.all.includes(hiddenBranch)) {

+ 249 - 0
src/services/checkpoints/ShadowCheckpointService.ts

@@ -0,0 +1,249 @@
+import fs from "fs/promises"
+import os from "os"
+import * as path from "path"
+import { globby } from "globby"
+import simpleGit, { SimpleGit } from "simple-git"
+
+import { GIT_DISABLED_SUFFIX, GIT_EXCLUDES } from "./constants"
+import { CheckpointStrategy, CheckpointService, CheckpointServiceOptions } from "./types"
+
+export interface ShadowCheckpointServiceOptions extends CheckpointServiceOptions {
+	shadowDir: string
+}
+
+export class ShadowCheckpointService implements CheckpointService {
+	public readonly strategy: CheckpointStrategy = "shadow"
+	public readonly version = 1
+
+	private _baseHash?: string
+
+	public get baseHash() {
+		return this._baseHash
+	}
+
+	private set baseHash(value: string | undefined) {
+		this._baseHash = value
+	}
+
+	private readonly shadowGitDir: string
+	private shadowGitConfigWorktree?: string
+
+	private constructor(
+		public readonly taskId: string,
+		public readonly git: SimpleGit,
+		public readonly shadowDir: string,
+		public readonly workspaceDir: string,
+		private readonly log: (message: string) => void,
+	) {
+		this.shadowGitDir = path.join(this.shadowDir, "tasks", this.taskId, "checkpoints", ".git")
+	}
+
+	private async initShadowGit() {
+		const fileExistsAtPath = (path: string) =>
+			fs
+				.access(path)
+				.then(() => true)
+				.catch(() => false)
+
+		if (await fileExistsAtPath(this.shadowGitDir)) {
+			this.log(`[initShadowGit] shadow git repo already exists at ${this.shadowGitDir}`)
+			const worktree = await this.getShadowGitConfigWorktree()
+
+			if (worktree !== this.workspaceDir) {
+				throw new Error(
+					`Checkpoints can only be used in the original workspace: ${worktree} !== ${this.workspaceDir}`,
+				)
+			}
+
+			this.baseHash = await this.git.revparse(["--abbrev-ref", "HEAD"])
+		} else {
+			this.log(`[initShadowGit] creating shadow git repo at ${this.workspaceDir}`)
+
+			await this.git.init()
+			await this.git.addConfig("core.worktree", this.workspaceDir) // Sets the working tree to the current workspace.
+			await this.git.addConfig("commit.gpgSign", "false") // Disable commit signing for shadow repo.
+			await this.git.addConfig("user.name", "Roo Code")
+			await this.git.addConfig("user.email", "[email protected]")
+
+			let lfsPatterns: string[] = [] // Get LFS patterns from workspace if they exist.
+
+			try {
+				const attributesPath = path.join(this.workspaceDir, ".gitattributes")
+
+				if (await fileExistsAtPath(attributesPath)) {
+					lfsPatterns = (await fs.readFile(attributesPath, "utf8"))
+						.split("\n")
+						.filter((line) => line.includes("filter=lfs"))
+						.map((line) => line.split(" ")[0].trim())
+				}
+			} catch (error) {
+				this.log(
+					`[initShadowGit] failed to read .gitattributes: ${error instanceof Error ? error.message : String(error)}`,
+				)
+			}
+
+			// Add basic excludes directly in git config, while respecting any
+			// .gitignore in the workspace.
+			// .git/info/exclude is local to the shadow git repo, so it's not
+			// shared with the main repo - and won't conflict with user's
+			// .gitignore.
+			await fs.mkdir(path.join(this.shadowGitDir, "info"), { recursive: true })
+			const excludesPath = path.join(this.shadowGitDir, "info", "exclude")
+			await fs.writeFile(excludesPath, [...GIT_EXCLUDES, ...lfsPatterns].join("\n"))
+			await this.stageAll()
+			const { commit } = await this.git.commit("initial commit", { "--allow-empty": null })
+			this.baseHash = commit
+			this.log(`[initShadowGit] base commit is ${commit}`)
+		}
+	}
+
+	private async stageAll() {
+		await this.renameNestedGitRepos(true)
+
+		try {
+			await this.git.add(".")
+		} catch (error) {
+			this.log(`[stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`)
+		} finally {
+			await this.renameNestedGitRepos(false)
+		}
+	}
+
+	// Since we use git to track checkpoints, we need to temporarily disable
+	// nested git repos to work around git's requirement of using submodules for
+	// nested repos.
+	private async renameNestedGitRepos(disable: boolean) {
+		// Find all .git directories that are not at the root level.
+		const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
+			cwd: this.workspaceDir,
+			onlyDirectories: true,
+			ignore: [".git"], // Ignore root level .git.
+			dot: true,
+			markDirectories: false,
+		})
+
+		// For each nested .git directory, rename it based on operation.
+		for (const gitPath of gitPaths) {
+			const fullPath = path.join(this.workspaceDir, gitPath)
+			let newPath: string
+
+			if (disable) {
+				newPath = fullPath + GIT_DISABLED_SUFFIX
+			} else {
+				newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX)
+					? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length)
+					: fullPath
+			}
+
+			try {
+				await fs.rename(fullPath, newPath)
+				this.log(`${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
+			} catch (error) {
+				this.log(
+					`failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}: ${error instanceof Error ? error.message : String(error)}`,
+				)
+			}
+		}
+	}
+
+	public async getShadowGitConfigWorktree() {
+		if (!this.shadowGitConfigWorktree) {
+			try {
+				this.shadowGitConfigWorktree = (await this.git.getConfig("core.worktree")).value || undefined
+			} catch (error) {
+				this.log(
+					`[getShadowGitConfigWorktree] failed to get core.worktree: ${error instanceof Error ? error.message : String(error)}`,
+				)
+			}
+		}
+
+		return this.shadowGitConfigWorktree
+	}
+
+	public async saveCheckpoint(message: string) {
+		try {
+			const startTime = Date.now()
+			await this.stageAll()
+			const result = await this.git.commit(message)
+
+			if (result.commit) {
+				const duration = Date.now() - startTime
+				this.log(`[saveCheckpoint] saved checkpoint ${result.commit} in ${duration}ms`)
+				return result
+			} else {
+				return undefined
+			}
+		} catch (error) {
+			this.log(
+				`[saveCheckpoint] failed to create checkpoint: ${error instanceof Error ? error.message : String(error)}`,
+			)
+
+			throw error
+		}
+	}
+
+	public async restoreCheckpoint(commitHash: string) {
+		const start = Date.now()
+		await this.git.clean("f", ["-d", "-f"])
+		await this.git.reset(["--hard", commitHash])
+		const duration = Date.now() - start
+		this.log(`[restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
+	}
+
+	public async getDiff({ from, to }: { from?: string; to?: string }) {
+		const result = []
+
+		if (!from) {
+			from = (await this.git.raw(["rev-list", "--max-parents=0", "HEAD"])).trim()
+		}
+
+		// Stage all changes so that untracked files appear in diff summary.
+		await this.stageAll()
+
+		const { files } = to ? await this.git.diffSummary([`${from}..${to}`]) : await this.git.diffSummary([from])
+
+		const cwdPath = (await this.getShadowGitConfigWorktree()) || this.workspaceDir || ""
+
+		for (const file of files) {
+			const relPath = file.file
+			const absPath = path.join(cwdPath, relPath)
+			const before = await this.git.show([`${from}:${relPath}`]).catch(() => "")
+
+			const after = to
+				? await this.git.show([`${to}:${relPath}`]).catch(() => "")
+				: await fs.readFile(absPath, "utf8").catch(() => "")
+
+			result.push({ paths: { relative: relPath, absolute: absPath }, content: { before, after } })
+		}
+
+		return result
+	}
+
+	public static async create({ taskId, shadowDir, workspaceDir, log = console.log }: ShadowCheckpointServiceOptions) {
+		try {
+			await simpleGit().version()
+		} catch (error) {
+			throw new Error("Git must be installed to use checkpoints.")
+		}
+
+		const homedir = os.homedir()
+		const desktopPath = path.join(homedir, "Desktop")
+		const documentsPath = path.join(homedir, "Documents")
+		const downloadsPath = path.join(homedir, "Downloads")
+		const protectedPaths = [homedir, desktopPath, documentsPath, downloadsPath]
+
+		if (protectedPaths.includes(workspaceDir)) {
+			throw new Error(`Cannot use checkpoints in ${workspaceDir}`)
+		}
+
+		const checkpointsDir = path.join(shadowDir, "tasks", taskId, "checkpoints")
+		await fs.mkdir(checkpointsDir, { recursive: true })
+		const gitDir = path.join(checkpointsDir, ".git")
+		const git = simpleGit(path.dirname(gitDir))
+
+		log(`[create] taskId = ${taskId}, workspaceDir = ${workspaceDir}, shadowDir = ${shadowDir}`)
+		const service = new ShadowCheckpointService(taskId, git, shadowDir, workspaceDir, log)
+		await service.initShadowGit()
+		return service
+	}
+}

+ 65 - 101
src/services/checkpoints/__tests__/CheckpointService.test.ts → src/services/checkpoints/__tests__/LocalCheckpointService.test.ts

@@ -1,64 +1,66 @@
-// npx jest src/services/checkpoints/__tests__/CheckpointService.test.ts
+// npx jest src/services/checkpoints/__tests__/LocalCheckpointService.test.ts
 
 import fs from "fs/promises"
 import path from "path"
 import os from "os"
 
-import { simpleGit, SimpleGit, SimpleGitTaskCallback } from "simple-git"
+import { simpleGit, SimpleGit } from "simple-git"
 
-import { CheckpointService } from "../CheckpointService"
+import { CheckpointServiceFactory } from "../CheckpointServiceFactory"
+import { LocalCheckpointService } from "../LocalCheckpointService"
 
-describe("CheckpointService", () => {
+describe("LocalCheckpointService", () => {
 	const taskId = "test-task"
 
-	let git: SimpleGit
 	let testFile: string
-	let service: CheckpointService
+	let service: LocalCheckpointService
 
 	const initRepo = async ({
-		baseDir,
+		workspaceDir,
 		userName = "Roo Code",
 		userEmail = "[email protected]",
 		testFileName = "test.txt",
 		textFileContent = "Hello, world!",
 	}: {
-		baseDir: string
+		workspaceDir: string
 		userName?: string
 		userEmail?: string
 		testFileName?: string
 		textFileContent?: string
 	}) => {
 		// Create a temporary directory for testing.
-		await fs.mkdir(baseDir)
+		await fs.mkdir(workspaceDir)
 
 		// Initialize git repo.
-		const git = simpleGit(baseDir)
+		const git = simpleGit(workspaceDir)
 		await git.init()
 		await git.addConfig("user.name", userName)
 		await git.addConfig("user.email", userEmail)
 
 		// Create test file.
-		const testFile = path.join(baseDir, testFileName)
+		const testFile = path.join(workspaceDir, testFileName)
 		await fs.writeFile(testFile, textFileContent)
 
 		// Create initial commit.
 		await git.add(".")
 		await git.commit("Initial commit")!
 
-		return { git, testFile }
+		return { testFile }
 	}
 
 	beforeEach(async () => {
-		const baseDir = path.join(os.tmpdir(), `checkpoint-service-test-${Date.now()}`)
-		const repo = await initRepo({ baseDir })
+		const workspaceDir = path.join(os.tmpdir(), `checkpoint-service-test-${Date.now()}`)
+		const repo = await initRepo({ workspaceDir })
 
-		git = repo.git
 		testFile = repo.testFile
-		service = await CheckpointService.create({ taskId, git, baseDir, log: () => {} })
+		service = await CheckpointServiceFactory.create({
+			strategy: "local",
+			options: { taskId, workspaceDir, log: () => {} },
+		})
 	})
 
 	afterEach(async () => {
-		await fs.rm(service.baseDir, { recursive: true, force: true })
+		await fs.rm(service.workspaceDir, { recursive: true, force: true })
 		jest.restoreAllMocks()
 	})
 
@@ -95,7 +97,7 @@ describe("CheckpointService", () => {
 		})
 
 		it("handles new files in diff", async () => {
-			const newFile = path.join(service.baseDir, "new.txt")
+			const newFile = path.join(service.workspaceDir, "new.txt")
 			await fs.writeFile(newFile, "New file content")
 			const commit = await service.saveCheckpoint("Add new file")
 			expect(commit?.commit).toBeTruthy()
@@ -108,7 +110,7 @@ describe("CheckpointService", () => {
 		})
 
 		it("handles deleted files in diff", async () => {
-			const fileToDelete = path.join(service.baseDir, "new.txt")
+			const fileToDelete = path.join(service.workspaceDir, "new.txt")
 			await fs.writeFile(fileToDelete, "New file content")
 			const commit1 = await service.saveCheckpoint("Add file")
 			expect(commit1?.commit).toBeTruthy()
@@ -130,14 +132,14 @@ describe("CheckpointService", () => {
 			await fs.writeFile(testFile, "Ahoy, world!")
 			const commit1 = await service.saveCheckpoint("First checkpoint")
 			expect(commit1?.commit).toBeTruthy()
-			const details1 = await git.show([commit1!.commit])
+			const details1 = await service.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])
+			const details2 = await service.git.show([commit2!.commit])
 			expect(details2).toContain("-Hello, world!")
 			expect(details2).toContain("+Hola, world!")
 
@@ -150,30 +152,31 @@ describe("CheckpointService", () => {
 			expect(await fs.readFile(testFile, "utf-8")).toBe("Hola, world!")
 
 			// Switch back to initial commit.
-			await service.restoreCheckpoint(service.baseCommitHash)
+			expect(service.baseHash).toBeTruthy()
+			await service.restoreCheckpoint(service.baseHash!)
 			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")
+			const unstagedFile = path.join(service.workspaceDir, "unstaged.txt")
+			const stagedFile = path.join(service.workspaceDir, "staged.txt")
+			const mixedFile = path.join(service.workspaceDir, "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")
+			await service.git.add(["."])
+			const result = await service.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 service.git.add([stagedFile])
 
 			await fs.writeFile(mixedFile, "Modified mixed - staged")
-			await git.add([mixedFile])
+			await service.git.add([mixedFile])
 			await fs.writeFile(mixedFile, "Modified mixed - unstaged")
 
 			// Save checkpoint.
@@ -181,7 +184,7 @@ describe("CheckpointService", () => {
 			expect(commit?.commit).toBeTruthy()
 
 			// Verify workspace state is preserved.
-			const status = await git.status()
+			const status = await service.git.status()
 
 			// All files should be modified.
 			expect(status.modified).toContain("unstaged.txt")
@@ -199,12 +202,12 @@ describe("CheckpointService", () => {
 			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"])
+			const stagedDiff = await service.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"])
+			const unstagedDiff = await service.git.diff(["mixed.txt"])
 			expect(unstagedDiff).toContain("-Modified mixed - staged")
 			expect(unstagedDiff).toContain("+Modified mixed - unstaged")
 		})
@@ -223,7 +226,7 @@ describe("CheckpointService", () => {
 
 		it("includes untracked files in checkpoints", async () => {
 			// Create an untracked file.
-			const untrackedFile = path.join(service.baseDir, "untracked.txt")
+			const untrackedFile = path.join(service.workspaceDir, "untracked.txt")
 			await fs.writeFile(untrackedFile, "I am untracked!")
 
 			// Save a checkpoint with the untracked file.
@@ -231,7 +234,7 @@ describe("CheckpointService", () => {
 			expect(commit1?.commit).toBeTruthy()
 
 			// Verify the untracked file was included in the checkpoint.
-			const details = await git.show([commit1!.commit])
+			const details = await service.git.show([commit1!.commit])
 			expect(details).toContain("+I am untracked!")
 
 			// Create another checkpoint with a different state.
@@ -253,16 +256,19 @@ describe("CheckpointService", () => {
 
 		it("throws if we're on the wrong branch", async () => {
 			// Create and switch to a feature branch.
-			await git.checkoutBranch("feature", service.mainBranch)
+			const currentBranch = await service.git.revparse(["--abbrev-ref", "HEAD"])
+			await service.git.checkoutBranch("feature", currentBranch)
 
 			// Attempt to save checkpoint from feature branch.
 			await expect(service.saveCheckpoint("test")).rejects.toThrow(
-				`Git branch mismatch: expected '${service.mainBranch}' but found 'feature'`,
+				`Git branch mismatch: expected '${currentBranch}' 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'`,
+			expect(service.baseHash).toBeTruthy()
+
+			await expect(service.restoreCheckpoint(service.baseHash!)).rejects.toThrow(
+				`Git branch mismatch: expected '${currentBranch}' but found 'feature'`,
 			)
 		})
 
@@ -270,19 +276,19 @@ describe("CheckpointService", () => {
 			await fs.writeFile(testFile, "Changed content")
 
 			// Mock git commit to simulate failure.
-			jest.spyOn(git, "commit").mockRejectedValue(new Error("Simulated commit failure"))
+			jest.spyOn(service.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()
+			const status = await service.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")
+			const untrackedFile = path.join(service.workspaceDir, "new.txt")
 			await fs.writeFile(untrackedFile, "I am untracked!")
 			const commit1 = await service.saveCheckpoint("First checkpoint")
 			expect(commit1?.commit).toBeTruthy()
@@ -310,17 +316,16 @@ describe("CheckpointService", () => {
 
 	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")
+			const workspaceDir = path.join(os.tmpdir(), `checkpoint-service-test2-${Date.now()}`)
+			await fs.mkdir(workspaceDir)
+			const newTestFile = path.join(workspaceDir, "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()
+			const gitDir = path.join(workspaceDir, ".git")
+			await expect(fs.stat(gitDir)).rejects.toThrow()
+			const newService = await LocalCheckpointService.create({ taskId, workspaceDir, log: () => {} })
+			expect(await fs.stat(gitDir)).toBeTruthy()
 
 			// Save a checkpoint: Hello, world!
 			const commit1 = await newService.saveCheckpoint("Hello, world!")
@@ -328,7 +333,8 @@ describe("CheckpointService", () => {
 			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
 
 			// Restore initial commit; the file should no longer exist.
-			await newService.restoreCheckpoint(newService.baseCommitHash)
+			expect(newService.baseHash).toBeTruthy()
+			await newService.restoreCheckpoint(newService.baseHash!)
 			await expect(fs.access(newTestFile)).rejects.toThrow()
 
 			// Restore to checkpoint 1; the file should now exist.
@@ -350,67 +356,25 @@ describe("CheckpointService", () => {
 			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
 
 			// Restore initial commit.
-			await newService.restoreCheckpoint(newService.baseCommitHash)
+			expect(newService.baseHash).toBeTruthy()
+			await newService.restoreCheckpoint(newService.baseHash!)
 			await expect(fs.access(newTestFile)).rejects.toThrow()
 
-			await fs.rm(newService.baseDir, { recursive: true, force: true })
+			await fs.rm(newService.workspaceDir, { recursive: true, force: true })
 		})
 
 		it("respects existing git user configuration", async () => {
-			const baseDir = path.join(os.tmpdir(), `checkpoint-service-test-config2-${Date.now()}`)
+			const workspaceDir = 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: () => {} })
+			await initRepo({ workspaceDir, userName, userEmail })
 
-			// 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")
+			const newService = await LocalCheckpointService.create({ taskId, workspaceDir, log: () => {} })
 
-			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]")
+			expect((await newService.git.getConfig("user.name")).value).toBe(userName)
+			expect((await newService.git.getConfig("user.email")).value).toBe(userEmail)
 
-			await fs.rm(baseDir, { recursive: true, force: true })
+			await fs.rm(workspaceDir, { recursive: true, force: true })
 		})
 	})
 })

+ 334 - 0
src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts

@@ -0,0 +1,334 @@
+// npx jest src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts
+
+import fs from "fs/promises"
+import path from "path"
+import os from "os"
+
+import { simpleGit, SimpleGit } from "simple-git"
+
+import { ShadowCheckpointService } from "../ShadowCheckpointService"
+import { CheckpointServiceFactory } from "../CheckpointServiceFactory"
+
+jest.mock("globby", () => ({
+	globby: jest.fn().mockResolvedValue([]),
+}))
+
+describe("ShadowCheckpointService", () => {
+	const taskId = "test-task"
+
+	let workspaceGit: SimpleGit
+	let shadowGit: SimpleGit
+	let testFile: string
+	let service: ShadowCheckpointService
+
+	const initRepo = async ({
+		workspaceDir,
+		userName = "Roo Code",
+		userEmail = "[email protected]",
+		testFileName = "test.txt",
+		textFileContent = "Hello, world!",
+	}: {
+		workspaceDir: string
+		userName?: string
+		userEmail?: string
+		testFileName?: string
+		textFileContent?: string
+	}) => {
+		// Create a temporary directory for testing.
+		await fs.mkdir(workspaceDir)
+
+		// Initialize git repo.
+		const git = simpleGit(workspaceDir)
+		await git.init()
+		await git.addConfig("user.name", userName)
+		await git.addConfig("user.email", userEmail)
+
+		// Create test file.
+		const testFile = path.join(workspaceDir, testFileName)
+		await fs.writeFile(testFile, textFileContent)
+
+		// Create initial commit.
+		await git.add(".")
+		await git.commit("Initial commit")!
+
+		return { git, testFile }
+	}
+
+	beforeEach(async () => {
+		jest.mocked(require("globby").globby).mockClear().mockResolvedValue([])
+
+		const shadowDir = path.join(os.tmpdir(), `shadow-${Date.now()}`)
+		const workspaceDir = path.join(os.tmpdir(), `workspace-${Date.now()}`)
+		const repo = await initRepo({ workspaceDir })
+
+		testFile = repo.testFile
+
+		service = await CheckpointServiceFactory.create({
+			strategy: "shadow",
+			options: { taskId, shadowDir, workspaceDir, log: () => {} },
+		})
+
+		workspaceGit = repo.git
+		shadowGit = service.git
+	})
+
+	afterEach(async () => {
+		await fs.rm(service.shadowDir, { recursive: true, force: true })
+		await fs.rm(service.workspaceDir, { 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.workspaceDir, "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.workspaceDir, "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 service.getDiff({ to: commit1!.commit })
+			expect(details1[0].content.before).toContain("Hello, world!")
+			expect(details1[0].content.after).toContain("Ahoy, world!")
+
+			await fs.writeFile(testFile, "Hola, world!")
+			const commit2 = await service.saveCheckpoint("Second checkpoint")
+			expect(commit2?.commit).toBeTruthy()
+			const details2 = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
+			expect(details2[0].content.before).toContain("Ahoy, world!")
+			expect(details2[0].content.after).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.
+			expect(service.baseHash).toBeTruthy()
+			await service.restoreCheckpoint(service.baseHash!)
+			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.workspaceDir, "unstaged.txt")
+			const stagedFile = path.join(service.workspaceDir, "staged.txt")
+			const mixedFile = path.join(service.workspaceDir, "mixed.txt")
+
+			await fs.writeFile(unstagedFile, "Initial unstaged")
+			await fs.writeFile(stagedFile, "Initial staged")
+			await fs.writeFile(mixedFile, "Initial mixed")
+			await workspaceGit.add(["."])
+			const result = await workspaceGit.commit("Add initial files")
+			expect(result?.commit).toBeTruthy()
+
+			await fs.writeFile(unstagedFile, "Modified unstaged")
+
+			await fs.writeFile(stagedFile, "Modified staged")
+			await workspaceGit.add([stagedFile])
+
+			await fs.writeFile(mixedFile, "Modified mixed - staged")
+			await workspaceGit.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 workspaceGit.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 workspaceGit.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 workspaceGit.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 () => {
+			const commit0 = await service.saveCheckpoint("Zeroth checkpoint")
+			expect(commit0?.commit).toBeFalsy()
+
+			await fs.writeFile(testFile, "Ahoy, world!")
+			const commit1 = await service.saveCheckpoint("First checkpoint")
+			expect(commit1?.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.workspaceDir, "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 service.getDiff({ to: commit1!.commit })
+			expect(details[0].content.before).toContain("")
+			expect(details[0].content.after).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("handles file deletions correctly", async () => {
+			await fs.writeFile(testFile, "I am tracked!")
+			const untrackedFile = path.join(service.workspaceDir, "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 shadowDir = path.join(os.tmpdir(), `shadow2-${Date.now()}`)
+			const workspaceDir = path.join(os.tmpdir(), `workspace2-${Date.now()}`)
+			await fs.mkdir(workspaceDir)
+
+			const newTestFile = path.join(workspaceDir, "test.txt")
+			await fs.writeFile(newTestFile, "Hello, world!")
+			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
+
+			// Ensure the git repository was initialized.
+			const gitDir = path.join(shadowDir, "tasks", taskId, "checkpoints", ".git")
+			await expect(fs.stat(gitDir)).rejects.toThrow()
+			const newService = await ShadowCheckpointService.create({ taskId, shadowDir, workspaceDir, log: () => {} })
+			expect(await fs.stat(gitDir)).toBeTruthy()
+
+			// Save a new checkpoint: Ahoy, world!
+			await fs.writeFile(newTestFile, "Ahoy, world!")
+			const commit1 = await newService.saveCheckpoint("Ahoy, world!")
+			expect(commit1?.commit).toBeTruthy()
+			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
+
+			// Restore "Hello, world!"
+			await newService.restoreCheckpoint(newService.baseHash!)
+			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
+
+			// Restore "Ahoy, world!"
+			await newService.restoreCheckpoint(commit1!.commit)
+			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
+
+			await fs.rm(newService.shadowDir, { recursive: true, force: true })
+			await fs.rm(newService.workspaceDir, { recursive: true, force: true })
+		})
+	})
+})

+ 89 - 0
src/services/checkpoints/constants.ts

@@ -0,0 +1,89 @@
+export const GIT_DISABLED_SUFFIX = "_disabled"
+
+export const GIT_EXCLUDES = [
+	".git/", // Ignore the user's .git.
+	`.git${GIT_DISABLED_SUFFIX}/`, // Ignore the disabled nested git repos.
+	".DS_Store",
+	"*.log",
+	"node_modules/",
+	"__pycache__/",
+	"env/",
+	"venv/",
+	"target/dependency/",
+	"build/dependencies/",
+	"dist/",
+	"out/",
+	"bundle/",
+	"vendor/",
+	"tmp/",
+	"temp/",
+	"deps/",
+	"pkg/",
+	"Pods/",
+	// Media files.
+	"*.jpg",
+	"*.jpeg",
+	"*.png",
+	"*.gif",
+	"*.bmp",
+	"*.ico",
+	// "*.svg",
+	"*.mp3",
+	"*.mp4",
+	"*.wav",
+	"*.avi",
+	"*.mov",
+	"*.wmv",
+	"*.webm",
+	"*.webp",
+	"*.m4a",
+	"*.flac",
+	// Build and dependency directories.
+	"build/",
+	"bin/",
+	"obj/",
+	".gradle/",
+	".idea/",
+	".vscode/",
+	".vs/",
+	"coverage/",
+	".next/",
+	".nuxt/",
+	// Cache and temporary files.
+	"*.cache",
+	"*.tmp",
+	"*.temp",
+	"*.swp",
+	"*.swo",
+	"*.pyc",
+	"*.pyo",
+	".pytest_cache/",
+	".eslintcache",
+	// Environment and config files.
+	".env*",
+	"*.local",
+	"*.development",
+	"*.production",
+	// Large data files.
+	"*.zip",
+	"*.tar",
+	"*.gz",
+	"*.rar",
+	"*.7z",
+	"*.iso",
+	"*.bin",
+	"*.exe",
+	"*.dll",
+	"*.so",
+	"*.dylib",
+	// Database files.
+	"*.sqlite",
+	"*.db",
+	"*.sql",
+	// Log files.
+	"*.logs",
+	"*.error",
+	"npm-debug.log*",
+	"yarn-debug.log*",
+	"yarn-error.log*",
+]

+ 2 - 0
src/services/checkpoints/index.ts

@@ -0,0 +1,2 @@
+export * from "./types"
+export * from "./CheckpointServiceFactory"

+ 32 - 0
src/services/checkpoints/types.ts

@@ -0,0 +1,32 @@
+import { CommitResult } from "simple-git"
+
+export type CheckpointResult = Partial<CommitResult> & Pick<CommitResult, "commit">
+
+export type CheckpointDiff = {
+	paths: {
+		relative: string
+		absolute: string
+	}
+	content: {
+		before: string
+		after: string
+	}
+}
+
+export type CheckpointStrategy = "local" | "shadow"
+
+export interface CheckpointService {
+	saveCheckpoint(message: string): Promise<CheckpointResult | undefined>
+	restoreCheckpoint(commit: string): Promise<void>
+	getDiff(range: { from?: string; to?: string }): Promise<CheckpointDiff[]>
+	workspaceDir: string
+	baseHash?: string
+	strategy: CheckpointStrategy
+	version: number
+}
+
+export interface CheckpointServiceOptions {
+	taskId: string
+	workspaceDir: string
+	log?: (message: string) => void
+}

+ 2 - 0
src/shared/ExtensionMessage.ts

@@ -107,6 +107,7 @@ export interface ExtensionState {
 	requestDelaySeconds: number
 	rateLimitSeconds: number // Minimum time between successive requests (0 = disabled)
 	uriScheme?: string
+	currentTaskItem?: HistoryItem
 	allowedCommands?: string[]
 	soundEnabled?: boolean
 	soundVolume?: number
@@ -127,6 +128,7 @@ export interface ExtensionState {
 	autoApprovalEnabled?: boolean
 	customModes: ModeConfig[]
 	toolRequirements?: Record<string, boolean> // Map of tool names to their requirements (e.g. {"apply_diff": true} if diffEnabled)
+	maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500)
 }
 
 export interface ClineMessage {

+ 1 - 0
src/shared/HistoryItem.ts

@@ -7,4 +7,5 @@ export type HistoryItem = {
 	cacheWrites?: number
 	cacheReads?: number
 	totalCost: number
+	size?: number
 }

+ 2 - 0
src/shared/WebviewMessage.ts

@@ -92,6 +92,7 @@ export interface WebviewMessage {
 		| "openCustomModesSettings"
 		| "checkpointDiff"
 		| "checkpointRestore"
+		| "maxOpenTabsContext"
 	text?: string
 	disabled?: boolean
 	askResponse?: ClineAskResponse
@@ -114,6 +115,7 @@ export interface WebviewMessage {
 	modeConfig?: ModeConfig
 	timeout?: number
 	payload?: WebViewMessagePayload
+	source?: "global" | "project"
 }
 
 export const checkoutDiffPayloadSchema = z.object({

+ 10 - 10
src/shared/__tests__/modes.test.ts

@@ -93,9 +93,9 @@ describe("isToolAllowedForMode", () => {
 				}),
 			).toBe(true)
 
-			// Should allow path-only for ask mode too
+			// Should allow path-only for architect mode too
 			expect(
-				isToolAllowedForMode("write_to_file", "ask", [], undefined, {
+				isToolAllowedForMode("write_to_file", "architect", [], undefined, {
 					path: "test.js",
 				}),
 			).toBe(true)
@@ -197,10 +197,10 @@ describe("isToolAllowedForMode", () => {
 			).toBe(true)
 		})
 
-		it("allows ask mode to edit markdown files only", () => {
+		it("allows architect mode to edit markdown files only", () => {
 			// Should allow editing markdown files
 			expect(
-				isToolAllowedForMode("write_to_file", "ask", [], undefined, {
+				isToolAllowedForMode("write_to_file", "architect", [], undefined, {
 					path: "test.md",
 					content: "# Test",
 				}),
@@ -208,7 +208,7 @@ describe("isToolAllowedForMode", () => {
 
 			// Should allow applying diffs to markdown files
 			expect(
-				isToolAllowedForMode("apply_diff", "ask", [], undefined, {
+				isToolAllowedForMode("apply_diff", "architect", [], undefined, {
 					path: "readme.md",
 					diff: "- old\n+ new",
 				}),
@@ -216,22 +216,22 @@ describe("isToolAllowedForMode", () => {
 
 			// Should reject non-markdown files
 			expect(() =>
-				isToolAllowedForMode("write_to_file", "ask", [], undefined, {
+				isToolAllowedForMode("write_to_file", "architect", [], undefined, {
 					path: "test.js",
 					content: "console.log('test')",
 				}),
 			).toThrow(FileRestrictionError)
 			expect(() =>
-				isToolAllowedForMode("write_to_file", "ask", [], undefined, {
+				isToolAllowedForMode("write_to_file", "architect", [], undefined, {
 					path: "test.js",
 					content: "console.log('test')",
 				}),
 			).toThrow(/Markdown files only/)
 
 			// Should maintain read capabilities
-			expect(isToolAllowedForMode("read_file", "ask", [])).toBe(true)
-			expect(isToolAllowedForMode("browser_action", "ask", [])).toBe(true)
-			expect(isToolAllowedForMode("use_mcp_tool", "ask", [])).toBe(true)
+			expect(isToolAllowedForMode("read_file", "architect", [])).toBe(true)
+			expect(isToolAllowedForMode("browser_action", "architect", [])).toBe(true)
+			expect(isToolAllowedForMode("use_mcp_tool", "architect", [])).toBe(true)
 		})
 	})
 

+ 3 - 2
src/shared/modes.ts

@@ -19,6 +19,7 @@ export type ModeConfig = {
 	roleDefinition: string
 	customInstructions?: string
 	groups: readonly GroupEntry[] // Now supports both simple strings and tuples with options
+	source?: "global" | "project" // Where this mode was loaded from
 }
 
 // Mode-specific prompts only
@@ -92,9 +93,9 @@ export const modes: readonly ModeConfig[] = [
 		name: "Ask",
 		roleDefinition:
 			"You are Roo, a knowledgeable technical assistant focused on answering questions and providing information about software development, technology, and related topics.",
-		groups: ["read", ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }], "browser", "mcp"],
+		groups: ["read", "browser", "mcp"],
 		customInstructions:
-			"You can analyze code, explain concepts, and access external resources. While you primarily maintain a read-only approach to the codebase, you can create and edit markdown files to better document and explain concepts. Make sure to answer the user's questions and don't rush to switch to implementing code.",
+			"You can analyze code, explain concepts, and access external resources. Make sure to answer the user's questions and don't rush to switch to implementing code.",
 	},
 ] as const
 

+ 156 - 0
src/test/VSCODE_INTEGRATION_TESTS.md

@@ -0,0 +1,156 @@
+# VSCode Integration Tests
+
+This document describes the integration test setup for the Roo Code VSCode extension.
+
+## Overview
+
+The integration tests use the `@vscode/test-electron` package to run tests in a real VSCode environment. These tests verify that the extension works correctly within VSCode, including features like mode switching, webview interactions, and API communication.
+
+## Test Setup
+
+### Directory Structure
+
+```
+src/test/
+├── runTest.ts           # Main test runner
+├── suite/
+│   ├── index.ts        # Test suite configuration
+│   ├── modes.test.ts   # Mode switching tests
+│   ├── tasks.test.ts   # Task execution tests
+│   └── extension.test.ts # Extension activation tests
+```
+
+### Test Runner Configuration
+
+The test runner (`runTest.ts`) is responsible for:
+
+- Setting up the extension development path
+- Configuring the test environment
+- Running the integration tests using `@vscode/test-electron`
+
+### Environment Setup
+
+1. Create a `.env.integration` file in the root directory with required environment variables:
+
+```
+OPENROUTER_API_KEY=sk-or-v1-...
+```
+
+2. The test suite (`suite/index.ts`) configures:
+
+- Mocha test framework with TDD interface
+- 10-minute timeout for LLM communication
+- Global extension API access
+- WebView panel setup
+- OpenRouter API configuration
+
+## Test Suite Structure
+
+Tests are organized using Mocha's TDD interface (`suite` and `test` functions). The main test files are:
+
+- `modes.test.ts`: Tests mode switching functionality
+- `tasks.test.ts`: Tests task execution
+- `extension.test.ts`: Tests extension activation
+
+### Global Objects
+
+The following global objects are available in tests:
+
+```typescript
+declare global {
+	var api: ClineAPI
+	var provider: ClineProvider
+	var extension: vscode.Extension<ClineAPI>
+	var panel: vscode.WebviewPanel
+}
+```
+
+## Running Tests
+
+1. Ensure you have the required environment variables set in `.env.integration`
+
+2. Run the integration tests:
+
+```bash
+npm run test:integration
+```
+
+3. If you want to run a specific test, you can use the `test.only` function in the test file. This will run only the test you specify and ignore the others. Be sure to remove the `test.only` function before committing your changes.
+
+The tests will:
+
+- Download and launch a clean VSCode instance
+- Install the extension
+- Execute the test suite
+- Report results
+
+## Writing New Tests
+
+When writing new integration tests:
+
+1. Create a new test file in `src/test/suite/` with the `.test.ts` extension
+
+2. Structure your tests using the TDD interface:
+
+```typescript
+import * as assert from "assert"
+import * as vscode from "vscode"
+
+suite("Your Test Suite Name", () => {
+	test("Should do something specific", async function () {
+		// Your test code here
+	})
+})
+```
+
+3. Use the global objects (`api`, `provider`, `extension`, `panel`) to interact with the extension
+
+### Best Practices
+
+1. **Timeouts**: Use appropriate timeouts for async operations:
+
+```typescript
+const timeout = 30000
+const interval = 1000
+```
+
+2. **State Management**: Reset extension state before/after tests:
+
+```typescript
+await globalThis.provider.updateGlobalState("mode", "Ask")
+await globalThis.provider.updateGlobalState("alwaysAllowModeSwitch", true)
+```
+
+3. **Assertions**: Use clear assertions with meaningful messages:
+
+```typescript
+assert.ok(condition, "Descriptive message about what failed")
+```
+
+4. **Error Handling**: Wrap test code in try/catch blocks and clean up resources:
+
+```typescript
+try {
+	// Test code
+} finally {
+	// Cleanup code
+}
+```
+
+5. **Wait for Operations**: Use polling when waiting for async operations:
+
+```typescript
+let startTime = Date.now()
+while (Date.now() - startTime < timeout) {
+	if (condition) break
+	await new Promise((resolve) => setTimeout(resolve, interval))
+}
+```
+
+6. **Grading**: When grading tests, use the `Grade:` format to ensure the test is graded correctly (See modes.test.ts for an example).
+
+```typescript
+await globalThis.api.startNewTask(
+	`Given this prompt: ${testPrompt} grade the response from 1 to 10 in the format of "Grade: (1-10)": ${output} \n Be sure to say 'I AM DONE GRADING' after the task is complete`,
+)
+```

+ 44 - 42
src/test/suite/modes.test.ts

@@ -5,7 +5,8 @@ suite("Roo Code Modes", () => {
 	test("Should handle switching modes correctly", async function () {
 		const timeout = 30000
 		const interval = 1000
-
+		const testPrompt =
+			"For each mode (Code, Architect, Ask) respond with the mode name and what it specializes in after switching to that mode, do not start with the current mode, be sure to say 'I AM DONE' after the task is complete"
 		if (!globalThis.extension) {
 			assert.fail("Extension not found")
 		}
@@ -27,9 +28,7 @@ suite("Roo Code Modes", () => {
 			await globalThis.provider.updateGlobalState("autoApprovalEnabled", true)
 
 			// Start a new task.
-			await globalThis.api.startNewTask(
-				"For each mode (Code, Architect, Ask) respond with the mode name and what it specializes in after switching to that mode, do not start with the current mode, be sure to say 'I AM DONE' after the task is complete",
-			)
+			await globalThis.api.startNewTask(testPrompt)
 
 			// Wait for task to appear in history with tokens.
 			startTime = Date.now()
@@ -52,47 +51,50 @@ suite("Roo Code Modes", () => {
 				assert.fail("No messages received")
 			}
 
-			assert.ok(
-				globalThis.provider.messages.some(
-					({ type, text }) => type === "say" && text?.includes(`"request":"[switch_mode to 'code' because:`),
-				),
-				"Did not receive expected response containing 'Roo wants to switch to code mode'",
-			)
-			assert.ok(
-				globalThis.provider.messages.some(
-					({ type, text }) => type === "say" && text?.includes("software engineer"),
-				),
-				"Did not receive expected response containing 'I am Roo in Code mode, specializing in software engineering'",
-			)
+			//Log the messages to the console
+			globalThis.provider.messages.forEach(({ type, text }) => {
+				if (type === "say") {
+					console.log(text)
+				}
+			})
 
-			assert.ok(
-				globalThis.provider.messages.some(
-					({ type, text }) =>
-						type === "say" && text?.includes(`"request":"[switch_mode to 'architect' because:`),
-				),
-				"Did not receive expected response containing 'Roo wants to switch to architect mode'",
-			)
-			assert.ok(
-				globalThis.provider.messages.some(
-					({ type, text }) =>
-						type === "say" && (text?.includes("technical planning") || text?.includes("technical leader")),
-				),
-				"Did not receive expected response containing 'I am Roo in Architect mode, specializing in analyzing codebases'",
+			//Start Grading Portion of test to grade the response from 1 to 10
+			await globalThis.provider.updateGlobalState("mode", "Ask")
+			let output = globalThis.provider.messages.map(({ type, text }) => (type === "say" ? text : "")).join("\n")
+			await globalThis.api.startNewTask(
+				`Given this prompt: ${testPrompt} grade the response from 1 to 10 in the format of "Grade: (1-10)": ${output} \n Be sure to say 'I AM DONE GRADING' after the task is complete`,
 			)
 
-			assert.ok(
-				globalThis.provider.messages.some(
-					({ type, text }) => type === "say" && text?.includes(`"request":"[switch_mode to 'ask' because:`),
-				),
-				"Did not receive expected response containing 'Roo wants to switch to ask mode'",
-			)
-			assert.ok(
-				globalThis.provider.messages.some(
-					({ type, text }) =>
-						type === "say" && (text?.includes("technical knowledge") || text?.includes("technical assist")),
-				),
-				"Did not receive expected response containing 'I am Roo in Ask mode, specializing in answering questions'",
-			)
+			startTime = Date.now()
+
+			while (Date.now() - startTime < timeout) {
+				const messages = globalThis.provider.messages
+
+				if (
+					messages.some(
+						({ type, text }) =>
+							type === "say" && text?.includes("I AM DONE GRADING") && !text?.includes("be sure to say"),
+					)
+				) {
+					break
+				}
+
+				await new Promise((resolve) => setTimeout(resolve, interval))
+			}
+			if (globalThis.provider.messages.length === 0) {
+				assert.fail("No messages received")
+			}
+			globalThis.provider.messages.forEach(({ type, text }) => {
+				if (type === "say" && text?.includes("Grade:")) {
+					console.log(text)
+				}
+			})
+			const gradeMessage = globalThis.provider.messages.find(
+				({ type, text }) => type === "say" && !text?.includes("Grade: (1-10)") && text?.includes("Grade:"),
+			)?.text
+			const gradeMatch = gradeMessage?.match(/Grade: (\d+)/)
+			const gradeNum = gradeMatch ? parseInt(gradeMatch[1]) : undefined
+			assert.ok(gradeNum !== undefined && gradeNum >= 7 && gradeNum <= 10, "Grade must be between 7 and 10")
 		} finally {
 		}
 	})

+ 6 - 3
src/test/suite/task.test.ts

@@ -22,16 +22,19 @@ suite("Roo Code Task", () => {
 				await new Promise((resolve) => setTimeout(resolve, interval))
 			}
 
+			await globalThis.provider.updateGlobalState("mode", "Code")
+			await globalThis.provider.updateGlobalState("alwaysAllowModeSwitch", true)
+			await globalThis.provider.updateGlobalState("autoApprovalEnabled", true)
+
 			await globalThis.api.startNewTask("Hello world, what is your name? Respond with 'My name is ...'")
 
 			// Wait for task to appear in history with tokens.
 			startTime = Date.now()
 
 			while (Date.now() - startTime < timeout) {
-				const state = await globalThis.provider.getState()
-				const task = state.taskHistory?.[0]
+				const messages = globalThis.provider.messages
 
-				if (task && task.tokensOut > 0) {
+				if (messages.some(({ type, text }) => type === "say" && text?.includes("My name is Roo"))) {
 					break
 				}
 

+ 12 - 0
webview-ui/src/__mocks__/pretty-bytes.js

@@ -0,0 +1,12 @@
+module.exports = function prettyBytes(bytes) {
+	if (typeof bytes !== "number") {
+		throw new TypeError("Expected a number")
+	}
+
+	// Simple mock implementation that returns formatted strings.
+	if (bytes === 0) return "0 B"
+	if (bytes < 1024) return `${bytes} B`
+	if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+	if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+	return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
+}

+ 1 - 1
webview-ui/src/components/chat/ChatRow.tsx

@@ -761,8 +761,8 @@ export const ChatRowContent = ({
 						<CheckpointSaved
 							ts={message.ts!}
 							commitHash={message.text!}
+							currentHash={currentCheckpoint}
 							checkpoint={message.checkpoint}
-							currentCheckpointHash={currentCheckpoint}
 						/>
 					)
 				default:

+ 66 - 72
webview-ui/src/components/chat/TaskHeader.tsx

@@ -1,6 +1,8 @@
-import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
 import React, { memo, useEffect, useMemo, useRef, useState } from "react"
 import { useWindowSize } from "react-use"
+import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
+import prettyBytes from "pretty-bytes"
+
 import { ClineMessage } from "../../../../src/shared/ExtensionMessage"
 import { useExtensionState } from "../../context/ExtensionStateContext"
 import { vscode } from "../../utils/vscode"
@@ -8,6 +10,8 @@ import Thumbnails from "../common/Thumbnails"
 import { mentionRegexGlobal } from "../../../../src/shared/context-mentions"
 import { formatLargeNumber } from "../../utils/format"
 import { normalizeApiConfiguration } from "../settings/ApiOptions"
+import { Button } from "../ui"
+import { HistoryItem } from "../../../../src/shared/HistoryItem"
 
 interface TaskHeaderProps {
 	task: ClineMessage
@@ -32,7 +36,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
 	contextTokens,
 	onClose,
 }) => {
-	const { apiConfiguration } = useExtensionState()
+	const { apiConfiguration, currentTaskItem } = useExtensionState()
 	const { selectedModelInfo } = useMemo(() => normalizeApiConfiguration(apiConfiguration), [apiConfiguration])
 	const [isTaskExpanded, setIsTaskExpanded] = useState(true)
 	const [isTextExpanded, setIsTextExpanded] = useState(false)
@@ -40,7 +44,6 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
 	const textContainerRef = useRef<HTMLDivElement>(null)
 	const textRef = useRef<HTMLDivElement>(null)
 	const contextWindow = selectedModelInfo?.contextWindow || 1
-	const contextPercentage = Math.round((contextTokens / contextWindow) * 100)
 
 	/*
 	When dealing with event listeners in React components that depend on state variables, we face a challenge. We want our listener to always use the most up-to-date version of a callback function that relies on current state, but we don't want to constantly add and remove event listeners as that function updates. This scenario often arises with resize listeners or other window events. Simply adding the listener in a useEffect with an empty dependency array risks using stale state, while including the callback in the dependencies can lead to unnecessary re-registrations of the listener. There are react hook libraries that provide a elegant solution to this problem by utilizing the useRef hook to maintain a reference to the latest callback function without triggering re-renders or effect re-runs. This approach ensures that our event listener always has access to the most current state while minimizing performance overhead and potential memory leaks from multiple listener registrations. 
@@ -250,14 +253,11 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
 								See less
 							</div>
 						)}
+
 						{task.images && task.images.length > 0 && <Thumbnails images={task.images} />}
+
 						<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
-							<div
-								style={{
-									display: "flex",
-									justifyContent: "space-between",
-									alignItems: "center",
-								}}>
+							<div className="flex justify-between items-center h-[20px]">
 								<div style={{ display: "flex", alignItems: "center", gap: "4px", flexWrap: "wrap" }}>
 									<span style={{ fontWeight: "bold" }}>Tokens:</span>
 									<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
@@ -275,83 +275,51 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
 										{formatLargeNumber(tokensOut || 0)}
 									</span>
 								</div>
-								{!isCostAvailable && <ExportButton />}
+								{!isCostAvailable && <TaskActions item={currentTaskItem} />}
 							</div>
 
-							<div style={{ display: "flex", alignItems: "center", gap: "4px", flexWrap: "wrap" }}>
-								<span style={{ fontWeight: "bold" }}>Context:</span>
-								<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
-									{contextTokens
-										? `${formatLargeNumber(contextTokens)} (${contextPercentage}%)`
-										: "-"}
-								</span>
-							</div>
+							{isTaskExpanded && contextWindow && (
+								<div className={`flex ${windowWidth < 270 ? "flex-col" : "flex-row"} gap-1 h-[20px]`}>
+									<ContextWindowProgress
+										contextWindow={contextWindow}
+										contextTokens={contextTokens || 0}
+									/>
+								</div>
+							)}
 
 							{shouldShowPromptCacheInfo && (cacheReads !== undefined || cacheWrites !== undefined) && (
-								<div style={{ display: "flex", alignItems: "center", gap: "4px", flexWrap: "wrap" }}>
+								<div className="flex items-center gap-1 flex-wrap h-[20px]">
 									<span style={{ fontWeight: "bold" }}>Cache:</span>
-									<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
+									<span className="flex items-center gap-1">
 										<i
 											className="codicon codicon-database"
-											style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-1px" }}
+											style={{ fontSize: "12px", fontWeight: "bold" }}
 										/>
 										+{formatLargeNumber(cacheWrites || 0)}
 									</span>
-									<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
+									<span className="flex items-center gap-1">
 										<i
 											className="codicon codicon-arrow-right"
-											style={{ fontSize: "12px", fontWeight: "bold", marginBottom: 0 }}
+											style={{ fontSize: "12px", fontWeight: "bold" }}
 										/>
 										{formatLargeNumber(cacheReads || 0)}
 									</span>
 								</div>
 							)}
+
 							{isCostAvailable && (
-								<div
-									style={{
-										display: "flex",
-										justifyContent: "space-between",
-										alignItems: "center",
-									}}>
-									<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
-										<span style={{ fontWeight: "bold" }}>API Cost:</span>
+								<div className="flex justify-between items-center h-[20px]">
+									<div className="flex items-center gap-1">
+										<span className="font-bold">API Cost:</span>
 										<span>${totalCost?.toFixed(4)}</span>
 									</div>
-									<ExportButton />
+									<TaskActions item={currentTaskItem} />
 								</div>
 							)}
 						</div>
 					</>
 				)}
 			</div>
-			{/* {apiProvider === "" && (
-				<div
-					style={{
-						backgroundColor: "color-mix(in srgb, var(--vscode-badge-background) 50%, transparent)",
-						color: "var(--vscode-badge-foreground)",
-						borderRadius: "0 0 3px 3px",
-						display: "flex",
-						justifyContent: "space-between",
-						alignItems: "center",
-						padding: "4px 12px 6px 12px",
-						fontSize: "0.9em",
-						marginLeft: "10px",
-						marginRight: "10px",
-					}}>
-					<div style={{ fontWeight: "500" }}>Credits Remaining:</div>
-					<div>
-						{formatPrice(Credits || 0)}
-						{(Credits || 0) < 1 && (
-							<>
-								{" "}
-								<VSCodeLink style={{ fontSize: "0.9em" }} href={getAddCreditsUrl(vscodeUriScheme)}>
-									(get more?)
-								</VSCodeLink>
-							</>
-						)}
-					</div>
-				</div>
-			)} */}
 		</div>
 	)
 }
@@ -378,18 +346,44 @@ export const highlightMentions = (text?: string, withShadow = true) => {
 	})
 }
 
-const ExportButton = () => (
-	<VSCodeButton
-		appearance="icon"
-		onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}
-		style={
-			{
-				// marginBottom: "-2px",
-				// marginRight: "-2.5px",
-			}
-		}>
-		<div style={{ fontSize: "10.5px", fontWeight: "bold", opacity: 0.6 }}>EXPORT</div>
-	</VSCodeButton>
+const TaskActions = ({ item }: { item: HistoryItem | undefined }) => (
+	<div className="flex flex-row gap-1">
+		<Button variant="ghost" size="sm" onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}>
+			<span className="codicon codicon-cloud-download" />
+		</Button>
+		{item?.size && (
+			<Button
+				variant="ghost"
+				size="sm"
+				onClick={() => vscode.postMessage({ type: "deleteTaskWithId", text: item.id })}>
+				<span className="codicon codicon-trash" />
+				{prettyBytes(item.size)}
+			</Button>
+		)}
+	</div>
+)
+
+const ContextWindowProgress = ({ contextWindow, contextTokens }: { contextWindow: number; contextTokens: number }) => (
+	<>
+		<div className="flex items-center gap-1 flex-shrink-0">
+			<span className="font-bold">Context Window:</span>
+		</div>
+		<div className="flex items-center gap-2 flex-1 whitespace-nowrap">
+			<div>{formatLargeNumber(contextTokens)}</div>
+			<div className="flex items-center gap-[3px] flex-1">
+				<div className="flex-1 h-1 rounded-[2px] overflow-hidden bg-[color-mix(in_srgb,var(--vscode-badge-foreground)_20%,transparent)]">
+					<div
+						className="h-full rounded-[2px] bg-[var(--vscode-badge-foreground)]"
+						style={{
+							width: `${(contextTokens / contextWindow) * 100}%`,
+							transition: "width 0.3s ease-out",
+						}}
+					/>
+				</div>
+			</div>
+			<div>{formatLargeNumber(contextWindow)}</div>
+		</div>
+	</>
 )
 
 export default memo(TaskHeader)

+ 68 - 58
webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx

@@ -9,16 +9,20 @@ import { Checkpoint } from "./schema"
 type CheckpointMenuProps = {
 	ts: number
 	commitHash: string
-	checkpoint?: Checkpoint
-	currentCheckpointHash?: string
+	currentHash?: string
+	checkpoint: Checkpoint
 }
 
-export const CheckpointMenu = ({ ts, commitHash, checkpoint, currentCheckpointHash }: CheckpointMenuProps) => {
+export const CheckpointMenu = ({ ts, commitHash, currentHash, checkpoint }: CheckpointMenuProps) => {
 	const [portalContainer, setPortalContainer] = useState<HTMLElement>()
 	const [isOpen, setIsOpen] = useState(false)
 	const [isConfirming, setIsConfirming] = useState(false)
 
-	const isCurrent = currentCheckpointHash === commitHash
+	const isCurrent = currentHash === commitHash
+	const isFirst = checkpoint.isFirst
+
+	const isDiffAvailable = !isFirst
+	const isRestoreAvailable = !isFirst || !isCurrent
 
 	const onCheckpointDiff = useCallback(() => {
 		vscode.postMessage({ type: "checkpointDiff", payload: { ts, commitHash, mode: "checkpoint" } })
@@ -45,69 +49,75 @@ export const CheckpointMenu = ({ ts, commitHash, checkpoint, currentCheckpointHa
 
 	return (
 		<div className="flex flex-row gap-1">
-			{!checkpoint?.isFirst && (
+			{isDiffAvailable && (
 				<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
+			{isRestoreAvailable && (
+				<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>
-								) : (
-									<>
-										<Button variant="default" onClick={onRestore} className="grow">
-											<div className="flex flex-row gap-1">
-												<CheckIcon />
-												<div>Confirm</div>
+									<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>
+							)}
+							{!isFirst && (
+								<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>
-										</Button>
-										<Button variant="secondary" onClick={() => setIsConfirming(false)}>
-											<div className="flex flex-row gap-1">
-												<Cross2Icon />
-												<div>Cancel</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>
-										</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>
-					</div>
-				</PopoverContent>
-			</Popover>
+					</PopoverContent>
+				</Popover>
+			)}
 		</div>
 	)
 }

+ 14 - 5
webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx

@@ -3,15 +3,17 @@ import { useMemo } from "react"
 import { CheckpointMenu } from "./CheckpointMenu"
 import { checkpointSchema } from "./schema"
 
+const REQUIRED_VERSION = 1
+
 type CheckpointSavedProps = {
 	ts: number
 	commitHash: string
+	currentHash?: string
 	checkpoint?: Record<string, unknown>
-	currentCheckpointHash?: string
 }
 
 export const CheckpointSaved = ({ checkpoint, ...props }: CheckpointSavedProps) => {
-	const isCurrent = props.currentCheckpointHash === props.commitHash
+	const isCurrent = props.currentHash === props.commitHash
 
 	const metadata = useMemo(() => {
 		if (!checkpoint) {
@@ -19,16 +21,23 @@ export const CheckpointSaved = ({ checkpoint, ...props }: CheckpointSavedProps)
 		}
 
 		const result = checkpointSchema.safeParse(checkpoint)
-		return result.success ? result.data : undefined
+
+		if (!result.success || result.data.version < REQUIRED_VERSION) {
+			return undefined
+		}
+
+		return result.data
 	}, [checkpoint])
 
-	const isFirst = !!metadata?.isFirst
+	if (!metadata) {
+		return null
+	}
 
 	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">{isFirst ? "Initial Checkpoint" : "Checkpoint"}</span>
+				<span className="font-bold">{metadata.isFirst ? "Initial Checkpoint" : "Checkpoint"}</span>
 				{isCurrent && <span className="text-muted text-sm">Current</span>}
 			</div>
 			<CheckpointMenu {...props} checkpoint={metadata} />

+ 2 - 0
webview-ui/src/components/chat/checkpoints/schema.ts

@@ -4,6 +4,8 @@ export const checkpointSchema = z.object({
 	isFirst: z.boolean(),
 	from: z.string(),
 	to: z.string(),
+	strategy: z.enum(["local", "shadow"]),
+	version: z.number(),
 })
 
 export type Checkpoint = z.infer<typeof checkpointSchema>

+ 271 - 297
webview-ui/src/components/history/HistoryView.tsx

@@ -1,12 +1,15 @@
+import React, { memo, useMemo, useState, useEffect } from "react"
+import { Fzf } from "fzf"
+import prettyBytes from "pretty-bytes"
+import { Virtuoso } from "react-virtuoso"
 import { VSCodeButton, VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@vscode/webview-ui-toolkit/react"
+
 import { useExtensionState } from "../../context/ExtensionStateContext"
 import { vscode } from "../../utils/vscode"
-import { Virtuoso } from "react-virtuoso"
-import React, { memo, useMemo, useState, useEffect } from "react"
-import { Fzf } from "fzf"
 import { formatLargeNumber } from "../../utils/format"
 import { highlightFzfMatch } from "../../utils/highlight"
 import { useCopyToClipboard } from "../../utils/clipboard"
+import { Button } from "../ui"
 
 type HistoryViewProps = {
 	onDone: () => void
@@ -19,7 +22,6 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 	const [searchQuery, setSearchQuery] = useState("")
 	const [sortOption, setSortOption] = useState<SortOption>("newest")
 	const [lastNonRelevantSort, setLastNonRelevantSort] = useState<SortOption | null>("newest")
-	const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard()
 
 	useEffect(() => {
 		if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) {
@@ -99,357 +101,329 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 	}, [presentableTasks, searchQuery, fzf, sortOption])
 
 	return (
-		<>
-			<style>
-				{`
-					.history-item:hover {
-						background-color: var(--vscode-list-hoverBackground);
-					}
-					.delete-button, .export-button, .copy-button {
-						opacity: 0;
-						pointer-events: none;
-					}
-					.history-item:hover .delete-button,
-					.history-item:hover .export-button,
-					.history-item:hover .copy-button {
-						opacity: 1;
-						pointer-events: auto;
-					}
-					.history-item-highlight {
-						background-color: var(--vscode-editor-findMatchHighlightBackground);
-						color: inherit;
-					}
-					.copy-modal {
-						position: fixed;
-						top: 50%;
-						left: 50%;
-						transform: translate(-50%, -50%);
-						background-color: var(--vscode-notifications-background);
-						color: var(--vscode-notifications-foreground);
-						padding: 12px 20px;
-						border-radius: 4px;
-						box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
-						z-index: 1000;
-						transition: opacity 0.2s ease-in-out;
-					}
-				`}
-			</style>
-			{showCopyFeedback && <div className="copy-modal">Prompt Copied to Clipboard</div>}
+		<div
+			style={{
+				position: "fixed",
+				top: 0,
+				left: 0,
+				right: 0,
+				bottom: 0,
+				display: "flex",
+				flexDirection: "column",
+				overflow: "hidden",
+			}}>
 			<div
 				style={{
-					position: "fixed",
-					top: 0,
-					left: 0,
-					right: 0,
-					bottom: 0,
 					display: "flex",
-					flexDirection: "column",
-					overflow: "hidden",
+					justifyContent: "space-between",
+					alignItems: "center",
+					padding: "10px 17px 10px 20px",
 				}}>
-				<div
-					style={{
-						display: "flex",
-						justifyContent: "space-between",
-						alignItems: "center",
-						padding: "10px 17px 10px 20px",
-					}}>
-					<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>History</h3>
-					<VSCodeButton onClick={onDone}>Done</VSCodeButton>
-				</div>
-				<div style={{ padding: "5px 17px 6px 17px" }}>
-					<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
-						<VSCodeTextField
-							style={{ width: "100%" }}
-							placeholder="Fuzzy search history..."
-							value={searchQuery}
-							onInput={(e) => {
-								const newValue = (e.target as HTMLInputElement)?.value
-								setSearchQuery(newValue)
-								if (newValue && !searchQuery && sortOption !== "mostRelevant") {
-									setLastNonRelevantSort(sortOption)
-									setSortOption("mostRelevant")
-								}
-							}}>
+				<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>History</h3>
+				<VSCodeButton onClick={onDone}>Done</VSCodeButton>
+			</div>
+			<div style={{ padding: "5px 17px 6px 17px" }}>
+				<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
+					<VSCodeTextField
+						style={{ width: "100%" }}
+						placeholder="Fuzzy search history..."
+						value={searchQuery}
+						onInput={(e) => {
+							const newValue = (e.target as HTMLInputElement)?.value
+							setSearchQuery(newValue)
+							if (newValue && !searchQuery && sortOption !== "mostRelevant") {
+								setLastNonRelevantSort(sortOption)
+								setSortOption("mostRelevant")
+							}
+						}}>
+						<div
+							slot="start"
+							className="codicon codicon-search"
+							style={{ fontSize: 13, marginTop: 2.5, opacity: 0.8 }}
+						/>
+						{searchQuery && (
 							<div
-								slot="start"
-								className="codicon codicon-search"
-								style={{ fontSize: 13, marginTop: 2.5, opacity: 0.8 }}></div>
-							{searchQuery && (
-								<div
-									className="input-icon-button codicon codicon-close"
-									aria-label="Clear search"
-									onClick={() => setSearchQuery("")}
-									slot="end"
-									style={{
-										display: "flex",
-										justifyContent: "center",
-										alignItems: "center",
-										height: "100%",
-									}}
-								/>
-							)}
-						</VSCodeTextField>
-						<VSCodeRadioGroup
-							style={{ display: "flex", flexWrap: "wrap" }}
-							value={sortOption}
-							role="radiogroup"
-							onChange={(e) => setSortOption((e.target as HTMLInputElement).value as SortOption)}>
-							<VSCodeRadio value="newest">Newest</VSCodeRadio>
-							<VSCodeRadio value="oldest">Oldest</VSCodeRadio>
-							<VSCodeRadio value="mostExpensive">Most Expensive</VSCodeRadio>
-							<VSCodeRadio value="mostTokens">Most Tokens</VSCodeRadio>
-							<VSCodeRadio
-								value="mostRelevant"
-								disabled={!searchQuery}
-								style={{ opacity: searchQuery ? 1 : 0.5 }}>
-								Most Relevant
-							</VSCodeRadio>
-						</VSCodeRadioGroup>
-					</div>
+								className="input-icon-button codicon codicon-close"
+								aria-label="Clear search"
+								onClick={() => setSearchQuery("")}
+								slot="end"
+								style={{
+									display: "flex",
+									justifyContent: "center",
+									alignItems: "center",
+									height: "100%",
+								}}
+							/>
+						)}
+					</VSCodeTextField>
+					<VSCodeRadioGroup
+						style={{ display: "flex", flexWrap: "wrap" }}
+						value={sortOption}
+						role="radiogroup"
+						onChange={(e) => setSortOption((e.target as HTMLInputElement).value as SortOption)}>
+						<VSCodeRadio value="newest">Newest</VSCodeRadio>
+						<VSCodeRadio value="oldest">Oldest</VSCodeRadio>
+						<VSCodeRadio value="mostExpensive">Most Expensive</VSCodeRadio>
+						<VSCodeRadio value="mostTokens">Most Tokens</VSCodeRadio>
+						<VSCodeRadio
+							value="mostRelevant"
+							disabled={!searchQuery}
+							style={{ opacity: searchQuery ? 1 : 0.5 }}>
+							Most Relevant
+						</VSCodeRadio>
+					</VSCodeRadioGroup>
 				</div>
-				<div style={{ flexGrow: 1, overflowY: "auto", margin: 0 }}>
-					<Virtuoso
-						style={{
-							flexGrow: 1,
-							overflowY: "scroll",
-						}}
-						data={taskHistorySearchResults}
-						data-testid="virtuoso-container"
-						components={{
-							List: React.forwardRef((props, ref) => (
-								<div {...props} ref={ref} data-testid="virtuoso-item-list" />
-							)),
-						}}
-						itemContent={(index, item) => (
+			</div>
+			<div style={{ flexGrow: 1, overflowY: "auto", margin: 0 }}>
+				<Virtuoso
+					style={{
+						flexGrow: 1,
+						overflowY: "scroll",
+					}}
+					data={taskHistorySearchResults}
+					data-testid="virtuoso-container"
+					components={{
+						List: React.forwardRef((props, ref) => (
+							<div {...props} ref={ref} data-testid="virtuoso-item-list" />
+						)),
+					}}
+					itemContent={(index, item) => (
+						<div
+							key={item.id}
+							data-testid={`task-item-${item.id}`}
+							className="history-item"
+							style={{
+								cursor: "pointer",
+								borderBottom:
+									index < taskHistory.length - 1 ? "1px solid var(--vscode-panel-border)" : "none",
+							}}
+							onClick={() => handleHistorySelect(item.id)}>
 							<div
-								key={item.id}
-								data-testid={`task-item-${item.id}`}
-								className="history-item"
 								style={{
-									cursor: "pointer",
-									borderBottom:
-										index < taskHistory.length - 1
-											? "1px solid var(--vscode-panel-border)"
-											: "none",
-								}}
-								onClick={() => handleHistorySelect(item.id)}>
+									display: "flex",
+									flexDirection: "column",
+									gap: "8px",
+									padding: "12px 20px",
+									position: "relative",
+								}}>
 								<div
 									style={{
 										display: "flex",
-										flexDirection: "column",
-										gap: "8px",
-										padding: "12px 20px",
-										position: "relative",
+										justifyContent: "space-between",
+										alignItems: "center",
 									}}>
+									<span
+										style={{
+											color: "var(--vscode-descriptionForeground)",
+											fontWeight: 500,
+											fontSize: "0.85em",
+											textTransform: "uppercase",
+										}}>
+										{formatDate(item.ts)}
+									</span>
+									<div className="flex flex-row">
+										<Button
+											variant="ghost"
+											size="sm"
+											title="Delete Task"
+											onClick={(e) => {
+												e.stopPropagation()
+												handleDeleteHistoryItem(item.id)
+											}}>
+											<span className="codicon codicon-trash" />
+											{item.size && prettyBytes(item.size)}
+										</Button>
+									</div>
+								</div>
+								<div
+									style={{
+										fontSize: "var(--vscode-font-size)",
+										color: "var(--vscode-foreground)",
+										display: "-webkit-box",
+										WebkitLineClamp: 3,
+										WebkitBoxOrient: "vertical",
+										overflow: "hidden",
+										whiteSpace: "pre-wrap",
+										wordBreak: "break-word",
+										overflowWrap: "anywhere",
+									}}
+									dangerouslySetInnerHTML={{ __html: item.task }}
+								/>
+								<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
 									<div
+										data-testid="tokens-container"
 										style={{
 											display: "flex",
 											justifyContent: "space-between",
 											alignItems: "center",
 										}}>
-										<span
+										<div
 											style={{
-												color: "var(--vscode-descriptionForeground)",
-												fontWeight: 500,
-												fontSize: "0.85em",
-												textTransform: "uppercase",
+												display: "flex",
+												alignItems: "center",
+												gap: "4px",
+												flexWrap: "wrap",
 											}}>
-											{formatDate(item.ts)}
-										</span>
-										<div style={{ display: "flex", gap: "4px" }}>
-											<button
-												title="Copy Prompt"
-												className="copy-button"
-												data-appearance="icon"
-												onClick={(e) => copyWithFeedback(item.task, e)}>
-												<span className="codicon codicon-copy"></span>
-											</button>
-											<button
-												title="Delete Task"
-												className="delete-button"
-												data-appearance="icon"
-												onClick={(e) => {
-													e.stopPropagation()
-													handleDeleteHistoryItem(item.id)
+											<span
+												style={{
+													fontWeight: 500,
+													color: "var(--vscode-descriptionForeground)",
+												}}>
+												Tokens:
+											</span>
+											<span
+												data-testid="tokens-in"
+												style={{
+													display: "flex",
+													alignItems: "center",
+													gap: "3px",
+													color: "var(--vscode-descriptionForeground)",
+												}}>
+												<i
+													className="codicon codicon-arrow-up"
+													style={{
+														fontSize: "12px",
+														fontWeight: "bold",
+														marginBottom: "-2px",
+													}}
+												/>
+												{formatLargeNumber(item.tokensIn || 0)}
+											</span>
+											<span
+												data-testid="tokens-out"
+												style={{
+													display: "flex",
+													alignItems: "center",
+													gap: "3px",
+													color: "var(--vscode-descriptionForeground)",
 												}}>
-												<span className="codicon codicon-trash"></span>
-											</button>
+												<i
+													className="codicon codicon-arrow-down"
+													style={{
+														fontSize: "12px",
+														fontWeight: "bold",
+														marginBottom: "-2px",
+													}}
+												/>
+												{formatLargeNumber(item.tokensOut || 0)}
+											</span>
 										</div>
+										{!item.totalCost && <ExportButton itemId={item.id} />}
 									</div>
-									<div
-										style={{
-											fontSize: "var(--vscode-font-size)",
-											color: "var(--vscode-foreground)",
-											display: "-webkit-box",
-											WebkitLineClamp: 3,
-											WebkitBoxOrient: "vertical",
-											overflow: "hidden",
-											whiteSpace: "pre-wrap",
-											wordBreak: "break-word",
-											overflowWrap: "anywhere",
-										}}
-										dangerouslySetInnerHTML={{ __html: item.task }}
-									/>
-									<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
+
+									{!!item.cacheWrites && (
 										<div
-											data-testid="tokens-container"
+											data-testid="cache-container"
 											style={{
 												display: "flex",
-												justifyContent: "space-between",
 												alignItems: "center",
+												gap: "4px",
+												flexWrap: "wrap",
 											}}>
-											<div
+											<span
+												style={{
+													fontWeight: 500,
+													color: "var(--vscode-descriptionForeground)",
+												}}>
+												Cache:
+											</span>
+											<span
+												data-testid="cache-writes"
 												style={{
 													display: "flex",
 													alignItems: "center",
-													gap: "4px",
-													flexWrap: "wrap",
+													gap: "3px",
+													color: "var(--vscode-descriptionForeground)",
 												}}>
-												<span
-													style={{
-														fontWeight: 500,
-														color: "var(--vscode-descriptionForeground)",
-													}}>
-													Tokens:
-												</span>
-												<span
-													data-testid="tokens-in"
-													style={{
-														display: "flex",
-														alignItems: "center",
-														gap: "3px",
-														color: "var(--vscode-descriptionForeground)",
-													}}>
-													<i
-														className="codicon codicon-arrow-up"
-														style={{
-															fontSize: "12px",
-															fontWeight: "bold",
-															marginBottom: "-2px",
-														}}
-													/>
-													{formatLargeNumber(item.tokensIn || 0)}
-												</span>
-												<span
-													data-testid="tokens-out"
+												<i
+													className="codicon codicon-database"
 													style={{
-														display: "flex",
-														alignItems: "center",
-														gap: "3px",
-														color: "var(--vscode-descriptionForeground)",
-													}}>
-													<i
-														className="codicon codicon-arrow-down"
-														style={{
-															fontSize: "12px",
-															fontWeight: "bold",
-															marginBottom: "-2px",
-														}}
-													/>
-													{formatLargeNumber(item.tokensOut || 0)}
-												</span>
-											</div>
-											{!item.totalCost && <ExportButton itemId={item.id} />}
-										</div>
-
-										{!!item.cacheWrites && (
-											<div
-												data-testid="cache-container"
+														fontSize: "12px",
+														fontWeight: "bold",
+														marginBottom: "-1px",
+													}}
+												/>
+												+{formatLargeNumber(item.cacheWrites || 0)}
+											</span>
+											<span
+												data-testid="cache-reads"
 												style={{
 													display: "flex",
 													alignItems: "center",
-													gap: "4px",
-													flexWrap: "wrap",
+													gap: "3px",
+													color: "var(--vscode-descriptionForeground)",
 												}}>
-												<span
+												<i
+													className="codicon codicon-arrow-right"
 													style={{
-														fontWeight: 500,
-														color: "var(--vscode-descriptionForeground)",
-													}}>
-													Cache:
-												</span>
+														fontSize: "12px",
+														fontWeight: "bold",
+														marginBottom: 0,
+													}}
+												/>
+												{formatLargeNumber(item.cacheReads || 0)}
+											</span>
+										</div>
+									)}
+
+									{!!item.totalCost && (
+										<div
+											style={{
+												display: "flex",
+												justifyContent: "space-between",
+												alignItems: "center",
+												marginTop: -2,
+											}}>
+											<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
 												<span
-													data-testid="cache-writes"
 													style={{
-														display: "flex",
-														alignItems: "center",
-														gap: "3px",
+														fontWeight: 500,
 														color: "var(--vscode-descriptionForeground)",
 													}}>
-													<i
-														className="codicon codicon-database"
-														style={{
-															fontSize: "12px",
-															fontWeight: "bold",
-															marginBottom: "-1px",
-														}}
-													/>
-													+{formatLargeNumber(item.cacheWrites || 0)}
+													API Cost:
 												</span>
-												<span
-													data-testid="cache-reads"
-													style={{
-														display: "flex",
-														alignItems: "center",
-														gap: "3px",
-														color: "var(--vscode-descriptionForeground)",
-													}}>
-													<i
-														className="codicon codicon-arrow-right"
-														style={{
-															fontSize: "12px",
-															fontWeight: "bold",
-															marginBottom: 0,
-														}}
-													/>
-													{formatLargeNumber(item.cacheReads || 0)}
+												<span style={{ color: "var(--vscode-descriptionForeground)" }}>
+													${item.totalCost?.toFixed(4)}
 												</span>
 											</div>
-										)}
-										{!!item.totalCost && (
-											<div
-												style={{
-													display: "flex",
-													justifyContent: "space-between",
-													alignItems: "center",
-													marginTop: -2,
-												}}>
-												<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
-													<span
-														style={{
-															fontWeight: 500,
-															color: "var(--vscode-descriptionForeground)",
-														}}>
-														API Cost:
-													</span>
-													<span style={{ color: "var(--vscode-descriptionForeground)" }}>
-														${item.totalCost?.toFixed(4)}
-													</span>
-												</div>
+											<div className="flex flex-row gap-1">
+												<CopyButton itemTask={item.task} />
 												<ExportButton itemId={item.id} />
 											</div>
-										)}
-									</div>
+										</div>
+									)}
 								</div>
 							</div>
-						)}
-					/>
-				</div>
+						</div>
+					)}
+				/>
 			</div>
-		</>
+		</div>
+	)
+}
+
+const CopyButton = ({ itemTask }: { itemTask: string }) => {
+	const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard()
+
+	return (
+		<Button variant="ghost" size="icon" title="Copy Prompt" onClick={(e) => copyWithFeedback(itemTask, e)}>
+			{showCopyFeedback ? <span className="codicon codicon-check" /> : <span className="codicon codicon-copy" />}
+		</Button>
 	)
 }
 
 const ExportButton = ({ itemId }: { itemId: string }) => (
-	<VSCodeButton
-		className="export-button"
-		appearance="icon"
+	<Button
+		data-testid="export"
+		variant="ghost"
+		size="icon"
+		title="Export Task"
 		onClick={(e) => {
 			e.stopPropagation()
 			vscode.postMessage({ type: "exportTaskWithId", text: itemId })
 		}}>
-		<div style={{ fontSize: "11px", fontWeight: 500, opacity: 1 }}>EXPORT</div>
-	</VSCodeButton>
+		<span className="codicon codicon-cloud-download" />
+	</Button>
 )
 
 export default memo(HistoryView)

+ 1 - 4
webview-ui/src/components/history/__tests__/HistoryView.test.tsx

@@ -181,9 +181,6 @@ describe("HistoryView", () => {
 		// Verify clipboard was called
 		expect(navigator.clipboard.writeText).toHaveBeenCalledWith("Test task 1")
 
-		// Verify modal appears immediately after clipboard operation
-		expect(screen.getByText("Prompt Copied to Clipboard")).toBeInTheDocument()
-
 		// Advance timer to trigger the setTimeout for modal disappearance
 		act(() => {
 			jest.advanceTimersByTime(2000)
@@ -239,7 +236,7 @@ describe("HistoryView", () => {
 		const taskContainer = screen.getByTestId("virtuoso-item-2")
 		fireEvent.mouseEnter(taskContainer)
 
-		const exportButton = within(taskContainer).getByText("EXPORT")
+		const exportButton = within(taskContainer).getByTestId("export")
 		fireEvent.click(exportButton)
 
 		// Verify vscode message was sent

+ 245 - 179
webview-ui/src/components/prompts/PromptsView.tsx

@@ -6,6 +6,8 @@ import {
 	VSCodeOption,
 	VSCodeTextField,
 	VSCodeCheckbox,
+	VSCodeRadioGroup,
+	VSCodeRadio,
 } from "@vscode/webview-ui-toolkit/react"
 import { useExtensionState } from "../../context/ExtensionStateContext"
 import {
@@ -17,6 +19,7 @@ import {
 	ModeConfig,
 	GroupEntry,
 } from "../../../../src/shared/modes"
+import { CustomModeSchema } from "../../../../src/core/config/CustomModesSchema"
 import {
 	supportPrompt,
 	SupportPromptType,
@@ -30,6 +33,8 @@ import { vscode } from "../../utils/vscode"
 // Get all available groups that should show in prompts view
 const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group) => !TOOL_GROUPS[group].alwaysAvailable)
 
+type ModeSource = "global" | "project"
+
 type PromptsViewProps = {
 	onDone: () => void
 }
@@ -64,6 +69,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 	const [selectedPromptContent, setSelectedPromptContent] = useState("")
 	const [selectedPromptTitle, setSelectedPromptTitle] = useState("")
 	const [isToolsEditMode, setIsToolsEditMode] = useState(false)
+	const [showConfigMenu, setShowConfigMenu] = useState(false)
 	const [isCreateModeDialogOpen, setIsCreateModeDialogOpen] = useState(false)
 	const [activeSupportTab, setActiveSupportTab] = useState<SupportPromptType>("ENHANCE")
 
@@ -88,10 +94,14 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 	)
 
 	const updateCustomMode = useCallback((slug: string, modeConfig: ModeConfig) => {
+		const source = modeConfig.source || "global"
 		vscode.postMessage({
 			type: "updateCustomMode",
 			slug,
-			modeConfig,
+			modeConfig: {
+				...modeConfig,
+				source, // Ensure source is set
+			},
 		})
 	}, [])
 
@@ -146,15 +156,36 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 	const [newModeRoleDefinition, setNewModeRoleDefinition] = useState("")
 	const [newModeCustomInstructions, setNewModeCustomInstructions] = useState("")
 	const [newModeGroups, setNewModeGroups] = useState<GroupEntry[]>(availableGroups)
+	const [newModeSource, setNewModeSource] = useState<ModeSource>("global")
+
+	// Field-specific error states
+	const [nameError, setNameError] = useState<string>("")
+	const [slugError, setSlugError] = useState<string>("")
+	const [roleDefinitionError, setRoleDefinitionError] = useState<string>("")
+	const [groupsError, setGroupsError] = useState<string>("")
+
+	// Helper to reset form state
+	const resetFormState = useCallback(() => {
+		// Reset form fields
+		setNewModeName("")
+		setNewModeSlug("")
+		setNewModeGroups(availableGroups)
+		setNewModeRoleDefinition("")
+		setNewModeCustomInstructions("")
+		setNewModeSource("global")
+		// Reset error states
+		setNameError("")
+		setSlugError("")
+		setRoleDefinitionError("")
+		setGroupsError("")
+	}, [])
 
 	// Reset form fields when dialog opens
 	useEffect(() => {
 		if (isCreateModeDialogOpen) {
-			setNewModeGroups(availableGroups)
-			setNewModeRoleDefinition("")
-			setNewModeCustomInstructions("")
+			resetFormState()
 		}
-	}, [isCreateModeDialogOpen])
+	}, [isCreateModeDialogOpen, resetFormState])
 
 	// Helper function to generate a unique slug from a name
 	const generateSlug = useCallback((name: string, attempt = 0): string => {
@@ -175,25 +206,62 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 	)
 
 	const handleCreateMode = useCallback(() => {
-		if (!newModeName.trim() || !newModeSlug.trim()) return
+		// Clear previous errors
+		setNameError("")
+		setSlugError("")
+		setRoleDefinitionError("")
+		setGroupsError("")
 
+		const source = newModeSource
 		const newMode: ModeConfig = {
 			slug: newModeSlug,
 			name: newModeName,
-			roleDefinition: newModeRoleDefinition.trim() || "",
+			roleDefinition: newModeRoleDefinition.trim(),
 			customInstructions: newModeCustomInstructions.trim() || undefined,
 			groups: newModeGroups,
+			source,
 		}
+
+		// Validate the mode against the schema
+		const result = CustomModeSchema.safeParse(newMode)
+		if (!result.success) {
+			// Map Zod errors to specific fields
+			result.error.errors.forEach((error) => {
+				const field = error.path[0] as string
+				const message = error.message
+
+				switch (field) {
+					case "name":
+						setNameError(message)
+						break
+					case "slug":
+						setSlugError(message)
+						break
+					case "roleDefinition":
+						setRoleDefinitionError(message)
+						break
+					case "groups":
+						setGroupsError(message)
+						break
+				}
+			})
+			return
+		}
+
 		updateCustomMode(newModeSlug, newMode)
 		switchMode(newModeSlug)
 		setIsCreateModeDialogOpen(false)
-		setNewModeName("")
-		setNewModeSlug("")
-		setNewModeRoleDefinition("")
-		setNewModeCustomInstructions("")
-		setNewModeGroups(availableGroups)
+		resetFormState()
 		// eslint-disable-next-line react-hooks/exhaustive-deps
-	}, [newModeName, newModeSlug, newModeRoleDefinition, newModeCustomInstructions, newModeGroups, updateCustomMode])
+	}, [
+		newModeName,
+		newModeSlug,
+		newModeRoleDefinition,
+		newModeCustomInstructions,
+		newModeGroups,
+		newModeSource,
+		updateCustomMode,
+	])
 
 	const isNameOrSlugTaken = useCallback(
 		(name: string, slug: string) => {
@@ -233,15 +301,29 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 					newGroups = oldGroups.filter((g) => getGroupName(g) !== group)
 				}
 				if (customMode) {
+					const source = customMode.source || "global"
 					updateCustomMode(customMode.slug, {
 						...customMode,
 						groups: newGroups,
+						source,
 					})
 				}
 			},
 		[updateCustomMode],
 	)
 
+	// Handle clicks outside the config menu
+	useEffect(() => {
+		const handleClickOutside = (event: MouseEvent) => {
+			if (showConfigMenu) {
+				setShowConfigMenu(false)
+			}
+		}
+
+		document.addEventListener("click", handleClickOutside)
+		return () => document.removeEventListener("click", handleClickOutside)
+	}, [showConfigMenu])
+
 	useEffect(() => {
 		const handler = (event: MessageEvent) => {
 			const message = event.data
@@ -307,31 +389,16 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 	}
 
 	return (
-		<div
-			style={{
-				position: "fixed",
-				top: 0,
-				left: 0,
-				right: 0,
-				bottom: 0,
-				display: "flex",
-				flexDirection: "column",
-			}}>
-			<div
-				style={{
-					display: "flex",
-					justifyContent: "space-between",
-					alignItems: "center",
-					padding: "10px 17px 10px 20px",
-				}}>
-				<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Prompts</h3>
+		<div className="fixed inset-0 flex flex-col">
+			<div className="flex justify-between items-center px-5 py-2.5">
+				<h3 className="text-vscode-foreground m-0">Prompts</h3>
 				<VSCodeButton onClick={onDone}>Done</VSCodeButton>
 			</div>
 
-			<div style={{ flex: 1, overflow: "auto", padding: "0 20px" }}>
-				<div style={{ paddingBottom: "20px", borderBottom: "1px solid var(--vscode-input-border)" }}>
-					<div style={{ marginBottom: "20px" }}>
-						<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Preferred Language</div>
+			<div className="flex-1 overflow-auto px-5">
+				<div className="pb-5 border-b border-vscode-input-border">
+					<div className="mb-5">
+						<div className="font-bold mb-1">Preferred Language</div>
 						<select
 							value={preferredLanguage}
 							onChange={(e) => {
@@ -341,15 +408,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									text: e.target.value,
 								})
 							}}
-							style={{
-								width: "100%",
-								padding: "4px 8px",
-								backgroundColor: "var(--vscode-input-background)",
-								color: "var(--vscode-input-foreground)",
-								border: "1px solid var(--vscode-input-border)",
-								borderRadius: "2px",
-								height: "28px",
-							}}>
+							className="w-full px-2 py-1 h-7 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded">
 							<option value="English">English</option>
 							<option value="Arabic">Arabic - العربية</option>
 							<option value="Brazilian Portuguese">Portuguese - Português (Brasil)</option>
@@ -369,19 +428,13 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 							<option value="Traditional Chinese">Traditional Chinese - 繁體中文</option>
 							<option value="Turkish">Turkish - Türkçe</option>
 						</select>
-						<p
-							style={{
-								fontSize: "12px",
-								marginTop: "5px",
-								color: "var(--vscode-descriptionForeground)",
-							}}>
+						<p className="text-xs mt-1.5 text-vscode-descriptionForeground">
 							Select the language that Cline should use for communication.
 						</p>
 					</div>
 
-					<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Custom Instructions for All Modes</div>
-					<div
-						style={{ fontSize: "13px", color: "var(--vscode-descriptionForeground)", marginBottom: "8px" }}>
+					<div className="font-bold mb-1">Custom Instructions for All Modes</div>
+					<div className="text-sm text-vscode-descriptionForeground mb-2">
 						These instructions apply to all modes. They provide a base set of behaviors that can be enhanced
 						by mode-specific instructions below.
 					</div>
@@ -399,23 +452,13 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 						}}
 						rows={4}
 						resize="vertical"
-						style={{ width: "100%" }}
+						className="w-full"
 						data-testid="global-custom-instructions-textarea"
 					/>
-					<div
-						style={{
-							fontSize: "12px",
-							color: "var(--vscode-descriptionForeground)",
-							marginTop: "5px",
-							marginBottom: "40px",
-						}}>
+					<div className="text-xs text-vscode-descriptionForeground mt-1.5 mb-10">
 						Instructions can also be loaded from{" "}
 						<span
-							style={{
-								color: "var(--vscode-textLink-foreground)",
-								cursor: "pointer",
-								textDecoration: "underline",
-							}}
+							className="text-vscode-textLink-foreground cursor-pointer underline"
 							onClick={() =>
 								vscode.postMessage({
 									type: "openFile",
@@ -432,50 +475,74 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 					</div>
 				</div>
 
-				<div style={{ marginTop: "20px" }}>
-					<div
-						style={{
-							display: "flex",
-							justifyContent: "space-between",
-							alignItems: "center",
-							marginBottom: "12px",
-						}}>
-						<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Mode-Specific Prompts</h3>
-						<div style={{ display: "flex", gap: "8px" }}>
+				<div className="mt-5">
+					<div onClick={(e) => e.stopPropagation()} className="flex justify-between items-center mb-3">
+						<h3 className="text-vscode-foreground m-0">Modes</h3>
+						<div className="flex gap-2">
 							<VSCodeButton appearance="icon" onClick={openCreateModeDialog} title="Create new mode">
 								<span className="codicon codicon-add"></span>
 							</VSCodeButton>
-							<VSCodeButton
-								appearance="icon"
-								title="Edit modes configuration"
-								onClick={() => {
-									vscode.postMessage({
-										type: "openCustomModesSettings",
-									})
-								}}>
-								<span className="codicon codicon-json"></span>
-							</VSCodeButton>
+							<div className="relative inline-block">
+								<VSCodeButton
+									appearance="icon"
+									title="Edit modes configuration"
+									className="flex"
+									onClick={(e: React.MouseEvent) => {
+										e.preventDefault()
+										e.stopPropagation()
+										setShowConfigMenu((prev) => !prev)
+									}}
+									onBlur={() => {
+										// Add slight delay to allow menu item clicks to register
+										setTimeout(() => setShowConfigMenu(false), 200)
+									}}>
+									<span className="codicon codicon-json"></span>
+								</VSCodeButton>
+								{showConfigMenu && (
+									<div
+										onClick={(e) => e.stopPropagation()}
+										onMouseDown={(e) => e.stopPropagation()}
+										className="absolute top-full right-0 w-[200px] mt-1 bg-vscode-editor-background border border-vscode-input-border rounded shadow-md z-[1000]">
+										<div
+											className="p-2 cursor-pointer text-vscode-foreground text-sm"
+											onMouseDown={(e) => {
+												e.preventDefault() // Prevent blur
+												vscode.postMessage({
+													type: "openCustomModesSettings",
+												})
+												setShowConfigMenu(false)
+											}}
+											onClick={(e) => e.preventDefault()}>
+											Edit Global Modes
+										</div>
+										<div
+											className="p-2 cursor-pointer text-vscode-foreground text-sm border-t border-vscode-input-border"
+											onMouseDown={(e) => {
+												e.preventDefault() // Prevent blur
+												vscode.postMessage({
+													type: "openFile",
+													text: "./.roomodes",
+													values: {
+														create: true,
+														content: JSON.stringify({ customModes: [] }, null, 2),
+													},
+												})
+												setShowConfigMenu(false)
+											}}
+											onClick={(e) => e.preventDefault()}>
+											Edit Project Modes (.roomodes)
+										</div>
+									</div>
+								)}
+							</div>
 						</div>
 					</div>
 
-					<div
-						style={{
-							fontSize: "13px",
-							color: "var(--vscode-descriptionForeground)",
-							marginBottom: "12px",
-						}}>
+					<div className="text-sm text-vscode-descriptionForeground mb-3">
 						Hit the + to create a new custom mode, or just ask Roo in chat to create one for you!
 					</div>
 
-					<div
-						style={{
-							display: "flex",
-							gap: "8px",
-							alignItems: "center",
-							marginBottom: "12px",
-							flexWrap: "wrap",
-							padding: "4px 0",
-						}}>
+					<div className="flex gap-2 items-center mb-3 flex-wrap py-1">
 						{modes.map((modeConfig) => {
 							const isActive = mode === modeConfig.slug
 							return (
@@ -484,18 +551,11 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									data-testid={`${modeConfig.slug}-tab`}
 									data-active={isActive ? "true" : "false"}
 									onClick={() => handleModeSwitch(modeConfig)}
-									style={{
-										padding: "4px 8px",
-										border: "none",
-										background: isActive ? "var(--vscode-button-background)" : "none",
-										color: isActive
-											? "var(--vscode-button-foreground)"
-											: "var(--vscode-foreground)",
-										cursor: "pointer",
-										opacity: isActive ? 1 : 0.8,
-										borderRadius: "3px",
-										fontWeight: "bold",
-									}}>
+									className={`px-2 py-1 border-none rounded cursor-pointer font-bold ${
+										isActive
+											? "bg-vscode-button-background text-vscode-button-foreground opacity-100"
+											: "bg-transparent text-vscode-foreground opacity-80"
+									}`}>
 									{modeConfig.name}
 								</button>
 							)
@@ -506,10 +566,10 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 				<div style={{ marginBottom: "20px" }}>
 					{/* Only show name and delete for custom modes */}
 					{mode && findModeBySlug(mode, customModes) && (
-						<div style={{ display: "flex", gap: "12px", marginBottom: "16px" }}>
-							<div style={{ flex: 1 }}>
-								<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Name</div>
-								<div style={{ display: "flex", gap: "8px" }}>
+						<div className="flex gap-3 mb-4">
+							<div className="flex-1">
+								<div className="font-bold mb-1">Name</div>
+								<div className="flex gap-2">
 									<VSCodeTextField
 										value={getModeProperty(findModeBySlug(mode, customModes), "name") ?? ""}
 										onChange={(e: Event | React.FormEvent<HTMLElement>) => {
@@ -521,10 +581,11 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 												updateCustomMode(mode, {
 													...customMode,
 													name: target.value,
+													source: customMode.source || "global",
 												})
 											}
 										}}
-										style={{ width: "100%" }}
+										className="w-full"
 									/>
 									<VSCodeButton
 										appearance="icon"
@@ -542,14 +603,8 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 						</div>
 					)}
 					<div style={{ marginBottom: "16px" }}>
-						<div
-							style={{
-								display: "flex",
-								justifyContent: "space-between",
-								alignItems: "center",
-								marginBottom: "4px",
-							}}>
-							<div style={{ fontWeight: "bold" }}>Role Definition</div>
+						<div className="flex justify-between items-center mb-1">
+							<div className="font-bold">Role Definition</div>
 							{!findModeBySlug(mode, customModes) && (
 								<VSCodeButton
 									appearance="icon"
@@ -565,12 +620,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 								</VSCodeButton>
 							)}
 						</div>
-						<div
-							style={{
-								fontSize: "13px",
-								color: "var(--vscode-descriptionForeground)",
-								marginBottom: "8px",
-							}}>
+						<div className="text-sm text-vscode-descriptionForeground mb-2">
 							Define Roo's expertise and personality for this mode. This description shapes how Roo
 							presents itself and approaches tasks.
 						</div>
@@ -590,6 +640,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									updateCustomMode(mode, {
 										...customMode,
 										roleDefinition: value.trim() || "",
+										source: customMode.source || "global",
 									})
 								} else {
 									// For built-in modes, update the prompts
@@ -618,34 +669,23 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 											text: value,
 										})
 									}}
-									style={{ width: "100%" }}>
+									className="w-full">
 									{(listApiConfigMeta || []).map((config) => (
 										<VSCodeOption key={config.id} value={config.name}>
 											{config.name}
 										</VSCodeOption>
 									))}
 								</VSCodeDropdown>
-								<div
-									style={{
-										fontSize: "12px",
-										marginTop: "5px",
-										color: "var(--vscode-descriptionForeground)",
-									}}>
+								<div className="text-xs mt-1.5 text-vscode-descriptionForeground">
 									Select which API configuration to use for this mode
 								</div>
 							</div>
 						</div>
 
 						{/* Show tools for all modes */}
-						<div style={{ marginBottom: "16px" }}>
-							<div
-								style={{
-									display: "flex",
-									justifyContent: "space-between",
-									alignItems: "center",
-									marginBottom: "4px",
-								}}>
-								<div style={{ fontWeight: "bold" }}>Available Tools</div>
+						<div className="mb-4">
+							<div className="flex justify-between items-center mb-1">
+								<div className="font-bold">Available Tools</div>
 								{findModeBySlug(mode, customModes) && (
 									<VSCodeButton
 										appearance="icon"
@@ -657,22 +697,12 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 								)}
 							</div>
 							{!findModeBySlug(mode, customModes) && (
-								<div
-									style={{
-										fontSize: "13px",
-										color: "var(--vscode-descriptionForeground)",
-										marginBottom: "8px",
-									}}>
+								<div className="text-sm text-vscode-descriptionForeground mb-2">
 									Tools for built-in modes cannot be modified
 								</div>
 							)}
 							{isToolsEditMode && findModeBySlug(mode, customModes) ? (
-								<div
-									style={{
-										display: "grid",
-										gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
-										gap: "8px",
-									}}>
+								<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2">
 									{availableGroups.map((group) => {
 										const currentMode = getCurrentMode()
 										const isCustomMode = findModeBySlug(mode, customModes)
@@ -689,12 +719,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 												disabled={!isCustomMode}>
 												{GROUP_DISPLAY_NAMES[group]}
 												{group === "edit" && (
-													<div
-														style={{
-															fontSize: "12px",
-															color: "var(--vscode-descriptionForeground)",
-															marginTop: "2px",
-														}}>
+													<div className="text-xs text-vscode-descriptionForeground mt-0.5">
 														Allowed files:{" "}
 														{(() => {
 															const currentMode = getCurrentMode()
@@ -717,13 +742,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									})}
 								</div>
 							) : (
-								<div
-									style={{
-										fontSize: "13px",
-										color: "var(--vscode-foreground)",
-										marginBottom: "8px",
-										lineHeight: "1.4",
-									}}>
+								<div className="text-sm text-vscode-foreground mb-2 leading-relaxed">
 									{(() => {
 										const currentMode = getCurrentMode()
 										const enabledGroups = currentMode?.groups || []
@@ -754,7 +773,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 								alignItems: "center",
 								marginBottom: "4px",
 							}}>
-							<div style={{ fontWeight: "bold" }}>Mode-specific Custom Instructions</div>
+							<div style={{ fontWeight: "bold" }}>Mode-specific Custom Instructions (optional)</div>
 							{!findModeBySlug(mode, customModes) && (
 								<VSCodeButton
 									appearance="icon"
@@ -798,6 +817,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									updateCustomMode(mode, {
 										...customMode,
 										customInstructions: value.trim() || undefined,
+										source: customMode.source || "global",
 									})
 								} else {
 									// For built-in modes, update the prompts
@@ -1095,6 +1115,9 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									}}
 									style={{ width: "100%" }}
 								/>
+								{nameError && (
+									<div className="text-xs text-vscode-errorForeground mt-1">{nameError}</div>
+								)}
 							</div>
 							<div style={{ marginBottom: "16px" }}>
 								<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Slug</div>
@@ -1117,7 +1140,43 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									The slug is used in URLs and file names. It should be lowercase and contain only
 									letters, numbers, and hyphens.
 								</div>
+								{slugError && (
+									<div className="text-xs text-vscode-errorForeground mt-1">{slugError}</div>
+								)}
+							</div>
+							<div style={{ marginBottom: "16px" }}>
+								<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Save Location</div>
+								<div className="text-sm text-vscode-descriptionForeground mb-2">
+									Choose where to save this mode. Project-specific modes take precedence over global
+									modes.
+								</div>
+								<VSCodeRadioGroup
+									value={newModeSource}
+									onChange={(e: Event | React.FormEvent<HTMLElement>) => {
+										const target = ((e as CustomEvent)?.detail?.target ||
+											(e.target as HTMLInputElement)) as HTMLInputElement
+										setNewModeSource(target.value as ModeSource)
+									}}>
+									<VSCodeRadio value="global">
+										Global
+										<div
+											style={{
+												fontSize: "12px",
+												color: "var(--vscode-descriptionForeground)",
+												marginTop: "2px",
+											}}>
+											Available in all workspaces
+										</div>
+									</VSCodeRadio>
+									<VSCodeRadio value="project">
+										Project-specific (.roomodes)
+										<div className="text-xs text-vscode-descriptionForeground mt-0.5">
+											Only available in this workspace, takes precedence over global
+										</div>
+									</VSCodeRadio>
+								</VSCodeRadioGroup>
 							</div>
+
 							<div style={{ marginBottom: "16px" }}>
 								<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Role Definition</div>
 								<div
@@ -1140,6 +1199,11 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									resize="vertical"
 									style={{ width: "100%" }}
 								/>
+								{roleDefinitionError && (
+									<div className="text-xs text-vscode-errorForeground mt-1">
+										{roleDefinitionError}
+									</div>
+								)}
 							</div>
 							<div style={{ marginBottom: "16px" }}>
 								<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Available Tools</div>
@@ -1177,9 +1241,14 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 										</VSCodeCheckbox>
 									))}
 								</div>
+								{groupsError && (
+									<div className="text-xs text-vscode-errorForeground mt-1">{groupsError}</div>
+								)}
 							</div>
 							<div style={{ marginBottom: "16px" }}>
-								<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Custom Instructions</div>
+								<div style={{ fontWeight: "bold", marginBottom: "4px" }}>
+									Custom Instructions (optional)
+								</div>
 								<div
 									style={{
 										fontSize: "13px",
@@ -1212,10 +1281,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 								backgroundColor: "var(--vscode-editor-background)",
 							}}>
 							<VSCodeButton onClick={() => setIsCreateModeDialogOpen(false)}>Cancel</VSCodeButton>
-							<VSCodeButton
-								appearance="primary"
-								onClick={handleCreateMode}
-								disabled={!newModeName.trim() || !newModeSlug.trim()}>
+							<VSCodeButton appearance="primary" onClick={handleCreateMode}>
 								Create Mode
 							</VSCodeButton>
 						</div>

+ 25 - 0
webview-ui/src/components/settings/SettingsView.tsx

@@ -63,6 +63,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 		setExperimentEnabled,
 		alwaysAllowModeSwitch,
 		setAlwaysAllowModeSwitch,
+		maxOpenTabsContext,
+		setMaxOpenTabsContext,
 	} = useExtensionState()
 	const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
 	const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
@@ -104,6 +106,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 			vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit })
 			vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds })
 			vscode.postMessage({ type: "rateLimitSeconds", value: rateLimitSeconds })
+			vscode.postMessage({ type: "maxOpenTabsContext", value: maxOpenTabsContext })
 			vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName })
 			vscode.postMessage({
 				type: "upsertApiConfiguration",
@@ -626,6 +629,28 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 						</p>
 					</div>
 
+					<div style={{ marginBottom: 15 }}>
+						<div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
+							<span style={{ fontWeight: "500" }}>Open tabs context limit</span>
+							<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
+								<input
+									type="range"
+									min="0"
+									max="500"
+									step="1"
+									value={maxOpenTabsContext ?? 20}
+									onChange={(e) => setMaxOpenTabsContext(parseInt(e.target.value))}
+									style={{ ...sliderStyle }}
+								/>
+								<span style={{ ...sliderLabelStyle }}>{maxOpenTabsContext ?? 20}</span>
+							</div>
+						</div>
+						<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
+							Maximum number of VSCode open tabs to include in context. Higher values provide more context
+							but increase token usage.
+						</p>
+					</div>
+
 					<div style={{ marginBottom: 15 }}>
 						<VSCodeCheckbox
 							checked={diffEnabled}

+ 3 - 0
webview-ui/src/context/ExtensionStateContext.tsx

@@ -82,6 +82,7 @@ export interface ExtensionStateContextType extends ExtensionState {
 	handleInputChange: (field: keyof ApiConfiguration, softUpdate?: boolean) => (event: any) => void
 	customModes: ModeConfig[]
 	setCustomModes: (value: ModeConfig[]) => void
+	setMaxOpenTabsContext: (value: number) => void
 }
 
 export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@@ -117,6 +118,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		enhancementApiConfigId: "",
 		autoApprovalEnabled: false,
 		customModes: [],
+		maxOpenTabsContext: 20,
 	})
 
 	const [didHydrateState, setDidHydrateState] = useState(false)
@@ -350,6 +352,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		setAutoApprovalEnabled: (value) => setState((prevState) => ({ ...prevState, autoApprovalEnabled: value })),
 		handleInputChange,
 		setCustomModes: (value) => setState((prevState) => ({ ...prevState, customModes: value })),
+		setMaxOpenTabsContext: (value) => setState((prevState) => ({ ...prevState, maxOpenTabsContext: value })),
 	}
 
 	return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>

+ 2 - 0
webview-ui/src/index.css

@@ -83,6 +83,8 @@
 	--color-vscode-notifications-foreground: var(--vscode-notifications-foreground);
 	--color-vscode-notifications-background: var(--vscode-notifications-background);
 	--color-vscode-notifications-border: var(--vscode-notifications-border);
+	--color-vscode-descriptionForeground: var(--vscode-descriptionForeground);
+	--color-vscode-errorForeground: var(--vscode-errorForeground);
 }
 
 @layer base {