Sfoglia il codice sorgente

Merge branch 'upstream-at-v3.16.6' into roo-v3.16.6

beatlevic 9 mesi fa
parent
commit
223d3c08c0
100 ha cambiato i file con 4168 aggiunte e 3288 eliminazioni
  1. 5 0
      .changeset/twenty-carrots-act.md
  2. 1 0
      .gitignore
  3. 11 1
      .vscode/tasks.json
  4. 4 4
      e2e/package-lock.json
  5. 1 1
      e2e/package.json
  6. 1 1
      evals/.tool-versions
  7. 1 1
      evals/apps/web/package.json
  8. 0 1
      evals/config/eslint/package.json
  9. 1 1
      evals/package.json
  10. 1 1
      evals/packages/db/package.json
  11. 245 46
      evals/packages/types/src/roo-code.ts
  12. 328 109
      evals/pnpm-lock.yaml
  13. 0 1
      jest.config.js
  14. 342 241
      package-lock.json
  15. 4 4
      package.json
  16. 1 1
      src/activate/CodeActionProvider.ts
  17. 5 13
      src/activate/__tests__/CodeActionProvider.test.ts
  18. 3 1
      src/activate/handleTask.ts
  19. 1 0
      src/activate/index.ts
  20. 3 2
      src/activate/registerCodeActions.ts
  21. 2 2
      src/api/index.ts
  22. 1 1
      src/api/providers/fetchers/openrouter.ts
  23. 15 2
      src/api/providers/openrouter.ts
  24. 0 408
      src/core/__tests__/read-file-maxReadFileLine.test.ts
  25. 2 1
      src/core/assistant-message/index.ts
  26. 0 0
      src/core/assistant-message/parseAssistantMessage.ts
  27. 523 0
      src/core/assistant-message/presentAssistantMessage.ts
  28. 291 0
      src/core/checkpoints/index.ts
  29. 57 31
      src/core/config/ProviderSettingsManager.ts
  30. 72 56
      src/core/config/__tests__/ProviderSettingsManager.test.ts
  31. 15 15
      src/core/environment/__tests__/getEnvironmentDetails.test.ts
  32. 2 2
      src/core/environment/getEnvironmentDetails.ts
  33. 10 4
      src/core/mentions/index.ts
  34. 92 0
      src/core/mentions/processUserContentMentions.ts
  35. 117 921
      src/core/task/Task.ts
  36. 82 100
      src/core/task/__tests__/Task.test.ts
  37. 2 2
      src/core/tools/ToolRepetitionDetector.ts
  38. 7 6
      src/core/tools/__tests__/ToolRepetitionDetector.test.ts
  39. 8 8
      src/core/tools/__tests__/executeCommandTool.test.ts
  40. 383 43
      src/core/tools/__tests__/readFileTool.test.ts
  41. 4 4
      src/core/tools/__tests__/validateToolUse.test.ts
  42. 2 2
      src/core/tools/accessMcpResourceTool.ts
  43. 2 2
      src/core/tools/applyDiffTool.ts
  44. 2 2
      src/core/tools/askFollowupQuestionTool.ts
  45. 2 2
      src/core/tools/attemptCompletionTool.ts
  46. 2 2
      src/core/tools/browserActionTool.ts
  47. 9 6
      src/core/tools/executeCommandTool.ts
  48. 2 2
      src/core/tools/fetchInstructionsTool.ts
  49. 2 2
      src/core/tools/insertContentTool.ts
  50. 2 2
      src/core/tools/listCodeDefinitionNamesTool.ts
  51. 2 2
      src/core/tools/listFilesTool.ts
  52. 2 2
      src/core/tools/newTaskTool.ts
  53. 17 3
      src/core/tools/readFileTool.ts
  54. 3 3
      src/core/tools/searchAndReplaceTool.ts
  55. 2 2
      src/core/tools/searchFilesTool.ts
  56. 2 2
      src/core/tools/switchModeTool.ts
  57. 2 2
      src/core/tools/useMcpToolTool.ts
  58. 2 2
      src/core/tools/validateToolUse.ts
  59. 2 2
      src/core/tools/writeToFileTool.ts
  60. 143 76
      src/core/webview/ClineProvider.ts
  61. 136 156
      src/core/webview/__tests__/ClineProvider.test.ts
  62. 44 94
      src/core/webview/webviewMessageHandler.ts
  63. 107 86
      src/exports/api.ts
  64. 61 16
      src/exports/interface.ts
  65. 219 160
      src/exports/roo-code.d.ts
  66. 182 148
      src/exports/types.ts
  67. 9 4
      src/extension.ts
  68. 0 0
      src/integrations/editor/EditorUtils.ts
  69. 1 1
      src/integrations/editor/__tests__/EditorUtils.test.ts
  70. 11 11
      src/integrations/misc/__tests__/read-file-tool.test.ts
  71. 154 54
      src/schemas/index.ts
  72. 5 5
      src/shared/ExtensionMessage.ts
  73. 2 3
      src/shared/WebviewMessage.ts
  74. 6 6
      src/shared/__tests__/checkExistApiConfig.test.ts
  75. 1 3
      src/shared/api.ts
  76. 4 4
      src/utils/__tests__/enhance-prompt.test.ts
  77. 2 2
      src/utils/single-completion-handler.ts
  78. 235 237
      webview-ui/package-lock.json
  79. 2 2
      webview-ui/package.json
  80. 1 0
      webview-ui/src/components/chat/ChatRow.tsx
  81. 9 2
      webview-ui/src/components/chat/ChatTextArea.tsx
  82. 34 48
      webview-ui/src/components/chat/CommandExecution.tsx
  83. 2 2
      webview-ui/src/components/chat/__tests__/TaskHeader.test.tsx
  84. 35 14
      webview-ui/src/components/prompts/PromptsView.tsx
  85. 2 0
      webview-ui/src/components/prompts/__tests__/PromptsView.test.tsx
  86. 2 2
      webview-ui/src/components/settings/ApiConfigManager.tsx
  87. 6 24
      webview-ui/src/components/settings/ApiOptions.tsx
  88. 3 3
      webview-ui/src/components/settings/PromptCachingControl.tsx
  89. 3 3
      webview-ui/src/components/settings/ReasoningEffort.tsx
  90. 2 2
      webview-ui/src/components/settings/SettingsView.tsx
  91. 3 3
      webview-ui/src/components/settings/ThinkingBudget.tsx
  92. 2 2
      webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx
  93. 5 5
      webview-ui/src/components/settings/providers/Anthropic.tsx
  94. 5 5
      webview-ui/src/components/settings/providers/Bedrock.tsx
  95. 3 3
      webview-ui/src/components/settings/providers/BedrockCustomArn.tsx
  96. 5 5
      webview-ui/src/components/settings/providers/Chutes.tsx
  97. 5 5
      webview-ui/src/components/settings/providers/DeepSeek.tsx
  98. 5 5
      webview-ui/src/components/settings/providers/Gemini.tsx
  99. 5 5
      webview-ui/src/components/settings/providers/Glama.tsx
  100. 5 5
      webview-ui/src/components/settings/providers/Groq.tsx

+ 5 - 0
.changeset/twenty-carrots-act.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": minor
+---
+
+Include changes from Roo Code v3.16.6

+ 1 - 0
.gitignore

@@ -4,6 +4,7 @@ out
 out-*
 node_modules
 coverage/
+mock/
 
 .idea
 

+ 11 - 1
.vscode/tasks.json

@@ -44,7 +44,17 @@
 			"type": "npm",
 			"script": "watch:esbuild",
 			"group": "build",
-			"problemMatcher": "$esbuild-watch",
+			"problemMatcher": {
+				"owner": "esbuild",
+				"pattern": {
+					"regexp": "^$"
+				},
+				"background": {
+					"activeOnStart": true,
+					"beginsPattern": "\\[watch\\] build started",
+					"endsPattern": "\\[watch\\] build finished"
+				}
+			},
 			"isBackground": true,
 			"presentation": {
 				"group": "watch",

+ 4 - 4
e2e/package-lock.json

@@ -12,7 +12,7 @@
 				"@vscode/test-cli": "^0.0.10",
 				"@vscode/test-electron": "^2.4.0",
 				"mocha": "^11.1.0",
-				"typescript": "^5.4.5"
+				"typescript": "5.8.3"
 			}
 		},
 		"node_modules/@bcoe/v8-coverage": {
@@ -2072,9 +2072,9 @@
 			}
 		},
 		"node_modules/typescript": {
-			"version": "5.8.2",
-			"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
-			"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
+			"version": "5.8.3",
+			"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
+			"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
 			"dev": true,
 			"license": "Apache-2.0",
 			"bin": {

+ 1 - 1
e2e/package.json

@@ -16,6 +16,6 @@
 		"@vscode/test-cli": "^0.0.10",
 		"@vscode/test-electron": "^2.4.0",
 		"mocha": "^11.1.0",
-		"typescript": "^5.4.5"
+		"typescript": "5.8.3"
 	}
 }

+ 1 - 1
evals/.tool-versions

@@ -1,4 +1,4 @@
 python 3.13.2
 golang 1.24.2
-rust 1.85.1
+rust 1.86.0
 nodejs 20.18.1

+ 1 - 1
evals/apps/web/package.json

@@ -31,7 +31,7 @@
 		"clsx": "^2.1.1",
 		"cmdk": "^1.1.0",
 		"fuzzysort": "^3.1.0",
-		"lucide-react": "^0.479.0",
+		"lucide-react": "^0.510.0",
 		"next": "15.2.2",
 		"next-themes": "^0.4.6",
 		"p-map": "^7.0.3",

+ 0 - 1
evals/config/eslint/package.json

@@ -16,7 +16,6 @@
 		"eslint-plugin-react-hooks": "^5.2.0",
 		"eslint-plugin-turbo": "^2.4.4",
 		"globals": "^16.0.0",
-		"typescript": "^5",
 		"typescript-eslint": "^8.26.0"
 	}
 }

+ 1 - 1
evals/package.json

@@ -20,7 +20,7 @@
 		"prettier": "^3.5.3",
 		"tsx": "^4.19.4",
 		"turbo": "^2.5.2",
-		"typescript": "^5.8.3",
+		"typescript": "5.8.3",
 		"typescript-eslint": "^8.31.1"
 	}
 }

+ 1 - 1
evals/packages/db/package.json

@@ -21,7 +21,7 @@
 	},
 	"dependencies": {
 		"@evals/types": "workspace:^",
-		"@libsql/client": "^0.14.0",
+		"@libsql/client": "^0.15.0",
 		"drizzle-orm": "^0.40.0",
 		"drizzle-zod": "^0.7.0",
 		"p-map": "^7.0.3",

+ 245 - 46
evals/packages/types/src/roo-code.ts

@@ -128,18 +128,6 @@ export const modelInfoSchema = z.object({
 
 export type ModelInfo = z.infer<typeof modelInfoSchema>
 
-/**
- * ApiConfigMeta
- */
-
-export const apiConfigMetaSchema = z.object({
-	id: z.string(),
-	name: z.string(),
-	apiProvider: providerNamesSchema.optional(),
-})
-
-export type ApiConfigMeta = z.infer<typeof apiConfigMetaSchema>
-
 /**
  * HistoryItem
  */
@@ -330,26 +318,55 @@ export type Experiments = z.infer<typeof experimentsSchema>
 type _AssertExperiments = AssertEqual<Equals<ExperimentId, Keys<Experiments>>>
 
 /**
- * ProviderSettings
+ * ProviderSettingsEntry
  */
 
-export const providerSettingsSchema = z.object({
+export const providerSettingsEntrySchema = z.object({
+	id: z.string(),
+	name: z.string(),
 	apiProvider: providerNamesSchema.optional(),
-	// Anthropic
+})
+
+export type ProviderSettingsEntry = z.infer<typeof providerSettingsEntrySchema>
+
+/**
+ * ProviderSettings
+ */
+
+const genericProviderSettingsSchema = z.object({
+	includeMaxTokens: z.boolean().optional(),
+	reasoningEffort: reasoningEffortsSchema.optional(),
+	promptCachingDisabled: z.boolean().optional(),
+	diffEnabled: z.boolean().optional(),
+	fuzzyMatchThreshold: z.number().optional(),
+	modelTemperature: z.number().nullish(),
+	rateLimitSeconds: z.number().optional(),
+	// Claude 3.7 Sonnet Thinking
+	modelMaxTokens: z.number().optional(),
+	modelMaxThinkingTokens: z.number().optional(),
+})
+
+const anthropicSchema = z.object({
 	apiModelId: z.string().optional(),
 	apiKey: z.string().optional(),
 	anthropicBaseUrl: z.string().optional(),
 	anthropicUseAuthToken: z.boolean().optional(),
-	// Glama
+})
+
+const glamaSchema = z.object({
 	glamaModelId: z.string().optional(),
 	glamaApiKey: z.string().optional(),
-	// OpenRouter
+})
+
+const openRouterSchema = z.object({
 	openRouterApiKey: z.string().optional(),
 	openRouterModelId: z.string().optional(),
 	openRouterBaseUrl: z.string().optional(),
 	openRouterSpecificProvider: z.string().optional(),
 	openRouterUseMiddleOutTransform: z.boolean().optional(),
-	// Amazon Bedrock
+})
+
+const bedrockSchema = z.object({
 	awsAccessKey: z.string().optional(),
 	awsSecretKey: z.string().optional(),
 	awsSessionToken: z.string().optional(),
@@ -359,15 +376,18 @@ export const providerSettingsSchema = z.object({
 	awsProfile: z.string().optional(),
 	awsUseProfile: z.boolean().optional(),
 	awsCustomArn: z.string().optional(),
-	// Google Vertex
+})
+
+const vertexSchema = z.object({
 	vertexKeyFile: z.string().optional(),
 	vertexJsonCredentials: z.string().optional(),
 	vertexProjectId: z.string().optional(),
 	vertexRegion: z.string().optional(),
-	// OpenAI
+})
+
+const openAiSchema = z.object({
 	openAiBaseUrl: z.string().optional(),
 	openAiApiKey: z.string().optional(),
-	openAiHostHeader: z.string().optional(),
 	openAiLegacyFormat: z.boolean().optional(),
 	openAiR1FormatEnabled: z.boolean().optional(),
 	openAiModelId: z.string().optional(),
@@ -376,10 +396,16 @@ export const providerSettingsSchema = z.object({
 	azureApiVersion: z.string().optional(),
 	openAiStreamingEnabled: z.boolean().optional(),
 	enableReasoningEffort: z.boolean().optional(),
-	// Ollama
+	openAiHostHeader: z.string().optional(), // Keep temporarily for backward compatibility during migration.
+	openAiHeaders: z.record(z.string(), z.string()).optional(),
+})
+
+const ollamaSchema = z.object({
 	ollamaModelId: z.string().optional(),
 	ollamaBaseUrl: z.string().optional(),
-	// VS Code LM
+})
+
+const vsCodeLmSchema = z.object({
 	vsCodeLmModelSelector: z
 		.object({
 			vendor: z.string().optional(),
@@ -388,46 +414,210 @@ export const providerSettingsSchema = z.object({
 			id: z.string().optional(),
 		})
 		.optional(),
-	// LM Studio
+})
+
+const lmStudioSchema = z.object({
 	lmStudioModelId: z.string().optional(),
 	lmStudioBaseUrl: z.string().optional(),
 	lmStudioDraftModelId: z.string().optional(),
 	lmStudioSpeculativeDecodingEnabled: z.boolean().optional(),
-	// Gemini
+})
+
+const geminiSchema = z.object({
 	geminiApiKey: z.string().optional(),
 	googleGeminiBaseUrl: z.string().optional(),
-	// OpenAI Native
+})
+
+const openAiNativeSchema = z.object({
 	openAiNativeApiKey: z.string().optional(),
 	openAiNativeBaseUrl: z.string().optional(),
-	// Mistral
+})
+
+const mistralSchema = z.object({
 	mistralApiKey: z.string().optional(),
 	mistralCodestralUrl: z.string().optional(),
-	// DeepSeek
+})
+
+const deepSeekSchema = z.object({
 	deepSeekBaseUrl: z.string().optional(),
 	deepSeekApiKey: z.string().optional(),
-	// Unbound
+})
+
+const unboundSchema = z.object({
 	unboundApiKey: z.string().optional(),
 	unboundModelId: z.string().optional(),
-	// Requesty
+})
+
+const requestySchema = z.object({
 	requestyApiKey: z.string().optional(),
 	requestyModelId: z.string().optional(),
-	// X.AI (Grok)
-	xaiApiKey: z.string().optional(),
-	// Claude 3.7 Sonnet Thinking
-	modelMaxTokens: z.number().optional(),
-	modelMaxThinkingTokens: z.number().optional(),
-	// Generic
-	includeMaxTokens: z.boolean().optional(),
-	reasoningEffort: reasoningEffortsSchema.optional(),
-	promptCachingDisabled: z.boolean().optional(),
-	diffEnabled: z.boolean().optional(),
-	fuzzyMatchThreshold: z.number().optional(),
-	modelTemperature: z.number().nullish(),
-	rateLimitSeconds: z.number().optional(),
-	// Fake AI
+})
+
+const humanRelaySchema = z.object({})
+
+const fakeAiSchema = z.object({
 	fakeAi: z.unknown().optional(),
 })
 
+const xaiSchema = z.object({
+	xaiApiKey: z.string().optional(),
+})
+
+const groqSchema = z.object({
+	groqApiKey: z.string().optional(),
+})
+
+const chutesSchema = z.object({
+	chutesApiKey: z.string().optional(),
+})
+
+const litellmSchema = z.object({
+	litellmBaseUrl: z.string().optional(),
+	litellmApiKey: z.string().optional(),
+	litellmModelId: z.string().optional(),
+})
+
+const defaultSchema = z.object({
+	apiProvider: z.undefined(),
+})
+
+export const providerSettingsSchemaDiscriminated = z
+	.discriminatedUnion("apiProvider", [
+		anthropicSchema.merge(
+			z.object({
+				apiProvider: z.literal("anthropic"),
+			}),
+		),
+		glamaSchema.merge(
+			z.object({
+				apiProvider: z.literal("glama"),
+			}),
+		),
+		openRouterSchema.merge(
+			z.object({
+				apiProvider: z.literal("openrouter"),
+			}),
+		),
+		bedrockSchema.merge(
+			z.object({
+				apiProvider: z.literal("bedrock"),
+			}),
+		),
+		vertexSchema.merge(
+			z.object({
+				apiProvider: z.literal("vertex"),
+			}),
+		),
+		openAiSchema.merge(
+			z.object({
+				apiProvider: z.literal("openai"),
+			}),
+		),
+		ollamaSchema.merge(
+			z.object({
+				apiProvider: z.literal("ollama"),
+			}),
+		),
+		vsCodeLmSchema.merge(
+			z.object({
+				apiProvider: z.literal("vscode-lm"),
+			}),
+		),
+		lmStudioSchema.merge(
+			z.object({
+				apiProvider: z.literal("lmstudio"),
+			}),
+		),
+		geminiSchema.merge(
+			z.object({
+				apiProvider: z.literal("gemini"),
+			}),
+		),
+		openAiNativeSchema.merge(
+			z.object({
+				apiProvider: z.literal("openai-native"),
+			}),
+		),
+		mistralSchema.merge(
+			z.object({
+				apiProvider: z.literal("mistral"),
+			}),
+		),
+		deepSeekSchema.merge(
+			z.object({
+				apiProvider: z.literal("deepseek"),
+			}),
+		),
+		unboundSchema.merge(
+			z.object({
+				apiProvider: z.literal("unbound"),
+			}),
+		),
+		requestySchema.merge(
+			z.object({
+				apiProvider: z.literal("requesty"),
+			}),
+		),
+		humanRelaySchema.merge(
+			z.object({
+				apiProvider: z.literal("human-relay"),
+			}),
+		),
+		fakeAiSchema.merge(
+			z.object({
+				apiProvider: z.literal("fake-ai"),
+			}),
+		),
+		xaiSchema.merge(
+			z.object({
+				apiProvider: z.literal("xai"),
+			}),
+		),
+		groqSchema.merge(
+			z.object({
+				apiProvider: z.literal("groq"),
+			}),
+		),
+		chutesSchema.merge(
+			z.object({
+				apiProvider: z.literal("chutes"),
+			}),
+		),
+		litellmSchema.merge(
+			z.object({
+				apiProvider: z.literal("litellm"),
+			}),
+		),
+		defaultSchema,
+	])
+	.and(genericProviderSettingsSchema)
+
+export const providerSettingsSchema = z.object({
+	apiProvider: providerNamesSchema.optional(),
+	...anthropicSchema.shape,
+	...glamaSchema.shape,
+	...openRouterSchema.shape,
+	...bedrockSchema.shape,
+	...vertexSchema.shape,
+	...openAiSchema.shape,
+	...ollamaSchema.shape,
+	...vsCodeLmSchema.shape,
+	...lmStudioSchema.shape,
+	...geminiSchema.shape,
+	...openAiNativeSchema.shape,
+	...mistralSchema.shape,
+	...deepSeekSchema.shape,
+	...unboundSchema.shape,
+	...requestySchema.shape,
+	...humanRelaySchema.shape,
+	...fakeAiSchema.shape,
+	...xaiSchema.shape,
+	...groqSchema.shape,
+	...chutesSchema.shape,
+	...litellmSchema.shape,
+	...genericProviderSettingsSchema.shape,
+})
+
 export type ProviderSettings = z.infer<typeof providerSettingsSchema>
 
 type ProviderSettingsRecord = Record<Keys<ProviderSettings>, undefined>
@@ -466,7 +656,6 @@ const providerSettingsRecord: ProviderSettingsRecord = {
 	// OpenAI
 	openAiBaseUrl: undefined,
 	openAiApiKey: undefined,
-	openAiHostHeader: undefined,
 	openAiLegacyFormat: undefined,
 	openAiR1FormatEnabled: undefined,
 	openAiModelId: undefined,
@@ -475,6 +664,8 @@ const providerSettingsRecord: ProviderSettingsRecord = {
 	azureApiVersion: undefined,
 	openAiStreamingEnabled: undefined,
 	enableReasoningEffort: undefined,
+	openAiHostHeader: undefined, // Keep temporarily for backward compatibility during migration
+	openAiHeaders: undefined,
 	// Ollama
 	ollamaModelId: undefined,
 	ollamaBaseUrl: undefined,
@@ -517,6 +708,14 @@ const providerSettingsRecord: ProviderSettingsRecord = {
 	fakeAi: undefined,
 	// X.AI (Grok)
 	xaiApiKey: undefined,
+	// Groq
+	groqApiKey: undefined,
+	// Chutes AI
+	chutesApiKey: undefined,
+	// LiteLLM
+	litellmBaseUrl: undefined,
+	litellmApiKey: undefined,
+	litellmModelId: undefined,
 }
 
 export const PROVIDER_SETTINGS_KEYS = Object.keys(providerSettingsRecord) as Keys<ProviderSettings>[]
@@ -527,7 +726,7 @@ export const PROVIDER_SETTINGS_KEYS = Object.keys(providerSettingsRecord) as Key
 
 export const globalSettingsSchema = z.object({
 	currentApiConfigName: z.string().optional(),
-	listApiConfigMeta: z.array(apiConfigMetaSchema).optional(),
+	listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(),
 	pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(),
 
 	lastShownAnnouncementId: z.string().optional(),

File diff suppressed because it is too large
+ 328 - 109
evals/pnpm-lock.yaml


+ 0 - 1
jest.config.js

@@ -14,7 +14,6 @@ module.exports = {
 					allowJs: true,
 				},
 				diagnostics: false,
-				isolatedModules: true,
 			},
 		],
 	},

File diff suppressed because it is too large
+ 342 - 241
package-lock.json


+ 4 - 4
package.json

@@ -445,7 +445,7 @@
 		"@anthropic-ai/sdk": "^0.37.0",
 		"@anthropic-ai/vertex-sdk": "^0.7.0",
 		"@aws-sdk/client-bedrock-runtime": "^3.779.0",
-		"@google/genai": "^0.12.0",
+		"@google/genai": "^0.13.0",
 		"@mistralai/mistralai": "^1.3.6",
 		"@modelcontextprotocol/sdk": "^1.9.0",
 		"@types/clone-deep": "^4.0.4",
@@ -498,7 +498,7 @@
 		"vscode-material-icons": "^0.1.1",
 		"web-tree-sitter": "^0.22.6",
 		"workerpool": "^9.2.0",
-		"zod": "^3.23.8"
+		"zod": "^3.24.2"
 	},
 	"devDependencies": {
 		"@changesets/changelog-github": "^0.5.1",
@@ -520,7 +520,7 @@
 		"@typescript-eslint/parser": "^7.11.0",
 		"@vscode/test-electron": "^2.5.2",
 		"@vscode/vsce": "^3.3.2",
-		"esbuild": "^0.24.0",
+		"esbuild": "^0.25.0",
 		"eslint": "^8.57.0",
 		"execa": "^9.5.2",
 		"glob": "^11.0.1",
@@ -537,7 +537,7 @@
 		"ts-jest": "^29.2.5",
 		"tsup": "^8.4.0",
 		"tsx": "^4.19.3",
-		"typescript": "^5.4.5",
+		"typescript": "5.8.3",
 		"vitest": "^3.1.3",
 		"zod-to-ts": "^1.2.0"
 	},

+ 1 - 1
src/core/CodeActionProvider.ts → src/activate/CodeActionProvider.ts

@@ -1,6 +1,6 @@
 import * as vscode from "vscode"
 
-import { EditorUtils } from "./EditorUtils"
+import { EditorUtils } from "../integrations/editor/EditorUtils"
 
 export type CodeActionName = "EXPLAIN" | "FIX" | "IMPROVE" | "ADD_TO_CONTEXT" | "NEW_TASK"
 

+ 5 - 13
src/core/__tests__/CodeActionProvider.test.ts → src/activate/__tests__/CodeActionProvider.test.ts

@@ -1,12 +1,11 @@
-// npx jest src/core/__tests__/CodeActionProvider.test.ts
+// npx jest src/activate/__tests__/CodeActionProvider.test.ts
 
 import * as vscode from "vscode"
 
-import { EditorUtils } from "../EditorUtils"
+import { EditorUtils } from "../../integrations/editor/EditorUtils"
 
 import { CodeActionProvider, ACTION_TITLES } from "../CodeActionProvider"
 
-// Mock VSCode API
 jest.mock("vscode", () => ({
 	CodeAction: jest.fn().mockImplementation((title, kind) => ({
 		title,
@@ -29,8 +28,7 @@ jest.mock("vscode", () => ({
 	},
 }))
 
-// Mock EditorUtils
-jest.mock("../EditorUtils", () => ({
+jest.mock("../../integrations/editor/EditorUtils", () => ({
 	EditorUtils: {
 		getEffectiveRange: jest.fn(),
 		getFilePath: jest.fn(),
@@ -48,7 +46,6 @@ describe("CodeActionProvider", () => {
 	beforeEach(() => {
 		provider = new CodeActionProvider()
 
-		// Mock document
 		mockDocument = {
 			getText: jest.fn(),
 			lineAt: jest.fn(),
@@ -56,15 +53,9 @@ describe("CodeActionProvider", () => {
 			uri: { fsPath: "/test/file.ts" },
 		}
 
-		// Mock range
 		mockRange = new vscode.Range(0, 0, 0, 10)
 
-		// Mock context
-		mockContext = {
-			diagnostics: [],
-		}
-
-		// Setup default EditorUtils mocks
+		mockContext = { diagnostics: [] }
 		;(EditorUtils.getEffectiveRange as jest.Mock).mockReturnValue({
 			range: mockRange,
 			text: "test code",
@@ -106,6 +97,7 @@ describe("CodeActionProvider", () => {
 
 		it("should handle errors gracefully", () => {
 			const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
+
 			;(EditorUtils.getEffectiveRange as jest.Mock).mockImplementation(() => {
 				throw new Error("Test error")
 			})

+ 3 - 1
src/activate/handleTask.ts

@@ -1,9 +1,11 @@
 import * as vscode from "vscode"
 
-import { COMMAND_IDS } from "../core/CodeActionProvider"
 import { ClineProvider } from "../core/webview/ClineProvider"
+
 import { t } from "../i18n"
 
+import { COMMAND_IDS } from "./CodeActionProvider"
+
 export const handleNewTask = async (params: { prompt?: string } | null | undefined) => {
 	let prompt = params?.prompt
 

+ 1 - 0
src/activate/index.ts

@@ -2,3 +2,4 @@ export { handleUri } from "./handleUri"
 export { registerCommands } from "./registerCommands"
 export { registerCodeActions } from "./registerCodeActions"
 export { registerTerminalActions } from "./registerTerminalActions"
+export { CodeActionProvider } from "./CodeActionProvider"

+ 3 - 2
src/activate/registerCodeActions.ts

@@ -1,9 +1,10 @@
 import * as vscode from "vscode"
 
-import { type CodeActionName, type CodeActionId, COMMAND_IDS } from "../core/CodeActionProvider"
-import { EditorUtils } from "../core/EditorUtils"
+import { EditorUtils } from "../integrations/editor/EditorUtils"
 import { ClineProvider } from "../core/webview/ClineProvider"
 
+import { type CodeActionName, type CodeActionId, COMMAND_IDS } from "./CodeActionProvider"
+
 export const registerCodeActions = (context: vscode.ExtensionContext) => {
 	registerCodeAction(context, COMMAND_IDS.EXPLAIN, "EXPLAIN")
 	registerCodeAction(context, COMMAND_IDS.FIX, "FIX")

+ 2 - 2
src/api/index.ts

@@ -1,6 +1,6 @@
 import { Anthropic } from "@anthropic-ai/sdk"
 
-import { ApiConfiguration, ModelInfo } from "../shared/api"
+import { ProviderSettings, ModelInfo } from "../shared/api"
 import { GlamaHandler } from "./providers/glama"
 import { AnthropicHandler } from "./providers/anthropic"
 import { AwsBedrockHandler } from "./providers/bedrock"
@@ -47,7 +47,7 @@ export interface ApiHandler {
 	countTokens(content: Array<Anthropic.Messages.ContentBlockParam>): Promise<number>
 }
 
-export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
+export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
 	const { apiProvider, ...options } = configuration
 
 	switch (apiProvider) {

+ 1 - 1
src/api/providers/fetchers/openrouter.ts

@@ -77,7 +77,7 @@ export async function getOpenRouterModels(options?: ApiHandlerOptions): Promise<
 				typeof cacheWritesPrice !== "undefined" && typeof cacheReadsPrice !== "undefined"
 
 			const modelInfo: ModelInfo = {
-				maxTokens: rawModel.top_provider?.max_completion_tokens,
+				maxTokens: 0,
 				contextWindow: rawModel.context_length,
 				supportsImages: rawModel.architecture?.modality?.includes("image"),
 				supportsPromptCache,

+ 15 - 2
src/api/providers/openrouter.ts

@@ -108,7 +108,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
 		// https://openrouter.ai/docs/transforms
 		const completionParams: OpenRouterChatCompletionParams = {
 			model: modelId,
-			max_tokens: maxTokens,
+			...(maxTokens && maxTokens > 0 && { max_tokens: maxTokens }),
 			temperature,
 			thinking, // OpenRouter is temporarily supporting this.
 			top_p: topP,
@@ -118,7 +118,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
 			// Only include provider if openRouterSpecificProvider is not "[default]".
 			...(this.options.openRouterSpecificProvider &&
 				this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME && {
-					provider: { order: [this.options.openRouterSpecificProvider] },
+					provider: {
+						order: [this.options.openRouterSpecificProvider],
+						only: [this.options.openRouterSpecificProvider],
+						allow_fallbacks: false,
+					},
 				}),
 			// This way, the transforms field will only be included in the parameters when openRouterUseMiddleOutTransform is true.
 			...((this.options.openRouterUseMiddleOutTransform ?? true) && { transforms: ["middle-out"] }),
@@ -209,6 +213,15 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
 			temperature,
 			messages: [{ role: "user", content: prompt }],
 			stream: false,
+			// Only include provider if openRouterSpecificProvider is not "[default]".
+			...(this.options.openRouterSpecificProvider &&
+				this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME && {
+					provider: {
+						order: [this.options.openRouterSpecificProvider],
+						only: [this.options.openRouterSpecificProvider],
+						allow_fallbacks: false,
+					},
+				}),
 		}
 
 		const response = await this.client.chat.completions.create(completionParams)

+ 0 - 408
src/core/__tests__/read-file-maxReadFileLine.test.ts

@@ -1,408 +0,0 @@
-// npx jest src/core/__tests__/read-file-maxReadFileLine.test.ts
-
-import * as path from "path"
-
-import { countFileLines } from "../../integrations/misc/line-counter"
-import { readLines } from "../../integrations/misc/read-lines"
-import { extractTextFromFile } from "../../integrations/misc/extract-text"
-import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter"
-import { isBinaryFile } from "isbinaryfile"
-import { ReadFileToolUse } from "../../shared/tools"
-
-// Mock dependencies
-jest.mock("../../integrations/misc/line-counter")
-jest.mock("../../integrations/misc/read-lines")
-jest.mock("../../integrations/misc/extract-text", () => {
-	const actual = jest.requireActual("../../integrations/misc/extract-text")
-	// Create a spy on the actual addLineNumbers function
-	const addLineNumbersSpy = jest.spyOn(actual, "addLineNumbers")
-
-	return {
-		...actual,
-		// Expose the spy so tests can access it
-		__addLineNumbersSpy: addLineNumbersSpy,
-		extractTextFromFile: jest.fn(),
-	}
-})
-
-// Get a reference to the spy
-const addLineNumbersSpy = jest.requireMock("../../integrations/misc/extract-text").__addLineNumbersSpy
-
-jest.mock("../../services/tree-sitter")
-jest.mock("isbinaryfile")
-jest.mock("../ignore/RooIgnoreController", () => ({
-	RooIgnoreController: class {
-		initialize() {
-			return Promise.resolve()
-		}
-		validateAccess() {
-			return true
-		}
-	},
-}))
-jest.mock("fs/promises", () => ({
-	mkdir: jest.fn().mockResolvedValue(undefined),
-	writeFile: jest.fn().mockResolvedValue(undefined),
-	readFile: jest.fn().mockResolvedValue("{}"),
-}))
-jest.mock("../../utils/fs", () => ({
-	fileExistsAtPath: jest.fn().mockReturnValue(true),
-}))
-
-// Mock path
-jest.mock("path", () => {
-	const originalPath = jest.requireActual("path")
-	return {
-		...originalPath,
-		resolve: jest.fn().mockImplementation((...args) => args.join("/")),
-	}
-})
-
-describe("read_file tool with maxReadFileLine setting", () => {
-	// Test data
-	const testFilePath = "test/file.txt"
-	const absoluteFilePath = "/test/file.txt"
-	const fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
-	const numberedFileContent = "1 | Line 1\n2 | Line 2\n3 | Line 3\n4 | Line 4\n5 | Line 5\n"
-	const sourceCodeDef = "\n\n# file.txt\n1--5 | Content"
-	const expectedFullFileXml = `<file><path>${testFilePath}</path>\n<content lines="1-5">\n${numberedFileContent}</content>\n</file>`
-
-	// Mocked functions with correct types
-	const mockedCountFileLines = countFileLines as jest.MockedFunction<typeof countFileLines>
-	const mockedReadLines = readLines as jest.MockedFunction<typeof readLines>
-	const mockedExtractTextFromFile = extractTextFromFile as jest.MockedFunction<typeof extractTextFromFile>
-	const mockedParseSourceCodeDefinitionsForFile = parseSourceCodeDefinitionsForFile as jest.MockedFunction<
-		typeof parseSourceCodeDefinitionsForFile
-	>
-
-	// Variable to control what content is used by the mock - set in beforeEach
-	let mockInputContent = ""
-
-	const mockedIsBinaryFile = isBinaryFile as jest.MockedFunction<typeof isBinaryFile>
-	const mockedPathResolve = path.resolve as jest.MockedFunction<typeof path.resolve>
-
-	// Mock instances
-	const mockCline: any = {}
-	let mockProvider: any
-	let toolResult: string | undefined
-
-	beforeEach(() => {
-		jest.clearAllMocks()
-
-		// Setup path resolution
-		mockedPathResolve.mockReturnValue(absoluteFilePath)
-
-		// Setup mocks for file operations
-		mockedIsBinaryFile.mockResolvedValue(false)
-
-		// Set the default content for the mock
-		mockInputContent = fileContent
-
-		// Setup the extractTextFromFile mock implementation with the current mockInputContent
-		mockedExtractTextFromFile.mockImplementation((_filePath) => {
-			const actual = jest.requireActual("../../integrations/misc/extract-text")
-			return Promise.resolve(actual.addLineNumbers(mockInputContent))
-		})
-
-		// No need to setup the extractTextFromFile mock implementation here
-		// as it's already defined at the module level
-
-		// Setup mock provider
-		mockProvider = {
-			getState: jest.fn(),
-			deref: jest.fn().mockReturnThis(),
-		}
-
-		// Setup Cline instance with mock methods
-		mockCline.cwd = "/"
-		mockCline.task = "Test"
-		mockCline.providerRef = mockProvider
-		mockCline.rooIgnoreController = {
-			validateAccess: jest.fn().mockReturnValue(true),
-		}
-		mockCline.say = jest.fn().mockResolvedValue(undefined)
-		mockCline.ask = jest.fn().mockResolvedValue(true)
-		mockCline.presentAssistantMessage = jest.fn()
-		mockCline.getFileContextTracker = jest.fn().mockReturnValue({
-			trackFileContext: jest.fn().mockResolvedValue(undefined),
-		})
-		mockCline.recordToolUsage = jest.fn().mockReturnValue(undefined)
-		mockCline.recordToolError = jest.fn().mockReturnValue(undefined)
-		// Reset tool result
-		toolResult = undefined
-	})
-
-	/**
-	 * Helper function to execute the read file tool with different maxReadFileLine settings
-	 */
-	async function executeReadFileTool(
-		params: Partial<ReadFileToolUse["params"]> = {},
-		options: {
-			maxReadFileLine?: number
-			totalLines?: number
-			skipAddLineNumbersCheck?: boolean // Flag to skip addLineNumbers check
-		} = {},
-	): Promise<string | undefined> {
-		// Configure mocks based on test scenario
-		const maxReadFileLine = options.maxReadFileLine ?? 500
-		const totalLines = options.totalLines ?? 5
-
-		mockProvider.getState.mockResolvedValue({ maxReadFileLine })
-		mockedCountFileLines.mockResolvedValue(totalLines)
-
-		// Reset the spy before each test
-		addLineNumbersSpy.mockClear()
-
-		// Create a tool use object
-		const toolUse: ReadFileToolUse = {
-			type: "tool_use",
-			name: "read_file",
-			params: {
-				path: testFilePath,
-				...params,
-			},
-			partial: false,
-		}
-
-		// Import the tool implementation dynamically to avoid hoisting issues
-		const { readFileTool } = require("../tools/readFileTool")
-
-		// Execute the tool
-		await readFileTool(
-			mockCline,
-			toolUse,
-			mockCline.ask,
-			jest.fn(),
-			(result: string) => {
-				toolResult = result
-			},
-			(param: string, value: string) => value,
-		)
-
-		// Verify addLineNumbers was called appropriately
-		if (!options.skipAddLineNumbersCheck) {
-			expect(addLineNumbersSpy).toHaveBeenCalled()
-		} else {
-			expect(addLineNumbersSpy).not.toHaveBeenCalled()
-		}
-
-		return toolResult
-	}
-	describe("when maxReadFileLine is negative", () => {
-		it("should read the entire file using extractTextFromFile", async () => {
-			// Setup - use default mockInputContent
-			mockInputContent = fileContent
-
-			// Execute
-			const result = await executeReadFileTool({}, { maxReadFileLine: -1 })
-
-			// Verify
-			expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
-			expect(mockedReadLines).not.toHaveBeenCalled()
-			expect(mockedParseSourceCodeDefinitionsForFile).not.toHaveBeenCalled()
-			expect(result).toBe(expectedFullFileXml)
-		})
-
-		it("should ignore range parameters and read entire file when maxReadFileLine is -1", async () => {
-			// Setup - use default mockInputContent
-			mockInputContent = fileContent
-
-			// Execute with range parameters
-			const result = await executeReadFileTool(
-				{
-					start_line: "2",
-					end_line: "4",
-				},
-				{ maxReadFileLine: -1 },
-			)
-
-			// Verify that extractTextFromFile is still used (not readLines)
-			expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
-			expect(mockedReadLines).not.toHaveBeenCalled()
-			expect(mockedParseSourceCodeDefinitionsForFile).not.toHaveBeenCalled()
-			expect(result).toBe(expectedFullFileXml)
-		})
-
-		it("should not show line snippet in approval message when maxReadFileLine is -1", async () => {
-			// This test verifies the line snippet behavior for the approval message
-			// Setup - use default mockInputContent
-			mockInputContent = fileContent
-
-			// Execute - we'll reuse executeReadFileTool to run the tool
-			await executeReadFileTool({}, { maxReadFileLine: -1 })
-
-			// Verify the empty line snippet for full read was passed to the approval message
-			// Look at the parameters passed to the 'ask' method in the approval message
-			const askCall = mockCline.ask.mock.calls[0]
-			const completeMessage = JSON.parse(askCall[1])
-
-			// Verify the reason (lineSnippet) is empty or undefined for full read
-			expect(completeMessage.reason).toBeFalsy()
-		})
-	})
-
-	describe("when maxReadFileLine is 0", () => {
-		it("should return an empty content with source code definitions", async () => {
-			// Setup - for maxReadFileLine = 0, the implementation won't call readLines
-			mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef)
-
-			// Execute - skip addLineNumbers check as it's not called for maxReadFileLine=0
-			const result = await executeReadFileTool(
-				{},
-				{
-					maxReadFileLine: 0,
-					totalLines: 5,
-					skipAddLineNumbersCheck: true,
-				},
-			)
-
-			// Verify
-			expect(mockedExtractTextFromFile).not.toHaveBeenCalled()
-			expect(mockedReadLines).not.toHaveBeenCalled() // Per implementation line 141
-			expect(mockedParseSourceCodeDefinitionsForFile).toHaveBeenCalledWith(
-				absoluteFilePath,
-				mockCline.rooIgnoreController,
-			)
-
-			// Verify XML structure
-			expect(result).toContain(`<file><path>${testFilePath}</path>`)
-			expect(result).toContain("<notice>Showing only 0 of 5 total lines")
-			expect(result).toContain("</notice>")
-			expect(result).toContain("<list_code_definition_names>")
-			expect(result).toContain(sourceCodeDef.trim())
-			expect(result).toContain("</list_code_definition_names>")
-			expect(result).not.toContain("<content") // No content when maxReadFileLine is 0
-		})
-	})
-
-	describe("when maxReadFileLine is less than file length", () => {
-		it("should read only maxReadFileLine lines and add source code definitions", async () => {
-			// Setup
-			const content = "Line 1\nLine 2\nLine 3"
-			mockedReadLines.mockResolvedValue(content)
-			mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef)
-
-			// Execute
-			const result = await executeReadFileTool({}, { maxReadFileLine: 3 })
-
-			// Verify - check behavior but not specific implementation details
-			expect(mockedExtractTextFromFile).not.toHaveBeenCalled()
-			expect(mockedReadLines).toHaveBeenCalled()
-			expect(mockedParseSourceCodeDefinitionsForFile).toHaveBeenCalledWith(
-				absoluteFilePath,
-				mockCline.rooIgnoreController,
-			)
-
-			// Verify XML structure
-			expect(result).toContain(`<file><path>${testFilePath}</path>`)
-			expect(result).toContain('<content lines="1-3">')
-			expect(result).toContain("1 | Line 1")
-			expect(result).toContain("2 | Line 2")
-			expect(result).toContain("3 | Line 3")
-			expect(result).toContain("</content>")
-			expect(result).toContain("<notice>Showing only 3 of 5 total lines")
-			expect(result).toContain("</notice>")
-			expect(result).toContain("<list_code_definition_names>")
-			expect(result).toContain(sourceCodeDef.trim())
-			expect(result).toContain("</list_code_definition_names>")
-			expect(result).toContain("<list_code_definition_names>")
-			expect(result).toContain(sourceCodeDef.trim())
-		})
-	})
-
-	describe("when maxReadFileLine equals or exceeds file length", () => {
-		it("should use extractTextFromFile when maxReadFileLine > totalLines", async () => {
-			// Setup
-			mockedCountFileLines.mockResolvedValue(5) // File shorter than maxReadFileLine
-			mockInputContent = fileContent
-
-			// Execute
-			const result = await executeReadFileTool({}, { maxReadFileLine: 10, totalLines: 5 })
-
-			// Verify
-			expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
-			expect(result).toBe(expectedFullFileXml)
-		})
-
-		it("should read with extractTextFromFile when file has few lines", async () => {
-			// Setup
-			mockedCountFileLines.mockResolvedValue(3) // File shorter than maxReadFileLine
-			mockInputContent = fileContent
-
-			// Execute
-			const result = await executeReadFileTool({}, { maxReadFileLine: 5, totalLines: 3 })
-
-			// Verify
-			expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
-			expect(mockedReadLines).not.toHaveBeenCalled()
-			// Create a custom expected XML with lines="1-3" since totalLines is 3
-			const expectedXml = `<file><path>${testFilePath}</path>\n<content lines="1-3">\n${numberedFileContent}</content>\n</file>`
-			expect(result).toBe(expectedXml)
-		})
-	})
-
-	describe("when file is binary", () => {
-		it("should always use extractTextFromFile regardless of maxReadFileLine", async () => {
-			// Setup
-			mockedIsBinaryFile.mockResolvedValue(true)
-			// For binary files, we're using a maxReadFileLine of 3 and totalLines is assumed to be 3
-			mockedCountFileLines.mockResolvedValue(3)
-
-			// For binary files, we need a special mock implementation that doesn't use addLineNumbers
-			// Save the original mock implementation
-			const originalMockImplementation = mockedExtractTextFromFile.getMockImplementation()
-			// Create a special mock implementation that doesn't call addLineNumbers
-			mockedExtractTextFromFile.mockImplementation(() => {
-				return Promise.resolve(numberedFileContent)
-			})
-
-			// Reset the spy to clear any previous calls
-			addLineNumbersSpy.mockClear()
-
-			// Execute - skip addLineNumbers check as we're directly providing the numbered content
-			const result = await executeReadFileTool(
-				{},
-				{
-					maxReadFileLine: 3,
-					totalLines: 3,
-					skipAddLineNumbersCheck: true,
-				},
-			)
-
-			// Restore the original mock implementation after the test
-			mockedExtractTextFromFile.mockImplementation(originalMockImplementation)
-
-			// Verify
-			expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
-			expect(mockedReadLines).not.toHaveBeenCalled()
-			// Create a custom expected XML with lines="1-3" for binary files
-			const expectedXml = `<file><path>${testFilePath}</path>\n<content lines="1-3">\n${numberedFileContent}</content>\n</file>`
-			expect(result).toBe(expectedXml)
-		})
-	})
-
-	describe("with range parameters", () => {
-		it("should honor start_line and end_line when provided", async () => {
-			// Setup
-			mockedReadLines.mockResolvedValue("Line 2\nLine 3\nLine 4")
-
-			// Execute using executeReadFileTool with range parameters
-			const rangeResult = await executeReadFileTool({
-				start_line: "2",
-				end_line: "4",
-			})
-
-			// Verify
-			expect(mockedReadLines).toHaveBeenCalledWith(absoluteFilePath, 3, 1) // end_line - 1, start_line - 1
-			expect(addLineNumbersSpy).toHaveBeenCalledWith(expect.any(String), 2) // start with proper line numbers
-
-			// Verify XML structure with lines attribute
-			expect(rangeResult).toContain(`<file><path>${testFilePath}</path>`)
-			expect(rangeResult).toContain(`<content lines="2-4">`)
-			expect(rangeResult).toContain("2 | Line 2")
-			expect(rangeResult).toContain("3 | Line 3")
-			expect(rangeResult).toContain("4 | Line 4")
-			expect(rangeResult).toContain("</content>")
-		})
-	})
-})

+ 2 - 1
src/core/assistant-message/index.ts

@@ -1 +1,2 @@
-export { type AssistantMessageContent, parseAssistantMessage } from "./parse-assistant-message"
+export { type AssistantMessageContent, parseAssistantMessage } from "./parseAssistantMessage"
+export { presentAssistantMessage } from "./presentAssistantMessage"

+ 0 - 0
src/core/assistant-message/parse-assistant-message.ts → src/core/assistant-message/parseAssistantMessage.ts


+ 523 - 0
src/core/assistant-message/presentAssistantMessage.ts

@@ -0,0 +1,523 @@
+import cloneDeep from "clone-deep"
+import { serializeError } from "serialize-error"
+
+import type { ToolName } from "../../schemas"
+
+import { defaultModeSlug, getModeBySlug } from "../../shared/modes"
+import type { ToolParamName, ToolResponse } from "../../shared/tools"
+import type { ClineAsk, ToolProgressStatus } from "../../shared/ExtensionMessage"
+
+import { fetchInstructionsTool } from "../tools/fetchInstructionsTool"
+import { listFilesTool } from "../tools/listFilesTool"
+import { readFileTool } from "../tools/readFileTool"
+import { writeToFileTool } from "../tools/writeToFileTool"
+import { applyDiffTool } from "../tools/applyDiffTool"
+import { insertContentTool } from "../tools/insertContentTool"
+import { searchAndReplaceTool } from "../tools/searchAndReplaceTool"
+import { listCodeDefinitionNamesTool } from "../tools/listCodeDefinitionNamesTool"
+import { searchFilesTool } from "../tools/searchFilesTool"
+import { browserActionTool } from "../tools/browserActionTool"
+import { executeCommandTool } from "../tools/executeCommandTool"
+import { useMcpToolTool } from "../tools/useMcpToolTool"
+import { accessMcpResourceTool } from "../tools/accessMcpResourceTool"
+import { askFollowupQuestionTool } from "../tools/askFollowupQuestionTool"
+import { switchModeTool } from "../tools/switchModeTool"
+import { attemptCompletionTool } from "../tools/attemptCompletionTool"
+import { newTaskTool } from "../tools/newTaskTool"
+
+import { checkpointSave } from "../checkpoints"
+
+import { formatResponse } from "../prompts/responses"
+import { validateToolUse } from "../tools/validateToolUse"
+import { Task } from "../task/Task"
+
+/**
+ * Processes and presents assistant message content to the user interface.
+ *
+ * This function is the core message handling system that:
+ * - Sequentially processes content blocks from the assistant's response.
+ * - Displays text content to the user.
+ * - Executes tool use requests with appropriate user approval.
+ * - Manages the flow of conversation by determining when to proceed to the next content block.
+ * - Coordinates file system checkpointing for modified files.
+ * - Controls the conversation state to determine when to continue to the next request.
+ *
+ * The function uses a locking mechanism to prevent concurrent execution and handles
+ * partial content blocks during streaming. It's designed to work with the streaming
+ * API response pattern, where content arrives incrementally and needs to be processed
+ * as it becomes available.
+ */
+
+export async function presentAssistantMessage(cline: Task) {
+	if (cline.abort) {
+		throw new Error(`[Cline#presentAssistantMessage] task ${cline.taskId}.${cline.instanceId} aborted`)
+	}
+
+	if (cline.presentAssistantMessageLocked) {
+		cline.presentAssistantMessageHasPendingUpdates = true
+		return
+	}
+
+	cline.presentAssistantMessageLocked = true
+	cline.presentAssistantMessageHasPendingUpdates = false
+
+	if (cline.currentStreamingContentIndex >= cline.assistantMessageContent.length) {
+		// This may happen if the last content block was completed before
+		// streaming could finish. If streaming is finished, and we're out of
+		// bounds then this means we already  presented/executed the last
+		// content block and are ready to continue to next request.
+		if (cline.didCompleteReadingStream) {
+			cline.userMessageContentReady = true
+		}
+
+		cline.presentAssistantMessageLocked = false
+		return
+	}
+
+	const block = cloneDeep(cline.assistantMessageContent[cline.currentStreamingContentIndex]) // need to create copy bc while stream is updating the array, it could be updating the reference block properties too
+
+	switch (block.type) {
+		case "text": {
+			if (cline.didRejectTool || cline.didAlreadyUseTool) {
+				break
+			}
+
+			let content = block.content
+
+			if (content) {
+				// Have to do this for partial and complete since sending
+				// content in thinking tags to markdown renderer will
+				// automatically be removed.
+				// Remove end substrings of <thinking or </thinking (below xml
+				// parsing is only for opening tags).
+				// Tthis is done with the xml parsing below now, but keeping
+				// here for reference.
+				// content = content.replace(/<\/?t(?:h(?:i(?:n(?:k(?:i(?:n(?:g)?)?)?$/, "")
+				//
+				// Remove all instances of <thinking> (with optional line break
+				// after) and </thinking> (with optional line break before).
+				// - Needs to be separate since we dont want to remove the line
+				//   break before the first tag.
+				// - Needs to happen before the xml parsing below.
+				content = content.replace(/<thinking>\s?/g, "")
+				content = content.replace(/\s?<\/thinking>/g, "")
+
+				// Remove partial XML tag at the very end of the content (for
+				// tool use and thinking tags), Prevents scrollview from
+				// jumping when tags are automatically removed.
+				const lastOpenBracketIndex = content.lastIndexOf("<")
+
+				if (lastOpenBracketIndex !== -1) {
+					const possibleTag = content.slice(lastOpenBracketIndex)
+
+					// Check if there's a '>' after the last '<' (i.e., if the
+					// tag is complete) (complete thinking and tool tags will
+					// have been removed by now.)
+					const hasCloseBracket = possibleTag.includes(">")
+
+					if (!hasCloseBracket) {
+						// Extract the potential tag name.
+						let tagContent: string
+
+						if (possibleTag.startsWith("</")) {
+							tagContent = possibleTag.slice(2).trim()
+						} else {
+							tagContent = possibleTag.slice(1).trim()
+						}
+
+						// Check if tagContent is likely an incomplete tag name
+						// (letters and underscores only).
+						const isLikelyTagName = /^[a-zA-Z_]+$/.test(tagContent)
+
+						// Preemptively remove < or </ to keep from these
+						// artifacts showing up in chat (also handles closing
+						// thinking tags).
+						const isOpeningOrClosing = possibleTag === "<" || possibleTag === "</"
+
+						// If the tag is incomplete and at the end, remove it
+						// from the content.
+						if (isOpeningOrClosing || isLikelyTagName) {
+							content = content.slice(0, lastOpenBracketIndex).trim()
+						}
+					}
+				}
+			}
+
+			await cline.say("text", content, undefined, block.partial)
+			break
+		}
+		case "tool_use":
+			const toolDescription = (): string => {
+				switch (block.name) {
+					case "execute_command":
+						return `[${block.name} for '${block.params.command}']`
+					case "read_file":
+						return `[${block.name} for '${block.params.path}']`
+					case "fetch_instructions":
+						return `[${block.name} for '${block.params.task}']`
+					case "write_to_file":
+						return `[${block.name} for '${block.params.path}']`
+					case "apply_diff":
+						return `[${block.name} for '${block.params.path}']`
+					case "search_files":
+						return `[${block.name} for '${block.params.regex}'${
+							block.params.file_pattern ? ` in '${block.params.file_pattern}'` : ""
+						}]`
+					case "insert_content":
+						return `[${block.name} for '${block.params.path}']`
+					case "search_and_replace":
+						return `[${block.name} for '${block.params.path}']`
+					case "list_files":
+						return `[${block.name} for '${block.params.path}']`
+					case "list_code_definition_names":
+						return `[${block.name} for '${block.params.path}']`
+					case "browser_action":
+						return `[${block.name} for '${block.params.action}']`
+					case "use_mcp_tool":
+						return `[${block.name} for '${block.params.server_name}']`
+					case "access_mcp_resource":
+						return `[${block.name} for '${block.params.server_name}']`
+					case "ask_followup_question":
+						return `[${block.name} for '${block.params.question}']`
+					case "attempt_completion":
+						return `[${block.name}]`
+					case "switch_mode":
+						return `[${block.name} to '${block.params.mode_slug}'${block.params.reason ? ` because: ${block.params.reason}` : ""}]`
+					case "new_task": {
+						const mode = block.params.mode ?? defaultModeSlug
+						const message = block.params.message ?? "(no message)"
+						const modeName = getModeBySlug(mode, customModes)?.name ?? mode
+						return `[${block.name} in ${modeName} mode: '${message}']`
+					}
+				}
+			}
+
+			if (cline.didRejectTool) {
+				// Ignore any tool content after user has rejected tool once.
+				if (!block.partial) {
+					cline.userMessageContent.push({
+						type: "text",
+						text: `Skipping tool ${toolDescription()} due to user rejecting a previous tool.`,
+					})
+				} else {
+					// Partial tool after user rejected a previous tool.
+					cline.userMessageContent.push({
+						type: "text",
+						text: `Tool ${toolDescription()} was interrupted and not executed due to user rejecting a previous tool.`,
+					})
+				}
+
+				break
+			}
+
+			if (cline.didAlreadyUseTool) {
+				// Ignore any content after a tool has already been used.
+				cline.userMessageContent.push({
+					type: "text",
+					text: `Tool [${block.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`,
+				})
+
+				break
+			}
+
+			const pushToolResult = (content: ToolResponse) => {
+				cline.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` })
+
+				if (typeof content === "string") {
+					cline.userMessageContent.push({ type: "text", text: content || "(tool did not return anything)" })
+				} else {
+					cline.userMessageContent.push(...content)
+				}
+
+				// Once a tool result has been collected, ignore all other tool
+				// uses since we should only ever present one tool result per
+				// message.
+				cline.didAlreadyUseTool = true
+			}
+
+			const askApproval = async (
+				type: ClineAsk,
+				partialMessage?: string,
+				progressStatus?: ToolProgressStatus,
+			) => {
+				const { response, text, images } = await cline.ask(type, partialMessage, false, progressStatus)
+
+				if (response !== "yesButtonClicked") {
+					// Handle both messageResponse and noButtonClicked with text.
+					if (text) {
+						await cline.say("user_feedback", text, images)
+						pushToolResult(formatResponse.toolResult(formatResponse.toolDeniedWithFeedback(text), images))
+					} else {
+						pushToolResult(formatResponse.toolDenied())
+					}
+					cline.didRejectTool = true
+					return false
+				}
+
+				// Handle yesButtonClicked with text.
+				if (text) {
+					await cline.say("user_feedback", text, images)
+					pushToolResult(formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text), images))
+				}
+
+				return true
+			}
+
+			const askFinishSubTaskApproval = async () => {
+				// Ask the user to approve this task has completed, and he has
+				// reviewed it, and we can declare task is finished and return
+				// control to the parent task to continue running the rest of
+				// the sub-tasks.
+				const toolMessage = JSON.stringify({ tool: "finishTask" })
+				return await askApproval("tool", toolMessage)
+			}
+
+			const handleError = async (action: string, error: Error) => {
+				const errorString = `Error ${action}: ${JSON.stringify(serializeError(error))}`
+
+				await cline.say(
+					"error",
+					`Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`,
+				)
+
+				pushToolResult(formatResponse.toolError(errorString))
+			}
+
+			// If block is partial, remove partial closing tag so its not
+			// presented to user.
+			const removeClosingTag = (tag: ToolParamName, text?: string): string => {
+				if (!block.partial) {
+					return text || ""
+				}
+
+				if (!text) {
+					return ""
+				}
+
+				// This regex dynamically constructs a pattern to match the
+				// closing tag:
+				// - Optionally matches whitespace before the tag.
+				// - Matches '<' or '</' optionally followed by any subset of
+				//   characters from the tag name.
+				const tagRegex = new RegExp(
+					`\\s?<\/?${tag
+						.split("")
+						.map((char) => `(?:${char})?`)
+						.join("")}$`,
+					"g",
+				)
+
+				return text.replace(tagRegex, "")
+			}
+
+			if (block.name !== "browser_action") {
+				await cline.browserSession.closeBrowser()
+			}
+
+			if (!block.partial) {
+				cline.recordToolUsage(block.name)
+				// telemetryService.captureToolUsage(cline.taskId, block.name)
+			}
+
+			// Validate tool use before execution.
+			const { mode, customModes } = (await cline.providerRef.deref()?.getState()) ?? {}
+
+			try {
+				validateToolUse(
+					block.name as ToolName,
+					mode ?? defaultModeSlug,
+					customModes ?? [],
+					{ apply_diff: cline.diffEnabled },
+					block.params,
+				)
+			} catch (error) {
+				cline.consecutiveMistakeCount++
+				pushToolResult(formatResponse.toolError(error.message))
+				break
+			}
+
+			// Check for identical consecutive tool calls.
+			if (!block.partial) {
+				// Use the detector to check for repetition, passing the ToolUse
+				// block directly.
+				const repetitionCheck = cline.toolRepetitionDetector.check(block)
+
+				// If execution is not allowed, notify user and break.
+				if (!repetitionCheck.allowExecution && repetitionCheck.askUser) {
+					// Handle repetition similar to mistake_limit_reached pattern.
+					const { response, text, images } = await cline.ask(
+						repetitionCheck.askUser.messageKey as ClineAsk,
+						repetitionCheck.askUser.messageDetail.replace("{toolName}", block.name),
+					)
+
+					if (response === "messageResponse") {
+						// Add user feedback to userContent.
+						cline.userMessageContent.push(
+							{
+								type: "text" as const,
+								text: `Tool repetition limit reached. User feedback: ${text}`,
+							},
+							...formatResponse.imageBlocks(images),
+						)
+
+						// Add user feedback to chat.
+						await cline.say("user_feedback", text, images)
+
+						// Track tool repetition in telemetry.
+						// telemetryService.captureConsecutiveMistakeError(cline.taskId)
+					}
+
+					// Return tool result message about the repetition
+					pushToolResult(
+						formatResponse.toolError(
+							`Tool call repetition limit reached for ${block.name}. Please try a different approach.`,
+						),
+					)
+					break
+				}
+			}
+
+			switch (block.name) {
+				case "write_to_file":
+					await writeToFileTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
+					break
+				case "apply_diff":
+					await applyDiffTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
+					break
+				case "insert_content":
+					await insertContentTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
+					break
+				case "search_and_replace":
+					await searchAndReplaceTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
+					break
+				case "read_file":
+					await readFileTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
+
+					break
+				case "fetch_instructions":
+					await fetchInstructionsTool(cline, block, askApproval, handleError, pushToolResult)
+					break
+				case "list_files":
+					await listFilesTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
+					break
+				case "list_code_definition_names":
+					await listCodeDefinitionNamesTool(
+						cline,
+						block,
+						askApproval,
+						handleError,
+						pushToolResult,
+						removeClosingTag,
+					)
+					break
+				case "search_files":
+					await searchFilesTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
+					break
+				case "browser_action":
+					await browserActionTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
+					break
+				case "execute_command":
+					await executeCommandTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
+					break
+				case "use_mcp_tool":
+					await useMcpToolTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
+					break
+				case "access_mcp_resource":
+					await accessMcpResourceTool(
+						cline,
+						block,
+						askApproval,
+						handleError,
+						pushToolResult,
+						removeClosingTag,
+					)
+					break
+				case "ask_followup_question":
+					await askFollowupQuestionTool(
+						cline,
+						block,
+						askApproval,
+						handleError,
+						pushToolResult,
+						removeClosingTag,
+					)
+					break
+				case "switch_mode":
+					await switchModeTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
+					break
+				case "new_task":
+					await newTaskTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
+					break
+				case "attempt_completion":
+					await attemptCompletionTool(
+						cline,
+						block,
+						askApproval,
+						handleError,
+						pushToolResult,
+						removeClosingTag,
+						toolDescription,
+						askFinishSubTaskApproval,
+					)
+					break
+			}
+
+			break
+	}
+
+	const recentlyModifiedFiles = cline.fileContextTracker.getAndClearCheckpointPossibleFile()
+
+	if (recentlyModifiedFiles.length > 0) {
+		// TODO: We can track what file changes were made and only
+		// checkpoint those files, this will be save storage.
+		await checkpointSave(cline)
+	}
+
+	// Seeing out of bounds is fine, it means that the next too call is being
+	// built up and ready to add to assistantMessageContent to present.
+	// When you see the UI inactive during this, it means that a tool is
+	// breaking without presenting any UI. For example the write_to_file tool
+	// was breaking when relpath was undefined, and for invalid relpath it never
+	// presented UI.
+	// This needs to be placed here, if not then calling
+	// cline.presentAssistantMessage below would fail (sometimes) since it's
+	// locked.
+	cline.presentAssistantMessageLocked = false
+
+	// NOTE: When tool is rejected, iterator stream is interrupted and it waits
+	// for `userMessageContentReady` to be true. Future calls to present will
+	// skip execution since `didRejectTool` and iterate until `contentIndex` is
+	// set to message length and it sets userMessageContentReady to true itself
+	// (instead of preemptively doing it in iterator).
+	if (!block.partial || cline.didRejectTool || cline.didAlreadyUseTool) {
+		// Block is finished streaming and executing.
+		if (cline.currentStreamingContentIndex === cline.assistantMessageContent.length - 1) {
+			// It's okay that we increment if !didCompleteReadingStream, it'll
+			// just return because out of bounds and as streaming continues it
+			// will call `presentAssitantMessage` if a new block is ready. If
+			// streaming is finished then we set `userMessageContentReady` to
+			// true when out of bounds. This gracefully allows the stream to
+			// continue on and all potential content blocks be presented.
+			// Last block is complete and it is finished executing
+			cline.userMessageContentReady = true // Will allow `pWaitFor` to continue.
+		}
+
+		// Call next block if it exists (if not then read stream will call it
+		// when it's ready).
+		// Need to increment regardless, so when read stream calls this function
+		// again it will be streaming the next block.
+		cline.currentStreamingContentIndex++
+
+		if (cline.currentStreamingContentIndex < cline.assistantMessageContent.length) {
+			// There are already more content blocks to stream, so we'll call
+			// this function ourselves.
+			presentAssistantMessage(cline)
+			return
+		}
+	}
+
+	// Block is partial, but the read stream may have finished.
+	if (cline.presentAssistantMessageHasPendingUpdates) {
+		presentAssistantMessage(cline)
+	}
+}

+ 291 - 0
src/core/checkpoints/index.ts

@@ -0,0 +1,291 @@
+import pWaitFor from "p-wait-for"
+import * as vscode from "vscode"
+
+import { Task } from "../task/Task"
+
+import { getWorkspacePath } from "../../utils/path"
+
+import { ClineApiReqInfo } from "../../shared/ExtensionMessage"
+import { getApiMetrics } from "../../shared/getApiMetrics"
+
+import { DIFF_VIEW_URI_SCHEME } from "../../integrations/editor/DiffViewProvider"
+
+import { CheckpointServiceOptions, RepoPerTaskCheckpointService } from "../../services/checkpoints"
+
+export function getCheckpointService(cline: Task) {
+	if (!cline.enableCheckpoints) {
+		return undefined
+	}
+
+	if (cline.checkpointService) {
+		return cline.checkpointService
+	}
+
+	if (cline.checkpointServiceInitializing) {
+		console.log("[Cline#getCheckpointService] checkpoint service is still initializing")
+		return undefined
+	}
+
+	const provider = cline.providerRef.deref()
+
+	const log = (message: string) => {
+		console.log(message)
+
+		try {
+			provider?.log(message)
+		} catch (err) {
+			// NO-OP
+		}
+	}
+
+	console.log("[Cline#getCheckpointService] initializing checkpoints service")
+
+	try {
+		const workspaceDir = getWorkspacePath()
+
+		if (!workspaceDir) {
+			log("[Cline#getCheckpointService] workspace folder not found, disabling checkpoints")
+			cline.enableCheckpoints = false
+			return undefined
+		}
+
+		const globalStorageDir = provider?.context.globalStorageUri.fsPath
+
+		if (!globalStorageDir) {
+			log("[Cline#getCheckpointService] globalStorageDir not found, disabling checkpoints")
+			cline.enableCheckpoints = false
+			return undefined
+		}
+
+		const options: CheckpointServiceOptions = {
+			taskId: cline.taskId,
+			workspaceDir,
+			shadowDir: globalStorageDir,
+			log,
+		}
+
+		const service = RepoPerTaskCheckpointService.create(options)
+
+		cline.checkpointServiceInitializing = true
+
+		service.on("initialize", () => {
+			log("[Cline#getCheckpointService] service initialized")
+
+			try {
+				const isCheckpointNeeded =
+					typeof cline.clineMessages.find(({ say }) => say === "checkpoint_saved") === "undefined"
+
+				cline.checkpointService = service
+				cline.checkpointServiceInitializing = false
+
+				if (isCheckpointNeeded) {
+					log("[Cline#getCheckpointService] no checkpoints found, saving initial checkpoint")
+					checkpointSave(cline)
+				}
+			} catch (err) {
+				log("[Cline#getCheckpointService] caught error in on('initialize'), disabling checkpoints")
+				cline.enableCheckpoints = false
+			}
+		})
+
+		service.on("checkpoint", ({ isFirst, fromHash: from, toHash: to }) => {
+			try {
+				provider?.postMessageToWebview({ type: "currentCheckpointUpdated", text: to })
+
+				cline.say("checkpoint_saved", to, undefined, undefined, { isFirst, from, to }).catch((err) => {
+					log("[Cline#getCheckpointService] caught unexpected error in say('checkpoint_saved')")
+					console.error(err)
+				})
+			} catch (err) {
+				log("[Cline#getCheckpointService] caught unexpected error in on('checkpoint'), disabling checkpoints")
+				console.error(err)
+				cline.enableCheckpoints = false
+			}
+		})
+
+		log("[Cline#getCheckpointService] initializing shadow git")
+
+		service.initShadowGit().catch((err) => {
+			log(
+				`[Cline#getCheckpointService] caught unexpected error in initShadowGit, disabling checkpoints (${err.message})`,
+			)
+
+			console.error(err)
+			cline.enableCheckpoints = false
+		})
+
+		return service
+	} catch (err) {
+		log("[Cline#getCheckpointService] caught unexpected error, disabling checkpoints")
+		cline.enableCheckpoints = false
+		return undefined
+	}
+}
+
+async function getInitializedCheckpointService(
+	cline: Task,
+	{ interval = 250, timeout = 15_000 }: { interval?: number; timeout?: number } = {},
+) {
+	const service = getCheckpointService(cline)
+
+	if (!service || service.isInitialized) {
+		return service
+	}
+
+	try {
+		await pWaitFor(
+			() => {
+				console.log("[Cline#getCheckpointService] waiting for service to initialize")
+				return service.isInitialized
+			},
+			{ interval, timeout },
+		)
+
+		return service
+	} catch (err) {
+		return undefined
+	}
+}
+
+export async function checkpointSave(cline: Task) {
+	const service = getCheckpointService(cline)
+
+	if (!service) {
+		return
+	}
+
+	if (!service.isInitialized) {
+		const provider = cline.providerRef.deref()
+		provider?.log("[checkpointSave] checkpoints didn't initialize in time, disabling checkpoints for this task")
+		cline.enableCheckpoints = false
+		return
+	}
+
+	// telemetryService.captureCheckpointCreated(cline.taskId)
+
+	// Start the checkpoint process in the background.
+	return service.saveCheckpoint(`Task: ${cline.taskId}, Time: ${Date.now()}`).catch((err) => {
+		console.error("[Cline#checkpointSave] caught unexpected error, disabling checkpoints", err)
+		cline.enableCheckpoints = false
+	})
+}
+
+export type CheckpointRestoreOptions = {
+	ts: number
+	commitHash: string
+	mode: "preview" | "restore"
+}
+
+export async function checkpointRestore(cline: Task, { ts, commitHash, mode }: CheckpointRestoreOptions) {
+	const service = await getInitializedCheckpointService(cline)
+
+	if (!service) {
+		return
+	}
+
+	const index = cline.clineMessages.findIndex((m) => m.ts === ts)
+
+	if (index === -1) {
+		return
+	}
+
+	const provider = cline.providerRef.deref()
+
+	try {
+		await service.restoreCheckpoint(commitHash)
+		// telemetryService.captureCheckpointRestored(cline.taskId)
+		await provider?.postMessageToWebview({ type: "currentCheckpointUpdated", text: commitHash })
+
+		if (mode === "restore") {
+			await cline.overwriteApiConversationHistory(cline.apiConversationHistory.filter((m) => !m.ts || m.ts < ts))
+
+			const deletedMessages = cline.clineMessages.slice(index + 1)
+
+			const { totalTokensIn, totalTokensOut, totalCacheWrites, totalCacheReads, totalCost } = getApiMetrics(
+				cline.combineMessages(deletedMessages),
+			)
+
+			await cline.overwriteClineMessages(cline.clineMessages.slice(0, index + 1))
+
+			// TODO: Verify that this is working as expected.
+			await cline.say(
+				"api_req_deleted",
+				JSON.stringify({
+					tokensIn: totalTokensIn,
+					tokensOut: totalTokensOut,
+					cacheWrites: totalCacheWrites,
+					cacheReads: totalCacheReads,
+					cost: totalCost,
+				} satisfies ClineApiReqInfo),
+			)
+		}
+
+		// The task is already cancelled by the provider beforehand, but we
+		// need to re-init to get the updated messages.
+		//
+		// This was take from Cline's implementation of the checkpoints
+		// feature. The cline instance will hang if we don't cancel twice,
+		// so this is currently necessary, but it seems like a complicated
+		// and hacky solution to a problem that I don't fully understand.
+		// I'd like to revisit this in the future and try to improve the
+		// task flow and the communication between the webview and the
+		// Cline instance.
+		provider?.cancelTask()
+	} catch (err) {
+		provider?.log("[checkpointRestore] disabling checkpoints for this task")
+		cline.enableCheckpoints = false
+	}
+}
+
+export type CheckpointDiffOptions = {
+	ts: number
+	previousCommitHash?: string
+	commitHash: string
+	mode: "full" | "checkpoint"
+}
+
+export async function checkpointDiff(cline: Task, { ts, previousCommitHash, commitHash, mode }: CheckpointDiffOptions) {
+	const service = await getInitializedCheckpointService(cline)
+
+	if (!service) {
+		return
+	}
+
+	// telemetryService.captureCheckpointDiffed(cline.taskId)
+
+	if (!previousCommitHash && mode === "checkpoint") {
+		const previousCheckpoint = cline.clineMessages
+			.filter(({ say }) => say === "checkpoint_saved")
+			.sort((a, b) => b.ts - a.ts)
+			.find((message) => message.ts < ts)
+
+		previousCommitHash = previousCheckpoint?.text
+	}
+
+	try {
+		const changes = await service.getDiff({ from: previousCommitHash, to: commitHash })
+
+		if (!changes?.length) {
+			vscode.window.showInformationMessage("No changes found.")
+			return
+		}
+
+		await vscode.commands.executeCommand(
+			"vscode.changes",
+			mode === "full" ? "Changes since task started" : "Changes since previous checkpoint",
+			changes.map((change) => [
+				vscode.Uri.file(change.paths.absolute),
+				vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${change.paths.relative}`).with({
+					query: Buffer.from(change.content.before ?? "").toString("base64"),
+				}),
+				vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${change.paths.relative}`).with({
+					query: Buffer.from(change.content.after ?? "").toString("base64"),
+				}),
+			]),
+		)
+	} catch (err) {
+		const provider = cline.providerRef.deref()
+		provider?.log("[checkpointDiff] disabling checkpoints for this task")
+		cline.enableCheckpoints = false
+	}
+}

+ 57 - 31
src/core/config/ProviderSettingsManager.ts

@@ -1,10 +1,13 @@
 import { ExtensionContext } from "vscode"
 import { z } from "zod"
 
-import { providerSettingsSchema, ApiConfigMeta } from "../../schemas"
+import { providerSettingsSchema, ProviderSettingsEntry, providerSettingsSchemaDiscriminated } from "../../schemas"
 import { Mode, modes } from "../../shared/modes"
 
 const providerSettingsWithIdSchema = providerSettingsSchema.extend({ id: z.string().optional() })
+const discriminatedProviderSettingsWithIdSchema = providerSettingsSchemaDiscriminated.and(
+	z.object({ id: z.string().optional() }),
+)
 
 type ProviderSettingsWithId = z.infer<typeof providerSettingsWithIdSchema>
 
@@ -222,7 +225,7 @@ export class ProviderSettingsManager {
 	/**
 	 * List all available configs with metadata.
 	 */
-	public async listConfig(): Promise<ApiConfigMeta[]> {
+	public async listConfig(): Promise<ProviderSettingsEntry[]> {
 		try {
 			return await this.lock(async () => {
 				const providerProfiles = await this.load()
@@ -243,66 +246,81 @@ export class ProviderSettingsManager {
 	 * Preserves the ID from the input 'config' object if it exists,
 	 * otherwise generates a new one (for creation scenarios).
 	 */
-	public async saveConfig(name: string, config: ProviderSettingsWithId) {
+	public async saveConfig(name: string, config: ProviderSettingsWithId): Promise<string> {
 		try {
 			return await this.lock(async () => {
 				const providerProfiles = await this.load()
 				// Preserve the existing ID if this is an update to an existing config.
 				const existingId = providerProfiles.apiConfigs[name]?.id
-				providerProfiles.apiConfigs[name] = { ...config, id: config.id || existingId || this.generateId() }
+				const id = config.id || existingId || this.generateId()
+
+				// Filter out settings from other providers.
+				const filteredConfig = providerSettingsSchemaDiscriminated.parse(config)
+				providerProfiles.apiConfigs[name] = { ...filteredConfig, id }
 				await this.store(providerProfiles)
+				return id
 			})
 		} catch (error) {
 			throw new Error(`Failed to save config: ${error}`)
 		}
 	}
 
-	/**
-	 * Load a config by name and set it as the current config.
-	 */
-	public async loadConfig(name: string) {
+	public async getProfile(
+		params: { name: string } | { id: string },
+	): Promise<ProviderSettingsWithId & { name: string }> {
 		try {
 			return await this.lock(async () => {
 				const providerProfiles = await this.load()
-				const providerSettings = providerProfiles.apiConfigs[name]
+				let name: string
+				let providerSettings: ProviderSettingsWithId
 
-				if (!providerSettings) {
-					throw new Error(`Config '${name}' not found`)
-				}
+				if ("name" in params) {
+					name = params.name
 
-				providerProfiles.currentApiConfigName = name
-				await this.store(providerProfiles)
+					if (!providerProfiles.apiConfigs[name]) {
+						throw new Error(`Config with name '${name}' not found`)
+					}
+
+					providerSettings = providerProfiles.apiConfigs[name]
+				} else {
+					const id = params.id
+
+					const entry = Object.entries(providerProfiles.apiConfigs).find(
+						([_, apiConfig]) => apiConfig.id === id,
+					)
+
+					if (!entry) {
+						throw new Error(`Config with ID '${id}' not found`)
+					}
+
+					name = entry[0]
+					providerSettings = entry[1]
+				}
 
-				return providerSettings
+				return { name, ...providerSettings }
 			})
 		} catch (error) {
-			throw new Error(`Failed to load config: ${error}`)
+			throw new Error(`Failed to get profile: ${error instanceof Error ? error.message : error}`)
 		}
 	}
 
 	/**
-	 * Load a config by ID and set it as the current config.
+	 * Activate a profile by name or ID.
 	 */
-	public async loadConfigById(id: string) {
+	public async activateProfile(
+		params: { name: string } | { id: string },
+	): Promise<ProviderSettingsWithId & { name: string }> {
+		const { name, ...providerSettings } = await this.getProfile(params)
+
 		try {
 			return await this.lock(async () => {
 				const providerProfiles = await this.load()
-				const providerSettings = Object.entries(providerProfiles.apiConfigs).find(
-					([_, apiConfig]) => apiConfig.id === id,
-				)
-
-				if (!providerSettings) {
-					throw new Error(`Config with ID '${id}' not found`)
-				}
-
-				const [name, apiConfig] = providerSettings
 				providerProfiles.currentApiConfigName = name
 				await this.store(providerProfiles)
-
-				return { config: apiConfig, name }
+				return { name, ...providerSettings }
 			})
 		} catch (error) {
-			throw new Error(`Failed to load config by ID: ${error}`)
+			throw new Error(`Failed to activate profile: ${error instanceof Error ? error.message : error}`)
 		}
 	}
 
@@ -380,7 +398,15 @@ export class ProviderSettingsManager {
 
 	public async export() {
 		try {
-			return await this.lock(async () => providerProfilesSchema.parse(await this.load()))
+			return await this.lock(async () => {
+				const profiles = providerProfilesSchema.parse(await this.load())
+				const configs = profiles.apiConfigs
+				for (const name in configs) {
+					// Avoid leaking properties from other providers.
+					configs[name] = discriminatedProviderSettingsWithIdSchema.parse(configs[name])
+				}
+				return profiles
+			})
 		} catch (error) {
 			throw new Error(`Failed to export provider profiles: ${error}`)
 		}

+ 72 - 56
src/core/config/__tests__/ProviderSettingsManager.test.ts

@@ -221,8 +221,9 @@ describe("ProviderSettingsManager", () => {
 			)
 
 			const newConfig: ProviderSettings = {
-				apiProvider: "anthropic",
-				apiKey: "test-key",
+				apiProvider: "vertex",
+				apiModelId: "gemini-2.5-flash-preview-04-17",
+				vertexKeyFile: "test-key-file",
 			}
 
 			await providerSettingsManager.saveConfig("test", newConfig)
@@ -247,10 +248,58 @@ describe("ProviderSettingsManager", () => {
 				},
 			}
 
-			expect(mockSecrets.store).toHaveBeenCalledWith(
-				"roo_cline_config_api_config",
-				JSON.stringify(expectedConfig, null, 2),
+			expect(mockSecrets.store.mock.calls[0][0]).toEqual("roo_cline_config_api_config")
+			expect(storedConfig).toEqual(expectedConfig)
+		})
+
+		it("should only save provider relevant settings", async () => {
+			mockSecrets.get.mockResolvedValue(
+				JSON.stringify({
+					currentApiConfigName: "default",
+					apiConfigs: {
+						default: {},
+					},
+					modeApiConfigs: {
+						code: "default",
+						architect: "default",
+						ask: "default",
+					},
+				}),
 			)
+
+			const newConfig: ProviderSettings = {
+				apiProvider: "anthropic",
+				apiKey: "test-key",
+			}
+			const newConfigWithExtra: ProviderSettings = {
+				...newConfig,
+				openRouterApiKey: "another-key",
+			}
+
+			await providerSettingsManager.saveConfig("test", newConfigWithExtra)
+
+			// Get the actual stored config to check the generated ID
+			const storedConfig = JSON.parse(mockSecrets.store.mock.lastCall[1])
+			const testConfigId = storedConfig.apiConfigs.test.id
+
+			const expectedConfig = {
+				currentApiConfigName: "default",
+				apiConfigs: {
+					default: {},
+					test: {
+						...newConfig,
+						id: testConfigId,
+					},
+				},
+				modeApiConfigs: {
+					code: "default",
+					architect: "default",
+					ask: "default",
+				},
+			}
+
+			expect(mockSecrets.store.mock.calls[0][0]).toEqual("roo_cline_config_api_config")
+			expect(storedConfig).toEqual(expectedConfig)
 		})
 
 		it("should update existing config", async () => {
@@ -291,10 +340,9 @@ describe("ProviderSettingsManager", () => {
 				},
 			}
 
-			expect(mockSecrets.store).toHaveBeenCalledWith(
-				"roo_cline_config_api_config",
-				JSON.stringify(expectedConfig, null, 2),
-			)
+			const storedConfig = JSON.parse(mockSecrets.store.mock.lastCall[1])
+			expect(mockSecrets.store.mock.lastCall[0]).toEqual("roo_cline_config_api_config")
+			expect(storedConfig).toEqual(expectedConfig)
 		})
 
 		it("should throw error if secrets storage fails", async () => {
@@ -391,17 +439,15 @@ describe("ProviderSettingsManager", () => {
 			mockGlobalState.get.mockResolvedValue(42)
 			mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
 
-			const config = await providerSettingsManager.loadConfig("test")
+			const { name, ...providerSettings } = await providerSettingsManager.activateProfile({ name: "test" })
 
-			expect(config).toEqual({
-				apiProvider: "anthropic",
-				apiKey: "test-key",
-				id: "test-id",
-			})
+			expect(name).toBe("test")
+			expect(providerSettings).toEqual({ apiProvider: "anthropic", apiKey: "test-key", id: "test-id" })
 
-			// Get the stored config to check the structure
+			// Get the stored config to check the structure.
 			const storedConfig = JSON.parse(mockSecrets.store.mock.calls[1][1])
 			expect(storedConfig.currentApiConfigName).toBe("test")
+
 			expect(storedConfig.apiConfigs.test).toEqual({
 				apiProvider: "anthropic",
 				apiKey: "test-key",
@@ -413,17 +459,12 @@ describe("ProviderSettingsManager", () => {
 			mockSecrets.get.mockResolvedValue(
 				JSON.stringify({
 					currentApiConfigName: "default",
-					apiConfigs: {
-						default: {
-							config: {},
-							id: "default",
-						},
-					},
+					apiConfigs: { default: { config: {}, id: "default" } },
 				}),
 			)
 
-			await expect(providerSettingsManager.loadConfig("nonexistent")).rejects.toThrow(
-				"Config 'nonexistent' not found",
+			await expect(providerSettingsManager.activateProfile({ name: "nonexistent" })).rejects.toThrow(
+				"Config with name 'nonexistent' not found",
 			)
 		})
 
@@ -431,20 +472,13 @@ describe("ProviderSettingsManager", () => {
 			mockSecrets.get.mockResolvedValue(
 				JSON.stringify({
 					currentApiConfigName: "default",
-					apiConfigs: {
-						test: {
-							config: {
-								apiProvider: "anthropic",
-							},
-							id: "test-id",
-						},
-					},
+					apiConfigs: { test: { config: { apiProvider: "anthropic" }, id: "test-id" } },
 				}),
 			)
 			mockSecrets.store.mockRejectedValueOnce(new Error("Storage failed"))
 
-			await expect(providerSettingsManager.loadConfig("test")).rejects.toThrow(
-				"Failed to load config: Error: Failed to write provider profiles to secrets: Error: Storage failed",
+			await expect(providerSettingsManager.activateProfile({ name: "test" })).rejects.toThrow(
+				"Failed to activate profile: Failed to write provider profiles to secrets: Error: Storage failed",
 			)
 		})
 
@@ -494,12 +528,7 @@ describe("ProviderSettingsManager", () => {
 			mockSecrets.get.mockResolvedValue(
 				JSON.stringify({
 					currentApiConfigName: "test",
-					apiConfigs: {
-						test: {
-							apiProvider: "anthropic",
-							id: "test-id",
-						},
-					},
+					apiConfigs: { test: { apiProvider: "anthropic", id: "test-id" } },
 				}),
 			)
 
@@ -514,18 +543,8 @@ describe("ProviderSettingsManager", () => {
 		it("should return true for existing config", async () => {
 			const existingConfig: ProviderProfiles = {
 				currentApiConfigName: "default",
-				apiConfigs: {
-					default: {
-						id: "default",
-					},
-					test: {
-						apiProvider: "anthropic",
-						id: "test-id",
-					},
-				},
-				migrations: {
-					rateLimitSecondsMigrated: false,
-				},
+				apiConfigs: { default: { id: "default" }, test: { apiProvider: "anthropic", id: "test-id" } },
+				migrations: { rateLimitSecondsMigrated: false },
 			}
 
 			mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
@@ -536,10 +555,7 @@ describe("ProviderSettingsManager", () => {
 
 		it("should return false for non-existent config", async () => {
 			mockSecrets.get.mockResolvedValue(
-				JSON.stringify({
-					currentApiConfigName: "default",
-					apiConfigs: { default: {} },
-				}),
+				JSON.stringify({ currentApiConfigName: "default", apiConfigs: { default: {} } }),
 			)
 
 			const hasConfig = await providerSettingsManager.hasConfig("nonexistent")

+ 15 - 15
src/core/environment/__tests__/getEnvironmentDetails.test.ts

@@ -16,7 +16,7 @@ import { ApiHandler } from "../../../api/index"
 import { ClineProvider } from "../../webview/ClineProvider"
 import { RooIgnoreController } from "../../ignore/RooIgnoreController"
 import { formatResponse } from "../../prompts/responses"
-import { Cline } from "../../Cline"
+import { Task } from "../../task/Task"
 
 jest.mock("vscode", () => ({
 	window: {
@@ -56,7 +56,7 @@ describe("getEnvironmentDetails", () => {
 		cleanCompletedProcessQueue?: jest.Mock
 	}
 
-	let mockCline: Partial<Cline>
+	let mockCline: Partial<Task>
 	let mockProvider: any
 	let mockState: any
 
@@ -134,7 +134,7 @@ describe("getEnvironmentDetails", () => {
 	})
 
 	it("should return basic environment details", async () => {
-		const result = await getEnvironmentDetails(mockCline as Cline)
+		const result = await getEnvironmentDetails(mockCline as Task)
 
 		expect(result).toContain("<environment_details>")
 		expect(result).toContain("</environment_details>")
@@ -157,7 +157,7 @@ describe("getEnvironmentDetails", () => {
 	})
 
 	it("should include file details when includeFileDetails is true", async () => {
-		const result = await getEnvironmentDetails(mockCline as Cline, true)
+		const result = await getEnvironmentDetails(mockCline as Task, true)
 		expect(result).toContain("# Current Workspace Directory")
 		expect(result).toContain("Files")
 
@@ -173,14 +173,14 @@ describe("getEnvironmentDetails", () => {
 	})
 
 	it("should not include file details when includeFileDetails is false", async () => {
-		await getEnvironmentDetails(mockCline as Cline, false)
+		await getEnvironmentDetails(mockCline as Task, false)
 		expect(listFiles).not.toHaveBeenCalled()
 		expect(formatResponse.formatFilesList).not.toHaveBeenCalled()
 	})
 
 	it("should handle desktop directory specially", async () => {
 		;(arePathsEqual as jest.Mock).mockReturnValue(true)
-		const result = await getEnvironmentDetails(mockCline as Cline, true)
+		const result = await getEnvironmentDetails(mockCline as Task, true)
 		expect(result).toContain("Desktop files not shown automatically")
 		expect(listFiles).not.toHaveBeenCalled()
 	})
@@ -191,7 +191,7 @@ describe("getEnvironmentDetails", () => {
 			"modified2.ts",
 		])
 
-		const result = await getEnvironmentDetails(mockCline as Cline)
+		const result = await getEnvironmentDetails(mockCline as Task)
 
 		expect(result).toContain("# Recently Modified Files")
 		expect(result).toContain("modified1.ts")
@@ -208,14 +208,14 @@ describe("getEnvironmentDetails", () => {
 		;(TerminalRegistry.getTerminals as jest.Mock).mockReturnValue([mockActiveTerminal])
 		;(TerminalRegistry.getUnretrievedOutput as jest.Mock).mockReturnValue("Test output")
 
-		const result = await getEnvironmentDetails(mockCline as Cline)
+		const result = await getEnvironmentDetails(mockCline as Task)
 
 		expect(result).toContain("# Actively Running Terminals")
 		expect(result).toContain("Original command: `npm test`")
 		expect(result).toContain("Test output")
 
 		mockCline.didEditFile = true
-		await getEnvironmentDetails(mockCline as Cline)
+		await getEnvironmentDetails(mockCline as Task)
 		expect(delay).toHaveBeenCalledWith(300)
 
 		expect(pWaitFor).toHaveBeenCalled()
@@ -237,7 +237,7 @@ describe("getEnvironmentDetails", () => {
 			active ? [] : [mockInactiveTerminal],
 		)
 
-		const result = await getEnvironmentDetails(mockCline as Cline)
+		const result = await getEnvironmentDetails(mockCline as Task)
 
 		expect(result).toContain("# Inactive Terminals with Completed Process Output")
 		expect(result).toContain("Terminal terminal-2")
@@ -261,7 +261,7 @@ describe("getEnvironmentDetails", () => {
 			return null
 		})
 
-		const result = await getEnvironmentDetails(mockCline as Cline)
+		const result = await getEnvironmentDetails(mockCline as Task)
 
 		expect(result).toContain("NOTE: You are currently in '💻 Code' mode, which does not allow write operations")
 	})
@@ -270,7 +270,7 @@ describe("getEnvironmentDetails", () => {
 		mockState.experiments = { [EXPERIMENT_IDS.POWER_STEERING]: true }
 		;(experiments.isEnabled as jest.Mock).mockReturnValue(true)
 
-		const result = await getEnvironmentDetails(mockCline as Cline)
+		const result = await getEnvironmentDetails(mockCline as Task)
 
 		expect(result).toContain("<role>You are a code assistant</role>")
 		expect(result).toContain("<custom_instructions>Custom instructions</custom_instructions>")
@@ -280,7 +280,7 @@ describe("getEnvironmentDetails", () => {
 		// Mock provider to return null.
 		mockCline.providerRef!.deref = jest.fn().mockReturnValue(null)
 
-		const result = await getEnvironmentDetails(mockCline as Cline)
+		const result = await getEnvironmentDetails(mockCline as Task)
 
 		// Verify the function still returns a result.
 		expect(result).toContain("<environment_details>")
@@ -291,7 +291,7 @@ describe("getEnvironmentDetails", () => {
 			getState: jest.fn().mockResolvedValue(null),
 		})
 
-		const result2 = await getEnvironmentDetails(mockCline as Cline)
+		const result2 = await getEnvironmentDetails(mockCline as Task)
 
 		// Verify the function still returns a result.
 		expect(result2).toContain("<environment_details>")
@@ -311,6 +311,6 @@ describe("getEnvironmentDetails", () => {
 		;(TerminalRegistry.getBackgroundTerminals as jest.Mock).mockReturnValue([])
 		;(mockCline.fileContextTracker!.getAndClearRecentlyModifiedFiles as jest.Mock).mockReturnValue([])
 
-		await expect(getEnvironmentDetails(mockCline as Cline)).resolves.not.toThrow()
+		await expect(getEnvironmentDetails(mockCline as Task)).resolves.not.toThrow()
 	})
 })

+ 2 - 2
src/core/environment/getEnvironmentDetails.ts

@@ -15,12 +15,12 @@ import { Terminal } from "../../integrations/terminal/Terminal"
 import { arePathsEqual } from "../../utils/path"
 import { formatResponse } from "../prompts/responses"
 
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 
 import { OpenRouterHandler } from "../../api/providers/openrouter"
 import { KilocodeOpenrouterHandler } from "../../api/providers/kilocode-openrouter"
 
-export async function getEnvironmentDetails(cline: Cline, includeFileDetails: boolean = false) {
+export async function getEnvironmentDetails(cline: Task, includeFileDetails: boolean = false) {
 	let details = ""
 
 	const clineProvider = cline.providerRef.deref()

+ 10 - 4
src/core/mentions/index.ts

@@ -4,14 +4,17 @@ import * as path from "path"
 import * as vscode from "vscode"
 import { isBinaryFile } from "isbinaryfile"
 
-import { openFile } from "../../integrations/misc/open-file"
-import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
 import { mentionRegexGlobal, unescapeSpaces } from "../../shared/context-mentions"
 
-import { extractTextFromFile } from "../../integrations/misc/extract-text"
-import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
 import { getCommitInfo, getWorkingState } from "../../utils/git"
 import { getWorkspacePath } from "../../utils/path"
+
+import { openFile } from "../../integrations/misc/open-file"
+import { extractTextFromFile } from "../../integrations/misc/extract-text"
+import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
+
+import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
+
 import { FileContextTracker } from "../context-tracking/FileContextTracker"
 
 export async function openMention(mention?: string): Promise<void> {
@@ -273,3 +276,6 @@ export async function getLatestTerminalOutput(): Promise<string> {
 		await vscode.env.clipboard.writeText(originalClipboard)
 	}
 }
+
+// Export processUserContentMentions from its own file
+export { processUserContentMentions } from "./processUserContentMentions"

+ 92 - 0
src/core/mentions/processUserContentMentions.ts

@@ -0,0 +1,92 @@
+import { Anthropic } from "@anthropic-ai/sdk"
+import { parseMentions } from "./index"
+import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
+import { FileContextTracker } from "../context-tracking/FileContextTracker"
+
+// kilocode_change begin
+import { parseSlashCommands } from "../slash-commands"
+// kilocode_change end
+
+/**
+ * Process mentions in user content, specifically within task and feedback tags
+ */
+export async function processUserContentMentions({
+	userContent,
+	cwd,
+	urlContentFetcher,
+	fileContextTracker,
+}: {
+	userContent: Anthropic.Messages.ContentBlockParam[]
+	cwd: string
+	urlContentFetcher: UrlContentFetcher
+	fileContextTracker: FileContextTracker
+}) {
+	// Process userContent array, which contains various block types:
+	// TextBlockParam, ImageBlockParam, ToolUseBlockParam, and ToolResultBlockParam.
+	// We need to apply parseMentions() to:
+	// 1. All TextBlockParam's text (first user message with task)
+	// 2. ToolResultBlockParam's content/context text arrays if it contains
+	// "<feedback>" (see formatToolDeniedFeedback, attemptCompletion,
+	// executeCommand, and consecutiveMistakeCount >= 3) or "<answer>"
+	// (see askFollowupQuestion), we place all user generated content in
+	// these tags so they can effectively be used as markers for when we
+	// should parse mentions).
+	return Promise.all(
+		userContent.map(async (block) => {
+			const shouldProcessMentions = (text: string) => text.includes("<task>") || text.includes("<feedback>")
+
+			if (block.type === "text") {
+				if (shouldProcessMentions(block.text)) {
+					// kilocode_change begin: pull slash commands from Cline
+					let parsedText = await parseMentions(block.text, cwd, urlContentFetcher, fileContextTracker)
+
+					// when parsing slash commands, we still want to allow the user to provide their desired context
+					parsedText = parseSlashCommands(parsedText)
+
+					return {
+						...block,
+						text: parsedText,
+					}
+					// kilocode_change end
+				}
+
+				return block
+			} else if (block.type === "tool_result") {
+				if (typeof block.content === "string") {
+					if (shouldProcessMentions(block.content)) {
+						return {
+							...block,
+							content: await parseMentions(block.content, cwd, urlContentFetcher, fileContextTracker),
+						}
+					}
+
+					return block
+				} else if (Array.isArray(block.content)) {
+					const parsedContent = await Promise.all(
+						block.content.map(async (contentBlock) => {
+							if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) {
+								return {
+									...contentBlock,
+									text: await parseMentions(
+										contentBlock.text,
+										cwd,
+										urlContentFetcher,
+										fileContextTracker,
+									),
+								}
+							}
+
+							return contentBlock
+						}),
+					)
+
+					return { ...block, content: parsedContent }
+				}
+
+				return block
+			}
+
+			return block
+		}),
+	)
+}

+ 117 - 921
src/core/Cline.ts → src/core/task/Task.ts

@@ -4,26 +4,24 @@ import crypto from "crypto"
 import EventEmitter from "events"
 
 import { Anthropic } from "@anthropic-ai/sdk"
-import cloneDeep from "clone-deep"
 import delay from "delay"
 import pWaitFor from "p-wait-for"
 import { serializeError } from "serialize-error"
-import * as vscode from "vscode"
 
 // schemas
-import { TokenUsage, ToolUsage, ToolName } from "../schemas"
+import { TokenUsage, ToolUsage, ToolName } from "../../schemas"
 
 // api
-import { ApiHandler, buildApiHandler } from "../api"
-import { ApiStream } from "../api/transform/stream"
+import { ApiHandler, buildApiHandler } from "../../api"
+import { ApiStream } from "../../api/transform/stream"
 
-import { t } from "../i18n" // kilocode_change
+import { t } from "../../i18n" // kilocode_change
 
 // shared
-import { ApiConfiguration } from "../shared/api"
-import { findLastIndex } from "../shared/array"
-import { combineApiRequests } from "../shared/combineApiRequests"
-import { combineCommandSequences } from "../shared/combineCommandSequences"
+import { ProviderSettings } from "../../shared/api"
+import { findLastIndex } from "../../shared/array"
+import { combineApiRequests } from "../../shared/combineApiRequests"
+import { combineCommandSequences } from "../../shared/combineCommandSequences"
 import {
 	ClineApiReqCancelReason,
 	ClineApiReqInfo,
@@ -31,71 +29,53 @@ import {
 	ClineMessage,
 	ClineSay,
 	ToolProgressStatus,
-} from "../shared/ExtensionMessage"
-import { getApiMetrics } from "../shared/getApiMetrics"
-import { HistoryItem } from "../shared/HistoryItem"
-import { ClineAskResponse } from "../shared/WebviewMessage"
-import { defaultModeSlug, getModeBySlug } from "../shared/modes"
-import { ToolParamName, ToolResponse, DiffStrategy } from "../shared/tools"
+} from "../../shared/ExtensionMessage"
+import { getApiMetrics } from "../../shared/getApiMetrics"
+import { HistoryItem } from "../../shared/HistoryItem"
+import { ClineAskResponse } from "../../shared/WebviewMessage"
+import { defaultModeSlug } from "../../shared/modes"
+import { DiffStrategy } from "../../shared/tools"
 
 // services
-import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
-import { BrowserSession } from "../services/browser/BrowserSession"
-import { McpHub } from "../services/mcp/McpHub"
-import { ToolRepetitionDetector } from "./ToolRepetitionDetector"
-import { McpServerManager } from "../services/mcp/McpServerManager"
-import { CheckpointServiceOptions, RepoPerTaskCheckpointService } from "../services/checkpoints"
+import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
+import { BrowserSession } from "../../services/browser/BrowserSession"
+import { McpHub } from "../../services/mcp/McpHub"
+import { McpServerManager } from "../../services/mcp/McpServerManager"
+import { RepoPerTaskCheckpointService } from "../../services/checkpoints"
 
 // integrations
-import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
-import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
-import { RooTerminalProcess } from "../integrations/terminal/types"
-import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry"
+import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider"
+import { findToolName, formatContentBlockToMarkdown } from "../../integrations/misc/export-markdown"
+import { RooTerminalProcess } from "../../integrations/terminal/types"
+import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
 
 // utils
-import { calculateApiCostAnthropic } from "../utils/cost"
-import { getWorkspacePath } from "../utils/path"
-
-// tools
-import { fetchInstructionsTool } from "./tools/fetchInstructionsTool"
-import { listFilesTool } from "./tools/listFilesTool"
-import { readFileTool } from "./tools/readFileTool"
-import { writeToFileTool } from "./tools/writeToFileTool"
-import { applyDiffTool } from "./tools/applyDiffTool"
-import { insertContentTool } from "./tools/insertContentTool"
-import { searchAndReplaceTool } from "./tools/searchAndReplaceTool"
-import { listCodeDefinitionNamesTool } from "./tools/listCodeDefinitionNamesTool"
-import { searchFilesTool } from "./tools/searchFilesTool"
-import { browserActionTool } from "./tools/browserActionTool"
-import { executeCommandTool } from "./tools/executeCommandTool"
-import { useMcpToolTool } from "./tools/useMcpToolTool"
-import { accessMcpResourceTool } from "./tools/accessMcpResourceTool"
-import { askFollowupQuestionTool } from "./tools/askFollowupQuestionTool"
-import { switchModeTool } from "./tools/switchModeTool"
-import { attemptCompletionTool } from "./tools/attemptCompletionTool"
-import { newTaskTool } from "./tools/newTaskTool"
+import { calculateApiCostAnthropic } from "../../utils/cost"
+import { getWorkspacePath } from "../../utils/path"
 
 // prompts
-import { formatResponse } from "./prompts/responses"
-import { SYSTEM_PROMPT } from "./prompts/system"
-
-// ... everything else
-import { parseMentions } from "./mentions"
-import { FileContextTracker } from "./context-tracking/FileContextTracker"
-import { RooIgnoreController } from "./ignore/RooIgnoreController"
-import { type AssistantMessageContent, parseAssistantMessage } from "./assistant-message"
-import { truncateConversationIfNeeded } from "./sliding-window"
-import { ClineProvider } from "./webview/ClineProvider"
-import { validateToolUse } from "./mode-validator"
-import { MultiSearchReplaceDiffStrategy } from "./diff/strategies/multi-search-replace"
-import { readApiMessages, saveApiMessages, readTaskMessages, saveTaskMessages, taskMetadata } from "./task-persistence"
-import { getEnvironmentDetails } from "./environment/getEnvironmentDetails"
-
-// kilocode_change begin
-import { parseSlashCommands } from "./slash-commands"
-// kilocode_change end
-
-type UserContent = Array<Anthropic.Messages.ContentBlockParam>
+import { formatResponse } from "../prompts/responses"
+import { SYSTEM_PROMPT } from "../prompts/system"
+
+// core modules
+import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector"
+import { FileContextTracker } from "../context-tracking/FileContextTracker"
+import { RooIgnoreController } from "../ignore/RooIgnoreController"
+import { type AssistantMessageContent, parseAssistantMessage, presentAssistantMessage } from "../assistant-message"
+import { truncateConversationIfNeeded } from "../sliding-window"
+import { ClineProvider } from "../webview/ClineProvider"
+import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace"
+import { readApiMessages, saveApiMessages, readTaskMessages, saveTaskMessages, taskMetadata } from "../task-persistence"
+import { getEnvironmentDetails } from "../environment/getEnvironmentDetails"
+import {
+	type CheckpointDiffOptions,
+	type CheckpointRestoreOptions,
+	getCheckpointService,
+	checkpointSave,
+	checkpointRestore,
+	checkpointDiff,
+} from "../checkpoints"
+import { processUserContentMentions } from "../mentions/processUserContentMentions"
 
 export type ClineEvents = {
 	message: [{ action: "created" | "updated"; message: ClineMessage }]
@@ -111,9 +91,9 @@ export type ClineEvents = {
 	taskToolFailed: [taskId: string, tool: ToolName, error: string]
 }
 
-export type ClineOptions = {
+export type TaskOptions = {
 	provider: ClineProvider
-	apiConfiguration: ApiConfiguration
+	apiConfiguration: ProviderSettings
 	customInstructions?: string
 	enableDiff?: boolean
 	enableCheckpoints?: boolean
@@ -124,89 +104,87 @@ export type ClineOptions = {
 	historyItem?: HistoryItem
 	experiments?: Record<string, boolean>
 	startTask?: boolean
-	rootTask?: Cline
-	parentTask?: Cline
+	rootTask?: Task
+	parentTask?: Task
 	taskNumber?: number
-	onCreated?: (cline: Cline) => void
+	onCreated?: (cline: Task) => void
 }
 
-export class Cline extends EventEmitter<ClineEvents> {
+export class Task extends EventEmitter<ClineEvents> {
 	readonly taskId: string
 	readonly instanceId: string
 
-	readonly rootTask: Cline | undefined = undefined
-	readonly parentTask: Cline | undefined = undefined
+	readonly rootTask: Task | undefined = undefined
+	readonly parentTask: Task | undefined = undefined
 	readonly taskNumber: number
 	readonly workspacePath: string
 
+	providerRef: WeakRef<ClineProvider>
+	private readonly globalStoragePath: string
+	abort: boolean = false
+	didFinishAbortingStream = false
+	abandoned = false
+	isInitialized = false
 	isPaused: boolean = false
 	pausedModeSlug: string = defaultModeSlug
 	private pauseInterval: NodeJS.Timeout | undefined
+	customInstructions?: string
 
-	readonly apiConfiguration: ApiConfiguration
+	// API
+	readonly apiConfiguration: ProviderSettings
 	api: ApiHandler
 	private promptCacheKey: string
+	private lastApiRequestTime?: number
 
+	toolRepetitionDetector: ToolRepetitionDetector
 	rooIgnoreController?: RooIgnoreController
 	fileContextTracker: FileContextTracker
-	private urlContentFetcher: UrlContentFetcher
+	urlContentFetcher: UrlContentFetcher
+	terminalProcess?: RooTerminalProcess
+
+	// Computer User
 	browserSession: BrowserSession
-	didEditFile: boolean = false
-	customInstructions?: string
 
+	// Editing
+	diffViewProvider: DiffViewProvider
 	diffStrategy?: DiffStrategy
 	diffEnabled: boolean = false
 	fuzzyMatchThreshold: number
+	didEditFile: boolean = false
 
+	// LLM Messages & Chat Messages
 	apiConversationHistory: (Anthropic.MessageParam & { ts?: number })[] = []
 	clineMessages: ClineMessage[] = []
 
+	// Ask
 	private askResponse?: ClineAskResponse
 	private askResponseText?: string
 	private askResponseImages?: string[]
 	public lastMessageTs?: number
 
-	// Not private since it needs to be accessible by tools.
+	// Tool Use
 	consecutiveMistakeCount: number = 0
 	consecutiveMistakeLimit: number
 	consecutiveMistakeCountForApplyDiff: Map<string, number> = new Map()
+	private toolUsage: ToolUsage = {}
 
-	// For tracking identical consecutive tool calls
-	private toolRepetitionDetector: ToolRepetitionDetector
-
-	// Not private since it needs to be accessible by tools.
-	providerRef: WeakRef<ClineProvider>
-	private readonly globalStoragePath: string
-	private abort: boolean = false
-	didFinishAbortingStream = false
-	abandoned = false
-	diffViewProvider: DiffViewProvider
-	private lastApiRequestTime?: number
-	isInitialized = false
-
-	// checkpoints
-	private enableCheckpoints: boolean
-	private checkpointService?: RepoPerTaskCheckpointService
-	private checkpointServiceInitializing = false
+	// Checkpoints
+	enableCheckpoints: boolean
+	checkpointService?: RepoPerTaskCheckpointService
+	checkpointServiceInitializing = false
 
-	// streaming
+	// Streaming
 	isWaitingForFirstChunk = false
 	isStreaming = false
-	private currentStreamingContentIndex = 0
-	private assistantMessageContent: AssistantMessageContent[] = []
-	private presentAssistantMessageLocked = false
-	private presentAssistantMessageHasPendingUpdates = false
+	currentStreamingContentIndex = 0
+	assistantMessageContent: AssistantMessageContent[] = []
+	presentAssistantMessageLocked = false
+	presentAssistantMessageHasPendingUpdates = false
 	userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = []
-	private userMessageContentReady = false
+	userMessageContentReady = false
 	didRejectTool = false
-	private didAlreadyUseTool = false
-	private didCompleteReadingStream = false
-
-	// metrics
-	private toolUsage: ToolUsage = {}
-
-	// terminal
-	public terminalProcess?: RooTerminalProcess
+	didAlreadyUseTool = false
+	didCompleteReadingStream = false
 
 	constructor({
 		provider,
@@ -224,7 +202,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 		parentTask,
 		taskNumber = -1,
 		onCreated,
-	}: ClineOptions) {
+	}: TaskOptions) {
 		super()
 
 		if (startTask && !task && !images && !historyItem) {
@@ -281,8 +259,8 @@ export class Cline extends EventEmitter<ClineEvents> {
 		}
 	}
 
-	static create(options: ClineOptions): [Cline, Promise<void>] {
-		const instance = new Cline({ ...options, startTask: false })
+	static create(options: TaskOptions): [Task, Promise<void>] {
+		const instance = new Task({ ...options, startTask: false })
 		const { images, task, historyItem } = options
 		let promise
 
@@ -732,7 +710,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 
 		// if the last message is a user message, we can need to get the assistant message before it to see if it made tool calls, and if so, fill in the remaining tool responses with 'interrupted'
 
-		let modifiedOldUserContent: UserContent // either the last message if its user message, or the user message before the last (assistant) message
+		let modifiedOldUserContent: Anthropic.Messages.ContentBlockParam[] // either the last message if its user message, or the user message before the last (assistant) message
 		let modifiedApiConversationHistory: Anthropic.Messages.MessageParam[] // need to remove the last user message to replace with new modified user message
 		if (existingApiConversationHistory.length > 0) {
 			const lastMessage = existingApiConversationHistory[existingApiConversationHistory.length - 1]
@@ -762,7 +740,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 				const previousAssistantMessage: Anthropic.Messages.MessageParam | undefined =
 					existingApiConversationHistory[existingApiConversationHistory.length - 2]
 
-				const existingUserContent: UserContent = Array.isArray(lastMessage.content)
+				const existingUserContent: Anthropic.Messages.ContentBlockParam[] = Array.isArray(lastMessage.content)
 					? lastMessage.content
 					: [{ type: "text", text: lastMessage.content }]
 				if (previousAssistantMessage && previousAssistantMessage.role === "assistant") {
@@ -806,7 +784,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 			throw new Error("Unexpected: No existing API conversation history")
 		}
 
-		let newUserContent: UserContent = [...modifiedOldUserContent]
+		let newUserContent: Anthropic.Messages.ContentBlockParam[] = [...modifiedOldUserContent]
 
 		const agoText = ((): string => {
 			const timestamp = lastClineMessage?.ts ?? Date.now()
@@ -914,9 +892,9 @@ export class Cline extends EventEmitter<ClineEvents> {
 
 	// Task Loop
 
-	private async initiateTaskLoop(userContent: UserContent): Promise<void> {
+	private async initiateTaskLoop(userContent: Anthropic.Messages.ContentBlockParam[]): Promise<void> {
 		// Kicks off the checkpoints initialization process in the background.
-		this.getCheckpointService()
+		getCheckpointService(this)
 
 		let nextUserContent = userContent
 		let includeFileDetails = true
@@ -950,7 +928,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 	}
 
 	public async recursivelyMakeClineRequests(
-		userContent: UserContent,
+		userContent: Anthropic.Messages.ContentBlockParam[],
 		includeFileDetails: boolean = false,
 	): Promise<boolean> {
 		if (this.abort) {
@@ -1021,7 +999,13 @@ export class Cline extends EventEmitter<ClineEvents> {
 			}),
 		)
 
-		const parsedUserContent = await this.parseUserContent(userContent)
+		const parsedUserContent = await processUserContentMentions({
+			userContent,
+			cwd: this.cwd,
+			urlContentFetcher: this.urlContentFetcher,
+			fileContextTracker: this.fileContextTracker,
+		})
+
 		const environmentDetails = await getEnvironmentDetails(this, includeFileDetails)
 
 		// Add environment details as its own text block, separate from tool
@@ -1177,7 +1161,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 							}
 
 							// Present content to user.
-							this.presentAssistantMessage()
+							presentAssistantMessage(this)
 							break
 					}
 
@@ -1268,7 +1252,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 				// `pWaitFor` before making the next request. All this is really
 				// doing is presenting the last partial message that we just set
 				// to complete.
-				this.presentAssistantMessage()
+				presentAssistantMessage(this)
 			}
 
 			updateApiReqMsg()
@@ -1624,816 +1608,28 @@ export class Cline extends EventEmitter<ClineEvents> {
 		yield* iterator
 	}
 
-	public async presentAssistantMessage() {
-		if (this.abort) {
-			throw new Error(`[Cline#presentAssistantMessage] task ${this.taskId}.${this.instanceId} aborted`)
-		}
-
-		if (this.presentAssistantMessageLocked) {
-			this.presentAssistantMessageHasPendingUpdates = true
-			return
-		}
-		this.presentAssistantMessageLocked = true
-		this.presentAssistantMessageHasPendingUpdates = false
-
-		if (this.currentStreamingContentIndex >= this.assistantMessageContent.length) {
-			// this may happen if the last content block was completed before streaming could finish. if streaming is finished, and we're out of bounds then this means we already presented/executed the last content block and are ready to continue to next request
-			if (this.didCompleteReadingStream) {
-				this.userMessageContentReady = true
-			}
-			// console.log("no more content blocks to stream! this shouldn't happen?")
-			this.presentAssistantMessageLocked = false
-			return
-			//throw new Error("No more content blocks to stream! This shouldn't happen...") // remove and just return after testing
-		}
-
-		const block = cloneDeep(this.assistantMessageContent[this.currentStreamingContentIndex]) // need to create copy bc while stream is updating the array, it could be updating the reference block properties too
-
-		switch (block.type) {
-			case "text": {
-				if (this.didRejectTool || this.didAlreadyUseTool) {
-					break
-				}
-				let content = block.content
-				if (content) {
-					// (have to do this for partial and complete since sending content in thinking tags to markdown renderer will automatically be removed)
-					// Remove end substrings of <thinking or </thinking (below xml parsing is only for opening tags)
-					// (this is done with the xml parsing below now, but keeping here for reference)
-					// content = content.replace(/<\/?t(?:h(?:i(?:n(?:k(?:i(?:n(?:g)?)?)?$/, "")
-					// Remove all instances of <thinking> (with optional line break after) and </thinking> (with optional line break before)
-					// - Needs to be separate since we dont want to remove the line break before the first tag
-					// - Needs to happen before the xml parsing below
-					content = content.replace(/<thinking>\s?/g, "")
-					content = content.replace(/\s?<\/thinking>/g, "")
-
-					// Remove partial XML tag at the very end of the content (for tool use and thinking tags)
-					// (prevents scrollview from jumping when tags are automatically removed)
-					const lastOpenBracketIndex = content.lastIndexOf("<")
-					if (lastOpenBracketIndex !== -1) {
-						const possibleTag = content.slice(lastOpenBracketIndex)
-						// Check if there's a '>' after the last '<' (i.e., if the tag is complete) (complete thinking and tool tags will have been removed by now)
-						const hasCloseBracket = possibleTag.includes(">")
-						if (!hasCloseBracket) {
-							// Extract the potential tag name
-							let tagContent: string
-							if (possibleTag.startsWith("</")) {
-								tagContent = possibleTag.slice(2).trim()
-							} else {
-								tagContent = possibleTag.slice(1).trim()
-							}
-							// Check if tagContent is likely an incomplete tag name (letters and underscores only)
-							const isLikelyTagName = /^[a-zA-Z_]+$/.test(tagContent)
-							// Preemptively remove < or </ to keep from these artifacts showing up in chat (also handles closing thinking tags)
-							const isOpeningOrClosing = possibleTag === "<" || possibleTag === "</"
-							// If the tag is incomplete and at the end, remove it from the content
-							if (isOpeningOrClosing || isLikelyTagName) {
-								content = content.slice(0, lastOpenBracketIndex).trim()
-							}
-						}
-					}
-				}
-				await this.say("text", content, undefined, block.partial)
-				break
-			}
-			case "tool_use":
-				const toolDescription = (): string => {
-					switch (block.name) {
-						case "execute_command":
-							return `[${block.name} for '${block.params.command}']`
-						case "read_file":
-							return `[${block.name} for '${block.params.path}']`
-						case "fetch_instructions":
-							return `[${block.name} for '${block.params.task}']`
-						case "write_to_file":
-							return `[${block.name} for '${block.params.path}']`
-						case "apply_diff":
-							return `[${block.name} for '${block.params.path}']`
-						case "search_files":
-							return `[${block.name} for '${block.params.regex}'${
-								block.params.file_pattern ? ` in '${block.params.file_pattern}'` : ""
-							}]`
-						case "insert_content":
-							return `[${block.name} for '${block.params.path}']`
-						case "search_and_replace":
-							return `[${block.name} for '${block.params.path}']`
-						case "list_files":
-							return `[${block.name} for '${block.params.path}']`
-						case "list_code_definition_names":
-							return `[${block.name} for '${block.params.path}']`
-						case "browser_action":
-							return `[${block.name} for '${block.params.action}']`
-						case "use_mcp_tool":
-							return `[${block.name} for '${block.params.server_name}']`
-						case "access_mcp_resource":
-							return `[${block.name} for '${block.params.server_name}']`
-						case "ask_followup_question":
-							return `[${block.name} for '${block.params.question}']`
-						case "attempt_completion":
-							return `[${block.name}]`
-						case "switch_mode":
-							return `[${block.name} to '${block.params.mode_slug}'${block.params.reason ? ` because: ${block.params.reason}` : ""}]`
-						case "new_task": {
-							const mode = block.params.mode ?? defaultModeSlug
-							const message = block.params.message ?? "(no message)"
-							const modeName = getModeBySlug(mode, customModes)?.name ?? mode
-							return `[${block.name} in ${modeName} mode: '${message}']`
-						}
-					}
-				}
-
-				if (this.didRejectTool) {
-					// ignore any tool content after user has rejected tool once
-					if (!block.partial) {
-						this.userMessageContent.push({
-							type: "text",
-							text: `Skipping tool ${toolDescription()} due to user rejecting a previous tool.`,
-						})
-					} else {
-						// partial tool after user rejected a previous tool
-						this.userMessageContent.push({
-							type: "text",
-							text: `Tool ${toolDescription()} was interrupted and not executed due to user rejecting a previous tool.`,
-						})
-					}
-					break
-				}
-
-				if (this.didAlreadyUseTool) {
-					// ignore any content after a tool has already been used
-					this.userMessageContent.push({
-						type: "text",
-						text: `Tool [${block.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`,
-					})
-					break
-				}
-
-				const pushToolResult = (content: ToolResponse) => {
-					this.userMessageContent.push({
-						type: "text",
-						text: `${toolDescription()} Result:`,
-					})
-					if (typeof content === "string") {
-						this.userMessageContent.push({
-							type: "text",
-							text: content || "(tool did not return anything)",
-						})
-					} else {
-						this.userMessageContent.push(...content)
-					}
-					// once a tool result has been collected, ignore all other tool uses since we should only ever present one tool result per message
-					this.didAlreadyUseTool = true
-
-					// Flag a checkpoint as possible since we've used a tool
-					// which may have changed the file system.
-				}
-
-				const askApproval = async (
-					type: ClineAsk,
-					partialMessage?: string,
-					progressStatus?: ToolProgressStatus,
-				) => {
-					const { response, text, images } = await this.ask(type, partialMessage, false, progressStatus)
-					if (response !== "yesButtonClicked") {
-						// Handle both messageResponse and noButtonClicked with text
-						if (text) {
-							await this.say("user_feedback", text, images)
-							pushToolResult(
-								formatResponse.toolResult(formatResponse.toolDeniedWithFeedback(text), images),
-							)
-						} else {
-							pushToolResult(formatResponse.toolDenied())
-						}
-						this.didRejectTool = true
-						return false
-					}
-					// Handle yesButtonClicked with text
-					if (text) {
-						await this.say("user_feedback", text, images)
-						pushToolResult(formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text), images))
-					}
-					return true
-				}
-
-				const askFinishSubTaskApproval = async () => {
-					// ask the user to approve this task has completed, and he has reviewd it, and we can declare task is finished
-					// and return control to the parent task to continue running the rest of the sub-tasks
-					const toolMessage = JSON.stringify({
-						tool: "finishTask",
-					})
-
-					return await askApproval("tool", toolMessage)
-				}
-
-				const handleError = async (action: string, error: Error) => {
-					const errorString = `Error ${action}: ${JSON.stringify(serializeError(error))}`
-					await this.say(
-						"error",
-						`Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`,
-					)
-					// this.toolResults.push({
-					// 	type: "tool_result",
-					// 	tool_use_id: toolUseId,
-					// 	content: await this.formatToolError(errorString),
-					// })
-					pushToolResult(formatResponse.toolError(errorString))
-				}
-
-				// If block is partial, remove partial closing tag so its not presented to user
-				const removeClosingTag = (tag: ToolParamName, text?: string): string => {
-					if (!block.partial) {
-						return text || ""
-					}
-					if (!text) {
-						return ""
-					}
-					// This regex dynamically constructs a pattern to match the closing tag:
-					// - Optionally matches whitespace before the tag
-					// - Matches '<' or '</' optionally followed by any subset of characters from the tag name
-					const tagRegex = new RegExp(
-						`\\s?<\/?${tag
-							.split("")
-							.map((char) => `(?:${char})?`)
-							.join("")}$`,
-						"g",
-					)
-					return text.replace(tagRegex, "")
-				}
-
-				if (block.name !== "browser_action") {
-					await this.browserSession.closeBrowser()
-				}
-
-				if (!block.partial) {
-					this.recordToolUsage(block.name)
-				}
-
-				// Validate tool use before execution
-				const { mode, customModes } = (await this.providerRef.deref()?.getState()) ?? {}
-				try {
-					validateToolUse(
-						block.name as ToolName,
-						mode ?? defaultModeSlug,
-						customModes ?? [],
-						{
-							apply_diff: this.diffEnabled,
-						},
-						block.params,
-					)
-				} catch (error) {
-					this.consecutiveMistakeCount++
-					pushToolResult(formatResponse.toolError(error.message))
-					break
-				}
-
-				// Check for identical consecutive tool calls
-				if (!block.partial) {
-					// Use the detector to check for repetition, passing the ToolUse block directly
-					const repetitionCheck = this.toolRepetitionDetector.check(block)
-
-					// If execution is not allowed, notify user and break
-					if (!repetitionCheck.allowExecution && repetitionCheck.askUser) {
-						// Handle repetition similar to mistake_limit_reached pattern
-						const { response, text, images } = await this.ask(
-							repetitionCheck.askUser.messageKey as ClineAsk,
-							repetitionCheck.askUser.messageDetail.replace("{toolName}", block.name),
-						)
-
-						if (response === "messageResponse") {
-							// Add user feedback to userContent
-							this.userMessageContent.push(
-								{
-									type: "text" as const,
-									text: `Tool repetition limit reached. User feedback: ${text}`,
-								},
-								...formatResponse.imageBlocks(images),
-							)
-
-							// Add user feedback to chat
-							await this.say("user_feedback", text, images)
-
-							// Track tool repetition in telemetry
-							// telemetryService.captureConsecutiveMistakeError(this.taskId) // Using existing telemetry method
-						}
-
-						// Return tool result message about the repetition
-						pushToolResult(
-							formatResponse.toolError(
-								`Tool call repetition limit reached for ${block.name}. Please try a different approach.`,
-							),
-						)
-						break
-					}
-				}
-
-				switch (block.name) {
-					case "write_to_file":
-						await writeToFileTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
-						break
-					case "apply_diff":
-						await applyDiffTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
-						break
-					case "insert_content":
-						await insertContentTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
-						break
-					case "search_and_replace":
-						await searchAndReplaceTool(
-							this,
-							block,
-							askApproval,
-							handleError,
-							pushToolResult,
-							removeClosingTag,
-						)
-						break
-					case "read_file":
-						await readFileTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
-
-						break
-					case "fetch_instructions":
-						await fetchInstructionsTool(this, block, askApproval, handleError, pushToolResult)
-						break
-					case "list_files":
-						await listFilesTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
-						break
-					case "list_code_definition_names":
-						await listCodeDefinitionNamesTool(
-							this,
-							block,
-							askApproval,
-							handleError,
-							pushToolResult,
-							removeClosingTag,
-						)
-						break
-					case "search_files":
-						await searchFilesTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
-						break
-					case "browser_action":
-						await browserActionTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
-						break
-					case "execute_command":
-						await executeCommandTool(
-							this,
-							block,
-							askApproval,
-							handleError,
-							pushToolResult,
-							removeClosingTag,
-						)
-						break
-					case "use_mcp_tool":
-						await useMcpToolTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
-						break
-					case "access_mcp_resource":
-						await accessMcpResourceTool(
-							this,
-							block,
-							askApproval,
-							handleError,
-							pushToolResult,
-							removeClosingTag,
-						)
-						break
-					case "ask_followup_question":
-						await askFollowupQuestionTool(
-							this,
-							block,
-							askApproval,
-							handleError,
-							pushToolResult,
-							removeClosingTag,
-						)
-						break
-					case "switch_mode":
-						await switchModeTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
-						break
-					case "new_task":
-						await newTaskTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
-						break
-					case "attempt_completion":
-						await attemptCompletionTool(
-							this,
-							block,
-							askApproval,
-							handleError,
-							pushToolResult,
-							removeClosingTag,
-							toolDescription,
-							askFinishSubTaskApproval,
-						)
-						break
-				}
-
-				break
-		}
-
-		const recentlyModifiedFiles = this.fileContextTracker.getAndClearCheckpointPossibleFile()
-
-		if (recentlyModifiedFiles.length > 0) {
-			// TODO: We can track what file changes were made and only
-			// checkpoint those files, this will be save storage.
-			await this.checkpointSave()
-		}
-
-		/*
-		Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present.
-		When you see the UI inactive during this, it means that a tool is breaking without presenting any UI. For example the write_to_file tool was breaking when relpath was undefined, and for invalid relpath it never presented UI.
-		*/
-		this.presentAssistantMessageLocked = false // this needs to be placed here, if not then calling this.presentAssistantMessage below would fail (sometimes) since it's locked
-		// NOTE: when tool is rejected, iterator stream is interrupted and it waits for userMessageContentReady to be true. Future calls to present will skip execution since didRejectTool and iterate until contentIndex is set to message length and it sets userMessageContentReady to true itself (instead of preemptively doing it in iterator)
-		if (!block.partial || this.didRejectTool || this.didAlreadyUseTool) {
-			// block is finished streaming and executing
-			if (this.currentStreamingContentIndex === this.assistantMessageContent.length - 1) {
-				// its okay that we increment if !didCompleteReadingStream, it'll just return bc out of bounds and as streaming continues it will call presentAssitantMessage if a new block is ready. if streaming is finished then we set userMessageContentReady to true when out of bounds. This gracefully allows the stream to continue on and all potential content blocks be presented.
-				// last block is complete and it is finished executing
-				this.userMessageContentReady = true // will allow pwaitfor to continue
-			}
-
-			// call next block if it exists (if not then read stream will call it when its ready)
-			this.currentStreamingContentIndex++ // need to increment regardless, so when read stream calls this function again it will be streaming the next block
-
-			if (this.currentStreamingContentIndex < this.assistantMessageContent.length) {
-				// there are already more content blocks to stream, so we'll call this function ourselves
-				// await this.presentAssistantContent()
-
-				this.presentAssistantMessage()
-				return
-			}
-		}
-		// block is partial, but the read stream may have finished
-		if (this.presentAssistantMessageHasPendingUpdates) {
-			this.presentAssistantMessage()
-		}
-	}
-
-	// Transform
-
-	public async parseUserContent(userContent: UserContent) {
-		// Process userContent array, which contains various block types:
-		// TextBlockParam, ImageBlockParam, ToolUseBlockParam, and ToolResultBlockParam.
-		// We need to apply parseMentions() to:
-		// 1. All TextBlockParam's text (first user message with task)
-		// 2. ToolResultBlockParam's content/context text arrays if it contains
-		// "<feedback>" (see formatToolDeniedFeedback, attemptCompletion,
-		// executeCommand, and consecutiveMistakeCount >= 3) or "<answer>"
-		// (see askFollowupQuestion), we place all user generated content in
-		// these tags so they can effectively be used as markers for when we
-		// should parse mentions).
-		return Promise.all(
-			userContent.map(async (block) => {
-				const shouldProcessMentions = (text: string) => text.includes("<task>") || text.includes("<feedback>")
-
-				if (block.type === "text") {
-					if (shouldProcessMentions(block.text)) {
-						// kilocode_change begin: pull slash commands from Cline
-						let parsedText = await parseMentions(
-							block.text,
-							this.cwd,
-							this.urlContentFetcher,
-							this.fileContextTracker,
-						)
-
-						// when parsing slash commands, we still want to allow the user to provide their desired context
-						parsedText = parseSlashCommands(parsedText)
-
-						return {
-							...block,
-							text: parsedText,
-						}
-						// kilocode_change end
-					}
-
-					return block
-				} else if (block.type === "tool_result") {
-					if (typeof block.content === "string") {
-						if (shouldProcessMentions(block.content)) {
-							return {
-								...block,
-								content: await parseMentions(
-									block.content,
-									this.cwd,
-									this.urlContentFetcher,
-									this.fileContextTracker,
-								),
-							}
-						}
-
-						return block
-					} else if (Array.isArray(block.content)) {
-						const parsedContent = await Promise.all(
-							block.content.map(async (contentBlock) => {
-								if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) {
-									return {
-										...contentBlock,
-										text: await parseMentions(
-											contentBlock.text,
-											this.cwd,
-											this.urlContentFetcher,
-											this.fileContextTracker,
-										),
-									}
-								}
-
-								return contentBlock
-							}),
-						)
-
-						return { ...block, content: parsedContent }
-					}
-
-					return block
-				}
-
-				return block
-			}),
-		)
-	}
-
 	// Checkpoints
 
-	private getCheckpointService() {
-		if (!this.enableCheckpoints) {
-			return undefined
-		}
-
-		if (this.checkpointService) {
-			return this.checkpointService
-		}
-
-		if (this.checkpointServiceInitializing) {
-			console.log("[Cline#getCheckpointService] checkpoint service is still initializing")
-			return undefined
-		}
-
-		const log = (message: string) => {
-			console.log(message)
-
-			try {
-				this.providerRef.deref()?.log(message)
-			} catch (err) {
-				// NO-OP
-			}
-		}
-
-		console.log("[Cline#getCheckpointService] initializing checkpoints service")
-
-		try {
-			const workspaceDir = getWorkspacePath()
-
-			if (!workspaceDir) {
-				log("[Cline#getCheckpointService] workspace folder not found, disabling checkpoints")
-				this.enableCheckpoints = false
-				return undefined
-			}
-
-			const globalStorageDir = this.providerRef.deref()?.context.globalStorageUri.fsPath
-
-			if (!globalStorageDir) {
-				log("[Cline#getCheckpointService] globalStorageDir not found, disabling checkpoints")
-				this.enableCheckpoints = false
-				return undefined
-			}
-
-			const options: CheckpointServiceOptions = {
-				taskId: this.taskId,
-				workspaceDir,
-				shadowDir: globalStorageDir,
-				log,
-			}
-
-			const service = RepoPerTaskCheckpointService.create(options)
-
-			this.checkpointServiceInitializing = true
-
-			service.on("initialize", () => {
-				log("[Cline#getCheckpointService] service initialized")
-
-				try {
-					const isCheckpointNeeded =
-						typeof this.clineMessages.find(({ say }) => say === "checkpoint_saved") === "undefined"
-
-					this.checkpointService = service
-					this.checkpointServiceInitializing = false
-
-					if (isCheckpointNeeded) {
-						log("[Cline#getCheckpointService] no checkpoints found, saving initial checkpoint")
-						this.checkpointSave()
-					}
-				} catch (err) {
-					log("[Cline#getCheckpointService] caught error in on('initialize'), disabling checkpoints")
-					this.enableCheckpoints = false
-				}
-			})
-
-			service.on("checkpoint", ({ isFirst, fromHash: from, toHash: to }) => {
-				try {
-					this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: to })
-
-					this.say("checkpoint_saved", to, undefined, undefined, { isFirst, from, to }).catch((err) => {
-						log("[Cline#getCheckpointService] caught unexpected error in say('checkpoint_saved')")
-						console.error(err)
-					})
-				} catch (err) {
-					log(
-						"[Cline#getCheckpointService] caught unexpected error in on('checkpoint'), disabling checkpoints",
-					)
-					console.error(err)
-					this.enableCheckpoints = false
-				}
-			})
-
-			log("[Cline#getCheckpointService] initializing shadow git")
-
-			service.initShadowGit().catch((err) => {
-				log(
-					`[Cline#getCheckpointService] caught unexpected error in initShadowGit, disabling checkpoints (${err.message})`,
-				)
-				console.error(err)
-				this.enableCheckpoints = false
-			})
-
-			return service
-		} catch (err) {
-			log("[Cline#getCheckpointService] caught unexpected error, disabling checkpoints")
-			this.enableCheckpoints = false
-			return undefined
-		}
+	public async checkpointSave() {
+		return checkpointSave(this)
 	}
 
-	private async getInitializedCheckpointService({
-		interval = 250,
-		timeout = 15_000,
-	}: { interval?: number; timeout?: number } = {}) {
-		const service = this.getCheckpointService()
-
-		if (!service || service.isInitialized) {
-			return service
-		}
-
-		try {
-			await pWaitFor(
-				() => {
-					console.log("[Cline#getCheckpointService] waiting for service to initialize")
-					return service.isInitialized
-				},
-				{ interval, timeout },
-			)
-
-			return service
-		} catch (err) {
-			return undefined
-		}
+	public async checkpointRestore(options: CheckpointRestoreOptions) {
+		return checkpointRestore(this, options)
 	}
 
-	public async checkpointDiff({
-		ts,
-		previousCommitHash,
-		commitHash,
-		mode,
-	}: {
-		ts: number
-		previousCommitHash?: string
-		commitHash: string
-		mode: "full" | "checkpoint"
-	}) {
-		const service = await this.getInitializedCheckpointService()
-
-		if (!service) {
-			return
-		}
-
-		if (!previousCommitHash && mode === "checkpoint") {
-			const previousCheckpoint = this.clineMessages
-				.filter(({ say }) => say === "checkpoint_saved")
-				.sort((a, b) => b.ts - a.ts)
-				.find((message) => message.ts < ts)
-
-			previousCommitHash = previousCheckpoint?.text
-		}
-
-		try {
-			const changes = await service.getDiff({ from: previousCommitHash, to: commitHash })
-
-			if (!changes?.length) {
-				vscode.window.showInformationMessage("No changes found.")
-				return
-			}
-
-			await vscode.commands.executeCommand(
-				"vscode.changes",
-				mode === "full" ? "Changes since task started" : "Changes since previous checkpoint",
-				changes.map((change) => [
-					vscode.Uri.file(change.paths.absolute),
-					vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${change.paths.relative}`).with({
-						query: Buffer.from(change.content.before ?? "").toString("base64"),
-					}),
-					vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${change.paths.relative}`).with({
-						query: Buffer.from(change.content.after ?? "").toString("base64"),
-					}),
-				]),
-			)
-		} catch (err) {
-			this.providerRef.deref()?.log("[checkpointDiff] disabling checkpoints for this task")
-			this.enableCheckpoints = false
-		}
+	public async checkpointDiff(options: CheckpointDiffOptions) {
+		return checkpointDiff(this, options)
 	}
 
-	public async checkpointSave() {
-		const service = this.getCheckpointService()
-
-		if (!service) {
-			return
-		}
-
-		if (!service.isInitialized) {
-			this.providerRef
-				.deref()
-				?.log("[checkpointSave] checkpoints didn't initialize in time, disabling checkpoints for this task")
-
-			this.enableCheckpoints = false
-			return
-		}
-
-		// Start the checkpoint process in the background.
-		return service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`).catch((err) => {
-			console.error("[Cline#checkpointSave] caught unexpected error, disabling checkpoints", err)
-			this.enableCheckpoints = false
-		})
-	}
-
-	public async checkpointRestore({
-		ts,
-		commitHash,
-		mode,
-	}: {
-		ts: number
-		commitHash: string
-		mode: "preview" | "restore"
-	}) {
-		const service = await this.getInitializedCheckpointService()
-
-		if (!service) {
-			return
-		}
-
-		const index = this.clineMessages.findIndex((m) => m.ts === ts)
-
-		if (index === -1) {
-			return
-		}
-
-		try {
-			await service.restoreCheckpoint(commitHash)
-
-			await this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: commitHash })
-
-			if (mode === "restore") {
-				await this.overwriteApiConversationHistory(
-					this.apiConversationHistory.filter((m) => !m.ts || m.ts < ts),
-				)
-
-				const deletedMessages = this.clineMessages.slice(index + 1)
-
-				const { totalTokensIn, totalTokensOut, totalCacheWrites, totalCacheReads, totalCost } = getApiMetrics(
-					combineApiRequests(combineCommandSequences(deletedMessages)),
-				)
-
-				await this.overwriteClineMessages(this.clineMessages.slice(0, index + 1))
-
-				// TODO: Verify that this is working as expected.
-				await this.say(
-					"api_req_deleted",
-					JSON.stringify({
-						tokensIn: totalTokensIn,
-						tokensOut: totalTokensOut,
-						cacheWrites: totalCacheWrites,
-						cacheReads: totalCacheReads,
-						cost: totalCost,
-					} satisfies ClineApiReqInfo),
-				)
-			}
+	// Metrics
 
-			// The task is already cancelled by the provider beforehand, but we
-			// need to re-init to get the updated messages.
-			//
-			// This was take from Cline's implementation of the checkpoints
-			// feature. The cline instance will hang if we don't cancel twice,
-			// so this is currently necessary, but it seems like a complicated
-			// and hacky solution to a problem that I don't fully understand.
-			// I'd like to revisit this in the future and try to improve the
-			// task flow and the communication between the webview and the
-			// Cline instance.
-			this.providerRef.deref()?.cancelTask()
-		} catch (err) {
-			this.providerRef.deref()?.log("[checkpointRestore] disabling checkpoints for this task")
-			this.enableCheckpoints = false
-		}
+	public combineMessages(messages: ClineMessage[]) {
+		return combineApiRequests(combineCommandSequences(messages))
 	}
 
-	// Metrics
-
 	public getTokenUsage() {
-		return getApiMetrics(combineApiRequests(combineCommandSequences(this.clineMessages.slice(1))))
+		return getApiMetrics(this.combineMessages(this.clineMessages.slice(1)))
 	}
 
 	public recordToolUsage(toolName: ToolName) {

+ 82 - 100
src/core/__tests__/Cline.test.ts → src/core/task/__tests__/Task.test.ts

@@ -1,4 +1,4 @@
-// npx jest src/core/__tests__/Cline.test.ts
+// npx jest src/core/task/__tests__/Task.test.ts
 
 import * as os from "os"
 import * as path from "path"
@@ -6,51 +6,18 @@ import * as path from "path"
 import * as vscode from "vscode"
 import { Anthropic } from "@anthropic-ai/sdk"
 
-import { GlobalState } from "../../schemas"
-import { Cline } from "../Cline"
-import { ClineProvider } from "../webview/ClineProvider"
-import { ApiConfiguration, ModelInfo } from "../../shared/api"
-import { ApiStreamChunk } from "../../api/transform/stream"
-import { ContextProxy } from "../config/ContextProxy"
-
-jest.mock("../environment/getEnvironmentDetails", () => ({
-	getEnvironmentDetails: jest.fn().mockResolvedValue(""),
-}))
+import { GlobalState } from "../../../schemas"
+import { Task } from "../Task"
+import { ClineProvider } from "../../webview/ClineProvider"
+import { ProviderSettings, ModelInfo } from "../../../shared/api"
+import { ApiStreamChunk } from "../../../api/transform/stream"
+import { ContextProxy } from "../../config/ContextProxy"
+import { processUserContentMentions } from "../../mentions/processUserContentMentions"
 
 jest.mock("execa", () => ({
 	execa: jest.fn(),
 }))
 
-// Mock RooIgnoreController
-jest.mock("../ignore/RooIgnoreController")
-
-// Mock storagePathManager to prevent dynamic import issues
-jest.mock("../../shared/storagePathManager", () => ({
-	getTaskDirectoryPath: jest
-		.fn()
-		.mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)),
-	getSettingsDirectoryPath: jest
-		.fn()
-		.mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)),
-}))
-
-// Mock fileExistsAtPath
-jest.mock("../../utils/fs", () => ({
-	fileExistsAtPath: jest.fn().mockImplementation((filePath) => {
-		return filePath.includes("ui_messages.json") || filePath.includes("api_conversation_history.json")
-	}),
-}))
-
-// Mock fs/promises
-const mockMessages = [
-	{
-		ts: Date.now(),
-		type: "say",
-		say: "text",
-		text: "historical task",
-	},
-]
-
 jest.mock("fs/promises", () => ({
 	mkdir: jest.fn().mockResolvedValue(undefined),
 	writeFile: jest.fn().mockResolvedValue(undefined),
@@ -80,35 +47,18 @@ jest.mock("fs/promises", () => ({
 	rmdir: jest.fn().mockResolvedValue(undefined),
 }))
 
-// Mock dependencies
+jest.mock("p-wait-for", () => ({
+	__esModule: true,
+	default: jest.fn().mockImplementation(async () => Promise.resolve()),
+}))
+
 jest.mock("vscode", () => {
 	const mockDisposable = { dispose: jest.fn() }
-	const mockEventEmitter = {
-		event: jest.fn(),
-		fire: jest.fn(),
-	}
-
-	const mockTextDocument = {
-		uri: {
-			fsPath: "/mock/workspace/path/file.ts",
-		},
-	}
-
-	const mockTextEditor = {
-		document: mockTextDocument,
-	}
-
-	const mockTab = {
-		input: {
-			uri: {
-				fsPath: "/mock/workspace/path/file.ts",
-			},
-		},
-	}
-
-	const mockTabGroup = {
-		tabs: [mockTab],
-	}
+	const mockEventEmitter = { event: jest.fn(), fire: jest.fn() }
+	const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } }
+	const mockTextEditor = { document: mockTextDocument }
+	const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } }
+	const mockTabGroup = { tabs: [mockTab] }
 
 	return {
 		CodeActionKind: {
@@ -129,9 +79,7 @@ jest.mock("vscode", () => {
 		workspace: {
 			workspaceFolders: [
 				{
-					uri: {
-						fsPath: "/mock/workspace/path",
-					},
+					uri: { fsPath: "/mock/workspace/path" },
 					name: "mock-workspace",
 					index: 0,
 				},
@@ -160,15 +108,55 @@ jest.mock("vscode", () => {
 	}
 })
 
-// Mock p-wait-for to resolve immediately
-jest.mock("p-wait-for", () => ({
-	__esModule: true,
-	default: jest.fn().mockImplementation(async () => Promise.resolve()),
+jest.mock("../../mentions", () => ({
+	parseMentions: jest.fn().mockImplementation((text) => {
+		return Promise.resolve(`processed: ${text}`)
+	}),
+	openMention: jest.fn(),
+	getLatestTerminalOutput: jest.fn(),
+}))
+
+jest.mock("../../../integrations/misc/extract-text", () => ({
+	extractTextFromFile: jest.fn().mockResolvedValue("Mock file content"),
 }))
 
+jest.mock("../../environment/getEnvironmentDetails", () => ({
+	getEnvironmentDetails: jest.fn().mockResolvedValue(""),
+}))
+
+// Mock RooIgnoreController
+jest.mock("../../ignore/RooIgnoreController")
+
+// Mock storagePathManager to prevent dynamic import issues
+jest.mock("../../../shared/storagePathManager", () => ({
+	getTaskDirectoryPath: jest
+		.fn()
+		.mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)),
+	getSettingsDirectoryPath: jest
+		.fn()
+		.mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)),
+}))
+
+// Mock fileExistsAtPath
+jest.mock("../../../utils/fs", () => ({
+	fileExistsAtPath: jest.fn().mockImplementation((filePath) => {
+		return filePath.includes("ui_messages.json") || filePath.includes("api_conversation_history.json")
+	}),
+}))
+
+// Mock fs/promises
+const mockMessages = [
+	{
+		ts: Date.now(),
+		type: "say",
+		say: "text",
+		text: "historical task",
+	},
+]
+
 describe("Cline", () => {
 	let mockProvider: jest.Mocked<ClineProvider>
-	let mockApiConfig: ApiConfiguration
+	let mockApiConfig: ProviderSettings
 	let mockOutputChannel: any
 	let mockExtensionContext: vscode.ExtensionContext
 
@@ -282,7 +270,7 @@ describe("Cline", () => {
 
 	describe("constructor", () => {
 		it("should respect provided settings", async () => {
-			const cline = new Cline({
+			const cline = new Task({
 				provider: mockProvider,
 				apiConfiguration: mockApiConfig,
 				customInstructions: "custom instructions",
@@ -296,7 +284,7 @@ describe("Cline", () => {
 		})
 
 		it("should use default fuzzy match threshold when not provided", async () => {
-			const cline = new Cline({
+			const cline = new Task({
 				provider: mockProvider,
 				apiConfiguration: mockApiConfig,
 				customInstructions: "custom instructions",
@@ -314,7 +302,7 @@ describe("Cline", () => {
 
 		it("should require either task or historyItem", () => {
 			expect(() => {
-				new Cline({ provider: mockProvider, apiConfiguration: mockApiConfig })
+				new Task({ provider: mockProvider, apiConfiguration: mockApiConfig })
 			}).toThrow("Either historyItem or task/images must be provided")
 		})
 	})
@@ -323,7 +311,7 @@ describe("Cline", () => {
 		describe("API conversation handling", () => {
 			it("should clean conversation history before sending to API", async () => {
 				// Cline.create will now use our mocked getEnvironmentDetails
-				const [cline, task] = Cline.create({
+				const [cline, task] = Task.create({
 					provider: mockProvider,
 					apiConfiguration: mockApiConfig,
 					task: "test task",
@@ -431,7 +419,7 @@ describe("Cline", () => {
 				]
 
 				// Test with model that supports images
-				const [clineWithImages, taskWithImages] = Cline.create({
+				const [clineWithImages, taskWithImages] = Task.create({
 					provider: mockProvider,
 					apiConfiguration: configWithImages,
 					task: "test task",
@@ -454,7 +442,7 @@ describe("Cline", () => {
 				clineWithImages.apiConversationHistory = conversationHistory
 
 				// Test with model that doesn't support images
-				const [clineWithoutImages, taskWithoutImages] = Cline.create({
+				const [clineWithoutImages, taskWithoutImages] = Task.create({
 					provider: mockProvider,
 					apiConfiguration: configWithoutImages,
 					task: "test task",
@@ -545,7 +533,7 @@ describe("Cline", () => {
 			})
 
 			it.skip("should handle API retry with countdown", async () => {
-				const [cline, task] = Cline.create({
+				const [cline, task] = Task.create({
 					provider: mockProvider,
 					apiConfiguration: mockApiConfig,
 					task: "test task",
@@ -669,7 +657,7 @@ describe("Cline", () => {
 			})
 
 			it.skip("should not apply retry delay twice", async () => {
-				const [cline, task] = Cline.create({
+				const [cline, task] = Task.create({
 					provider: mockProvider,
 					apiConfiguration: mockApiConfig,
 					task: "test task",
@@ -791,18 +779,14 @@ describe("Cline", () => {
 				await task.catch(() => {})
 			})
 
-			describe("parseUserContent", () => {
+			describe("processUserContentMentions", () => {
 				it("should process mentions in task and feedback tags", async () => {
-					const [cline, task] = Cline.create({
+					const [cline, task] = Task.create({
 						provider: mockProvider,
 						apiConfiguration: mockApiConfig,
 						task: "test task",
 					})
 
-					// Mock parseMentions to track calls
-					const mockParseMentions = jest.fn().mockImplementation((text) => `processed: ${text}`)
-					jest.spyOn(require("../../core/mentions"), "parseMentions").mockImplementation(mockParseMentions)
-
 					const userContent = [
 						{
 							type: "text",
@@ -834,30 +818,28 @@ describe("Cline", () => {
 						} as Anthropic.ToolResultBlockParam,
 					]
 
-					// Process the content
-					const processedContent = await cline.parseUserContent(userContent)
+					const processedContent = await processUserContentMentions({
+						userContent,
+						cwd: cline.cwd,
+						urlContentFetcher: cline.urlContentFetcher,
+						fileContextTracker: cline.fileContextTracker,
+					})
 
 					// Regular text should not be processed
 					expect((processedContent[0] as Anthropic.TextBlockParam).text).toBe("Regular text with @/some/path")
 
 					// Text within task tags should be processed
 					expect((processedContent[1] as Anthropic.TextBlockParam).text).toContain("processed:")
-					expect(mockParseMentions).toHaveBeenCalledWith(
+					expect((processedContent[1] as Anthropic.TextBlockParam).text).toContain(
 						"<task>Text with @/some/path in task tags</task>",
-						expect.any(String),
-						expect.any(Object),
-						expect.any(Object),
 					)
 
 					// Feedback tag content should be processed
 					const toolResult1 = processedContent[2] as Anthropic.ToolResultBlockParam
 					const content1 = Array.isArray(toolResult1.content) ? toolResult1.content[0] : toolResult1.content
 					expect((content1 as Anthropic.TextBlockParam).text).toContain("processed:")
-					expect(mockParseMentions).toHaveBeenCalledWith(
+					expect((content1 as Anthropic.TextBlockParam).text).toContain(
 						"<feedback>Check @/some/path</feedback>",
-						expect.any(String),
-						expect.any(Object),
-						expect.any(Object),
 					)
 
 					// Regular tool result should not be processed

+ 2 - 2
src/core/ToolRepetitionDetector.ts → src/core/tools/ToolRepetitionDetector.ts

@@ -1,5 +1,5 @@
-import { ToolUse } from "../shared/tools"
-import { t } from "../i18n"
+import { ToolUse } from "../../shared/tools"
+import { t } from "../../i18n"
 
 /**
  * Class for detecting consecutive identical tool calls

+ 7 - 6
src/core/__tests__/ToolRepetitionDetector.test.ts → src/core/tools/__tests__/ToolRepetitionDetector.test.ts

@@ -1,11 +1,13 @@
+// npx jest src/core/tools/__tests__/ToolRepetitionDetector.test.ts
+
+import type { ToolName } from "../../../schemas"
+import type { ToolUse } from "../../../shared/tools"
+
 import { ToolRepetitionDetector } from "../ToolRepetitionDetector"
-import { ToolUse } from "../../shared/tools"
-import { ToolName } from "../../schemas"
 
-// Mock the i18n system
-jest.mock("../../i18n", () => ({
+jest.mock("../../../i18n", () => ({
 	t: jest.fn((key, options) => {
-		// For toolRepetitionLimitReached key, return a message with the tool name
+		// For toolRepetitionLimitReached key, return a message with the tool name.
 		if (key === "tools:toolRepetitionLimitReached" && options?.toolName) {
 			return `Roo appears to be stuck in a loop, attempting the same action (${options.toolName}) repeatedly. This might indicate a problem with its current strategy.`
 		}
@@ -13,7 +15,6 @@ jest.mock("../../i18n", () => ({
 	}),
 }))
 
-// Helper function to create a mock ToolUse
 function createToolUse(name: string, displayName?: string, params: Record<string, string> = {}): ToolUse {
 	return {
 		type: "tool_use",

+ 8 - 8
src/core/tools/__tests__/executeCommandTool.test.ts

@@ -2,7 +2,7 @@
 
 import { describe, expect, it, jest, beforeEach } from "@jest/globals"
 
-import { Cline } from "../../Cline"
+import { Task } from "../../task/Task"
 import { formatResponse } from "../../prompts/responses"
 import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../../shared/tools"
 import { ToolUsage } from "../../../schemas"
@@ -13,7 +13,7 @@ jest.mock("execa", () => ({
 	execa: jest.fn(),
 }))
 
-jest.mock("../../Cline")
+jest.mock("../../task/Task")
 jest.mock("../../prompts/responses")
 
 // Create a mock for the executeCommand function
@@ -74,7 +74,7 @@ beforeEach(() => {
 
 describe("executeCommandTool", () => {
 	// Setup common test variables
-	let mockCline: jest.Mocked<Partial<Cline>> & { consecutiveMistakeCount: number; didRejectTool: boolean }
+	let mockCline: jest.Mocked<Partial<Task>> & { consecutiveMistakeCount: number; didRejectTool: boolean }
 	let mockAskApproval: jest.Mock
 	let mockHandleError: jest.Mock
 	let mockPushToolResult: jest.Mock
@@ -160,7 +160,7 @@ describe("executeCommandTool", () => {
 
 			// Execute
 			await executeCommandTool(
-				mockCline as unknown as Cline,
+				mockCline as unknown as Task,
 				mockToolUse,
 				mockAskApproval as unknown as AskApproval,
 				mockHandleError as unknown as HandleError,
@@ -181,7 +181,7 @@ describe("executeCommandTool", () => {
 
 			// Execute
 			await executeCommandTool(
-				mockCline as unknown as Cline,
+				mockCline as unknown as Task,
 				mockToolUse,
 				mockAskApproval as unknown as AskApproval,
 				mockHandleError as unknown as HandleError,
@@ -204,7 +204,7 @@ describe("executeCommandTool", () => {
 
 			// Execute
 			await executeCommandTool(
-				mockCline as unknown as Cline,
+				mockCline as unknown as Task,
 				mockToolUse,
 				mockAskApproval as unknown as AskApproval,
 				mockHandleError as unknown as HandleError,
@@ -228,7 +228,7 @@ describe("executeCommandTool", () => {
 
 			// Execute
 			await executeCommandTool(
-				mockCline as unknown as Cline,
+				mockCline as unknown as Task,
 				mockToolUse,
 				mockAskApproval as unknown as AskApproval,
 				mockHandleError as unknown as HandleError,
@@ -258,7 +258,7 @@ describe("executeCommandTool", () => {
 
 			// Execute
 			await executeCommandTool(
-				mockCline as unknown as Cline,
+				mockCline as unknown as Task,
 				mockToolUse,
 				mockAskApproval as unknown as AskApproval,
 				mockHandleError as unknown as HandleError,

+ 383 - 43
src/core/__tests__/read-file-xml.test.ts → src/core/tools/__tests__/readFileTool.test.ts

@@ -1,19 +1,38 @@
-// npx jest src/core/__tests__/read-file-xml.test.ts
+// npx jest src/core/tools/__tests__/readFileTool.test.ts
 
 import * as path from "path"
 
-import { countFileLines } from "../../integrations/misc/line-counter"
-import { readLines } from "../../integrations/misc/read-lines"
-import { extractTextFromFile } from "../../integrations/misc/extract-text"
-import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter"
+import { countFileLines } from "../../../integrations/misc/line-counter"
+import { readLines } from "../../../integrations/misc/read-lines"
+import { extractTextFromFile } from "../../../integrations/misc/extract-text"
+import { parseSourceCodeDefinitionsForFile } from "../../../services/tree-sitter"
 import { isBinaryFile } from "isbinaryfile"
-import { ReadFileToolUse } from "../../shared/tools"
+import { ReadFileToolUse, ToolParamName, ToolResponse } from "../../../shared/tools"
+import { readFileTool } from "../readFileTool"
 
-// Mock dependencies
-jest.mock("../../integrations/misc/line-counter")
-jest.mock("../../integrations/misc/read-lines")
-jest.mock("../../integrations/misc/extract-text", () => {
-	const actual = jest.requireActual("../../integrations/misc/extract-text")
+jest.mock("path", () => {
+	const originalPath = jest.requireActual("path")
+	return {
+		...originalPath,
+		resolve: jest.fn().mockImplementation((...args) => args.join("/")),
+	}
+})
+
+jest.mock("fs/promises", () => ({
+	mkdir: jest.fn().mockResolvedValue(undefined),
+	writeFile: jest.fn().mockResolvedValue(undefined),
+	readFile: jest.fn().mockResolvedValue("{}"),
+}))
+
+jest.mock("isbinaryfile")
+
+jest.mock("../../../integrations/misc/line-counter")
+jest.mock("../../../integrations/misc/read-lines")
+
+let mockInputContent = ""
+
+jest.mock("../../../integrations/misc/extract-text", () => {
+	const actual = jest.requireActual("../../../integrations/misc/extract-text")
 	// Create a spy on the actual addLineNumbers function
 	const addLineNumbersSpy = jest.spyOn(actual, "addLineNumbers")
 
@@ -29,14 +48,11 @@ jest.mock("../../integrations/misc/extract-text", () => {
 	}
 })
 
-// Get a reference to the spy
-const addLineNumbersSpy = jest.requireMock("../../integrations/misc/extract-text").__addLineNumbersSpy
+const addLineNumbersSpy = jest.requireMock("../../../integrations/misc/extract-text").__addLineNumbersSpy
 
-// Variable to control what content is used by the mock
-let mockInputContent = ""
-jest.mock("../../services/tree-sitter")
-jest.mock("isbinaryfile")
-jest.mock("../ignore/RooIgnoreController", () => ({
+jest.mock("../../../services/tree-sitter")
+
+jest.mock("../../ignore/RooIgnoreController", () => ({
 	RooIgnoreController: class {
 		initialize() {
 			return Promise.resolve()
@@ -46,22 +62,349 @@ jest.mock("../ignore/RooIgnoreController", () => ({
 		}
 	},
 }))
-jest.mock("fs/promises", () => ({
-	mkdir: jest.fn().mockResolvedValue(undefined),
-	writeFile: jest.fn().mockResolvedValue(undefined),
-	readFile: jest.fn().mockResolvedValue("{}"),
-}))
-jest.mock("../../utils/fs", () => ({
+
+jest.mock("../../../utils/fs", () => ({
 	fileExistsAtPath: jest.fn().mockReturnValue(true),
 }))
 
-// Mock path
-jest.mock("path", () => {
-	const originalPath = jest.requireActual("path")
-	return {
-		...originalPath,
-		resolve: jest.fn().mockImplementation((...args) => args.join("/")),
+describe("read_file tool with maxReadFileLine setting", () => {
+	// Test data
+	const testFilePath = "test/file.txt"
+	const absoluteFilePath = "/test/file.txt"
+	const fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
+	const numberedFileContent = "1 | Line 1\n2 | Line 2\n3 | Line 3\n4 | Line 4\n5 | Line 5\n"
+	const sourceCodeDef = "\n\n# file.txt\n1--5 | Content"
+	const expectedFullFileXml = `<file><path>${testFilePath}</path>\n<content lines="1-5">\n${numberedFileContent}</content>\n</file>`
+
+	// Mocked functions with correct types
+	const mockedCountFileLines = countFileLines as jest.MockedFunction<typeof countFileLines>
+	const mockedReadLines = readLines as jest.MockedFunction<typeof readLines>
+	const mockedExtractTextFromFile = extractTextFromFile as jest.MockedFunction<typeof extractTextFromFile>
+	const mockedParseSourceCodeDefinitionsForFile = parseSourceCodeDefinitionsForFile as jest.MockedFunction<
+		typeof parseSourceCodeDefinitionsForFile
+	>
+
+	const mockedIsBinaryFile = isBinaryFile as jest.MockedFunction<typeof isBinaryFile>
+	const mockedPathResolve = path.resolve as jest.MockedFunction<typeof path.resolve>
+
+	// Mock instances
+	const mockCline: any = {}
+	let mockProvider: any
+	let toolResult: ToolResponse | undefined
+
+	beforeEach(() => {
+		jest.clearAllMocks()
+
+		// Setup path resolution
+		mockedPathResolve.mockReturnValue(absoluteFilePath)
+
+		// Setup mocks for file operations
+		mockedIsBinaryFile.mockResolvedValue(false)
+
+		// Set the default content for the mock
+		mockInputContent = fileContent
+
+		// Setup the extractTextFromFile mock implementation with the current mockInputContent
+		mockedExtractTextFromFile.mockImplementation((_filePath) => {
+			const actual = jest.requireActual("../../../integrations/misc/extract-text")
+			return Promise.resolve(actual.addLineNumbers(mockInputContent))
+		})
+
+		// No need to setup the extractTextFromFile mock implementation here
+		// as it's already defined at the module level
+
+		// Setup mock provider
+		mockProvider = {
+			getState: jest.fn(),
+			deref: jest.fn().mockReturnThis(),
+		}
+
+		// Setup Cline instance with mock methods
+		mockCline.cwd = "/"
+		mockCline.task = "Test"
+		mockCline.providerRef = mockProvider
+		mockCline.rooIgnoreController = {
+			validateAccess: jest.fn().mockReturnValue(true),
+		}
+		mockCline.say = jest.fn().mockResolvedValue(undefined)
+		mockCline.ask = jest.fn().mockResolvedValue(true)
+		mockCline.presentAssistantMessage = jest.fn()
+		mockCline.getFileContextTracker = jest.fn().mockReturnValue({
+			trackFileContext: jest.fn().mockResolvedValue(undefined),
+		})
+		mockCline.recordToolUsage = jest.fn().mockReturnValue(undefined)
+		mockCline.recordToolError = jest.fn().mockReturnValue(undefined)
+		// Reset tool result
+		toolResult = undefined
+	})
+
+	/**
+	 * Helper function to execute the read file tool with different maxReadFileLine settings
+	 */
+	async function executeReadFileTool(
+		params: Partial<ReadFileToolUse["params"]> = {},
+		options: {
+			maxReadFileLine?: number
+			totalLines?: number
+			skipAddLineNumbersCheck?: boolean // Flag to skip addLineNumbers check
+		} = {},
+	): Promise<ToolResponse | undefined> {
+		// Configure mocks based on test scenario
+		const maxReadFileLine = options.maxReadFileLine ?? 500
+		const totalLines = options.totalLines ?? 5
+
+		mockProvider.getState.mockResolvedValue({ maxReadFileLine })
+		mockedCountFileLines.mockResolvedValue(totalLines)
+
+		// Reset the spy before each test
+		addLineNumbersSpy.mockClear()
+
+		// Create a tool use object
+		const toolUse: ReadFileToolUse = {
+			type: "tool_use",
+			name: "read_file",
+			params: { path: testFilePath, ...params },
+			partial: false,
+		}
+
+		await readFileTool(
+			mockCline,
+			toolUse,
+			mockCline.ask,
+			jest.fn(),
+			(result: ToolResponse) => {
+				toolResult = result
+			},
+			(_: ToolParamName, content?: string) => content ?? "",
+		)
+
+		// Verify addLineNumbers was called appropriately
+		if (!options.skipAddLineNumbersCheck) {
+			expect(addLineNumbersSpy).toHaveBeenCalled()
+		} else {
+			expect(addLineNumbersSpy).not.toHaveBeenCalled()
+		}
+
+		return toolResult
 	}
+
+	describe("when maxReadFileLine is negative", () => {
+		it("should read the entire file using extractTextFromFile", async () => {
+			// Setup - use default mockInputContent
+			mockInputContent = fileContent
+
+			// Execute
+			const result = await executeReadFileTool({}, { maxReadFileLine: -1 })
+
+			// Verify
+			expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
+			expect(mockedReadLines).not.toHaveBeenCalled()
+			expect(mockedParseSourceCodeDefinitionsForFile).not.toHaveBeenCalled()
+			expect(result).toBe(expectedFullFileXml)
+		})
+
+		it("should ignore range parameters and read entire file when maxReadFileLine is -1", async () => {
+			// Setup - use default mockInputContent
+			mockInputContent = fileContent
+
+			// Execute with range parameters
+			const result = await executeReadFileTool(
+				{
+					start_line: "2",
+					end_line: "4",
+				},
+				{ maxReadFileLine: -1 },
+			)
+
+			// Verify that extractTextFromFile is still used (not readLines)
+			expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
+			expect(mockedReadLines).not.toHaveBeenCalled()
+			expect(mockedParseSourceCodeDefinitionsForFile).not.toHaveBeenCalled()
+			expect(result).toBe(expectedFullFileXml)
+		})
+
+		it("should not show line snippet in approval message when maxReadFileLine is -1", async () => {
+			// This test verifies the line snippet behavior for the approval message
+			// Setup - use default mockInputContent
+			mockInputContent = fileContent
+
+			// Execute - we'll reuse executeReadFileTool to run the tool
+			await executeReadFileTool({}, { maxReadFileLine: -1 })
+
+			// Verify the empty line snippet for full read was passed to the approval message
+			// Look at the parameters passed to the 'ask' method in the approval message
+			const askCall = mockCline.ask.mock.calls[0]
+			const completeMessage = JSON.parse(askCall[1])
+
+			// Verify the reason (lineSnippet) is empty or undefined for full read
+			expect(completeMessage.reason).toBeFalsy()
+		})
+	})
+
+	describe("when maxReadFileLine is 0", () => {
+		it("should return an empty content with source code definitions", async () => {
+			// Setup - for maxReadFileLine = 0, the implementation won't call readLines
+			mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef)
+
+			// Execute - skip addLineNumbers check as it's not called for maxReadFileLine=0
+			const result = await executeReadFileTool(
+				{},
+				{
+					maxReadFileLine: 0,
+					totalLines: 5,
+					skipAddLineNumbersCheck: true,
+				},
+			)
+
+			// Verify
+			expect(mockedExtractTextFromFile).not.toHaveBeenCalled()
+			expect(mockedReadLines).not.toHaveBeenCalled() // Per implementation line 141
+			expect(mockedParseSourceCodeDefinitionsForFile).toHaveBeenCalledWith(
+				absoluteFilePath,
+				mockCline.rooIgnoreController,
+			)
+
+			// Verify XML structure
+			expect(result).toContain(`<file><path>${testFilePath}</path>`)
+			expect(result).toContain("<notice>Showing only 0 of 5 total lines")
+			expect(result).toContain("</notice>")
+			expect(result).toContain("<list_code_definition_names>")
+			expect(result).toContain(sourceCodeDef.trim())
+			expect(result).toContain("</list_code_definition_names>")
+			expect(result).not.toContain("<content") // No content when maxReadFileLine is 0
+		})
+	})
+
+	describe("when maxReadFileLine is less than file length", () => {
+		it("should read only maxReadFileLine lines and add source code definitions", async () => {
+			// Setup
+			const content = "Line 1\nLine 2\nLine 3"
+			mockedReadLines.mockResolvedValue(content)
+			mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef)
+
+			// Execute
+			const result = await executeReadFileTool({}, { maxReadFileLine: 3 })
+
+			// Verify - check behavior but not specific implementation details
+			expect(mockedExtractTextFromFile).not.toHaveBeenCalled()
+			expect(mockedReadLines).toHaveBeenCalled()
+			expect(mockedParseSourceCodeDefinitionsForFile).toHaveBeenCalledWith(
+				absoluteFilePath,
+				mockCline.rooIgnoreController,
+			)
+
+			// Verify XML structure
+			expect(result).toContain(`<file><path>${testFilePath}</path>`)
+			expect(result).toContain('<content lines="1-3">')
+			expect(result).toContain("1 | Line 1")
+			expect(result).toContain("2 | Line 2")
+			expect(result).toContain("3 | Line 3")
+			expect(result).toContain("</content>")
+			expect(result).toContain("<notice>Showing only 3 of 5 total lines")
+			expect(result).toContain("</notice>")
+			expect(result).toContain("<list_code_definition_names>")
+			expect(result).toContain(sourceCodeDef.trim())
+			expect(result).toContain("</list_code_definition_names>")
+			expect(result).toContain("<list_code_definition_names>")
+			expect(result).toContain(sourceCodeDef.trim())
+		})
+	})
+
+	describe("when maxReadFileLine equals or exceeds file length", () => {
+		it("should use extractTextFromFile when maxReadFileLine > totalLines", async () => {
+			// Setup
+			mockedCountFileLines.mockResolvedValue(5) // File shorter than maxReadFileLine
+			mockInputContent = fileContent
+
+			// Execute
+			const result = await executeReadFileTool({}, { maxReadFileLine: 10, totalLines: 5 })
+
+			// Verify
+			expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
+			expect(result).toBe(expectedFullFileXml)
+		})
+
+		it("should read with extractTextFromFile when file has few lines", async () => {
+			// Setup
+			mockedCountFileLines.mockResolvedValue(3) // File shorter than maxReadFileLine
+			mockInputContent = fileContent
+
+			// Execute
+			const result = await executeReadFileTool({}, { maxReadFileLine: 5, totalLines: 3 })
+
+			// Verify
+			expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
+			expect(mockedReadLines).not.toHaveBeenCalled()
+			// Create a custom expected XML with lines="1-3" since totalLines is 3
+			const expectedXml = `<file><path>${testFilePath}</path>\n<content lines="1-3">\n${numberedFileContent}</content>\n</file>`
+			expect(result).toBe(expectedXml)
+		})
+	})
+
+	describe("when file is binary", () => {
+		it("should always use extractTextFromFile regardless of maxReadFileLine", async () => {
+			// Setup
+			mockedIsBinaryFile.mockResolvedValue(true)
+			// For binary files, we're using a maxReadFileLine of 3 and totalLines is assumed to be 3
+			mockedCountFileLines.mockResolvedValue(3)
+
+			// For binary files, we need a special mock implementation that doesn't use addLineNumbers
+			// Save the original mock implementation
+			const originalMockImplementation = mockedExtractTextFromFile.getMockImplementation()
+			// Create a special mock implementation that doesn't call addLineNumbers
+			mockedExtractTextFromFile.mockImplementation(() => {
+				return Promise.resolve(numberedFileContent)
+			})
+
+			// Reset the spy to clear any previous calls
+			addLineNumbersSpy.mockClear()
+
+			// Execute - skip addLineNumbers check as we're directly providing the numbered content
+			const result = await executeReadFileTool(
+				{},
+				{
+					maxReadFileLine: 3,
+					totalLines: 3,
+					skipAddLineNumbersCheck: true,
+				},
+			)
+
+			// Restore the original mock implementation after the test
+			mockedExtractTextFromFile.mockImplementation(originalMockImplementation)
+
+			// Verify
+			expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
+			expect(mockedReadLines).not.toHaveBeenCalled()
+			// Create a custom expected XML with lines="1-3" for binary files
+			const expectedXml = `<file><path>${testFilePath}</path>\n<content lines="1-3">\n${numberedFileContent}</content>\n</file>`
+			expect(result).toBe(expectedXml)
+		})
+	})
+
+	describe("with range parameters", () => {
+		it("should honor start_line and end_line when provided", async () => {
+			// Setup
+			mockedReadLines.mockResolvedValue("Line 2\nLine 3\nLine 4")
+
+			// Execute using executeReadFileTool with range parameters
+			const rangeResult = await executeReadFileTool({
+				start_line: "2",
+				end_line: "4",
+			})
+
+			// Verify
+			expect(mockedReadLines).toHaveBeenCalledWith(absoluteFilePath, 3, 1) // end_line - 1, start_line - 1
+			expect(addLineNumbersSpy).toHaveBeenCalledWith(expect.any(String), 2) // start with proper line numbers
+
+			// Verify XML structure with lines attribute
+			expect(rangeResult).toContain(`<file><path>${testFilePath}</path>`)
+			expect(rangeResult).toContain(`<content lines="2-4">`)
+			expect(rangeResult).toContain("2 | Line 2")
+			expect(rangeResult).toContain("3 | Line 3")
+			expect(rangeResult).toContain("4 | Line 4")
+			expect(rangeResult).toContain("</content>")
+		})
+	})
 })
 
 describe("read_file tool XML output structure", () => {
@@ -85,7 +428,7 @@ describe("read_file tool XML output structure", () => {
 	// Mock instances
 	const mockCline: any = {}
 	let mockProvider: any
-	let toolResult: string | undefined
+	let toolResult: ToolResponse | undefined
 
 	beforeEach(() => {
 		jest.clearAllMocks()
@@ -139,7 +482,7 @@ describe("read_file tool XML output structure", () => {
 			validateAccess?: boolean
 			skipAddLineNumbersCheck?: boolean // Flag to skip addLineNumbers check
 		} = {},
-	): Promise<string | undefined> {
+	): Promise<ToolResponse | undefined> {
 		// Configure mocks based on test scenario
 		const totalLines = options.totalLines ?? 5
 		const maxReadFileLine = options.maxReadFileLine ?? 500
@@ -162,9 +505,6 @@ describe("read_file tool XML output structure", () => {
 			partial: false,
 		}
 
-		// Import the tool implementation dynamically to avoid hoisting issues
-		const { readFileTool } = require("../tools/readFileTool")
-
 		// Reset the spy's call history before each test
 		addLineNumbersSpy.mockClear()
 
@@ -174,10 +514,10 @@ describe("read_file tool XML output structure", () => {
 			toolUse,
 			mockCline.ask,
 			jest.fn(),
-			(result: string) => {
+			(result: ToolResponse) => {
 				toolResult = result
 			},
-			(param: string, value: string) => value,
+			(param: ToolParamName, content?: string) => content ?? "",
 		)
 		// Verify addLineNumbers was called (unless explicitly skipped)
 		if (!options.skipAddLineNumbersCheck) {
@@ -410,7 +750,9 @@ describe("read_file tool XML output structure", () => {
 
 			// Should contain all the requested lines, not just maxReadFileLine lines
 			expect(result).toBeDefined()
-			if (result) {
+			expect(typeof result).toBe("string")
+
+			if (typeof result === "string") {
 				expect(result.split("\n").length).toBeGreaterThan(maxReadFileLine)
 			}
 		})
@@ -507,19 +849,16 @@ describe("read_file tool XML output structure", () => {
 				partial: false,
 			}
 
-			// Import the tool implementation dynamically
-			const { readFileTool } = require("../tools/readFileTool")
-
 			// Execute the tool
 			await readFileTool(
 				mockCline,
 				toolUse,
 				mockCline.ask,
 				jest.fn(),
-				(result: string) => {
+				(result: ToolResponse) => {
 					toolResult = result
 				},
-				(param: string, value: string) => value,
+				(param: ToolParamName, content?: string) => content ?? "",
 			)
 
 			// Verify
@@ -565,6 +904,7 @@ describe("read_file tool XML output structure", () => {
 
 			// Execute
 			const result = await executeReadFileTool({}, { maxReadFileLine, totalLines })
+			console.log(result)
 
 			// Verify
 			// Empty files should include a content tag and notice

+ 4 - 4
src/core/__tests__/mode-validator.test.ts → src/core/tools/__tests__/validateToolUse.test.ts

@@ -1,8 +1,8 @@
-// npx jest src/core/__tests__/mode-validator.test.ts
+// npx jest src/core/tools/__tests__/validateToolUse.test.ts
 
-import { isToolAllowedForMode, modes, ModeConfig } from "../../shared/modes"
-import { TOOL_GROUPS } from "../../shared/tools"
-import { validateToolUse } from "../mode-validator"
+import { isToolAllowedForMode, modes, ModeConfig } from "../../../shared/modes"
+import { TOOL_GROUPS } from "../../../shared/tools"
+import { validateToolUse } from "../validateToolUse"
 
 const [codeMode, architectMode, askMode] = modes.map((mode) => mode.slug)
 

+ 2 - 2
src/core/tools/accessMcpResourceTool.ts

@@ -1,10 +1,10 @@
 import { ClineAskUseMcpServer } from "../../shared/ExtensionMessage"
 import { ToolUse, RemoveClosingTag, AskApproval, HandleError, PushToolResult } from "../../shared/tools"
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 import { formatResponse } from "../prompts/responses"
 
 export async function accessMcpResourceTool(
-	cline: Cline,
+	cline: Task,
 	block: ToolUse,
 	askApproval: AskApproval,
 	handleError: HandleError,

+ 2 - 2
src/core/tools/applyDiffTool.ts

@@ -3,7 +3,7 @@ import fs from "fs/promises"
 
 import { ClineSayTool } from "../../shared/ExtensionMessage"
 import { getReadablePath } from "../../utils/path"
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 import { ToolUse, RemoveClosingTag } from "../../shared/tools"
 import { formatResponse } from "../prompts/responses"
 import { AskApproval, HandleError, PushToolResult } from "../../shared/tools"
@@ -13,7 +13,7 @@ import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
 import { unescapeHtmlEntities } from "../../utils/text-normalization"
 
 export async function applyDiffTool(
-	cline: Cline,
+	cline: Task,
 	block: ToolUse,
 	askApproval: AskApproval,
 	handleError: HandleError,

+ 2 - 2
src/core/tools/askFollowupQuestionTool.ts

@@ -1,10 +1,10 @@
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools"
 import { formatResponse } from "../prompts/responses"
 import { parseXml } from "../../utils/xml"
 
 export async function askFollowupQuestionTool(
-	cline: Cline,
+	cline: Task,
 	block: ToolUse,
 	askApproval: AskApproval,
 	handleError: HandleError,

+ 2 - 2
src/core/tools/attemptCompletionTool.ts

@@ -1,6 +1,6 @@
 import Anthropic from "@anthropic-ai/sdk"
 
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 import {
 	ToolResponse,
 	ToolUse,
@@ -15,7 +15,7 @@ import { formatResponse } from "../prompts/responses"
 import { type ExecuteCommandOptions, executeCommand } from "./executeCommandTool"
 
 export async function attemptCompletionTool(
-	cline: Cline,
+	cline: Task,
 	block: ToolUse,
 	askApproval: AskApproval,
 	handleError: HandleError,

+ 2 - 2
src/core/tools/browserActionTool.ts

@@ -1,4 +1,4 @@
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools"
 import {
 	BrowserAction,
@@ -9,7 +9,7 @@ import {
 import { formatResponse } from "../prompts/responses"
 
 export async function browserActionTool(
-	cline: Cline,
+	cline: Task,
 	block: ToolUse,
 	askApproval: AskApproval,
 	handleError: HandleError,

+ 9 - 6
src/core/tools/executeCommandTool.ts

@@ -3,7 +3,7 @@ import * as path from "path"
 
 import delay from "delay"
 
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 import { CommandExecutionStatus } from "../../schemas"
 import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag, ToolResponse } from "../../shared/tools"
 import { formatResponse } from "../prompts/responses"
@@ -15,7 +15,7 @@ import { Terminal } from "../../integrations/terminal/Terminal"
 class ShellIntegrationError extends Error {}
 
 export async function executeCommandTool(
-	cline: Cline,
+	cline: Task,
 	block: ToolUse,
 	askApproval: AskApproval,
 	handleError: HandleError,
@@ -113,7 +113,7 @@ export type ExecuteCommandOptions = {
 }
 
 export async function executeCommand(
-	cline: Cline,
+	cline: Task,
 	{
 		executionId,
 		command,
@@ -148,9 +148,12 @@ export async function executeCommand(
 	const terminalProvider = terminalShellIntegrationDisabled ? "execa" : "vscode"
 	const clineProvider = await cline.providerRef.deref()
 
+	let accumulatedOutput = ""
 	const callbacks: RooTerminalCallbacks = {
-		onLine: async (output: string, process: RooTerminalProcess) => {
-			const status: CommandExecutionStatus = { executionId, status: "output", output }
+		onLine: async (lines: string, process: RooTerminalProcess) => {
+			accumulatedOutput += lines
+			const compressedOutput = Terminal.compressTerminalOutput(accumulatedOutput, terminalOutputLineLimit)
+			const status: CommandExecutionStatus = { executionId, status: "output", output: compressedOutput }
 			clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
 
 			if (runInBackground) {
@@ -193,7 +196,7 @@ export async function executeCommand(
 	const terminal = await TerminalRegistry.getOrCreateTerminal(workingDir, !!customCwd, cline.taskId, terminalProvider)
 
 	if (terminal instanceof Terminal) {
-		terminal.terminal.show()
+		terminal.terminal.show(true)
 
 		// Update the working directory in case the terminal we asked for has
 		// a different working directory so that the model will know where the

+ 2 - 2
src/core/tools/fetchInstructionsTool.ts

@@ -1,11 +1,11 @@
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 import { fetchInstructions } from "../prompts/instructions/instructions"
 import { ClineSayTool } from "../../shared/ExtensionMessage"
 import { formatResponse } from "../prompts/responses"
 import { ToolUse, AskApproval, HandleError, PushToolResult } from "../../shared/tools"
 
 export async function fetchInstructionsTool(
-	cline: Cline,
+	cline: Task,
 	block: ToolUse,
 	askApproval: AskApproval,
 	handleError: HandleError,

+ 2 - 2
src/core/tools/insertContentTool.ts

@@ -3,7 +3,7 @@ import fs from "fs/promises"
 import path from "path"
 
 import { getReadablePath } from "../../utils/path"
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools"
 import { formatResponse } from "../prompts/responses"
 import { ClineSayTool } from "../../shared/ExtensionMessage"
@@ -12,7 +12,7 @@ import { fileExistsAtPath } from "../../utils/fs"
 import { insertGroups } from "../diff/insert-groups"
 
 export async function insertContentTool(
-	cline: Cline,
+	cline: Task,
 	block: ToolUse,
 	askApproval: AskApproval,
 	handleError: HandleError,

+ 2 - 2
src/core/tools/listCodeDefinitionNamesTool.ts

@@ -2,14 +2,14 @@ import path from "path"
 import fs from "fs/promises"
 
 import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools"
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 import { ClineSayTool } from "../../shared/ExtensionMessage"
 import { getReadablePath } from "../../utils/path"
 import { parseSourceCodeForDefinitionsTopLevel, parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter"
 import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
 
 export async function listCodeDefinitionNamesTool(
-	cline: Cline,
+	cline: Task,
 	block: ToolUse,
 	askApproval: AskApproval,
 	handleError: HandleError,

+ 2 - 2
src/core/tools/listFilesTool.ts

@@ -1,6 +1,6 @@
 import * as path from "path"
 
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 import { ClineSayTool } from "../../shared/ExtensionMessage"
 import { formatResponse } from "../prompts/responses"
 import { listFiles } from "../../services/glob/list-files"
@@ -23,7 +23,7 @@ import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } f
  */
 
 export async function listFilesTool(
-	cline: Cline,
+	cline: Task,
 	block: ToolUse,
 	askApproval: AskApproval,
 	handleError: HandleError,

+ 2 - 2
src/core/tools/newTaskTool.ts

@@ -1,12 +1,12 @@
 import delay from "delay"
 
 import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools"
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 import { defaultModeSlug, getModeBySlug } from "../../shared/modes"
 import { formatResponse } from "../prompts/responses"
 
 export async function newTaskTool(
-	cline: Cline,
+	cline: Task,
 	block: ToolUse,
 	askApproval: AskApproval,
 	handleError: HandleError,

+ 17 - 3
src/core/tools/readFileTool.ts

@@ -1,7 +1,7 @@
 import path from "path"
 import { isBinaryFile } from "isbinaryfile"
 
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 import { ClineSayTool } from "../../shared/ExtensionMessage"
 import { formatResponse } from "../prompts/responses"
 import { t } from "../../i18n"
@@ -15,7 +15,7 @@ import { extractTextFromFile, addLineNumbers } from "../../integrations/misc/ext
 import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter"
 
 export async function readFileTool(
-	cline: Cline,
+	cline: Task,
 	block: ToolUse,
 	askApproval: AskApproval,
 	handleError: HandleError,
@@ -164,7 +164,21 @@ export async function readFileTool(
 
 				const res = await Promise.all([
 					maxReadFileLine > 0 ? readLines(absolutePath, maxReadFileLine - 1, 0) : "",
-					parseSourceCodeDefinitionsForFile(absolutePath, cline.rooIgnoreController),
+					(async () => {
+						try {
+							return await parseSourceCodeDefinitionsForFile(absolutePath, cline.rooIgnoreController)
+						} catch (error) {
+							if (error instanceof Error && error.message.startsWith("Unsupported language:")) {
+								console.warn(`[read_file] Warning: ${error.message}`)
+								return undefined
+							} else {
+								console.error(
+									`[read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`,
+								)
+								return undefined
+							}
+						}
+					})(),
 				])
 
 				content = res[0].length > 0 ? addLineNumbers(res[0]) : ""

+ 3 - 3
src/core/tools/searchAndReplaceTool.ts

@@ -4,7 +4,7 @@ import fs from "fs/promises"
 import delay from "delay"
 
 // Internal imports
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 import { AskApproval, HandleError, PushToolResult, RemoveClosingTag, ToolUse } from "../../shared/tools"
 import { formatResponse } from "../prompts/responses"
 import { ClineSayTool } from "../../shared/ExtensionMessage"
@@ -21,7 +21,7 @@ import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
  * Validates required parameters for search and replace operation
  */
 async function validateParams(
-	cline: Cline,
+	cline: Task,
 	relPath: string | undefined,
 	search: string | undefined,
 	replace: string | undefined,
@@ -61,7 +61,7 @@ async function validateParams(
  * @param removeClosingTag - Function to remove closing tags
  */
 export async function searchAndReplaceTool(
-	cline: Cline,
+	cline: Task,
 	block: ToolUse,
 	askApproval: AskApproval,
 	handleError: HandleError,

+ 2 - 2
src/core/tools/searchFilesTool.ts

@@ -1,13 +1,13 @@
 import path from "path"
 
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools"
 import { ClineSayTool } from "../../shared/ExtensionMessage"
 import { getReadablePath } from "../../utils/path"
 import { regexSearchFiles } from "../../services/ripgrep"
 
 export async function searchFilesTool(
-	cline: Cline,
+	cline: Task,
 	block: ToolUse,
 	askApproval: AskApproval,
 	handleError: HandleError,

+ 2 - 2
src/core/tools/switchModeTool.ts

@@ -1,12 +1,12 @@
 import delay from "delay"
 
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools"
 import { formatResponse } from "../prompts/responses"
 import { defaultModeSlug, getModeBySlug } from "../../shared/modes"
 
 export async function switchModeTool(
-	cline: Cline,
+	cline: Task,
 	block: ToolUse,
 	askApproval: AskApproval,
 	handleError: HandleError,

+ 2 - 2
src/core/tools/useMcpToolTool.ts

@@ -1,10 +1,10 @@
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools"
 import { formatResponse } from "../prompts/responses"
 import { ClineAskUseMcpServer } from "../../shared/ExtensionMessage"
 
 export async function useMcpToolTool(
-	cline: Cline,
+	cline: Task,
 	block: ToolUse,
 	askApproval: AskApproval,
 	handleError: HandleError,

+ 2 - 2
src/core/mode-validator.ts → src/core/tools/validateToolUse.ts

@@ -1,5 +1,5 @@
-import { ToolName } from "../schemas"
-import { Mode, isToolAllowedForMode, ModeConfig } from "../shared/modes"
+import { ToolName } from "../../schemas"
+import { Mode, isToolAllowedForMode, ModeConfig } from "../../shared/modes"
 
 export function validateToolUse(
 	toolName: ToolName,

+ 2 - 2
src/core/tools/writeToFileTool.ts

@@ -2,7 +2,7 @@ import path from "path"
 import delay from "delay"
 import * as vscode from "vscode"
 
-import { Cline } from "../Cline"
+import { Task } from "../task/Task"
 import { ClineSayTool } from "../../shared/ExtensionMessage"
 import { formatResponse } from "../prompts/responses"
 import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools"
@@ -15,7 +15,7 @@ import { detectCodeOmission } from "../../integrations/editor/detect-omission"
 import { unescapeHtmlEntities } from "../../utils/text-normalization"
 
 export async function writeToFileTool(
-	cline: Cline,
+	cline: Task,
 	block: ToolUse,
 	askApproval: AskApproval,
 	handleError: HandleError,

+ 143 - 76
src/core/webview/ClineProvider.ts

@@ -9,16 +9,10 @@ import axios from "axios"
 import pWaitFor from "p-wait-for"
 import * as vscode from "vscode"
 
-import { GlobalState, ProviderSettings, RooCodeSettings } from "../../schemas"
+import type { GlobalState, ProviderName, ProviderSettings, RooCodeSettings, ProviderSettingsEntry } from "../../schemas"
 import { t } from "../../i18n"
 import { setPanel } from "../../activate/registerCommands"
-import {
-	ProviderName,
-	ApiConfiguration,
-	requestyDefaultModelId,
-	openRouterDefaultModelId,
-	glamaDefaultModelId,
-} from "../../shared/api"
+import { requestyDefaultModelId, openRouterDefaultModelId, glamaDefaultModelId } from "../../shared/api"
 import { findLast } from "../../shared/array"
 import { supportPrompt } from "../../shared/support-prompt"
 import { GlobalFileNames } from "../../shared/globalFileNames"
@@ -41,8 +35,8 @@ import { ContextProxy } from "../config/ContextProxy"
 import { ProviderSettingsManager } from "../config/ProviderSettingsManager"
 import { CustomModesManager } from "../config/CustomModesManager"
 import { buildApiHandler } from "../../api"
-import { CodeActionName } from "../CodeActionProvider"
-import { Cline, ClineOptions } from "../Cline"
+import { CodeActionName } from "../../activate/CodeActionProvider"
+import { Task, TaskOptions } from "../task/Task"
 import { getNonce } from "./getNonce"
 import { getUri } from "./getUri"
 import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt"
@@ -59,7 +53,7 @@ import { McpServer } from "../../shared/mcp" // kilocode_change
  */
 
 export type ClineProviderEvents = {
-	clineCreated: [cline: Cline]
+	clineCreated: [cline: Task]
 }
 
 export class ClineProvider extends EventEmitter<ClineProviderEvents> implements vscode.WebviewViewProvider {
@@ -68,7 +62,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 	private static activeInstances: Set<ClineProvider> = new Set()
 	private disposables: vscode.Disposable[] = []
 	private view?: vscode.WebviewView | vscode.WebviewPanel
-	private clineStack: Cline[] = []
+	private clineStack: Task[] = []
 	private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class
 	public get workspaceTracker(): WorkspaceTracker | undefined {
 		return this._workspaceTracker
@@ -114,7 +108,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 	// Adds a new Cline instance to clineStack, marking the start of a new task.
 	// The instance is pushed to the top of the stack (LIFO order).
 	// When the task is completed, the top instance is removed, reactivating the previous task.
-	async addClineToStack(cline: Cline) {
+	async addClineToStack(cline: Task) {
 		console.log(`[subtasks] adding task ${cline.taskId}.${cline.instanceId} to stack`)
 
 		// Add this cline instance into the stack that represents the order of all the called tasks.
@@ -159,7 +153,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 
 	// returns the current cline object in the stack (the top one)
 	// if the stack is empty, returns undefined
-	getCurrentCline(): Cline | undefined {
+	getCurrentCline(): Task | undefined {
 		if (this.clineStack.length === 0) {
 			return undefined
 		}
@@ -175,15 +169,15 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 		return this.clineStack.map((cline) => cline.taskId)
 	}
 
-	// remove the current task/cline instance (at the top of the stack), ao this task is finished
+	// remove the current task/cline instance (at the top of the stack), so this task is finished
 	// and resume the previous task/cline instance (if it exists)
 	// this is used when a sub task is finished and the parent task needs to be resumed
 	async finishSubTask(lastMessage: string) {
 		console.log(`[subtasks] finishing subtask ${lastMessage}`)
 		// remove the last cline instance from the stack (this is the finished sub task)
 		await this.removeClineFromStack()
-		// resume the last cline instance in the stack (if it exists - this is the 'parnt' calling task)
-		this.getCurrentCline()?.resumePausedTask(lastMessage)
+		// resume the last cline instance in the stack (if it exists - this is the 'parent' calling task)
+		await this.getCurrentCline()?.resumePausedTask(lastMessage)
 	}
 
 	/*
@@ -250,7 +244,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 			return false
 		}
 
-		// check if there is a cline instance in the stack (if this provider has an active task)
+		// Check if there is a cline instance in the stack (if this provider has an active task)
 		if (visibleProvider.getCurrentCline()) {
 			return true
 		}
@@ -431,7 +425,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 		this.log("Webview view resolved")
 	}
 
-	public async initClineWithSubTask(parent: Cline, task?: string, images?: string[]) {
+	public async initClineWithSubTask(parent: Task, task?: string, images?: string[]) {
 		return this.initClineWithTask(task, images, parent)
 	}
 
@@ -444,10 +438,10 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 	public async initClineWithTask(
 		task?: string,
 		images?: string[],
-		parentTask?: Cline,
+		parentTask?: Task,
 		options: Partial<
 			Pick<
-				ClineOptions,
+				TaskOptions,
 				| "customInstructions"
 				| "enableDiff"
 				| "enableCheckpoints"
@@ -471,7 +465,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 		const modePrompt = customModePrompts?.[mode] as PromptComponent
 		const effectiveInstructions = [globalInstructions, modePrompt?.customInstructions].filter(Boolean).join("\n\n")
 
-		const cline = new Cline({
+		const cline = new Task({
 			provider: this,
 			apiConfiguration,
 			customInstructions: effectiveInstructions,
@@ -497,7 +491,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 		return cline
 	}
 
-	public async initClineWithHistoryItem(historyItem: HistoryItem & { rootTask?: Cline; parentTask?: Cline }) {
+	public async initClineWithHistoryItem(historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }) {
 		await this.removeClineFromStack()
 
 		const {
@@ -514,7 +508,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 		const modePrompt = customModePrompts?.[mode] as PromptComponent
 		const effectiveInstructions = [globalInstructions, modePrompt?.customInstructions].filter(Boolean).join("\n\n")
 
-		const cline = new Cline({
+		const cline = new Task({
 			provider: this,
 			apiConfiguration,
 			customInstructions: effectiveInstructions,
@@ -771,24 +765,19 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 		// Update listApiConfigMeta first to ensure UI has latest data
 		await this.updateGlobalState("listApiConfigMeta", listApiConfig)
 
-		// If this mode has a saved config, use it
+		// If this mode has a saved config, use it.
 		if (savedConfigId) {
-			const config = listApiConfig?.find((c) => c.id === savedConfigId)
+			const profile = listApiConfig.find(({ id }) => id === savedConfigId)
 
-			if (config?.name) {
-				const apiConfig = await this.providerSettingsManager.loadConfig(config.name)
-
-				await Promise.all([
-					this.updateGlobalState("currentApiConfigName", config.name),
-					this.updateApiConfiguration(apiConfig),
-				])
+			if (profile?.name) {
+				await this.activateProviderProfile({ name: profile.name })
 			}
 		} else {
-			// If no saved config for this mode, save current config as default
+			// If no saved config for this mode, save current config as default.
 			const currentApiConfigName = this.getGlobalState("currentApiConfigName")
 
 			if (currentApiConfigName) {
-				const config = listApiConfig?.find((c) => c.name === currentApiConfigName)
+				const config = listApiConfig.find((c) => c.name === currentApiConfigName)
 
 				if (config?.id) {
 					await this.providerSettingsManager.setModeConfig(newMode, config.id)
@@ -799,27 +788,127 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 		await this.postStateToWebview()
 	}
 
-	async updateApiConfiguration(providerSettings: ProviderSettings) {
-		// Update mode's default config.
-		const { mode } = await this.getState()
+	// Provider Profile Management
 
-		if (mode) {
-			const currentApiConfigName = this.getGlobalState("currentApiConfigName")
-			const listApiConfig = await this.providerSettingsManager.listConfig()
-			const config = listApiConfig?.find((c) => c.name === currentApiConfigName)
+	getProviderProfileEntries(): ProviderSettingsEntry[] {
+		return this.contextProxy.getValues().listApiConfigMeta || []
+	}
+
+	getProviderProfileEntry(name: string): ProviderSettingsEntry | undefined {
+		return this.getProviderProfileEntries().find((profile) => profile.name === name)
+	}
 
-			if (config?.id) {
-				await this.providerSettingsManager.setModeConfig(mode, config.id)
+	public hasProviderProfileEntry(name: string): boolean {
+		return !!this.getProviderProfileEntry(name)
+	}
+
+	async upsertProviderProfile(
+		name: string,
+		providerSettings: ProviderSettings,
+		activate: boolean = true,
+	): Promise<string | undefined> {
+		try {
+			// TODO: Do we need to be calling `activateProfile`? It's not
+			// clear to me what the source of truth should be; in some cases
+			// we rely on the `ContextProxy`'s data store and in other cases
+			// we rely on the `ProviderSettingsManager`'s data store. It might
+			// be simpler to unify these two.
+			const id = await this.providerSettingsManager.saveConfig(name, providerSettings)
+
+			if (activate) {
+				const { mode } = await this.getState()
+
+				// These promises do the following:
+				// 1. Adds or updates the list of provider profiles.
+				// 2. Sets the current provider profile.
+				// 3. Sets the current mode's provider profile.
+				// 4. Copies the provider settings to the context.
+				//
+				// Note: 1, 2, and 4 can be done in one `ContextProxy` call:
+				// this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... })
+				// We should probably switch to that and verify that it works.
+				// I left the original implementation in just to be safe.
+				await Promise.all([
+					this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()),
+					this.updateGlobalState("currentApiConfigName", name),
+					this.providerSettingsManager.setModeConfig(mode, id),
+					this.contextProxy.setProviderSettings(providerSettings),
+				])
+
+				// Change the provider for the current task.
+				// TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`).
+				const task = this.getCurrentCline()
+
+				if (task) {
+					task.api = buildApiHandler(providerSettings)
+				}
+			} else {
+				await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig())
 			}
+
+			await this.postStateToWebview()
+			return id
+		} catch (error) {
+			this.log(
+				`Error create new api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
+			)
+
+			vscode.window.showErrorMessage(t("common:errors.create_api_config"))
+			return undefined
 		}
+	}
 
-		await this.contextProxy.setProviderSettings(providerSettings)
+	async deleteProviderProfile(profileToDelete: ProviderSettingsEntry) {
+		const globalSettings = this.contextProxy.getValues()
+		let profileToActivate: string | undefined = globalSettings.currentApiConfigName
 
-		if (this.getCurrentCline()) {
-			this.getCurrentCline()!.api = buildApiHandler(providerSettings)
+		if (profileToDelete.name === profileToActivate) {
+			profileToActivate = this.getProviderProfileEntries().find(({ name }) => name !== profileToDelete.name)?.name
+		}
+
+		if (!profileToActivate) {
+			throw new Error("You cannot delete the last profile")
+		}
+
+		const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name)
+
+		await this.contextProxy.setValues({
+			...globalSettings,
+			currentApiConfigName: profileToActivate,
+			listApiConfigMeta: entries,
+		})
+
+		await this.postStateToWebview()
+	}
+
+	async activateProviderProfile(args: { name: string } | { id: string }) {
+		const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args)
+
+		// See `upsertProviderProfile` for a description of what this is doing.
+		await Promise.all([
+			this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()),
+			this.contextProxy.setValue("currentApiConfigName", name),
+			this.contextProxy.setProviderSettings(providerSettings),
+		])
+
+		const { mode } = await this.getState()
+
+		if (id) {
+			await this.providerSettingsManager.setModeConfig(mode, id)
 		}
+
+		// Change the provider for the current task.
+		const task = this.getCurrentCline()
+
+		if (task) {
+			task.api = buildApiHandler(providerSettings)
+		}
+
+		await this.postStateToWebview()
 	}
 
+	// Task Management
+
 	async cancelTask() {
 		const cline = this.getCurrentCline()
 
@@ -928,14 +1017,14 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 			throw error
 		}
 
-		const newConfiguration: ApiConfiguration = {
+		const newConfiguration: ProviderSettings = {
 			...apiConfiguration,
 			apiProvider: "openrouter",
 			openRouterApiKey: apiKey,
 			openRouterModelId: apiConfiguration?.openRouterModelId || openRouterDefaultModelId,
 		}
 
-		await this.upsertApiConfiguration(currentApiConfigName, newConfiguration)
+		await this.upsertProviderProfile(currentApiConfigName, newConfiguration)
 	}
 
 	// Glama
@@ -958,14 +1047,14 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 
 		const { apiConfiguration, currentApiConfigName } = await this.getState()
 
-		const newConfiguration: ApiConfiguration = {
+		const newConfiguration: ProviderSettings = {
 			...apiConfiguration,
 			apiProvider: "glama",
 			glamaApiKey: apiKey,
 			glamaModelId: apiConfiguration?.glamaModelId || glamaDefaultModelId,
 		}
 
-		await this.upsertApiConfiguration(currentApiConfigName, newConfiguration)
+		await this.upsertProviderProfile(currentApiConfigName, newConfiguration)
 	}
 
 	// Requesty
@@ -973,36 +1062,14 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 	async handleRequestyCallback(code: string) {
 		let { apiConfiguration, currentApiConfigName } = await this.getState()
 
-		const newConfiguration: ApiConfiguration = {
+		const newConfiguration: ProviderSettings = {
 			...apiConfiguration,
 			apiProvider: "requesty",
 			requestyApiKey: code,
 			requestyModelId: apiConfiguration?.requestyModelId || requestyDefaultModelId,
 		}
 
-		await this.upsertApiConfiguration(currentApiConfigName, newConfiguration)
-	}
-
-	// Save configuration
-
-	async upsertApiConfiguration(configName: string, apiConfiguration: ApiConfiguration) {
-		try {
-			await this.providerSettingsManager.saveConfig(configName, apiConfiguration)
-			const listApiConfig = await this.providerSettingsManager.listConfig()
-
-			await Promise.all([
-				this.updateGlobalState("listApiConfigMeta", listApiConfig),
-				this.updateApiConfiguration(apiConfiguration),
-				this.updateGlobalState("currentApiConfigName", configName),
-			])
-
-			await this.postStateToWebview()
-		} catch (error) {
-			this.log(
-				`Error create new api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
-			)
-			vscode.window.showErrorMessage(t("common:errors.create_api_config"))
-		}
+		await this.upsertProviderProfile(currentApiConfigName, newConfiguration)
 	}
 
 	// kilocode_change:
@@ -1010,7 +1077,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 		const kilocode: ProviderName = "kilocode"
 		let { apiConfiguration, currentApiConfigName } = await this.getState()
 
-		await this.upsertApiConfiguration(currentApiConfigName, {
+		await this.upsertProviderProfile(currentApiConfigName, {
 			...apiConfiguration,
 			apiProvider: "kilocode",
 			kilocodeToken: token,

+ 136 - 156
src/core/webview/__tests__/ClineProvider.test.ts

@@ -1,52 +1,41 @@
 // npx jest src/core/webview/__tests__/ClineProvider.test.ts
 
+import Anthropic from "@anthropic-ai/sdk"
 import * as vscode from "vscode"
 import axios from "axios"
 
 import { ClineProvider } from "../ClineProvider"
-import { ExtensionMessage, ExtensionState } from "../../../shared/ExtensionMessage"
+import { ProviderSettingsEntry, ClineMessage, ExtensionMessage, ExtensionState } from "../../../shared/ExtensionMessage"
 import { setSoundEnabled } from "../../../utils/sound"
 import { setTtsEnabled } from "../../../utils/tts"
 import { defaultModeSlug } from "../../../shared/modes"
 import { experimentDefault } from "../../../shared/experiments"
 import { ContextProxy } from "../../config/ContextProxy"
+import { Task, TaskOptions } from "../../task/Task"
 
 // Mock setup must come before imports
 jest.mock("../../prompts/sections/custom-instructions")
 
-// Mock dependencies
 jest.mock("vscode")
+
 jest.mock("delay")
 
-// Mock BrowserSession
-jest.mock("../../../services/browser/BrowserSession", () => ({
-	BrowserSession: jest.fn().mockImplementation(() => ({
-		testConnection: jest.fn().mockImplementation(async (url) => {
-			if (url === "http://localhost:9222") {
-				return {
-					success: true,
-					message: "Successfully connected to Chrome",
-					endpoint: "ws://localhost:9222/devtools/browser/123",
-				}
-			} else {
-				return {
-					success: false,
-					message: "Failed to connect to Chrome",
-					endpoint: undefined,
-				}
-			}
-		}),
-	})),
+jest.mock("p-wait-for", () => ({
+	__esModule: true,
+	default: jest.fn().mockResolvedValue(undefined),
 }))
 
-// Mock browserDiscovery
-jest.mock("../../../services/browser/browserDiscovery", () => ({
-	discoverChromeHostUrl: jest.fn().mockImplementation(async () => {
-		return "http://localhost:9222"
-	}),
-	tryChromeHostUrl: jest.fn().mockImplementation(async (url) => {
-		return url === "http://localhost:9222"
-	}),
+jest.mock("fs/promises", () => ({
+	mkdir: jest.fn(),
+	writeFile: jest.fn(),
+	readFile: jest.fn(),
+	unlink: jest.fn(),
+	rmdir: jest.fn(),
+}))
+
+jest.mock("axios", () => ({
+	get: jest.fn().mockResolvedValue({ data: { data: [] } }),
+	post: jest.fn(),
 }))
 
 jest.mock(
@@ -74,6 +63,35 @@ jest.mock(
 	{ virtual: true },
 )
 
+jest.mock("../../../services/browser/BrowserSession", () => ({
+	BrowserSession: jest.fn().mockImplementation(() => ({
+		testConnection: jest.fn().mockImplementation(async (url) => {
+			if (url === "http://localhost:9222") {
+				return {
+					success: true,
+					message: "Successfully connected to Chrome",
+					endpoint: "ws://localhost:9222/devtools/browser/123",
+				}
+			} else {
+				return {
+					success: false,
+					message: "Failed to connect to Chrome",
+					endpoint: undefined,
+				}
+			}
+		}),
+	})),
+}))
+
+jest.mock("../../../services/browser/browserDiscovery", () => ({
+	discoverChromeHostUrl: jest.fn().mockImplementation(async () => {
+		return "http://localhost:9222"
+	}),
+	tryChromeHostUrl: jest.fn().mockImplementation(async (url) => {
+		return url === "http://localhost:9222"
+	}),
+}))
+
 // Initialize mocks
 const mockAddCustomInstructions = jest.fn().mockResolvedValue("Combined instructions")
 
@@ -115,7 +133,6 @@ jest.mock(
 	{ virtual: true },
 )
 
-// Mock dependencies
 jest.mock("vscode", () => ({
 	ExtensionContext: jest.fn(),
 	OutputChannel: jest.fn(),
@@ -156,50 +173,24 @@ jest.mock("vscode", () => ({
 	},
 }))
 
-// Mock sound utility
 jest.mock("../../../utils/sound", () => ({
 	setSoundEnabled: jest.fn(),
 }))
 
-// Mock tts utility
 jest.mock("../../../utils/tts", () => ({
 	setTtsEnabled: jest.fn(),
 	setTtsSpeed: jest.fn(),
 }))
 
-// Mock ESM modules
-jest.mock("p-wait-for", () => ({
-	__esModule: true,
-	default: jest.fn().mockResolvedValue(undefined),
-}))
-
-// Mock fs/promises
-jest.mock("fs/promises", () => ({
-	mkdir: jest.fn(),
-	writeFile: jest.fn(),
-	readFile: jest.fn(),
-	unlink: jest.fn(),
-	rmdir: jest.fn(),
-}))
-
-// Mock axios
-jest.mock("axios", () => ({
-	get: jest.fn().mockResolvedValue({ data: { data: [] } }),
-	post: jest.fn(),
-}))
-
-// Mock buildApiHandler
 jest.mock("../../../api", () => ({
 	buildApiHandler: jest.fn(),
 }))
 
-// Mock system prompt
 jest.mock("../../prompts/system", () => ({
 	SYSTEM_PROMPT: jest.fn().mockImplementation(async () => "mocked system prompt"),
 	codeMode: "code",
 }))
 
-// Mock WorkspaceTracker
 jest.mock("../../../integrations/workspace/WorkspaceTracker", () => {
 	return jest.fn().mockImplementation(() => ({
 		initializeFilePaths: jest.fn(),
@@ -207,9 +198,8 @@ jest.mock("../../../integrations/workspace/WorkspaceTracker", () => {
 	}))
 })
 
-// Mock Cline
-jest.mock("../../Cline", () => ({
-	Cline: jest
+jest.mock("../../task/Task", () => ({
+	Task: jest
 		.fn()
 		.mockImplementation(
 			(_provider, _apiConfiguration, _customInstructions, _diffEnabled, _fuzzyMatchThreshold, _task, taskId) => ({
@@ -229,7 +219,6 @@ jest.mock("../../Cline", () => ({
 		),
 }))
 
-// Mock extract-text
 jest.mock("../../../integrations/misc/extract-text", () => ({
 	extractTextFromFile: jest.fn().mockImplementation(async (_filePath: string) => {
 		const content = "const x = 1;\nconst y = 2;\nconst z = 3;"
@@ -238,17 +227,13 @@ jest.mock("../../../integrations/misc/extract-text", () => ({
 	}),
 }))
 
-// Spy on console.error and console.log to suppress expected messages
-beforeAll(() => {
-	jest.spyOn(console, "error").mockImplementation(() => {})
-	jest.spyOn(console, "log").mockImplementation(() => {})
-})
-
 afterAll(() => {
 	jest.restoreAllMocks()
 })
 
 describe("ClineProvider", () => {
+	let defaultTaskOptions: TaskOptions
+
 	let provider: ClineProvider
 	let mockContext: vscode.ExtensionContext
 	let mockOutputChannel: vscode.OutputChannel
@@ -327,6 +312,13 @@ describe("ClineProvider", () => {
 
 		provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
 
+		defaultTaskOptions = {
+			provider,
+			apiConfiguration: {
+				apiProvider: "openrouter",
+			},
+		}
+
 		// @ts-ignore - Access private property for testing
 		updateGlobalStateSpy = jest.spyOn(provider.contextProxy, "setValue")
 
@@ -451,8 +443,7 @@ describe("ClineProvider", () => {
 
 	test("clearTask aborts current task", async () => {
 		// Setup Cline instance with auto-mock from the top of the file
-		const { Cline } = require("../../Cline") // Get the mocked class
-		const mockCline = new Cline() // Create a new mocked instance
+		const mockCline = new Task(defaultTaskOptions) // Create a new mocked instance
 
 		// add the mock object to the stack
 		await provider.addClineToStack(mockCline)
@@ -475,9 +466,8 @@ describe("ClineProvider", () => {
 
 	test("addClineToStack adds multiple Cline instances to the stack", async () => {
 		// Setup Cline instance with auto-mock from the top of the file
-		const { Cline } = require("../../Cline") // Get the mocked class
-		const mockCline1 = new Cline() // Create a new mocked instance
-		const mockCline2 = new Cline() // Create a new mocked instance
+		const mockCline1 = new Task(defaultTaskOptions) // Create a new mocked instance
+		const mockCline2 = new Task(defaultTaskOptions) // Create a new mocked instance
 		Object.defineProperty(mockCline1, "taskId", { value: "test-task-id-1", writable: true })
 		Object.defineProperty(mockCline2, "taskId", { value: "test-task-id-2", writable: true })
 
@@ -600,38 +590,16 @@ describe("ClineProvider", () => {
 		expect(state.alwaysApproveResubmit).toBe(false)
 	})
 
-	test("autoApprovalEnabled defaults to true", async () => {
-		// Mock globalState.get to return undefined for autoApprovalEnabled
-		;(mockContext.globalState.get as jest.Mock).mockReturnValue(undefined)
-
-		const state = await provider.getState()
-		expect(state.autoApprovalEnabled).toBe(true)
-	})
-
-	test("alwaysAllowReadOnly defaults to true", async () => {
-		// Mock globalState.get to return undefined for alwaysAllowReadOnly
-		;(mockContext.globalState.get as jest.Mock).mockReturnValue(undefined)
-
-		const state = await provider.getState()
-		expect(state.alwaysAllowReadOnly).toBe(true)
-	})
-
-	test("alwaysAllowWrite defaults to true", async () => {
-		// Mock globalState.get to return undefined for alwaysAllowWrite
-		;(mockContext.globalState.get as jest.Mock).mockReturnValue(undefined)
-
-		const state = await provider.getState()
-		expect(state.alwaysAllowWrite).toBe(true)
-	})
-
-	test("loads saved API config when switching modes", async () => {
+	it("loads saved API config when switching modes", async () => {
 		await provider.resolveWebviewView(mockWebviewView)
 		const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
 
+		const profile: ProviderSettingsEntry = { name: "test-config", id: "test-id", apiProvider: "anthropic" }
+
 		;(provider as any).providerSettingsManager = {
 			getModeConfigId: jest.fn().mockResolvedValue("test-id"),
-			listConfig: jest.fn().mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]),
-			loadConfig: jest.fn().mockResolvedValue({ apiProvider: "anthropic" }),
+			listConfig: jest.fn().mockResolvedValue([profile]),
+			activateProfile: jest.fn().mockResolvedValue(profile),
 			setModeConfig: jest.fn(),
 		} as any
 
@@ -640,11 +608,11 @@ describe("ClineProvider", () => {
 
 		// Should load the saved config for architect mode
 		expect(provider.providerSettingsManager.getModeConfigId).toHaveBeenCalledWith("architect")
-		expect(provider.providerSettingsManager.loadConfig).toHaveBeenCalledWith("test-config")
+		expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledWith({ name: "test-config" })
 		expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "test-config")
 	})
 
-	test("saves current config when switching to mode without config", async () => {
+	it("saves current config when switching to mode without config", async () => {
 		await provider.resolveWebviewView(mockWebviewView)
 		const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
 
@@ -665,16 +633,15 @@ describe("ClineProvider", () => {
 		expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("architect", "current-id")
 	})
 
-	test("saves config as default for current mode when loading config", async () => {
+	it("saves config as default for current mode when loading config", async () => {
 		await provider.resolveWebviewView(mockWebviewView)
 		const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
 
+		const profile: ProviderSettingsEntry = { apiProvider: "anthropic", id: "new-id", name: "new-config" }
+
 		;(provider as any).providerSettingsManager = {
-			loadConfig: jest.fn().mockResolvedValue({ apiProvider: "anthropic", id: "new-id" }),
-			loadConfigById: jest
-				.fn()
-				.mockResolvedValue({ config: { apiProvider: "anthropic", id: "new-id" }, name: "new-config" }),
-			listConfig: jest.fn().mockResolvedValue([{ name: "new-config", id: "new-id", apiProvider: "anthropic" }]),
+			activateProfile: jest.fn().mockResolvedValue(profile),
+			listConfig: jest.fn().mockResolvedValue([profile]),
 			setModeConfig: jest.fn(),
 			getModeConfigId: jest.fn().mockResolvedValue(undefined),
 		} as any
@@ -689,18 +656,19 @@ describe("ClineProvider", () => {
 		expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("architect", "new-id")
 	})
 
-	test("load API configuration by ID works and updates mode config", async () => {
+	it("load API configuration by ID works and updates mode config", async () => {
 		await provider.resolveWebviewView(mockWebviewView)
 		const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
 
+		const profile: ProviderSettingsEntry = {
+			name: "config-by-id",
+			id: "config-id-123",
+			apiProvider: "anthropic",
+		}
+
 		;(provider as any).providerSettingsManager = {
-			loadConfigById: jest.fn().mockResolvedValue({
-				config: { apiProvider: "anthropic", id: "config-id-123" },
-				name: "config-by-id",
-			}),
-			listConfig: jest
-				.fn()
-				.mockResolvedValue([{ name: "config-by-id", id: "config-id-123", apiProvider: "anthropic" }]),
+			activateProfile: jest.fn().mockResolvedValue(profile),
+			listConfig: jest.fn().mockResolvedValue([profile]),
 			setModeConfig: jest.fn(),
 			getModeConfigId: jest.fn().mockResolvedValue(undefined),
 		} as any
@@ -714,8 +682,8 @@ describe("ClineProvider", () => {
 		// Should save new config as default for architect mode
 		expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("architect", "config-id-123")
 
-		// Ensure the loadConfigById method was called with the correct ID
-		expect(provider.providerSettingsManager.loadConfigById).toHaveBeenCalledWith("config-id-123")
+		// Ensure the `activateProfile` method was called with the correct ID
+		expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledWith({ id: "config-id-123" })
 	})
 
 	test("handles browserToolEnabled setting", async () => {
@@ -857,15 +825,11 @@ describe("ClineProvider", () => {
 			experiments: experimentDefault,
 		} as any)
 
-		// Reset Cline mock
-		const { Cline } = require("../../Cline")
-		;(Cline as jest.Mock).mockClear()
-
 		// Initialize Cline with a task
 		await provider.initClineWithTask("Test task")
 
 		// Verify Cline was initialized with mode-specific instructions
-		expect(Cline).toHaveBeenCalledWith({
+		expect(Task).toHaveBeenCalledWith({
 			provider,
 			apiConfiguration: mockApiConfig,
 			customInstructions: modeCustomInstructions,
@@ -918,7 +882,7 @@ describe("ClineProvider", () => {
 		})
 	})
 
-	test("saves mode config when updating API configuration", async () => {
+	it("saves mode config when updating API configuration", async () => {
 		// Setup mock context with mode and config name
 		mockContext = {
 			...mockContext,
@@ -944,12 +908,14 @@ describe("ClineProvider", () => {
 
 		;(provider as any).providerSettingsManager = {
 			listConfig: jest.fn().mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]),
+			saveConfig: jest.fn().mockResolvedValue("test-id"),
 			setModeConfig: jest.fn(),
 		} as any
 
 		// Update API configuration
 		await messageHandler({
-			type: "apiConfiguration",
+			type: "upsertApiConfiguration",
+			text: "test-config",
 			apiConfiguration: { apiProvider: "anthropic" },
 		})
 
@@ -982,13 +948,19 @@ describe("ClineProvider", () => {
 				{ ts: 4000, type: "say", say: "browser_action" }, // Response to delete
 				{ ts: 5000, type: "say", say: "user_feedback" }, // Next user message
 				{ ts: 6000, type: "say", say: "user_feedback" }, // Final message
-			]
-
-			const mockApiHistory = [{ ts: 1000 }, { ts: 2000 }, { ts: 3000 }, { ts: 4000 }, { ts: 5000 }, { ts: 6000 }]
-
-			// Setup Cline instance with auto-mock from the top of the file
-			const { Cline } = require("../../Cline") // Get the mocked class
-			const mockCline = new Cline() // Create a new mocked instance
+			] as ClineMessage[]
+
+			const mockApiHistory = [
+				{ ts: 1000 },
+				{ ts: 2000 },
+				{ ts: 3000 },
+				{ ts: 4000 },
+				{ ts: 5000 },
+				{ ts: 6000 },
+			] as (Anthropic.MessageParam & { ts?: number })[]
+
+			// Setup Task instance with auto-mock from the top of the file
+			const mockCline = new Task(defaultTaskOptions) // Create a new mocked instance
 			mockCline.clineMessages = mockMessages // Set test-specific messages
 			mockCline.apiConversationHistory = mockApiHistory // Set API history
 			await provider.addClineToStack(mockCline) // Add the mocked instance to the stack
@@ -1029,13 +1001,19 @@ describe("ClineProvider", () => {
 				{ ts: 2000, type: "say", say: "text", value: 3000 }, // Message to delete
 				{ ts: 3000, type: "say", say: "user_feedback" },
 				{ ts: 4000, type: "say", say: "user_feedback" },
-			]
+			] as ClineMessage[]
 
-			const mockApiHistory = [{ ts: 1000 }, { ts: 2000 }, { ts: 3000 }, { ts: 4000 }]
+			const mockApiHistory = [
+				{ ts: 1000 },
+				{ ts: 2000 },
+				{ ts: 3000 },
+				{ ts: 4000 },
+			] as (Anthropic.MessageParam & {
+				ts?: number
+			})[]
 
 			// Setup Cline instance with auto-mock from the top of the file
-			const { Cline } = require("../../Cline") // Get the mocked class
-			const mockCline = new Cline() // Create a new mocked instance
+			const mockCline = new Task(defaultTaskOptions) // Create a new mocked instance
 			mockCline.clineMessages = mockMessages
 			mockCline.apiConversationHistory = mockApiHistory
 			await provider.addClineToStack(mockCline)
@@ -1061,10 +1039,11 @@ describe("ClineProvider", () => {
 			;(vscode.window.showInformationMessage as jest.Mock).mockResolvedValue("Cancel")
 
 			// Setup Cline instance with auto-mock from the top of the file
-			const { Cline } = require("../../Cline") // Get the mocked class
-			const mockCline = new Cline() // Create a new mocked instance
-			mockCline.clineMessages = [{ ts: 1000 }, { ts: 2000 }]
-			mockCline.apiConversationHistory = [{ ts: 1000 }, { ts: 2000 }]
+			const mockCline = new Task(defaultTaskOptions) // Create a new mocked instance
+			mockCline.clineMessages = [{ ts: 1000 }, { ts: 2000 }] as ClineMessage[]
+			mockCline.apiConversationHistory = [{ ts: 1000 }, { ts: 2000 }] as (Anthropic.MessageParam & {
+				ts?: number
+			})[]
 			await provider.addClineToStack(mockCline)
 
 			// Trigger message deletion
@@ -1236,15 +1215,16 @@ describe("ClineProvider", () => {
 		})
 
 		test("passes diffEnabled: false to SYSTEM_PROMPT when diff is disabled", async () => {
-			// Setup Cline instance with mocked api.getModel()
-			const { Cline } = require("../../Cline")
-			const mockCline = new Cline()
+			// Setup Task instance with mocked api.getModel()
+			const mockCline = new Task(defaultTaskOptions)
+
 			mockCline.api = {
 				getModel: jest.fn().mockReturnValue({
 					id: "claude-3-sonnet",
 					info: { supportsComputerUse: true },
 				}),
-			}
+			} as any
+
 			await provider.addClineToStack(mockCline)
 
 			// Mock getState to return diffEnabled: false
@@ -1560,13 +1540,17 @@ describe("ClineProvider", () => {
 			await provider.resolveWebviewView(mockWebviewView)
 		})
 
-		test("loads saved API config when switching modes", async () => {
+		it("loads saved API config when switching modes", async () => {
+			const profile: ProviderSettingsEntry = {
+				name: "saved-config",
+				id: "saved-config-id",
+				apiProvider: "anthropic",
+			}
+
 			;(provider as any).providerSettingsManager = {
 				getModeConfigId: jest.fn().mockResolvedValue("saved-config-id"),
-				listConfig: jest
-					.fn()
-					.mockResolvedValue([{ name: "saved-config", id: "saved-config-id", apiProvider: "anthropic" }]),
-				loadConfig: jest.fn().mockResolvedValue({ apiProvider: "anthropic" }),
+				listConfig: jest.fn().mockResolvedValue([profile]),
+				activateProfile: jest.fn().mockResolvedValue(profile),
 				setModeConfig: jest.fn(),
 			} as any
 
@@ -1578,7 +1562,7 @@ describe("ClineProvider", () => {
 
 			// Verify saved config was loaded
 			expect(provider.providerSettingsManager.getModeConfigId).toHaveBeenCalledWith("architect")
-			expect(provider.providerSettingsManager.loadConfig).toHaveBeenCalledWith("saved-config")
+			expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledWith({ name: "saved-config" })
 			expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "saved-config")
 
 			// Verify state was posted to webview
@@ -1694,14 +1678,11 @@ describe("ClineProvider", () => {
 				currentApiConfigName: "test-config",
 			} as any)
 
-			// Trigger updateApiConfiguration
+			// Trigger upsertApiConfiguration
 			await messageHandler({
 				type: "upsertApiConfiguration",
 				text: "test-config",
-				apiConfiguration: {
-					apiProvider: "anthropic",
-					apiKey: "test-key",
-				},
+				apiConfiguration: { apiProvider: "anthropic", apiKey: "test-key" },
 			})
 
 			// Verify error was logged and user was notified
@@ -1766,9 +1747,8 @@ describe("ClineProvider", () => {
 					.mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]),
 			} as any
 
-			// Setup Cline instance with auto-mock from the top of the file
-			const { Cline } = require("../../Cline") // Get the mocked class
-			const mockCline = new Cline() // Create a new mocked instance
+			// Setup Task instance with auto-mock from the top of the file
+			const mockCline = new Task(defaultTaskOptions) // Create a new mocked instance
 			await provider.addClineToStack(mockCline)
 
 			const testApiConfig = {

+ 44 - 94
src/core/webview/webviewMessageHandler.ts

@@ -4,9 +4,9 @@ import pWaitFor from "p-wait-for"
 import * as vscode from "vscode"
 
 import { ClineProvider } from "./ClineProvider"
-import { Language, ApiConfigMeta } from "../../schemas"
+import { Language, ProviderSettings } from "../../schemas"
 import { changeLanguage, t } from "../../i18n"
-import { ApiConfiguration, RouterName, toRouterName } from "../../shared/api"
+import { RouterName, toRouterName } from "../../shared/api"
 import { supportPrompt } from "../../shared/support-prompt"
 
 import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
@@ -88,19 +88,12 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
 
 					if (currentConfigName) {
 						if (!(await provider.providerSettingsManager.hasConfig(currentConfigName))) {
-							// current config name not valid, get first config in list
-							await updateGlobalState("currentApiConfigName", listApiConfig?.[0]?.name)
-							if (listApiConfig?.[0]?.name) {
-								const apiConfig = await provider.providerSettingsManager.loadConfig(
-									listApiConfig?.[0]?.name,
-								)
+							// Current config name not valid, get first config in list.
+							const name = listApiConfig[0]?.name
+							await updateGlobalState("currentApiConfigName", name)
 
-								await Promise.all([
-									updateGlobalState("listApiConfigMeta", listApiConfig),
-									provider.postMessageToWebview({ type: "listApiConfig", listApiConfig }),
-									provider.updateApiConfiguration(apiConfig),
-								])
-								await provider.postStateToWebview()
+							if (name) {
+								await provider.activateProviderProfile({ name })
 								return
 							}
 						}
@@ -125,12 +118,6 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
 			// task. This essentially creates a fresh slate for the new task.
 			await provider.initClineWithTask(message.text, message.images)
 			break
-		case "apiConfiguration":
-			if (message.apiConfiguration) {
-				await provider.updateApiConfiguration(message.apiConfiguration)
-			}
-			await provider.postStateToWebview()
-			break
 		case "customInstructions":
 			await provider.updateCustomInstructions(message.text)
 			break
@@ -952,27 +939,22 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
 					const { apiConfiguration, customSupportPrompts, listApiConfigMeta, enhancementApiConfigId } =
 						await provider.getState()
 
-					// Try to get enhancement config first, fall back to current config
-					let configToUse: ApiConfiguration = apiConfiguration
-					if (enhancementApiConfigId) {
-						const config = listApiConfigMeta?.find((c: ApiConfigMeta) => c.id === enhancementApiConfigId)
-						if (config?.name) {
-							const loadedConfig = await provider.providerSettingsManager.loadConfig(config.name)
-							if (loadedConfig.apiProvider) {
-								configToUse = loadedConfig
-							}
+					// Try to get enhancement config first, fall back to current config.
+					let configToUse: ProviderSettings = apiConfiguration
+
+					if (enhancementApiConfigId && !!listApiConfigMeta.find(({ id }) => id === enhancementApiConfigId)) {
+						const { name: _, ...providerSettings } = await provider.providerSettingsManager.getProfile({
+							id: enhancementApiConfigId,
+						})
+
+						if (providerSettings.apiProvider) {
+							configToUse = providerSettings
 						}
 					}
 
 					const enhancedPrompt = await singleCompletionHandler(
 						configToUse,
-						supportPrompt.create(
-							"ENHANCE",
-							{
-								userInput: message.text,
-							},
-							customSupportPrompts,
-						),
+						supportPrompt.create("ENHANCE", { userInput: message.text }, customSupportPrompts),
 					)
 
 					await provider.postMessageToWebview({
@@ -983,10 +965,9 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
 					provider.log(
 						`Error enhancing prompt: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
 					)
+
 					vscode.window.showErrorMessage(t("common:errors.enhance_prompt"))
-					await provider.postMessageToWebview({
-						type: "enhancedPrompt",
-					})
+					await provider.postMessageToWebview({ type: "enhancedPrompt" })
 				}
 			}
 			break
@@ -1117,7 +1098,7 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
 			break
 		case "upsertApiConfiguration":
 			if (message.text && message.apiConfiguration) {
-				await provider.upsertApiConfiguration(message.text, message.apiConfiguration)
+				await provider.upsertProviderProfile(message.text, message.apiConfiguration)
 			}
 			break
 		case "renameApiConfiguration":
@@ -1129,30 +1110,23 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
 						break
 					}
 
-					// Load the old configuration to get its ID
-					const oldConfig = await provider.providerSettingsManager.loadConfig(oldName)
+					// Load the old configuration to get its ID.
+					const { id } = await provider.providerSettingsManager.getProfile({ name: oldName })
 
-					// Create a new configuration with the same ID
-					const newConfig = {
-						...message.apiConfiguration,
-						id: oldConfig.id, // Preserve the ID
-					}
+					// Create a new configuration with the new name and old ID.
+					await provider.providerSettingsManager.saveConfig(newName, { ...message.apiConfiguration, id })
 
-					// Save with the new name but same ID
-					await provider.providerSettingsManager.saveConfig(newName, newConfig)
+					// Delete the old configuration.
 					await provider.providerSettingsManager.deleteConfig(oldName)
 
-					const listApiConfig = await provider.providerSettingsManager.listConfig()
-
-					// Update listApiConfigMeta first to ensure UI has latest data
-					await updateGlobalState("listApiConfigMeta", listApiConfig)
-					await updateGlobalState("currentApiConfigName", newName)
-
-					await provider.postStateToWebview()
+					// Re-activate to update the global settings related to the
+					// currently activated provider profile.
+					await provider.activateProviderProfile({ name: newName })
 				} catch (error) {
 					provider.log(
 						`Error rename api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
 					)
+
 					vscode.window.showErrorMessage(t("common:errors.rename_api_config"))
 				}
 			}
@@ -1160,16 +1134,7 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
 		case "loadApiConfiguration":
 			if (message.text) {
 				try {
-					const apiConfig = await provider.providerSettingsManager.loadConfig(message.text)
-					const listApiConfig = await provider.providerSettingsManager.listConfig()
-
-					await Promise.all([
-						updateGlobalState("listApiConfigMeta", listApiConfig),
-						updateGlobalState("currentApiConfigName", message.text),
-						provider.updateApiConfiguration(apiConfig),
-					])
-
-					await provider.postStateToWebview()
+					await provider.activateProviderProfile({ name: message.text })
 				} catch (error) {
 					provider.log(
 						`Error load api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
@@ -1181,18 +1146,7 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
 		case "loadApiConfigurationById":
 			if (message.text) {
 				try {
-					const { config: apiConfig, name } = await provider.providerSettingsManager.loadConfigById(
-						message.text,
-					)
-					const listApiConfig = await provider.providerSettingsManager.listConfig()
-
-					await Promise.all([
-						updateGlobalState("listApiConfigMeta", listApiConfig),
-						updateGlobalState("currentApiConfigName", name),
-						provider.updateApiConfiguration(apiConfig),
-					])
-
-					await provider.postStateToWebview()
+					await provider.activateProviderProfile({ id: message.text })
 				} catch (error) {
 					provider.log(
 						`Error load api configuration by ID: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
@@ -1213,29 +1167,25 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
 					break
 				}
 
-				try {
-					await provider.providerSettingsManager.deleteConfig(message.text)
-					const listApiConfig = await provider.providerSettingsManager.listConfig()
-
-					// Update listApiConfigMeta first to ensure UI has latest data
-					await updateGlobalState("listApiConfigMeta", listApiConfig)
+				const oldName = message.text
 
-					// If this was the current config, switch to first available
-					const currentApiConfigName = getGlobalState("currentApiConfigName")
+				const newName = (await provider.providerSettingsManager.listConfig()).filter(
+					(c) => c.name !== oldName,
+				)[0]?.name
 
-					if (message.text === currentApiConfigName && listApiConfig?.[0]?.name) {
-						const apiConfig = await provider.providerSettingsManager.loadConfig(listApiConfig[0].name)
-						await Promise.all([
-							updateGlobalState("currentApiConfigName", listApiConfig[0].name),
-							provider.updateApiConfiguration(apiConfig),
-						])
-					}
+				if (!newName) {
+					vscode.window.showErrorMessage(t("common:errors.delete_api_config"))
+					return
+				}
 
-					await provider.postStateToWebview()
+				try {
+					await provider.providerSettingsManager.deleteConfig(oldName)
+					await provider.activateProviderProfile({ name: newName })
 				} catch (error) {
 					provider.log(
 						`Error delete api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
 					)
+
 					vscode.window.showErrorMessage(t("common:errors.delete_api_config"))
 				}
 			}

+ 107 - 86
src/exports/api.ts

@@ -6,7 +6,14 @@ import * as path from "path"
 import { getWorkspacePath } from "../utils/path"
 import { ClineProvider } from "../core/webview/ClineProvider"
 import { openClineInNewTab } from "../activate/registerCommands"
-import { RooCodeSettings, RooCodeEvents, RooCodeEventName } from "../schemas"
+import {
+	RooCodeSettings,
+	RooCodeEvents,
+	RooCodeEventName,
+	ProviderSettings,
+	ProviderSettingsEntry,
+	isSecretStateKey,
+} from "../schemas"
 import { IpcOrigin, IpcMessageType, TaskCommandName, TaskEvent } from "../schemas/ipc"
 
 import { RooCodeAPI } from "./interface"
@@ -178,91 +185,6 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
 		await this.sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "secondaryButtonClick" })
 	}
 
-	public getConfiguration() {
-		return this.sidebarProvider.getValues()
-	}
-
-	public async setConfiguration(values: RooCodeSettings) {
-		await this.sidebarProvider.setValues(values)
-		await this.sidebarProvider.providerSettingsManager.saveConfig(values.currentApiConfigName || "default", values)
-		await this.sidebarProvider.postStateToWebview()
-	}
-
-	public async createProfile(name: string) {
-		if (!name || !name.trim()) {
-			throw new Error("Profile name cannot be empty")
-		}
-
-		const currentSettings = this.getConfiguration()
-		const profiles = currentSettings.listApiConfigMeta || []
-
-		if (profiles.some((profile) => profile.name === name)) {
-			throw new Error(`A profile with the name "${name}" already exists`)
-		}
-
-		const id = this.sidebarProvider.providerSettingsManager.generateId()
-
-		await this.setConfiguration({
-			...currentSettings,
-			listApiConfigMeta: [
-				...profiles,
-				{
-					id,
-					name: name.trim(),
-					apiProvider: "openai" as const,
-				},
-			],
-		})
-
-		return id
-	}
-
-	public getProfiles() {
-		return (this.getConfiguration().listApiConfigMeta || []).map((profile) => profile.name)
-	}
-
-	public async setActiveProfile(name: string) {
-		const currentSettings = this.getConfiguration()
-		const profiles = currentSettings.listApiConfigMeta || []
-
-		const profile = profiles.find((p) => p.name === name)
-
-		if (!profile) {
-			throw new Error(`Profile with name "${name}" does not exist`)
-		}
-
-		await this.setConfiguration({ ...currentSettings, currentApiConfigName: profile.name })
-	}
-
-	public getActiveProfile() {
-		return this.getConfiguration().currentApiConfigName
-	}
-
-	public async deleteProfile(name: string) {
-		const currentSettings = this.getConfiguration()
-		const profiles = currentSettings.listApiConfigMeta || []
-		const targetIndex = profiles.findIndex((p) => p.name === name)
-
-		if (targetIndex === -1) {
-			throw new Error(`Profile with name "${name}" does not exist`)
-		}
-
-		const profileToDelete = profiles[targetIndex]
-		profiles.splice(targetIndex, 1)
-
-		// If we're deleting the active profile, clear the currentApiConfigName.
-		const newSettings: RooCodeSettings = {
-			...currentSettings,
-			listApiConfigMeta: profiles,
-			currentApiConfigName:
-				currentSettings.currentApiConfigName === profileToDelete.name
-					? undefined
-					: currentSettings.currentApiConfigName,
-		}
-
-		await this.setConfiguration(newSettings)
-	}
-
 	public isReady() {
 		return this.sidebarProvider.viewLaunched
 	}
@@ -328,4 +250,103 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
 			this.logfile = undefined
 		}
 	}
+
+	// Global Settings Management
+
+	public getConfiguration(): RooCodeSettings {
+		return Object.fromEntries(
+			Object.entries(this.sidebarProvider.getValues()).filter(([key]) => !isSecretStateKey(key)),
+		)
+	}
+
+	public async setConfiguration(values: RooCodeSettings) {
+		await this.sidebarProvider.contextProxy.setValues(values)
+		await this.sidebarProvider.providerSettingsManager.saveConfig(values.currentApiConfigName || "default", values)
+		await this.sidebarProvider.postStateToWebview()
+	}
+
+	// Provider Profile Management
+
+	public getProfiles(): string[] {
+		return this.sidebarProvider.getProviderProfileEntries().map(({ name }) => name)
+	}
+
+	public getProfileEntry(name: string): ProviderSettingsEntry | undefined {
+		return this.sidebarProvider.getProviderProfileEntry(name)
+	}
+
+	public async createProfile(name: string, profile?: ProviderSettings, activate: boolean = true) {
+		const entry = this.getProfileEntry(name)
+
+		if (entry) {
+			throw new Error(`Profile with name "${name}" already exists`)
+		}
+
+		const id = await this.sidebarProvider.upsertProviderProfile(name, profile ?? {}, activate)
+
+		if (!id) {
+			throw new Error(`Failed to create profile with name "${name}"`)
+		}
+
+		return id
+	}
+
+	public async updateProfile(
+		name: string,
+		profile: ProviderSettings,
+		activate: boolean = true,
+	): Promise<string | undefined> {
+		const entry = this.getProfileEntry(name)
+
+		if (!entry) {
+			throw new Error(`Profile with name "${name}" does not exist`)
+		}
+
+		const id = await this.sidebarProvider.upsertProviderProfile(name, profile, activate)
+
+		if (!id) {
+			throw new Error(`Failed to update profile with name "${name}"`)
+		}
+
+		return id
+	}
+
+	public async upsertProfile(
+		name: string,
+		profile: ProviderSettings,
+		activate: boolean = true,
+	): Promise<string | undefined> {
+		const id = await this.sidebarProvider.upsertProviderProfile(name, profile, activate)
+
+		if (!id) {
+			throw new Error(`Failed to upsert profile with name "${name}"`)
+		}
+
+		return id
+	}
+
+	public async deleteProfile(name: string): Promise<void> {
+		const entry = this.getProfileEntry(name)
+
+		if (!entry) {
+			throw new Error(`Profile with name "${name}" does not exist`)
+		}
+
+		await this.sidebarProvider.deleteProviderProfile(entry)
+	}
+
+	public getActiveProfile(): string | undefined {
+		return this.getConfiguration().currentApiConfigName
+	}
+
+	public async setActiveProfile(name: string): Promise<string | undefined> {
+		const entry = this.getProfileEntry(name)
+
+		if (!entry) {
+			throw new Error(`Profile with name "${name}" does not exist`)
+		}
+
+		await this.sidebarProvider.activateProviderProfile({ name })
+		return this.getActiveProfile()
+	}
 }

+ 61 - 16
src/exports/interface.ts

@@ -1,7 +1,23 @@
 import { EventEmitter } from "events"
 
-import type { ProviderSettings, GlobalSettings, ClineMessage, TokenUsage, RooCodeEvents } from "./types"
-export type { RooCodeSettings, ProviderSettings, GlobalSettings, ClineMessage, TokenUsage, RooCodeEvents }
+import type {
+	GlobalSettings,
+	ProviderSettings,
+	ProviderSettingsEntry,
+	ClineMessage,
+	TokenUsage,
+	RooCodeEvents,
+} from "./types"
+
+export type {
+	RooCodeSettings,
+	GlobalSettings,
+	ProviderSettings,
+	ProviderSettingsEntry,
+	ClineMessage,
+	TokenUsage,
+	RooCodeEvents,
+}
 
 import { RooCodeEventName } from "../schemas"
 export type { RooCodeEventName }
@@ -74,6 +90,11 @@ export interface RooCodeAPI extends EventEmitter<RooCodeEvents> {
 	 */
 	pressSecondaryButton(): Promise<void>
 
+	/**
+	 * Returns true if the API is ready to use.
+	 */
+	isReady(): boolean
+
 	/**
 	 * Returns the current configuration.
 	 * @returns The current configuration.
@@ -87,30 +108,46 @@ export interface RooCodeAPI extends EventEmitter<RooCodeEvents> {
 	setConfiguration(values: RooCodeSettings): Promise<void>
 
 	/**
-	 * Creates a new API configuration profile
+	 * Returns a list of all configured profile names
+	 * @returns Array of profile names
+	 */
+	getProfiles(): string[]
+
+	/**
+	 * Returns the profile entry for a given name
 	 * @param name The name of the profile
-	 * @returns The ID of the created profile
+	 * @returns The profile entry, or undefined if the profile does not exist
 	 */
-	createProfile(name: string): Promise<string>
+	getProfileEntry(name: string): ProviderSettingsEntry | undefined
 
 	/**
-	 * Returns a list of all configured profile names
-	 * @returns Array of profile names
+	 * Creates a new API configuration profile
+	 * @param name The name of the profile
+	 * @param profile The profile to create; defaults to an empty object
+	 * @param activate Whether to activate the profile after creation; defaults to true
+	 * @returns The ID of the created profile
+	 * @throws Error if the profile already exists
 	 */
-	getProfiles(): string[]
+	createProfile(name: string, profile?: ProviderSettings, activate?: boolean): Promise<string>
 
 	/**
-	 * Changes the active API configuration profile
-	 * @param name The name of the profile to activate
+	 * Updates an existing API configuration profile
+	 * @param name The name of the profile
+	 * @param profile The profile to update
+	 * @param activate Whether to activate the profile after update; defaults to true
+	 * @returns The ID of the updated profile
 	 * @throws Error if the profile does not exist
 	 */
-	setActiveProfile(name: string): Promise<void>
+	updateProfile(name: string, profile: ProviderSettings, activate?: boolean): Promise<string | undefined>
 
 	/**
-	 * Returns the name of the currently active profile
-	 * @returns The profile name, or undefined if no profile is active
+	 * Creates a new API configuration profile or updates an existing one
+	 * @param name The name of the profile
+	 * @param profile The profile to create or update; defaults to an empty object
+	 * @param activate Whether to activate the profile after upsert; defaults to true
+	 * @returns The ID of the upserted profile
 	 */
-	getActiveProfile(): string | undefined
+	upsertProfile(name: string, profile: ProviderSettings, activate?: boolean): Promise<string | undefined>
 
 	/**
 	 * Deletes a profile by name
@@ -120,7 +157,15 @@ export interface RooCodeAPI extends EventEmitter<RooCodeEvents> {
 	deleteProfile(name: string): Promise<void>
 
 	/**
-	 * Returns true if the API is ready to use.
+	 * Returns the name of the currently active profile
+	 * @returns The profile name, or undefined if no profile is active
 	 */
-	isReady(): boolean
+	getActiveProfile(): string | undefined
+
+	/**
+	 * Changes the active API configuration profile
+	 * @param name The name of the profile to activate
+	 * @throws Error if the profile does not exist
+	 */
+	setActiveProfile(name: string): Promise<string | undefined>
 }

+ 219 - 160
src/exports/roo-code.d.ts

@@ -1,151 +1,5 @@
 import { EventEmitter } from "events"
 
-type ProviderSettings = {
-	apiProvider?:
-		| (
-				| "kilocode"
-				| "fireworks"
-				| "anthropic"
-				| "glama"
-				| "openrouter"
-				| "bedrock"
-				| "vertex"
-				| "openai"
-				| "ollama"
-				| "vscode-lm"
-				| "lmstudio"
-				| "gemini"
-				| "openai-native"
-				| "mistral"
-				| "deepseek"
-				| "unbound"
-				| "requesty"
-				| "human-relay"
-				| "fake-ai"
-				| "xai"
-				| "groq"
-				| "chutes"
-				| "litellm"
-		  )
-		| undefined
-	apiModelId?: string | undefined
-	apiKey?: string | undefined
-	anthropicBaseUrl?: string | undefined
-	anthropicUseAuthToken?: boolean | undefined
-	glamaModelId?: string | undefined
-	glamaApiKey?: string | undefined
-	openRouterApiKey?: string | undefined
-	openRouterModelId?: string | undefined
-	openRouterBaseUrl?: string | undefined
-	openRouterSpecificProvider?: string | undefined
-	openRouterUseMiddleOutTransform?: boolean | undefined
-	awsAccessKey?: string | undefined
-	awsSecretKey?: string | undefined
-	awsSessionToken?: string | undefined
-	awsRegion?: string | undefined
-	awsUseCrossRegionInference?: boolean | undefined
-	awsUsePromptCache?: boolean | undefined
-	awsProfile?: string | undefined
-	awsUseProfile?: boolean | undefined
-	awsCustomArn?: string | undefined
-	vertexKeyFile?: string | undefined
-	vertexJsonCredentials?: string | undefined
-	vertexProjectId?: string | undefined
-	vertexRegion?: string | undefined
-	openAiBaseUrl?: string | undefined
-	openAiApiKey?: string | undefined
-	openAiLegacyFormat?: boolean | undefined
-	openAiR1FormatEnabled?: boolean | undefined
-	openAiModelId?: string | undefined
-	openAiCustomModelInfo?:
-		| ({
-				preferredIndex?: number | undefined
-				maxTokens?: (number | null) | undefined
-				maxThinkingTokens?: (number | null) | undefined
-				contextWindow: number
-				supportsImages?: boolean | undefined
-				supportsComputerUse?: boolean | undefined
-				supportsPromptCache: boolean
-				isPromptCacheOptional?: boolean | undefined
-				inputPrice?: number | undefined
-				outputPrice?: number | undefined
-				cacheWritesPrice?: number | undefined
-				cacheReadsPrice?: number | undefined
-				description?: string | undefined
-				reasoningEffort?: ("low" | "medium" | "high") | undefined
-				thinking?: boolean | undefined
-				minTokensPerCachePoint?: number | undefined
-				maxCachePoints?: number | undefined
-				cachableFields?: string[] | undefined
-				tiers?:
-					| {
-							contextWindow: number
-							inputPrice?: number | undefined
-							outputPrice?: number | undefined
-							cacheWritesPrice?: number | undefined
-							cacheReadsPrice?: number | undefined
-					  }[]
-					| undefined
-		  } | null)
-		| undefined
-	openAiUseAzure?: boolean | undefined
-	azureApiVersion?: string | undefined
-	openAiStreamingEnabled?: boolean | undefined
-	enableReasoningEffort?: boolean | undefined
-	openAiHostHeader?: string | undefined
-	openAiHeaders?:
-		| {
-				[x: string]: string
-		  }
-		| undefined
-	ollamaModelId?: string | undefined
-	ollamaBaseUrl?: string | undefined
-	vsCodeLmModelSelector?:
-		| {
-				vendor?: string | undefined
-				family?: string | undefined
-				version?: string | undefined
-				id?: string | undefined
-		  }
-		| undefined
-	lmStudioModelId?: string | undefined
-	lmStudioBaseUrl?: string | undefined
-	lmStudioDraftModelId?: string | undefined
-	lmStudioSpeculativeDecodingEnabled?: boolean | undefined
-	geminiApiKey?: string | undefined
-	googleGeminiBaseUrl?: string | undefined
-	openAiNativeApiKey?: string | undefined
-	openAiNativeBaseUrl?: string | undefined
-	mistralApiKey?: string | undefined
-	mistralCodestralUrl?: string | undefined
-	deepSeekBaseUrl?: string | undefined
-	deepSeekApiKey?: string | undefined
-	unboundApiKey?: string | undefined
-	unboundModelId?: string | undefined
-	requestyApiKey?: string | undefined
-	requestyModelId?: string | undefined
-	xaiApiKey?: string | undefined
-	groqApiKey?: string | undefined
-	chutesApiKey?: string | undefined
-	litellmBaseUrl?: string | undefined
-	litellmApiKey?: string | undefined
-	litellmModelId?: string | undefined
-	modelMaxTokens?: number | undefined
-	modelMaxThinkingTokens?: number | undefined
-	includeMaxTokens?: boolean | undefined
-	reasoningEffort?: ("low" | "medium" | "high") | undefined
-	promptCachingDisabled?: boolean | undefined
-	diffEnabled?: boolean | undefined
-	fuzzyMatchThreshold?: number | undefined
-	modelTemperature?: (number | null) | undefined
-	rateLimitSeconds?: number | undefined
-	fakeAi?: unknown | undefined
-	kilocodeToken?: string | undefined
-	kilocodeModel?: string | undefined
-	fireworksModelId?: string | undefined
-	fireworksApiKey?: string | undefined
-}
-
 type GlobalSettings = {
 	currentApiConfigName?: string | undefined
 	listApiConfigMeta?:
@@ -320,6 +174,184 @@ type GlobalSettings = {
 	historyPreviewCollapsed?: boolean | undefined
 }
 
+type ProviderSettings = {
+	apiProvider?:
+		| (
+				| "kilocode"
+				| "fireworks"
+				| "anthropic"
+				| "glama"
+				| "openrouter"
+				| "bedrock"
+				| "vertex"
+				| "openai"
+				| "ollama"
+				| "vscode-lm"
+				| "lmstudio"
+				| "gemini"
+				| "openai-native"
+				| "mistral"
+				| "deepseek"
+				| "unbound"
+				| "requesty"
+				| "human-relay"
+				| "fake-ai"
+				| "xai"
+				| "groq"
+				| "chutes"
+				| "litellm"
+		  )
+		| undefined
+	includeMaxTokens?: boolean | undefined
+	reasoningEffort?: ("low" | "medium" | "high") | undefined
+	promptCachingDisabled?: boolean | undefined
+	diffEnabled?: boolean | undefined
+	fuzzyMatchThreshold?: number | undefined
+	modelTemperature?: (number | null) | undefined
+	rateLimitSeconds?: number | undefined
+	modelMaxTokens?: number | undefined
+	modelMaxThinkingTokens?: number | undefined
+	kilocodeToken?: string | undefined
+	kilocodeModel?: string | undefined
+	fireworksModelId?: string | undefined
+	fireworksApiKey?: string | undefined
+	apiModelId?: string | undefined
+	apiKey?: string | undefined
+	anthropicBaseUrl?: string | undefined
+	anthropicUseAuthToken?: boolean | undefined
+	glamaModelId?: string | undefined
+	glamaApiKey?: string | undefined
+	openRouterApiKey?: string | undefined
+	openRouterModelId?: string | undefined
+	openRouterBaseUrl?: string | undefined
+	openRouterSpecificProvider?: string | undefined
+	openRouterUseMiddleOutTransform?: boolean | undefined
+	awsAccessKey?: string | undefined
+	awsSecretKey?: string | undefined
+	awsSessionToken?: string | undefined
+	awsRegion?: string | undefined
+	awsUseCrossRegionInference?: boolean | undefined
+	awsUsePromptCache?: boolean | undefined
+	awsProfile?: string | undefined
+	awsUseProfile?: boolean | undefined
+	awsCustomArn?: string | undefined
+	vertexKeyFile?: string | undefined
+	vertexJsonCredentials?: string | undefined
+	vertexProjectId?: string | undefined
+	vertexRegion?: string | undefined
+	openAiBaseUrl?: string | undefined
+	openAiApiKey?: string | undefined
+	openAiLegacyFormat?: boolean | undefined
+	openAiR1FormatEnabled?: boolean | undefined
+	openAiModelId?: string | undefined
+	openAiCustomModelInfo?:
+		| ({
+				preferredIndex?: number | undefined
+				maxTokens?: (number | null) | undefined
+				maxThinkingTokens?: (number | null) | undefined
+				contextWindow: number
+				supportsImages?: boolean | undefined
+				supportsComputerUse?: boolean | undefined
+				supportsPromptCache: boolean
+				isPromptCacheOptional?: boolean | undefined
+				inputPrice?: number | undefined
+				outputPrice?: number | undefined
+				cacheWritesPrice?: number | undefined
+				cacheReadsPrice?: number | undefined
+				description?: string | undefined
+				reasoningEffort?: ("low" | "medium" | "high") | undefined
+				thinking?: boolean | undefined
+				minTokensPerCachePoint?: number | undefined
+				maxCachePoints?: number | undefined
+				cachableFields?: string[] | undefined
+				tiers?:
+					| {
+							contextWindow: number
+							inputPrice?: number | undefined
+							outputPrice?: number | undefined
+							cacheWritesPrice?: number | undefined
+							cacheReadsPrice?: number | undefined
+					  }[]
+					| undefined
+		  } | null)
+		| undefined
+	openAiUseAzure?: boolean | undefined
+	azureApiVersion?: string | undefined
+	openAiStreamingEnabled?: boolean | undefined
+	enableReasoningEffort?: boolean | undefined
+	openAiHostHeader?: string | undefined
+	openAiHeaders?:
+		| {
+				[x: string]: string
+		  }
+		| undefined
+	ollamaModelId?: string | undefined
+	ollamaBaseUrl?: string | undefined
+	vsCodeLmModelSelector?:
+		| {
+				vendor?: string | undefined
+				family?: string | undefined
+				version?: string | undefined
+				id?: string | undefined
+		  }
+		| undefined
+	lmStudioModelId?: string | undefined
+	lmStudioBaseUrl?: string | undefined
+	lmStudioDraftModelId?: string | undefined
+	lmStudioSpeculativeDecodingEnabled?: boolean | undefined
+	geminiApiKey?: string | undefined
+	googleGeminiBaseUrl?: string | undefined
+	openAiNativeApiKey?: string | undefined
+	openAiNativeBaseUrl?: string | undefined
+	mistralApiKey?: string | undefined
+	mistralCodestralUrl?: string | undefined
+	deepSeekBaseUrl?: string | undefined
+	deepSeekApiKey?: string | undefined
+	unboundApiKey?: string | undefined
+	unboundModelId?: string | undefined
+	requestyApiKey?: string | undefined
+	requestyModelId?: string | undefined
+	fakeAi?: unknown | undefined
+	xaiApiKey?: string | undefined
+	groqApiKey?: string | undefined
+	chutesApiKey?: string | undefined
+	litellmBaseUrl?: string | undefined
+	litellmApiKey?: string | undefined
+	litellmModelId?: string | undefined
+}
+
+type ProviderSettingsEntry = {
+	id: string
+	name: string
+	apiProvider?:
+		| (
+				| "kilocode"
+				| "fireworks"
+				| "anthropic"
+				| "glama"
+				| "openrouter"
+				| "bedrock"
+				| "vertex"
+				| "openai"
+				| "ollama"
+				| "vscode-lm"
+				| "lmstudio"
+				| "gemini"
+				| "openai-native"
+				| "mistral"
+				| "deepseek"
+				| "unbound"
+				| "requesty"
+				| "human-relay"
+				| "fake-ai"
+				| "xai"
+				| "groq"
+				| "chutes"
+				| "litellm"
+		  )
+		| undefined
+}
+
 type ClineMessage = {
 	ts: number
 	type: "ask" | "say"
@@ -596,6 +628,10 @@ interface RooCodeAPI extends EventEmitter<RooCodeEvents> {
 	 * Simulates pressing the secondary button in the chat interface.
 	 */
 	pressSecondaryButton(): Promise<void>
+	/**
+	 * Returns true if the API is ready to use.
+	 */
+	isReady(): boolean
 	/**
 	 * Returns the current configuration.
 	 * @returns The current configuration.
@@ -606,28 +642,43 @@ interface RooCodeAPI extends EventEmitter<RooCodeEvents> {
 	 * @param values An object containing key-value pairs to set.
 	 */
 	setConfiguration(values: RooCodeSettings): Promise<void>
-	/**
-	 * Creates a new API configuration profile
-	 * @param name The name of the profile
-	 * @returns The ID of the created profile
-	 */
-	createProfile(name: string): Promise<string>
 	/**
 	 * Returns a list of all configured profile names
 	 * @returns Array of profile names
 	 */
 	getProfiles(): string[]
 	/**
-	 * Changes the active API configuration profile
-	 * @param name The name of the profile to activate
+	 * Returns the profile entry for a given name
+	 * @param name The name of the profile
+	 * @returns The profile entry, or undefined if the profile does not exist
+	 */
+	getProfileEntry(name: string): ProviderSettingsEntry | undefined
+	/**
+	 * Creates a new API configuration profile
+	 * @param name The name of the profile
+	 * @param profile The profile to create; defaults to an empty object
+	 * @param activate Whether to activate the profile after creation; defaults to true
+	 * @returns The ID of the created profile
+	 * @throws Error if the profile already exists
+	 */
+	createProfile(name: string, profile?: ProviderSettings, activate?: boolean): Promise<string>
+	/**
+	 * Updates an existing API configuration profile
+	 * @param name The name of the profile
+	 * @param profile The profile to update
+	 * @param activate Whether to activate the profile after update; defaults to true
+	 * @returns The ID of the updated profile
 	 * @throws Error if the profile does not exist
 	 */
-	setActiveProfile(name: string): Promise<void>
+	updateProfile(name: string, profile: ProviderSettings, activate?: boolean): Promise<string | undefined>
 	/**
-	 * Returns the name of the currently active profile
-	 * @returns The profile name, or undefined if no profile is active
+	 * Creates a new API configuration profile or updates an existing one
+	 * @param name The name of the profile
+	 * @param profile The profile to create or update; defaults to an empty object
+	 * @param activate Whether to activate the profile after upsert; defaults to true
+	 * @returns The ID of the upserted profile
 	 */
-	getActiveProfile(): string | undefined
+	upsertProfile(name: string, profile: ProviderSettings, activate?: boolean): Promise<string | undefined>
 	/**
 	 * Deletes a profile by name
 	 * @param name The name of the profile to delete
@@ -635,15 +686,23 @@ interface RooCodeAPI extends EventEmitter<RooCodeEvents> {
 	 */
 	deleteProfile(name: string): Promise<void>
 	/**
-	 * Returns true if the API is ready to use.
+	 * Returns the name of the currently active profile
+	 * @returns The profile name, or undefined if no profile is active
 	 */
-	isReady(): boolean
+	getActiveProfile(): string | undefined
+	/**
+	 * Changes the active API configuration profile
+	 * @param name The name of the profile to activate
+	 * @throws Error if the profile does not exist
+	 */
+	setActiveProfile(name: string): Promise<string | undefined>
 }
 
 export {
 	type ClineMessage,
 	type GlobalSettings,
 	type ProviderSettings,
+	type ProviderSettingsEntry,
 	type RooCodeAPI,
 	RooCodeEventName,
 	type RooCodeEvents,

+ 182 - 148
src/exports/types.ts

@@ -1,154 +1,6 @@
 // This file is automatically generated by running `npm run generate-types`
 // Do not edit it directly.
 
-type ProviderSettings = {
-	apiProvider?:
-		| (
-				| "kilocode"
-				| "fireworks"
-				| "anthropic"
-				| "glama"
-				| "openrouter"
-				| "bedrock"
-				| "vertex"
-				| "openai"
-				| "ollama"
-				| "vscode-lm"
-				| "lmstudio"
-				| "gemini"
-				| "openai-native"
-				| "mistral"
-				| "deepseek"
-				| "unbound"
-				| "requesty"
-				| "human-relay"
-				| "fake-ai"
-				| "xai"
-				| "groq"
-				| "chutes"
-				| "litellm"
-		  )
-		| undefined
-	apiModelId?: string | undefined
-	apiKey?: string | undefined
-	anthropicBaseUrl?: string | undefined
-	anthropicUseAuthToken?: boolean | undefined
-	glamaModelId?: string | undefined
-	glamaApiKey?: string | undefined
-	openRouterApiKey?: string | undefined
-	openRouterModelId?: string | undefined
-	openRouterBaseUrl?: string | undefined
-	openRouterSpecificProvider?: string | undefined
-	openRouterUseMiddleOutTransform?: boolean | undefined
-	awsAccessKey?: string | undefined
-	awsSecretKey?: string | undefined
-	awsSessionToken?: string | undefined
-	awsRegion?: string | undefined
-	awsUseCrossRegionInference?: boolean | undefined
-	awsUsePromptCache?: boolean | undefined
-	awsProfile?: string | undefined
-	awsUseProfile?: boolean | undefined
-	awsCustomArn?: string | undefined
-	vertexKeyFile?: string | undefined
-	vertexJsonCredentials?: string | undefined
-	vertexProjectId?: string | undefined
-	vertexRegion?: string | undefined
-	openAiBaseUrl?: string | undefined
-	openAiApiKey?: string | undefined
-	openAiLegacyFormat?: boolean | undefined
-	openAiR1FormatEnabled?: boolean | undefined
-	openAiModelId?: string | undefined
-	openAiCustomModelInfo?:
-		| ({
-				preferredIndex?: number | undefined
-				maxTokens?: (number | null) | undefined
-				maxThinkingTokens?: (number | null) | undefined
-				contextWindow: number
-				supportsImages?: boolean | undefined
-				supportsComputerUse?: boolean | undefined
-				supportsPromptCache: boolean
-				isPromptCacheOptional?: boolean | undefined
-				inputPrice?: number | undefined
-				outputPrice?: number | undefined
-				cacheWritesPrice?: number | undefined
-				cacheReadsPrice?: number | undefined
-				description?: string | undefined
-				reasoningEffort?: ("low" | "medium" | "high") | undefined
-				thinking?: boolean | undefined
-				minTokensPerCachePoint?: number | undefined
-				maxCachePoints?: number | undefined
-				cachableFields?: string[] | undefined
-				tiers?:
-					| {
-							contextWindow: number
-							inputPrice?: number | undefined
-							outputPrice?: number | undefined
-							cacheWritesPrice?: number | undefined
-							cacheReadsPrice?: number | undefined
-					  }[]
-					| undefined
-		  } | null)
-		| undefined
-	openAiUseAzure?: boolean | undefined
-	azureApiVersion?: string | undefined
-	openAiStreamingEnabled?: boolean | undefined
-	enableReasoningEffort?: boolean | undefined
-	openAiHostHeader?: string | undefined
-	openAiHeaders?:
-		| {
-				[x: string]: string
-		  }
-		| undefined
-	ollamaModelId?: string | undefined
-	ollamaBaseUrl?: string | undefined
-	vsCodeLmModelSelector?:
-		| {
-				vendor?: string | undefined
-				family?: string | undefined
-				version?: string | undefined
-				id?: string | undefined
-		  }
-		| undefined
-	lmStudioModelId?: string | undefined
-	lmStudioBaseUrl?: string | undefined
-	lmStudioDraftModelId?: string | undefined
-	lmStudioSpeculativeDecodingEnabled?: boolean | undefined
-	geminiApiKey?: string | undefined
-	googleGeminiBaseUrl?: string | undefined
-	openAiNativeApiKey?: string | undefined
-	openAiNativeBaseUrl?: string | undefined
-	mistralApiKey?: string | undefined
-	mistralCodestralUrl?: string | undefined
-	deepSeekBaseUrl?: string | undefined
-	deepSeekApiKey?: string | undefined
-	unboundApiKey?: string | undefined
-	unboundModelId?: string | undefined
-	requestyApiKey?: string | undefined
-	requestyModelId?: string | undefined
-	xaiApiKey?: string | undefined
-	groqApiKey?: string | undefined
-	chutesApiKey?: string | undefined
-	litellmBaseUrl?: string | undefined
-	litellmApiKey?: string | undefined
-	litellmModelId?: string | undefined
-	modelMaxTokens?: number | undefined
-	modelMaxThinkingTokens?: number | undefined
-	includeMaxTokens?: boolean | undefined
-	reasoningEffort?: ("low" | "medium" | "high") | undefined
-	promptCachingDisabled?: boolean | undefined
-	diffEnabled?: boolean | undefined
-	fuzzyMatchThreshold?: number | undefined
-	modelTemperature?: (number | null) | undefined
-	rateLimitSeconds?: number | undefined
-	fakeAi?: unknown | undefined
-	kilocodeToken?: string | undefined
-	kilocodeModel?: string | undefined
-	fireworksModelId?: string | undefined
-	fireworksApiKey?: string | undefined
-}
-
-export type { ProviderSettings }
-
 type GlobalSettings = {
 	currentApiConfigName?: string | undefined
 	listApiConfigMeta?:
@@ -325,6 +177,188 @@ type GlobalSettings = {
 
 export type { GlobalSettings }
 
+type ProviderSettings = {
+	apiProvider?:
+		| (
+				| "kilocode"
+				| "fireworks"
+				| "anthropic"
+				| "glama"
+				| "openrouter"
+				| "bedrock"
+				| "vertex"
+				| "openai"
+				| "ollama"
+				| "vscode-lm"
+				| "lmstudio"
+				| "gemini"
+				| "openai-native"
+				| "mistral"
+				| "deepseek"
+				| "unbound"
+				| "requesty"
+				| "human-relay"
+				| "fake-ai"
+				| "xai"
+				| "groq"
+				| "chutes"
+				| "litellm"
+		  )
+		| undefined
+	includeMaxTokens?: boolean | undefined
+	reasoningEffort?: ("low" | "medium" | "high") | undefined
+	promptCachingDisabled?: boolean | undefined
+	diffEnabled?: boolean | undefined
+	fuzzyMatchThreshold?: number | undefined
+	modelTemperature?: (number | null) | undefined
+	rateLimitSeconds?: number | undefined
+	modelMaxTokens?: number | undefined
+	modelMaxThinkingTokens?: number | undefined
+	kilocodeToken?: string | undefined
+	kilocodeModel?: string | undefined
+	fireworksModelId?: string | undefined
+	fireworksApiKey?: string | undefined
+	apiModelId?: string | undefined
+	apiKey?: string | undefined
+	anthropicBaseUrl?: string | undefined
+	anthropicUseAuthToken?: boolean | undefined
+	glamaModelId?: string | undefined
+	glamaApiKey?: string | undefined
+	openRouterApiKey?: string | undefined
+	openRouterModelId?: string | undefined
+	openRouterBaseUrl?: string | undefined
+	openRouterSpecificProvider?: string | undefined
+	openRouterUseMiddleOutTransform?: boolean | undefined
+	awsAccessKey?: string | undefined
+	awsSecretKey?: string | undefined
+	awsSessionToken?: string | undefined
+	awsRegion?: string | undefined
+	awsUseCrossRegionInference?: boolean | undefined
+	awsUsePromptCache?: boolean | undefined
+	awsProfile?: string | undefined
+	awsUseProfile?: boolean | undefined
+	awsCustomArn?: string | undefined
+	vertexKeyFile?: string | undefined
+	vertexJsonCredentials?: string | undefined
+	vertexProjectId?: string | undefined
+	vertexRegion?: string | undefined
+	openAiBaseUrl?: string | undefined
+	openAiApiKey?: string | undefined
+	openAiLegacyFormat?: boolean | undefined
+	openAiR1FormatEnabled?: boolean | undefined
+	openAiModelId?: string | undefined
+	openAiCustomModelInfo?:
+		| ({
+				preferredIndex?: number | undefined
+				maxTokens?: (number | null) | undefined
+				maxThinkingTokens?: (number | null) | undefined
+				contextWindow: number
+				supportsImages?: boolean | undefined
+				supportsComputerUse?: boolean | undefined
+				supportsPromptCache: boolean
+				isPromptCacheOptional?: boolean | undefined
+				inputPrice?: number | undefined
+				outputPrice?: number | undefined
+				cacheWritesPrice?: number | undefined
+				cacheReadsPrice?: number | undefined
+				description?: string | undefined
+				reasoningEffort?: ("low" | "medium" | "high") | undefined
+				thinking?: boolean | undefined
+				minTokensPerCachePoint?: number | undefined
+				maxCachePoints?: number | undefined
+				cachableFields?: string[] | undefined
+				tiers?:
+					| {
+							contextWindow: number
+							inputPrice?: number | undefined
+							outputPrice?: number | undefined
+							cacheWritesPrice?: number | undefined
+							cacheReadsPrice?: number | undefined
+					  }[]
+					| undefined
+		  } | null)
+		| undefined
+	openAiUseAzure?: boolean | undefined
+	azureApiVersion?: string | undefined
+	openAiStreamingEnabled?: boolean | undefined
+	enableReasoningEffort?: boolean | undefined
+	openAiHostHeader?: string | undefined
+	openAiHeaders?:
+		| {
+				[x: string]: string
+		  }
+		| undefined
+	ollamaModelId?: string | undefined
+	ollamaBaseUrl?: string | undefined
+	vsCodeLmModelSelector?:
+		| {
+				vendor?: string | undefined
+				family?: string | undefined
+				version?: string | undefined
+				id?: string | undefined
+		  }
+		| undefined
+	lmStudioModelId?: string | undefined
+	lmStudioBaseUrl?: string | undefined
+	lmStudioDraftModelId?: string | undefined
+	lmStudioSpeculativeDecodingEnabled?: boolean | undefined
+	geminiApiKey?: string | undefined
+	googleGeminiBaseUrl?: string | undefined
+	openAiNativeApiKey?: string | undefined
+	openAiNativeBaseUrl?: string | undefined
+	mistralApiKey?: string | undefined
+	mistralCodestralUrl?: string | undefined
+	deepSeekBaseUrl?: string | undefined
+	deepSeekApiKey?: string | undefined
+	unboundApiKey?: string | undefined
+	unboundModelId?: string | undefined
+	requestyApiKey?: string | undefined
+	requestyModelId?: string | undefined
+	fakeAi?: unknown | undefined
+	xaiApiKey?: string | undefined
+	groqApiKey?: string | undefined
+	chutesApiKey?: string | undefined
+	litellmBaseUrl?: string | undefined
+	litellmApiKey?: string | undefined
+	litellmModelId?: string | undefined
+}
+
+export type { ProviderSettings }
+
+type ProviderSettingsEntry = {
+	id: string
+	name: string
+	apiProvider?:
+		| (
+				| "kilocode"
+				| "fireworks"
+				| "anthropic"
+				| "glama"
+				| "openrouter"
+				| "bedrock"
+				| "vertex"
+				| "openai"
+				| "ollama"
+				| "vscode-lm"
+				| "lmstudio"
+				| "gemini"
+				| "openai-native"
+				| "mistral"
+				| "deepseek"
+				| "unbound"
+				| "requesty"
+				| "human-relay"
+				| "fake-ai"
+				| "xai"
+				| "groq"
+				| "chutes"
+				| "litellm"
+		  )
+		| undefined
+}
+
+export type { ProviderSettingsEntry }
+
 type ClineMessage = {
 	ts: number
 	type: "ask" | "say"

+ 9 - 4
src/extension.ts

@@ -14,17 +14,22 @@ try {
 
 import "./utils/path" // Necessary to have access to String.prototype.toPosix.
 
-import { initializeI18n } from "./i18n"
 import { ContextProxy } from "./core/config/ContextProxy"
 import { ClineProvider } from "./core/webview/ClineProvider"
-import { CodeActionProvider } from "./core/CodeActionProvider"
 import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
-import { McpServerManager } from "./services/mcp/McpServerManager"
 import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry"
+import { McpServerManager } from "./services/mcp/McpServerManager"
 import { API } from "./exports/api"
 import { migrateSettings } from "./utils/migrateSettings"
 
-import { handleUri, registerCommands, registerCodeActions, registerTerminalActions } from "./activate"
+import {
+	handleUri,
+	registerCommands,
+	registerCodeActions,
+	registerTerminalActions,
+	CodeActionProvider,
+} from "./activate"
+import { initializeI18n } from "./i18n"
 
 /**
  * Built using https://github.com/microsoft/vscode-webview-ui-toolkit

+ 0 - 0
src/core/EditorUtils.ts → src/integrations/editor/EditorUtils.ts


+ 1 - 1
src/core/__tests__/EditorUtils.test.ts → src/integrations/editor/__tests__/EditorUtils.test.ts

@@ -1,4 +1,4 @@
-// npx jest src/core/__tests__/EditorUtils.test.ts
+// npx jest src/integrations/editor/__tests__/EditorUtils.test.ts
 
 import * as vscode from "vscode"
 

+ 11 - 11
src/core/__tests__/read-file-tool.test.ts → src/integrations/misc/__tests__/read-file-tool.test.ts

@@ -1,21 +1,21 @@
-// npx jest src/core/__tests__/read-file-tool.test.ts
+// npx jest src/integrations/misc/__tests__/read-file-tool.test.ts
 
 import * as path from "path"
-import { countFileLines } from "../../integrations/misc/line-counter"
-import { readLines } from "../../integrations/misc/read-lines"
-import { extractTextFromFile, addLineNumbers } from "../../integrations/misc/extract-text"
+import { countFileLines } from "../line-counter"
+import { readLines } from "../read-lines"
+import { extractTextFromFile, addLineNumbers } from "../extract-text"
 
 // Mock the required functions
-jest.mock("../../integrations/misc/line-counter")
-jest.mock("../../integrations/misc/read-lines")
-jest.mock("../../integrations/misc/extract-text")
+jest.mock("../line-counter")
+jest.mock("../read-lines")
+jest.mock("../extract-text")
 
 describe("read_file tool with maxReadFileLine setting", () => {
 	// Mock original implementation first to use in tests
-	const originalCountFileLines = jest.requireActual("../../integrations/misc/line-counter").countFileLines
-	const originalReadLines = jest.requireActual("../../integrations/misc/read-lines").readLines
-	const originalExtractTextFromFile = jest.requireActual("../../integrations/misc/extract-text").extractTextFromFile
-	const originalAddLineNumbers = jest.requireActual("../../integrations/misc/extract-text").addLineNumbers
+	const originalCountFileLines = jest.requireActual("../line-counter").countFileLines
+	const originalReadLines = jest.requireActual("../read-lines").readLines
+	const originalExtractTextFromFile = jest.requireActual("../extract-text").extractTextFromFile
+	const originalAddLineNumbers = jest.requireActual("../extract-text").addLineNumbers
 
 	beforeEach(() => {
 		jest.resetAllMocks()

+ 154 - 54
src/schemas/index.ts

@@ -140,18 +140,6 @@ export const modelInfoSchema = z.object({
 
 export type ModelInfo = z.infer<typeof modelInfoSchema>
 
-/**
- * ApiConfigMeta
- */
-
-export const apiConfigMetaSchema = z.object({
-	id: z.string(),
-	name: z.string(),
-	apiProvider: providerNamesSchema.optional(),
-})
-
-export type ApiConfigMeta = z.infer<typeof apiConfigMetaSchema>
-
 /**
  * HistoryItem
  */
@@ -348,26 +336,64 @@ export type Experiments = z.infer<typeof experimentsSchema>
 type _AssertExperiments = AssertEqual<Equals<ExperimentId, Keys<Experiments>>>
 
 /**
- * ProviderSettings
+ * ProviderSettingsEntry
  */
 
-export const providerSettingsSchema = z.object({
+export const providerSettingsEntrySchema = z.object({
+	id: z.string(),
+	name: z.string(),
 	apiProvider: providerNamesSchema.optional(),
-	// Anthropic
+})
+
+export type ProviderSettingsEntry = z.infer<typeof providerSettingsEntrySchema>
+
+/**
+ * ProviderSettings
+ */
+
+const baseProviderSettingsSchema = z.object({
+	includeMaxTokens: z.boolean().optional(),
+	reasoningEffort: reasoningEffortsSchema.optional(),
+	promptCachingDisabled: z.boolean().optional(),
+	diffEnabled: z.boolean().optional(),
+	fuzzyMatchThreshold: z.number().optional(),
+	modelTemperature: z.number().nullish(),
+	rateLimitSeconds: z.number().optional(),
+	// Claude 3.7 Sonnet Thinking
+	modelMaxTokens: z.number().optional(),
+	modelMaxThinkingTokens: z.number().optional(),
+	// kilocode_change
+	kilocodeToken: z.string().optional(),
+	kilocodeModel: z.string().optional(),
+	fireworksModelId: z.string().optional(),
+	fireworksApiKey: z.string().optional(),
+})
+
+// Several of the providers share common model config properties.
+const apiModelIdProviderModelSchema = baseProviderSettingsSchema.extend({
 	apiModelId: z.string().optional(),
+})
+
+const anthropicSchema = apiModelIdProviderModelSchema.extend({
 	apiKey: z.string().optional(),
 	anthropicBaseUrl: z.string().optional(),
 	anthropicUseAuthToken: z.boolean().optional(),
-	// Glama
+})
+
+const glamaSchema = baseProviderSettingsSchema.extend({
 	glamaModelId: z.string().optional(),
 	glamaApiKey: z.string().optional(),
-	// OpenRouter
+})
+
+const openRouterSchema = baseProviderSettingsSchema.extend({
 	openRouterApiKey: z.string().optional(),
 	openRouterModelId: z.string().optional(),
 	openRouterBaseUrl: z.string().optional(),
 	openRouterSpecificProvider: z.string().optional(),
 	openRouterUseMiddleOutTransform: z.boolean().optional(),
-	// Amazon Bedrock
+})
+
+const bedrockSchema = apiModelIdProviderModelSchema.extend({
 	awsAccessKey: z.string().optional(),
 	awsSecretKey: z.string().optional(),
 	awsSessionToken: z.string().optional(),
@@ -377,12 +403,16 @@ export const providerSettingsSchema = z.object({
 	awsProfile: z.string().optional(),
 	awsUseProfile: z.boolean().optional(),
 	awsCustomArn: z.string().optional(),
-	// Google Vertex
+})
+
+const vertexSchema = apiModelIdProviderModelSchema.extend({
 	vertexKeyFile: z.string().optional(),
 	vertexJsonCredentials: z.string().optional(),
 	vertexProjectId: z.string().optional(),
 	vertexRegion: z.string().optional(),
-	// OpenAI
+})
+
+const openAiSchema = baseProviderSettingsSchema.extend({
 	openAiBaseUrl: z.string().optional(),
 	openAiApiKey: z.string().optional(),
 	openAiLegacyFormat: z.boolean().optional(),
@@ -393,12 +423,16 @@ export const providerSettingsSchema = z.object({
 	azureApiVersion: z.string().optional(),
 	openAiStreamingEnabled: z.boolean().optional(),
 	enableReasoningEffort: z.boolean().optional(),
-	openAiHostHeader: z.string().optional(), // Keep temporarily for backward compatibility during migration
+	openAiHostHeader: z.string().optional(), // Keep temporarily for backward compatibility during migration.
 	openAiHeaders: z.record(z.string(), z.string()).optional(),
-	// Ollama
+})
+
+const ollamaSchema = baseProviderSettingsSchema.extend({
 	ollamaModelId: z.string().optional(),
 	ollamaBaseUrl: z.string().optional(),
-	// VS Code LM
+})
+
+const vsCodeLmSchema = baseProviderSettingsSchema.extend({
 	vsCodeLmModelSelector: z
 		.object({
 			vendor: z.string().optional(),
@@ -407,59 +441,124 @@ export const providerSettingsSchema = z.object({
 			id: z.string().optional(),
 		})
 		.optional(),
-	// LM Studio
+})
+
+const lmStudioSchema = baseProviderSettingsSchema.extend({
 	lmStudioModelId: z.string().optional(),
 	lmStudioBaseUrl: z.string().optional(),
 	lmStudioDraftModelId: z.string().optional(),
 	lmStudioSpeculativeDecodingEnabled: z.boolean().optional(),
-	// Gemini
+})
+
+const geminiSchema = apiModelIdProviderModelSchema.extend({
 	geminiApiKey: z.string().optional(),
 	googleGeminiBaseUrl: z.string().optional(),
-	// OpenAI Native
+})
+
+const openAiNativeSchema = apiModelIdProviderModelSchema.extend({
 	openAiNativeApiKey: z.string().optional(),
 	openAiNativeBaseUrl: z.string().optional(),
-	// Mistral
+})
+
+const mistralSchema = apiModelIdProviderModelSchema.extend({
 	mistralApiKey: z.string().optional(),
 	mistralCodestralUrl: z.string().optional(),
-	// DeepSeek
+})
+
+const deepSeekSchema = apiModelIdProviderModelSchema.extend({
 	deepSeekBaseUrl: z.string().optional(),
 	deepSeekApiKey: z.string().optional(),
-	// Unbound
+})
+
+const unboundSchema = baseProviderSettingsSchema.extend({
 	unboundApiKey: z.string().optional(),
 	unboundModelId: z.string().optional(),
-	// Requesty
+})
+
+const requestySchema = baseProviderSettingsSchema.extend({
 	requestyApiKey: z.string().optional(),
 	requestyModelId: z.string().optional(),
-	// X.AI (Grok)
+})
+
+const humanRelaySchema = baseProviderSettingsSchema
+
+const fakeAiSchema = baseProviderSettingsSchema.extend({
+	fakeAi: z.unknown().optional(),
+})
+
+const xaiSchema = apiModelIdProviderModelSchema.extend({
 	xaiApiKey: z.string().optional(),
-	// Groq
+})
+
+const groqSchema = apiModelIdProviderModelSchema.extend({
 	groqApiKey: z.string().optional(),
-	// Chutes AI
+})
+
+const chutesSchema = apiModelIdProviderModelSchema.extend({
 	chutesApiKey: z.string().optional(),
-	// LiteLLM
+})
+
+const litellmSchema = baseProviderSettingsSchema.extend({
 	litellmBaseUrl: z.string().optional(),
 	litellmApiKey: z.string().optional(),
 	litellmModelId: z.string().optional(),
-	// Claude 3.7 Sonnet Thinking
-	modelMaxTokens: z.number().optional(),
-	modelMaxThinkingTokens: z.number().optional(),
-	// Generic
-	includeMaxTokens: z.boolean().optional(),
-	reasoningEffort: reasoningEffortsSchema.optional(),
-	promptCachingDisabled: z.boolean().optional(),
-	diffEnabled: z.boolean().optional(),
-	fuzzyMatchThreshold: z.number().optional(),
-	modelTemperature: z.number().nullish(),
-	rateLimitSeconds: z.number().optional(),
-	// Fake AI
-	fakeAi: z.unknown().optional(),
-	// kilocode_change
-	kilocodeToken: z.string().optional(),
-	kilocodeModel: z.string().optional(),
-	fireworksModelId: z.string().optional(),
-	fireworksApiKey: z.string().optional(),
 })
 
+const defaultSchema = z.object({
+	apiProvider: z.undefined(),
+})
+
+export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProvider", [
+	anthropicSchema.merge(z.object({ apiProvider: z.literal("anthropic") })),
+	glamaSchema.merge(z.object({ apiProvider: z.literal("glama") })),
+	openRouterSchema.merge(z.object({ apiProvider: z.literal("openrouter") })),
+	bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })),
+	vertexSchema.merge(z.object({ apiProvider: z.literal("vertex") })),
+	openAiSchema.merge(z.object({ apiProvider: z.literal("openai") })),
+	ollamaSchema.merge(z.object({ apiProvider: z.literal("ollama") })),
+	vsCodeLmSchema.merge(z.object({ apiProvider: z.literal("vscode-lm") })),
+	lmStudioSchema.merge(z.object({ apiProvider: z.literal("lmstudio") })),
+	geminiSchema.merge(z.object({ apiProvider: z.literal("gemini") })),
+	openAiNativeSchema.merge(z.object({ apiProvider: z.literal("openai-native") })),
+	mistralSchema.merge(z.object({ apiProvider: z.literal("mistral") })),
+	deepSeekSchema.merge(z.object({ apiProvider: z.literal("deepseek") })),
+	unboundSchema.merge(z.object({ apiProvider: z.literal("unbound") })),
+	requestySchema.merge(z.object({ apiProvider: z.literal("requesty") })),
+	humanRelaySchema.merge(z.object({ apiProvider: z.literal("human-relay") })),
+	fakeAiSchema.merge(z.object({ apiProvider: z.literal("fake-ai") })),
+	xaiSchema.merge(z.object({ apiProvider: z.literal("xai") })),
+	groqSchema.merge(z.object({ apiProvider: z.literal("groq") })),
+	chutesSchema.merge(z.object({ apiProvider: z.literal("chutes") })),
+	litellmSchema.merge(z.object({ apiProvider: z.literal("litellm") })),
+	defaultSchema,
+])
+
+export const providerSettingsSchema = z
+	.object({
+		apiProvider: providerNamesSchema.optional(),
+	})
+	.merge(anthropicSchema)
+	.merge(glamaSchema)
+	.merge(openRouterSchema)
+	.merge(bedrockSchema)
+	.merge(vertexSchema)
+	.merge(openAiSchema)
+	.merge(ollamaSchema)
+	.merge(vsCodeLmSchema)
+	.merge(lmStudioSchema)
+	.merge(geminiSchema)
+	.merge(openAiNativeSchema)
+	.merge(mistralSchema)
+	.merge(deepSeekSchema)
+	.merge(unboundSchema)
+	.merge(requestySchema)
+	.merge(humanRelaySchema)
+	.merge(fakeAiSchema)
+	.merge(xaiSchema)
+	.merge(groqSchema)
+	.merge(chutesSchema)
+	.merge(litellmSchema)
+
 export type ProviderSettings = z.infer<typeof providerSettingsSchema>
 
 type ProviderSettingsRecord = Record<Keys<ProviderSettings>, undefined>
@@ -574,7 +673,7 @@ export const PROVIDER_SETTINGS_KEYS = Object.keys(providerSettingsRecord) as Key
 
 export const globalSettingsSchema = z.object({
 	currentApiConfigName: z.string().optional(),
-	listApiConfigMeta: z.array(apiConfigMetaSchema).optional(),
+	listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(),
 	pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(),
 
 	lastShownAnnouncementId: z.string().optional(),
@@ -1000,8 +1099,9 @@ export type TypeDefinition = {
 }
 
 export const typeDefinitions: TypeDefinition[] = [
-	{ schema: providerSettingsSchema, identifier: "ProviderSettings" },
 	{ schema: globalSettingsSchema, identifier: "GlobalSettings" },
+	{ schema: providerSettingsSchema, identifier: "ProviderSettings" },
+	{ schema: providerSettingsEntrySchema, identifier: "ProviderSettingsEntry" },
 	{ schema: clineMessageSchema, identifier: "ClineMessage" },
 	{ schema: tokenUsageSchema, identifier: "TokenUsage" },
 	{ schema: rooCodeEventsSchema, identifier: "RooCodeEvents" },

+ 5 - 5
src/shared/ExtensionMessage.ts

@@ -2,8 +2,8 @@ import { GitCommit } from "../utils/git"
 
 import {
 	GlobalSettings,
-	ApiConfigMeta,
-	ProviderSettings as ApiConfiguration,
+	ProviderSettingsEntry,
+	ProviderSettings,
 	HistoryItem,
 	ModeConfig,
 	ExperimentId,
@@ -17,7 +17,7 @@ import { McpMarketplaceCatalog, McpDownloadResponse } from "./kilocode/mcp"
 import { Mode } from "./modes"
 import { RouterModels } from "./api"
 
-export type { ApiConfigMeta, ToolProgressStatus }
+export type { ProviderSettingsEntry, ToolProgressStatus }
 
 export interface LanguageModelChatSelector {
 	vendor?: string
@@ -98,7 +98,7 @@ export interface ExtensionMessage {
 	vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
 	mcpServers?: McpServer[]
 	commits?: GitCommit[]
-	listApiConfig?: ApiConfigMeta[]
+	listApiConfig?: ProviderSettingsEntry[]
 	mode?: Mode
 	customMode?: ModeConfig
 	slug?: string
@@ -184,7 +184,7 @@ export type ExtensionState = Pick<
 	version: string
 	clineMessages: ClineMessage[]
 	currentTaskItem?: HistoryItem
-	apiConfiguration?: ApiConfiguration
+	apiConfiguration?: ProviderSettings
 	uriScheme?: string
 	shouldShowAnnouncement: boolean
 

+ 2 - 3
src/shared/WebviewMessage.ts

@@ -1,6 +1,6 @@
 import { z } from "zod"
 
-import { ApiConfiguration } from "./api"
+import { ProviderSettings } from "./api"
 import { Mode, PromptComponent, ModeConfig } from "./modes"
 
 export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" | "retry_clicked" // kilocode_change: Added for payment required dialog
@@ -11,7 +11,6 @@ export type AudioType = "notification" | "celebration" | "progress_loop"
 
 export interface WebviewMessage {
 	type:
-		| "apiConfiguration"
 		| "deleteMultipleTasksWithIds"
 		| "currentApiConfigName"
 		| "saveApiConfiguration"
@@ -145,7 +144,7 @@ export interface WebviewMessage {
 	text?: string
 	disabled?: boolean
 	askResponse?: ClineAskResponse
-	apiConfiguration?: ApiConfiguration
+	apiConfiguration?: ProviderSettings
 	images?: string[]
 	bool?: boolean
 	value?: number

+ 6 - 6
src/shared/__tests__/checkExistApiConfig.test.ts

@@ -1,5 +1,5 @@
 import { checkExistKey } from "../checkExistApiConfig"
-import { ApiConfiguration } from "../api"
+import { ProviderSettings } from "../api"
 
 describe("checkExistKey", () => {
 	it("should return false for undefined config", () => {
@@ -7,19 +7,19 @@ describe("checkExistKey", () => {
 	})
 
 	it("should return false for empty config", () => {
-		const config: ApiConfiguration = {}
+		const config: ProviderSettings = {}
 		expect(checkExistKey(config)).toBe(false)
 	})
 
 	it("should return true when one key is defined", () => {
-		const config: ApiConfiguration = {
+		const config: ProviderSettings = {
 			apiKey: "test-key",
 		}
 		expect(checkExistKey(config)).toBe(true)
 	})
 
 	it("should return true when multiple keys are defined", () => {
-		const config: ApiConfiguration = {
+		const config: ProviderSettings = {
 			apiKey: "test-key",
 			glamaApiKey: "glama-key",
 			openRouterApiKey: "openrouter-key",
@@ -28,7 +28,7 @@ describe("checkExistKey", () => {
 	})
 
 	it("should return true when only non-key fields are undefined", () => {
-		const config: ApiConfiguration = {
+		const config: ProviderSettings = {
 			apiKey: "test-key",
 			apiProvider: undefined,
 			anthropicBaseUrl: undefined,
@@ -38,7 +38,7 @@ describe("checkExistKey", () => {
 	})
 
 	it("should return false when all key fields are undefined", () => {
-		const config: ApiConfiguration = {
+		const config: ProviderSettings = {
 			apiKey: undefined,
 			glamaApiKey: undefined,
 			openRouterApiKey: undefined,

+ 1 - 3
src/shared/api.ts

@@ -1,11 +1,9 @@
 import { ModelInfo, ProviderName, ProviderSettings } from "../schemas"
 
-export type { ModelInfo, ProviderName }
+export type { ModelInfo, ProviderName, ProviderSettings }
 
 export type ApiHandlerOptions = Omit<ProviderSettings, "apiProvider" | "id">
 
-export type ApiConfiguration = ProviderSettings
-
 // Anthropic
 // https://docs.anthropic.com/en/docs/about-claude/models
 export type AnthropicModelId = keyof typeof anthropicModels

+ 4 - 4
src/utils/__tests__/enhance-prompt.test.ts

@@ -1,5 +1,5 @@
 import { singleCompletionHandler } from "../single-completion-handler"
-import { ApiConfiguration } from "../../shared/api"
+import { ProviderSettings } from "../../shared/api"
 import { buildApiHandler, SingleCompletionHandler } from "../../api"
 import { supportPrompt } from "../../shared/support-prompt"
 
@@ -9,7 +9,7 @@ jest.mock("../../api", () => ({
 }))
 
 describe("enhancePrompt", () => {
-	const mockApiConfig: ApiConfiguration = {
+	const mockApiConfig: ProviderSettings = {
 		apiProvider: "openai",
 		openAiApiKey: "test-key",
 		openAiBaseUrl: "https://api.openai.com/v1",
@@ -69,7 +69,7 @@ describe("enhancePrompt", () => {
 	})
 
 	it("throws error for missing API configuration", async () => {
-		await expect(singleCompletionHandler({} as ApiConfiguration, "Test prompt")).rejects.toThrow(
+		await expect(singleCompletionHandler({} as ProviderSettings, "Test prompt")).rejects.toThrow(
 			"No valid API configuration provided",
 		)
 	})
@@ -94,7 +94,7 @@ describe("enhancePrompt", () => {
 	})
 
 	it("uses appropriate model based on provider", async () => {
-		const openRouterConfig: ApiConfiguration = {
+		const openRouterConfig: ProviderSettings = {
 			apiProvider: "openrouter",
 			openRouterApiKey: "test-key",
 			openRouterModelId: "test-model",

+ 2 - 2
src/utils/single-completion-handler.ts

@@ -1,11 +1,11 @@
-import { ApiConfiguration } from "../shared/api"
+import { ProviderSettings } from "../shared/api"
 import { buildApiHandler, SingleCompletionHandler } from "../api"
 
 /**
  * Enhances a prompt using the configured API without creating a full Cline instance or task history.
  * This is a lightweight alternative that only uses the API's completion functionality.
  */
-export async function singleCompletionHandler(apiConfiguration: ApiConfiguration, promptText: string): Promise<string> {
+export async function singleCompletionHandler(apiConfiguration: ProviderSettings, promptText: string): Promise<string> {
 	if (!promptText) {
 		throw new Error("No prompt text provided")
 	}

File diff suppressed because it is too large
+ 235 - 237
webview-ui/package-lock.json


+ 2 - 2
webview-ui/package.json

@@ -43,7 +43,7 @@
 		"i18next": "^24.2.2",
 		"i18next-http-backend": "^3.0.2",
 		"knuth-shuffle-seeded": "^1.0.6",
-		"lucide-react": "^0.475.0",
+		"lucide-react": "^0.510.0",
 		"mermaid": "^11.4.1",
 		"posthog-js": "^1.227.2",
 		"react": "^18.3.1",
@@ -97,6 +97,6 @@
 		"storybook-dark-mode": "^4.0.2",
 		"ts-jest": "^29.2.5",
 		"typescript": "^5.4.5",
-		"vite": "6.0.11"
+		"vite": "6.3.5"
 	}
 }

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

@@ -1050,6 +1050,7 @@ export const ChatRowContent = ({
 														)?.alwaysAllow || false,
 												}}
 												serverName={useMcpServer.serverName}
+												serverSource={server?.source}
 												alwaysAllowMcp={alwaysAllowMcp}
 											/>
 										</div>

+ 9 - 2
webview-ui/src/components/chat/ChatTextArea.tsx

@@ -1017,6 +1017,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 									"py-2",
 									"px-[9px]",
 									"z-10",
+									"forced-color-adjust-none",
 								)}
 								style={{
 									color: "transparent",
@@ -1270,8 +1271,14 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 
 									return (
 										<div className="flex justify-between gap-2 w-full h-5">
-											<div className={cn({ "font-medium": isCurrentConfig })}>{label}</div>
-											<div className="flex justify-end w-10">
+											<div
+												className={cn("truncate min-w-0 overflow-hidden", {
+													"font-medium": isCurrentConfig,
+												})}
+												title={label}>
+												{label}
+											</div>
+											<div className="flex justify-end w-10 flex-shrink-0">
 												<div
 													className={cn("size-5 p-1", {
 														"block group-hover:hidden": !pinned,

+ 34 - 48
webview-ui/src/components/chat/CommandExecution.tsx

@@ -1,6 +1,5 @@
-import { HTMLAttributes, useCallback, useEffect, useMemo, useState } from "react"
+import { useCallback, useState, memo } from "react"
 import { useEvent } from "react-use"
-import { Virtuoso } from "react-virtuoso"
 import { ChevronDown, Skull } from "lucide-react"
 
 import { CommandExecutionStatus, commandExecutionStatusSchema } from "@roo/schemas"
@@ -12,12 +11,24 @@ import { vscode } from "@src/utils/vscode"
 import { useExtensionState } from "@src/context/ExtensionStateContext"
 import { cn } from "@src/lib/utils"
 import { Button } from "@src/components/ui"
+import CodeBlock from "../common/CodeBlock"
 
 interface CommandExecutionProps {
 	executionId: string
 	text?: string
 }
 
+const parseCommandAndOutput = (text: string) => {
+	const index = text.indexOf(COMMAND_OUTPUT_STRING)
+	if (index === -1) {
+		return { command: text, output: "" }
+	}
+	return {
+		command: text.slice(0, index),
+		output: text.slice(index + COMMAND_OUTPUT_STRING.length),
+	}
+}
+
 export const CommandExecution = ({ executionId, text }: CommandExecutionProps) => {
 	const { terminalShellIntegrationDisabled = false } = useExtensionState()
 
@@ -26,13 +37,11 @@ export const CommandExecution = ({ executionId, text }: CommandExecutionProps) =
 	const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled)
 
 	const [status, setStatus] = useState<CommandExecutionStatus | null>(null)
-	const [output, setOutput] = useState("")
-	const [command, setCommand] = useState(text)
-
-	const lines = useMemo(
-		() => [`$ ${command}`, ...output.split("\n").filter((line) => line.trim() !== "")],
-		[output, command],
-	)
+	const { command: initialCommand, output: initialOutput } = text
+		? parseCommandAndOutput(text)
+		: { command: "", output: "" }
+	const [output, setOutput] = useState(initialOutput)
+	const [command, setCommand] = useState(initialCommand)
 
 	const onMessage = useCallback(
 		(event: MessageEvent) => {
@@ -54,7 +63,7 @@ export const CommandExecution = ({ executionId, text }: CommandExecutionProps) =
 							setStatus(data)
 							break
 						case "output":
-							setOutput((output) => output + data.output)
+							setOutput(data.output)
 							break
 						case "fallback":
 							setIsExpanded(true)
@@ -71,23 +80,10 @@ export const CommandExecution = ({ executionId, text }: CommandExecutionProps) =
 
 	useEvent("message", onMessage)
 
-	useEffect(() => {
-		if (!status && text) {
-			const index = text.indexOf(COMMAND_OUTPUT_STRING)
-
-			if (index === -1) {
-				setCommand(text)
-			} else {
-				setCommand(text.slice(0, index))
-				setOutput(text.slice(index + COMMAND_OUTPUT_STRING.length))
-			}
-		}
-	}, [status, text])
-
 	return (
 		<div className="w-full bg-vscode-editor-background border border-vscode-border rounded-xs p-2">
+			<CodeBlock source={text ? parseCommandAndOutput(text).command : command} language="shell" />
 			<div className="flex flex-row items-center justify-between gap-2 px-1">
-				<Line className="text-sm whitespace-nowrap overflow-hidden text-ellipsis">{command}</Line>
 				<div className="flex flex-row items-center gap-1">
 					{status?.status === "started" && (
 						<div className="flex flex-row items-center gap-2 font-mono text-xs">
@@ -115,7 +111,7 @@ export const CommandExecution = ({ executionId, text }: CommandExecutionProps) =
 							<div className="whitespace-nowrap">Exited ({status.exitCode})</div>
 						</div>
 					)}
-					{lines.length > 0 && (
+					{output.length > 0 && (
 						<Button variant="ghost" size="icon" onClick={() => setIsExpanded(!isExpanded)}>
 							<ChevronDown
 								className={cn("size-4 transition-transform duration-300", {
@@ -126,31 +122,21 @@ export const CommandExecution = ({ executionId, text }: CommandExecutionProps) =
 					)}
 				</div>
 			</div>
-			<div
-				className={cn("mt-1 pt-1 border-t border-border/25", { hidden: !isExpanded })}
-				style={{ height: Math.min((lines.length + 1) * 16, 200) }}>
-				{lines.length > 0 && (
-					<Virtuoso
-						className="h-full"
-						totalCount={lines.length}
-						itemContent={(i) => <Line className="text-sm">{lines[i]}</Line>}
-						followOutput="auto"
-					/>
-				)}
-			</div>
+			<MemoizedOutputContainer isExpanded={isExpanded} output={output} />
 		</div>
 	)
 }
 
-type LineProps = HTMLAttributes<HTMLDivElement>
-
-const Line = ({ className, ...props }: LineProps) => {
-	return (
-		<div
-			className={cn("font-mono text-vscode-editor-foreground whitespace-pre-wrap break-words", className)}
-			{...props}
-		/>
-	)
-}
-
 CommandExecution.displayName = "CommandExecution"
+
+const OutputContainer = ({ isExpanded, output }: { isExpanded: boolean; output: string }) => (
+	<div
+		className={cn("mt-1 pt-1 border-t border-border/25 overflow-hidden transition-[max-height] duration-300", {
+			"max-h-0": !isExpanded,
+			"max-h-[100%]": isExpanded,
+		})}>
+		{output.length > 0 && <CodeBlock source={output} language="log" />}
+	</div>
+)
+
+const MemoizedOutputContainer = memo(OutputContainer)

+ 2 - 2
webview-ui/src/components/chat/__tests__/TaskHeader.test.tsx

@@ -4,7 +4,7 @@ import React from "react"
 import { render, screen } from "@testing-library/react"
 import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
 
-import { ApiConfiguration } from "@roo/shared/api"
+import { ProviderSettings } from "@roo/shared/api"
 
 import TaskHeader, { TaskHeaderProps } from "../TaskHeader"
 
@@ -27,7 +27,7 @@ jest.mock("@src/context/ExtensionStateContext", () => ({
 			apiProvider: "anthropic",
 			apiKey: "test-api-key", // Add relevant fields
 			apiModelId: "claude-3-opus-20240229", // Add relevant fields
-		} as ApiConfiguration, // Optional: Add type assertion if ApiConfiguration is imported
+		} as ProviderSettings, // Optional: Add type assertion if ProviderSettings is imported
 		currentTaskItem: null,
 	}),
 }))

+ 35 - 14
webview-ui/src/components/prompts/PromptsView.tsx

@@ -1,7 +1,7 @@
 import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"
 import BottomControls from "../chat/BottomControls" // kilocode_change
 import { Button } from "@/components/ui/button"
-import { VSCodeCheckbox, VSCodeRadioGroup, VSCodeRadio } from "@vscode/webview-ui-toolkit/react"
+import { VSCodeCheckbox, VSCodeRadioGroup, VSCodeRadio, VSCodeTextArea } from "@vscode/webview-ui-toolkit/react"
 
 import { useExtensionState } from "@src/context/ExtensionStateContext"
 import {
@@ -28,7 +28,6 @@ import {
 	SelectItem,
 	SelectTrigger,
 	SelectValue,
-	Textarea,
 	Popover,
 	PopoverContent,
 	PopoverTrigger,
@@ -572,8 +571,30 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 														}}
 														data-testid={`mode-option-${modeConfig.slug}`}>
 														<div className="flex items-center justify-between w-full">
-															<span>{modeConfig.name}</span>
-															<span className="text-foreground">{modeConfig.slug}</span>
+															<span
+																style={{
+																	whiteSpace: "nowrap",
+																	overflow: "hidden",
+																	textOverflow: "ellipsis",
+																	flex: 2,
+																	minWidth: 0,
+																}}>
+																{modeConfig.name}
+															</span>
+															<span
+																className="text-foreground"
+																style={{
+																	whiteSpace: "nowrap",
+																	overflow: "hidden",
+																	textOverflow: "ellipsis",
+																	direction: "rtl",
+																	textAlign: "right",
+																	flex: 1,
+																	minWidth: 0,
+																	marginLeft: "0.5em",
+																}}>
+																{modeConfig.slug}
+															</span>
 														</div>
 													</CommandItem>
 												))}
@@ -645,7 +666,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 						<div className="text-sm text-vscode-descriptionForeground mb-2">
 							{t("prompts:roleDefinition.description")}
 						</div>
-						<Textarea
+						<VSCodeTextArea
 							value={(() => {
 								const customMode = findModeBySlug(visualMode, customModes)
 								const prompt = customModePrompts?.[visualMode] as PromptComponent
@@ -827,7 +848,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 								modeName: getCurrentMode()?.name || "Code",
 							})}
 						</div>
-						<Textarea
+						<VSCodeTextArea
 							value={(() => {
 								const customMode = findModeBySlug(visualMode, customModes)
 								const prompt = customModePrompts?.[visualMode] as PromptComponent
@@ -982,8 +1003,8 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 							language: i18next.language,
 						})}
 					</div>
-					<Textarea
-						value={customInstructions}
+					<VSCodeTextArea
+						value={customInstructions || ""}
 						onChange={(e) => {
 							const value =
 								(e as unknown as CustomEvent)?.detail?.target?.value ||
@@ -1060,7 +1081,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 							</Button>
 						</div>
 
-						<Textarea
+						<VSCodeTextArea
 							value={getSupportPromptValue(activeSupportOption)}
 							onChange={(e) => {
 								const value =
@@ -1120,7 +1141,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 								</div>
 
 								<div className="mt-4">
-									<Textarea
+									<VSCodeTextArea
 										value={testPrompt}
 										onChange={(e) => setTestPrompt((e.target as HTMLTextAreaElement).value)}
 										placeholder={t("prompts:supportPrompts.enhance.testPromptPlaceholder")}
@@ -1225,10 +1246,10 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									}}>
 									{t("prompts:createModeDialog.roleDefinition.description")}
 								</div>
-								<Textarea
+								<VSCodeTextArea
 									value={newModeRoleDefinition}
 									onChange={(e) => {
-										setNewModeRoleDefinition(e.target.value)
+										setNewModeRoleDefinition((e.target as HTMLTextAreaElement).value)
 									}}
 									rows={4}
 									className="w-full resize-y"
@@ -1276,10 +1297,10 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 								<div className="text-[13px] text-vscode-descriptionForeground mb-2">
 									{t("prompts:createModeDialog.customInstructions.description")}
 								</div>
-								<Textarea
+								<VSCodeTextArea
 									value={newModeCustomInstructions}
 									onChange={(e) => {
-										setNewModeCustomInstructions(e.target.value)
+										setNewModeCustomInstructions((e.target as HTMLTextAreaElement).value)
 									}}
 									rows={4}
 									className="w-full resize-y"

+ 2 - 0
webview-ui/src/components/prompts/__tests__/PromptsView.test.tsx

@@ -1,3 +1,5 @@
+// npx jest src/components/prompts/__tests__/PromptsView.test.tsx
+
 import { render, screen, fireEvent, waitFor } from "@testing-library/react"
 import PromptsView from "../PromptsView"
 import { ExtensionStateContext } from "@src/context/ExtensionStateContext"

+ 2 - 2
webview-ui/src/components/settings/ApiConfigManager.tsx

@@ -2,7 +2,7 @@ import { memo, useEffect, useRef, useState } from "react"
 import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
 import { ChevronsUpDown, Check, X } from "lucide-react"
 
-import { ApiConfigMeta } from "@roo/shared/ExtensionMessage"
+import { ProviderSettingsEntry } from "@roo/shared/ExtensionMessage"
 
 import { useAppTranslation } from "@/i18n/TranslationContext"
 import { cn } from "@/lib/utils"
@@ -25,7 +25,7 @@ import {
 
 interface ApiConfigManagerProps {
 	currentApiConfigName?: string
-	listApiConfigMeta?: ApiConfigMeta[]
+	listApiConfigMeta?: ProviderSettingsEntry[]
 	onSelectConfig: (configName: string) => void
 	onDeleteConfig: (configName: string) => void
 	onRenameConfig: (oldName: string, newName: string) => void

+ 6 - 24
webview-ui/src/components/settings/ApiOptions.tsx

@@ -1,4 +1,5 @@
 import React, { memo, useCallback, useEffect, useMemo, useState } from "react"
+import { convertHeadersToObject } from "./utils/headers"
 import { useDebounce } from "react-use"
 import { VSCodeButtonLink } from "../common/VSCodeButtonLink"
 import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
@@ -7,7 +8,7 @@ import { getKiloCodeBackendAuthUrl } from "../kilocode/helpers" // kilocode_chan
 
 import {
 	type ProviderName,
-	type ApiConfiguration,
+	type ProviderSettings,
 	openRouterDefaultModelId,
 	requestyDefaultModelId,
 	glamaDefaultModelId,
@@ -66,8 +67,8 @@ import { BedrockCustomArn } from "./providers/BedrockCustomArn"
 
 export interface ApiOptionsProps {
 	uriScheme: string | undefined
-	apiConfiguration: ApiConfiguration
-	setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
+	apiConfiguration: ProviderSettings
+	setApiConfigurationField: <K extends keyof ProviderSettings>(field: K, value: ProviderSettings[K]) => void
 	fromWelcomeView?: boolean
 	errorMessage: string | undefined
 	setErrorMessage: React.Dispatch<React.SetStateAction<string | undefined>>
@@ -99,25 +100,6 @@ const ApiOptions = ({
 	}, [apiConfiguration?.openAiHeaders, customHeaders])
 
 	// Helper to convert array of tuples to object (filtering out empty keys).
-	const convertHeadersToObject = (headers: [string, string][]): Record<string, string> => {
-		const result: Record<string, string> = {}
-
-		// Process each header tuple.
-		for (const [key, value] of headers) {
-			const trimmedKey = key.trim()
-
-			// Skip empty keys.
-			if (!trimmedKey) {
-				continue
-			}
-
-			// For duplicates, the last one in the array wins.
-			// This matches how HTTP headers work in general.
-			result[trimmedKey] = value.trim()
-		}
-
-		return result
-	}
 
 	// Debounced effect to update the main configuration when local
 	// customHeaders state stabilizes.
@@ -138,9 +120,9 @@ const ApiOptions = ({
 	const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
 
 	const handleInputChange = useCallback(
-		<K extends keyof ApiConfiguration, E>(
+		<K extends keyof ProviderSettings, E>(
 			field: K,
-			transform: (event: E) => ApiConfiguration[K] = inputEventTransform,
+			transform: (event: E) => ProviderSettings[K] = inputEventTransform,
 		) =>
 			(event: E | Event) => {
 				setApiConfigurationField(field, transform(event as E))

+ 3 - 3
webview-ui/src/components/settings/PromptCachingControl.tsx

@@ -1,12 +1,12 @@
 import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
 
-import { ApiConfiguration } from "@roo/shared/api"
+import { ProviderSettings } from "@roo/shared/api"
 
 import { useAppTranslation } from "@src/i18n/TranslationContext"
 
 interface PromptCachingControlProps {
-	apiConfiguration: ApiConfiguration
-	setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
+	apiConfiguration: ProviderSettings
+	setApiConfigurationField: <K extends keyof ProviderSettings>(field: K, value: ProviderSettings[K]) => void
 }
 
 export const PromptCachingControl = ({ apiConfiguration, setApiConfigurationField }: PromptCachingControlProps) => {

+ 3 - 3
webview-ui/src/components/settings/ReasoningEffort.tsx

@@ -2,12 +2,12 @@ import { useAppTranslation } from "@/i18n/TranslationContext"
 
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui"
 
-import { ApiConfiguration } from "@roo/shared/api"
+import { ProviderSettings } from "@roo/shared/api"
 import { reasoningEfforts, ReasoningEffort as ReasoningEffortType } from "@roo/schemas"
 
 interface ReasoningEffortProps {
-	apiConfiguration: ApiConfiguration
-	setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
+	apiConfiguration: ProviderSettings
+	setApiConfigurationField: <K extends keyof ProviderSettings>(field: K, value: ProviderSettings[K]) => void
 }
 
 export const ReasoningEffort = ({ apiConfiguration, setApiConfigurationField }: ReasoningEffortProps) => {

+ 2 - 2
webview-ui/src/components/settings/SettingsView.tsx

@@ -30,7 +30,7 @@ import {
 import { ensureBodyPointerEventsRestored } from "@/utils/fixPointerEvents"
 
 import { ExperimentId } from "@roo/shared/experiments"
-import { ApiConfiguration } from "@roo/shared/api"
+import { ProviderSettings } from "@roo/shared/api"
 
 import { vscode } from "@/utils/vscode"
 import { ExtensionStateContextType, useExtensionState } from "@/context/ExtensionStateContext"
@@ -240,7 +240,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
 	}, [])
 
 	const setApiConfigurationField = useCallback(
-		<K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => {
+		<K extends keyof ProviderSettings>(field: K, value: ProviderSettings[K]) => {
 			setCachedState((prevState) => {
 				if (prevState.apiConfiguration?.[field] === value) {
 					return prevState

+ 3 - 3
webview-ui/src/components/settings/ThinkingBudget.tsx

@@ -3,14 +3,14 @@ import { useAppTranslation } from "@/i18n/TranslationContext"
 
 import { Slider } from "@/components/ui"
 
-import { ApiConfiguration, ModelInfo } from "@roo/shared/api"
+import { ProviderSettings, ModelInfo } from "@roo/shared/api"
 
 const DEFAULT_MAX_OUTPUT_TOKENS = 16_384
 const DEFAULT_MAX_THINKING_TOKENS = 8_192
 
 interface ThinkingBudgetProps {
-	apiConfiguration: ApiConfiguration
-	setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
+	apiConfiguration: ProviderSettings
+	setApiConfigurationField: <K extends keyof ProviderSettings>(field: K, value: ProviderSettings[K]) => void
 	modelInfo?: ModelInfo
 }
 

+ 2 - 2
webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx

@@ -3,7 +3,7 @@
 import { render, screen, fireEvent } from "@testing-library/react"
 import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
 
-import { ApiConfiguration } from "@roo/shared/api"
+import { ProviderSettings } from "@roo/shared/api"
 
 import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext"
 import { openAiModelInfoSaneDefaults } from "@roo/shared/api"
@@ -148,7 +148,7 @@ jest.mock("../DiffSettingsControl", () => ({
 }))
 
 jest.mock("@src/components/ui/hooks/useSelectedModel", () => ({
-	useSelectedModel: jest.fn((apiConfiguration: ApiConfiguration) => {
+	useSelectedModel: jest.fn((apiConfiguration: ProviderSettings) => {
 		if (apiConfiguration.apiModelId?.includes("thinking")) {
 			return {
 				provider: apiConfiguration.apiProvider,

+ 5 - 5
webview-ui/src/components/settings/providers/Anthropic.tsx

@@ -2,7 +2,7 @@ import { useCallback, useState } from "react"
 import { Checkbox } from "vscrui"
 import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
 
-import { ApiConfiguration } from "@roo/shared/api"
+import { ProviderSettings } from "@roo/shared/api"
 
 import { useAppTranslation } from "@src/i18n/TranslationContext"
 import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
@@ -10,8 +10,8 @@ import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
 import { inputEventTransform, noTransform } from "../transforms"
 
 type AnthropicProps = {
-	apiConfiguration: ApiConfiguration
-	setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void
+	apiConfiguration: ProviderSettings
+	setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
 }
 
 export const Anthropic = ({ apiConfiguration, setApiConfigurationField }: AnthropicProps) => {
@@ -20,9 +20,9 @@ export const Anthropic = ({ apiConfiguration, setApiConfigurationField }: Anthro
 	const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
 
 	const handleInputChange = useCallback(
-		<K extends keyof ApiConfiguration, E>(
+		<K extends keyof ProviderSettings, E>(
 			field: K,
-			transform: (event: E) => ApiConfiguration[K] = inputEventTransform,
+			transform: (event: E) => ProviderSettings[K] = inputEventTransform,
 		) =>
 			(event: E | Event) => {
 				setApiConfigurationField(field, transform(event as E))

+ 5 - 5
webview-ui/src/components/settings/providers/Bedrock.tsx

@@ -2,7 +2,7 @@ import { useCallback } from "react"
 import { Checkbox } from "vscrui"
 import { VSCodeTextField, VSCodeRadio, VSCodeRadioGroup } from "@vscode/webview-ui-toolkit/react"
 
-import { ApiConfiguration, ModelInfo } from "@roo/shared/api"
+import { ProviderSettings, ModelInfo } from "@roo/shared/api"
 
 import { useAppTranslation } from "@src/i18n/TranslationContext"
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui"
@@ -11,8 +11,8 @@ import { AWS_REGIONS } from "../constants"
 import { inputEventTransform, noTransform } from "../transforms"
 
 type BedrockProps = {
-	apiConfiguration: ApiConfiguration
-	setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void
+	apiConfiguration: ProviderSettings
+	setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
 	selectedModelInfo?: ModelInfo
 }
 
@@ -20,9 +20,9 @@ export const Bedrock = ({ apiConfiguration, setApiConfigurationField, selectedMo
 	const { t } = useAppTranslation()
 
 	const handleInputChange = useCallback(
-		<K extends keyof ApiConfiguration, E>(
+		<K extends keyof ProviderSettings, E>(
 			field: K,
-			transform: (event: E) => ApiConfiguration[K] = inputEventTransform,
+			transform: (event: E) => ProviderSettings[K] = inputEventTransform,
 		) =>
 			(event: E | Event) => {
 				setApiConfigurationField(field, transform(event as E))

+ 3 - 3
webview-ui/src/components/settings/providers/BedrockCustomArn.tsx

@@ -1,14 +1,14 @@
 import { useMemo } from "react"
 import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
 
-import { ApiConfiguration } from "@roo/shared/api"
+import { ProviderSettings } from "@roo/shared/api"
 
 import { validateBedrockArn } from "@src/utils/validate"
 import { useAppTranslation } from "@src/i18n/TranslationContext"
 
 type BedrockCustomArnProps = {
-	apiConfiguration: ApiConfiguration
-	setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void
+	apiConfiguration: ProviderSettings
+	setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
 }
 
 export const BedrockCustomArn = ({ apiConfiguration, setApiConfigurationField }: BedrockCustomArnProps) => {

+ 5 - 5
webview-ui/src/components/settings/providers/Chutes.tsx

@@ -1,7 +1,7 @@
 import { useCallback } from "react"
 import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
 
-import { ApiConfiguration } from "@roo/shared/api"
+import { ProviderSettings } from "@roo/shared/api"
 
 import { useAppTranslation } from "@src/i18n/TranslationContext"
 import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
@@ -9,17 +9,17 @@ import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
 import { inputEventTransform } from "../transforms"
 
 type ChutesProps = {
-	apiConfiguration: ApiConfiguration
-	setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void
+	apiConfiguration: ProviderSettings
+	setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
 }
 
 export const Chutes = ({ apiConfiguration, setApiConfigurationField }: ChutesProps) => {
 	const { t } = useAppTranslation()
 
 	const handleInputChange = useCallback(
-		<K extends keyof ApiConfiguration, E>(
+		<K extends keyof ProviderSettings, E>(
 			field: K,
-			transform: (event: E) => ApiConfiguration[K] = inputEventTransform,
+			transform: (event: E) => ProviderSettings[K] = inputEventTransform,
 		) =>
 			(event: E | Event) => {
 				setApiConfigurationField(field, transform(event as E))

+ 5 - 5
webview-ui/src/components/settings/providers/DeepSeek.tsx

@@ -1,7 +1,7 @@
 import { useCallback } from "react"
 import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
 
-import { ApiConfiguration } from "@roo/shared/api"
+import { ProviderSettings } from "@roo/shared/api"
 
 import { useAppTranslation } from "@src/i18n/TranslationContext"
 import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
@@ -9,17 +9,17 @@ import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
 import { inputEventTransform } from "../transforms"
 
 type DeepSeekProps = {
-	apiConfiguration: ApiConfiguration
-	setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void
+	apiConfiguration: ProviderSettings
+	setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
 }
 
 export const DeepSeek = ({ apiConfiguration, setApiConfigurationField }: DeepSeekProps) => {
 	const { t } = useAppTranslation()
 
 	const handleInputChange = useCallback(
-		<K extends keyof ApiConfiguration, E>(
+		<K extends keyof ProviderSettings, E>(
 			field: K,
-			transform: (event: E) => ApiConfiguration[K] = inputEventTransform,
+			transform: (event: E) => ProviderSettings[K] = inputEventTransform,
 		) =>
 			(event: E | Event) => {
 				setApiConfigurationField(field, transform(event as E))

+ 5 - 5
webview-ui/src/components/settings/providers/Gemini.tsx

@@ -2,7 +2,7 @@ import { useCallback, useState } from "react"
 import { Checkbox } from "vscrui"
 import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
 
-import { ApiConfiguration } from "@roo/shared/api"
+import { ProviderSettings } from "@roo/shared/api"
 
 import { useAppTranslation } from "@src/i18n/TranslationContext"
 import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
@@ -10,8 +10,8 @@ import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
 import { inputEventTransform } from "../transforms"
 
 type GeminiProps = {
-	apiConfiguration: ApiConfiguration
-	setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void
+	apiConfiguration: ProviderSettings
+	setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
 }
 
 export const Gemini = ({ apiConfiguration, setApiConfigurationField }: GeminiProps) => {
@@ -22,9 +22,9 @@ export const Gemini = ({ apiConfiguration, setApiConfigurationField }: GeminiPro
 	)
 
 	const handleInputChange = useCallback(
-		<K extends keyof ApiConfiguration, E>(
+		<K extends keyof ProviderSettings, E>(
 			field: K,
-			transform: (event: E) => ApiConfiguration[K] = inputEventTransform,
+			transform: (event: E) => ProviderSettings[K] = inputEventTransform,
 		) =>
 			(event: E | Event) => {
 				setApiConfigurationField(field, transform(event as E))

+ 5 - 5
webview-ui/src/components/settings/providers/Glama.tsx

@@ -1,7 +1,7 @@
 import { useCallback } from "react"
 import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
 
-import { ApiConfiguration, RouterModels, glamaDefaultModelId } from "@roo/shared/api"
+import { ProviderSettings, RouterModels, glamaDefaultModelId } from "@roo/shared/api"
 
 import { useAppTranslation } from "@src/i18n/TranslationContext"
 import { getGlamaAuthUrl } from "@src/oauth/urls"
@@ -11,8 +11,8 @@ import { inputEventTransform } from "../transforms"
 import { ModelPicker } from "../ModelPicker"
 
 type GlamaProps = {
-	apiConfiguration: ApiConfiguration
-	setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void
+	apiConfiguration: ProviderSettings
+	setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
 	routerModels?: RouterModels
 	uriScheme?: string
 }
@@ -21,9 +21,9 @@ export const Glama = ({ apiConfiguration, setApiConfigurationField, routerModels
 	const { t } = useAppTranslation()
 
 	const handleInputChange = useCallback(
-		<K extends keyof ApiConfiguration, E>(
+		<K extends keyof ProviderSettings, E>(
 			field: K,
-			transform: (event: E) => ApiConfiguration[K] = inputEventTransform,
+			transform: (event: E) => ProviderSettings[K] = inputEventTransform,
 		) =>
 			(event: E | Event) => {
 				setApiConfigurationField(field, transform(event as E))

+ 5 - 5
webview-ui/src/components/settings/providers/Groq.tsx

@@ -1,7 +1,7 @@
 import { useCallback } from "react"
 import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
 
-import { ApiConfiguration } from "@roo/shared/api"
+import { ProviderSettings } from "@roo/shared/api"
 
 import { useAppTranslation } from "@src/i18n/TranslationContext"
 import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
@@ -9,17 +9,17 @@ import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
 import { inputEventTransform } from "../transforms"
 
 type GroqProps = {
-	apiConfiguration: ApiConfiguration
-	setApiConfigurationField: (field: keyof ApiConfiguration, value: ApiConfiguration[keyof ApiConfiguration]) => void
+	apiConfiguration: ProviderSettings
+	setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
 }
 
 export const Groq = ({ apiConfiguration, setApiConfigurationField }: GroqProps) => {
 	const { t } = useAppTranslation()
 
 	const handleInputChange = useCallback(
-		<K extends keyof ApiConfiguration, E>(
+		<K extends keyof ProviderSettings, E>(
 			field: K,
-			transform: (event: E) => ApiConfiguration[K] = inputEventTransform,
+			transform: (event: E) => ProviderSettings[K] = inputEventTransform,
 		) =>
 			(event: E | Event) => {
 				setApiConfigurationField(field, transform(event as E))

Some files were not shown because too many files changed in this diff