Merge branch 'main' into cline-terminals-fix
9
.github/workflows/test.yml
vendored
|
|
@ -6,6 +6,12 @@ on:
|
|||
branches:
|
||||
- main
|
||||
|
||||
# Set default permissions for all jobs
|
||||
permissions:
|
||||
contents: read # Needed to check out code
|
||||
checks: write # Needed to report test results
|
||||
pull-requests: write # Needed to add comments/annotations to PRs
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -51,6 +57,5 @@ jobs:
|
|||
- name: Prettier / Format Check
|
||||
run: npm run format
|
||||
|
||||
- name: Tests
|
||||
- name: Extension Tests
|
||||
run: xvfb-run -a npm run test
|
||||
if: runner.os == 'Linux'
|
||||
|
|
|
|||
3
.gitignore
vendored
|
|
@ -1,7 +1,10 @@
|
|||
out
|
||||
dist
|
||||
node_modules
|
||||
tmp
|
||||
.vscode-test/
|
||||
*.vsix
|
||||
|
||||
.DS_Store
|
||||
|
||||
pnpm-lock.yaml
|
||||
17
.husky/pre-commit
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
echo "Running pre-commit checks..."
|
||||
|
||||
# Run ESLint
|
||||
echo "Running ESLint..."
|
||||
npm run lint || {
|
||||
echo "❌ ESLint check failed. Please fix the errors and try committing again."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Run Prettier
|
||||
echo "Running Prettier..."
|
||||
npm run format || {
|
||||
echo "❌ Prettier check failed. Run 'npm run format:fix' to automatically fix formatting issues."
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "✅ All checks passed!"
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
dist/
|
||||
node_modules
|
||||
webview-ui/build/
|
||||
CHANGELOG.md
|
||||
package-lock.json
|
||||
*.md
|
||||
package-lock.json
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import { defineConfig } from "@vscode/test-cli"
|
||||
import path from "path"
|
||||
|
||||
export default defineConfig({
|
||||
files: "out/**/*.test.js",
|
||||
files: "{out/test/**/*.test.js,src/test/suite/**/*.test.js}",
|
||||
mocha: {
|
||||
ui: "bdd",
|
||||
timeout: 20000, // Maximum time (in ms) that a test can run before failing
|
||||
},
|
||||
workspaceFolder: "test-workspace",
|
||||
version: "stable",
|
||||
extensionDevelopmentPath: path.resolve("./"),
|
||||
launchArgs: ["--disable-extensions"],
|
||||
})
|
||||
|
|
|
|||
28
CHANGELOG.md
|
|
@ -1,5 +1,33 @@
|
|||
# Change Log
|
||||
|
||||
## [3.2.5]
|
||||
|
||||
- Use yellow textfield outline in Plan mode to better distinguish from Act mode
|
||||
|
||||
## [3.2.3]
|
||||
|
||||
- Add DeepSeek-R1 (deepseek-reasoner) model support with proper parameter handling (thanks @slavakurilyak!)
|
||||
|
||||
## [3.2.0]
|
||||
|
||||
- Add Plan/Act mode toggle to let you plan tasks with Cline before letting him get to work
|
||||
- Easily switch between API providers and models using a new popup menu under the chat field
|
||||
- Add VS Code LM API provider to run models provided by other VS Code extensions (e.g. GitHub Copilot). Shoutout to @julesmons, @RaySinner, and @MrUbens for putting this together!
|
||||
- Add on/off toggle for MCP servers to disable them when not in use. Thanks @MrUbens!
|
||||
- Add Auto-approve option for individual tools in MCP servers. Thanks @MrUbens!
|
||||
|
||||
## [3.1.10]
|
||||
|
||||
- New icon!
|
||||
|
||||
## [3.1.9]
|
||||
|
||||
- Add Mistral API provider with codestral-latest model
|
||||
|
||||
## [3.1.7]
|
||||
|
||||
- Add ability to change viewport size and headless mode when Cline asks to launch the browser
|
||||
|
||||
## [3.1.6]
|
||||
|
||||
- Fix bug where filepaths with Chinese characters would not show up in context mention menu (thanks @chi-chat!)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ If you're planning to work on a bigger feature, please create a [feature request
|
|||
- If you dismissed the prompts, you can install them manually from the Extensions panel
|
||||
|
||||
2. **Local Development**
|
||||
- Run `npm install` to install dependencies
|
||||
- Run `npm run install:all` to install dependencies
|
||||
- Run `npm run test` to run tests locally
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@
|
|||
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong>Download on VS Marketplace</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://discord.gg/cline" target="_blank"><strong>Join the Discord</strong></a>
|
||||
<a href="https://discord.gg/cline" target="_blank"><strong>Discord</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://www.reddit.com/r/cline/" target="_blank"><strong>r/cline</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>Feature Requests</strong></a>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 4.9 KiB |
16
assets/icons/icon.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="92px" height="96px" viewBox="0 0 92 96" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Group Copy 2</title>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="icon-copy" transform="translate(-34, -40)" fill="#24292F">
|
||||
<g id="Group-Copy-2" transform="translate(34, 40.5)">
|
||||
<g id="Group-3-Copy-4" transform="translate(0, 0)">
|
||||
<path d="M65.4492701,16.3 C76.3374701,16.3 85.1635558,25.16479 85.1635558,36.1 L85.1635558,42.7 L90.9027661,54.1647464 C91.4694141,55.2966923 91.4668177,56.6300535 90.8957658,57.7597839 L85.1635558,69.1 L85.1635558,75.7 C85.1635558,86.63554 76.3374701,95.5 65.4492701,95.5 L26.0206986,95.5 C15.1328272,95.5 6.30641291,86.63554 6.30641291,75.7 L6.30641291,69.1 L0.448507752,57.7954874 C-0.14693501,56.6464093 -0.149634367,55.2802504 0.441262896,54.1288283 L6.30641291,42.7 L6.30641291,36.1 C6.30641291,25.16479 15.1328272,16.3 26.0206986,16.3 L65.4492701,16.3 Z M62.9301895,22 L29.189529,22 C19.8723267,22 12.3191987,29.5552188 12.3191987,38.875 L12.3191987,44.5 L7.44288578,53.9634655 C6.84794449,55.1180686 6.85066096,56.4896598 7.45017099,57.6418974 L12.3191987,67 L12.3191987,72.625 C12.3191987,81.9450625 19.8723267,89.5 29.189529,89.5 L62.9301895,89.5 C72.2476729,89.5 79.8005198,81.9450625 79.8005198,72.625 L79.8005198,67 L84.5682187,57.6061395 C85.1432011,56.473244 85.1458141,55.1345713 84.5752587,53.9994398 L79.8005198,44.5 L79.8005198,38.875 C79.8005198,29.5552188 72.2476729,22 62.9301895,22 Z" id="Combined-Shape" fill-rule="nonzero"></path>
|
||||
<circle id="Oval" cx="45.7349843" cy="11" r="11"></circle>
|
||||
</g>
|
||||
<rect id="Rectangle-Copy" stroke="#24292F" stroke-width="8" x="31" y="44.5" width="5" height="22" rx="2.5"></rect>
|
||||
<rect id="Rectangle-Copy-2" stroke="#24292F" stroke-width="8" x="55" y="44.5" width="5" height="22" rx="2.5"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 718 B After Width: | Height: | Size: 902 B |
|
Before Width: | Height: | Size: 689 B After Width: | Height: | Size: 666 B |
|
|
@ -35,7 +35,7 @@ STOP! Before proceeding, you MUST verify these requirements:
|
|||
1. From the Cline extension, click the `MCP Server` tab
|
||||
1. Click the `Edit MCP Settings` button
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/abf908b1-be98-4894-8dc7-ef3d27943a47" alt="MCP Server Panel" width="400" />
|
||||
<img src="https://github.com/user-attachments/assets/abf908b1-be98-4894-8dc7-ef3d27943a47" alt="MCP Server Panel" width="400" />
|
||||
|
||||
1. The MCP settings files should be display in a tab in VS Code.
|
||||
1. Replce the file's contents with this code:
|
||||
|
|
|
|||
1034
package-lock.json
generated
36
package.json
|
|
@ -2,7 +2,7 @@
|
|||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.1.6",
|
||||
"version": "3.2.5",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"galleryBanner": {
|
||||
"color": "#617A91",
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
{
|
||||
"id": "claude-dev-ActivityBar",
|
||||
"title": "Cline",
|
||||
"icon": "$(robot)"
|
||||
"icon": "assets/icons/icon.svg"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -124,6 +124,30 @@
|
|||
"when": "view == claude-dev.SidebarProvider"
|
||||
}
|
||||
]
|
||||
},
|
||||
"configuration": {
|
||||
"title": "Cline",
|
||||
"properties": {
|
||||
"cline.vsCodeLmModelSelector": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"vendor": {
|
||||
"type": "string",
|
||||
"description": "The vendor of the language model (e.g. copilot)"
|
||||
},
|
||||
"family": {
|
||||
"type": "string",
|
||||
"description": "The family of the language model (e.g. gpt-4)"
|
||||
}
|
||||
},
|
||||
"description": "Settings for VSCode Language Model API"
|
||||
},
|
||||
"cline.mcp.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Include MCP server functionality in AI prompts. When disabled, the AI will not be aware of MCP capabilities. This saves context window tokens."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
|
@ -145,9 +169,11 @@
|
|||
"start:webview": "cd webview-ui && npm run start",
|
||||
"build:webview": "cd webview-ui && npm run build",
|
||||
"test:webview": "cd webview-ui && npm run test",
|
||||
"publish:marketplace": "vsce publish && ovsx publish"
|
||||
"publish:marketplace": "vsce publish && ovsx publish",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^5.0.1",
|
||||
"@types/diff": "^5.2.1",
|
||||
"@types/mocha": "^10.0.7",
|
||||
"@types/node": "20.x",
|
||||
|
|
@ -157,8 +183,10 @@
|
|||
"@typescript-eslint/parser": "^7.11.0",
|
||||
"@vscode/test-cli": "^0.0.9",
|
||||
"@vscode/test-electron": "^2.4.0",
|
||||
"chai": "^4.3.10",
|
||||
"esbuild": "^0.21.5",
|
||||
"eslint": "^8.57.0",
|
||||
"husky": "^9.1.7",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.3.3",
|
||||
"should": "^13.2.3",
|
||||
|
|
@ -169,6 +197,7 @@
|
|||
"@anthropic-ai/sdk": "^0.26.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.4.1",
|
||||
"@google/generative-ai": "^0.18.0",
|
||||
"@mistralai/mistralai": "^1.3.6",
|
||||
"@modelcontextprotocol/sdk": "^1.0.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
|
|
@ -184,6 +213,7 @@
|
|||
"diff": "^5.2.0",
|
||||
"execa": "^9.5.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"firebase": "^11.2.0",
|
||||
"get-folder-size": "^5.0.0",
|
||||
"globby": "^14.0.2",
|
||||
"isbinaryfile": "^5.0.2",
|
||||
|
|
|
|||
|
|
@ -11,12 +11,18 @@ import { GeminiHandler } from "./providers/gemini"
|
|||
import { OpenAiNativeHandler } from "./providers/openai-native"
|
||||
import { ApiStream } from "./transform/stream"
|
||||
import { DeepSeekHandler } from "./providers/deepseek"
|
||||
import { MistralHandler } from "./providers/mistral"
|
||||
import { VsCodeLmHandler } from "./providers/vscode-lm"
|
||||
|
||||
export interface ApiHandler {
|
||||
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
|
||||
getModel(): { id: string; info: ModelInfo }
|
||||
}
|
||||
|
||||
export interface SingleCompletionHandler {
|
||||
completePrompt(prompt: string): Promise<string>
|
||||
}
|
||||
|
||||
export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
|
||||
const { apiProvider, ...options } = configuration
|
||||
switch (apiProvider) {
|
||||
|
|
@ -40,6 +46,10 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
|
|||
return new OpenAiNativeHandler(options)
|
||||
case "deepseek":
|
||||
return new DeepSeekHandler(options)
|
||||
case "mistral":
|
||||
return new MistralHandler(options)
|
||||
case "vscode-lm":
|
||||
return new VsCodeLmHandler(options)
|
||||
default:
|
||||
return new AnthropicHandler(options)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ export class AnthropicHandler implements ApiHandler {
|
|||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const model = this.getModel()
|
||||
let stream: AnthropicStream<Anthropic.Beta.PromptCaching.Messages.RawPromptCachingBetaMessageStreamEvent>
|
||||
const modelId = this.getModel().id
|
||||
const modelId = model.id
|
||||
switch (modelId) {
|
||||
// 'latest' alias does not support cache_control
|
||||
case "claude-3-5-sonnet-20241022":
|
||||
|
|
@ -37,7 +38,7 @@ export class AnthropicHandler implements ApiHandler {
|
|||
stream = await this.client.beta.promptCaching.messages.create(
|
||||
{
|
||||
model: modelId,
|
||||
max_tokens: this.getModel().info.maxTokens || 8192,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
temperature: 0,
|
||||
system: [
|
||||
{
|
||||
|
|
@ -104,7 +105,7 @@ export class AnthropicHandler implements ApiHandler {
|
|||
default: {
|
||||
stream = (await this.client.messages.create({
|
||||
model: modelId,
|
||||
max_tokens: this.getModel().info.maxTokens || 8192,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
temperature: 0,
|
||||
system: [{ text: systemPrompt, type: "text" }],
|
||||
messages,
|
||||
|
|
|
|||
|
|
@ -18,13 +18,15 @@ export class DeepSeekHandler implements ApiHandler {
|
|||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const model = this.getModel()
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
max_completion_tokens: this.getModel().info.maxTokens,
|
||||
temperature: 0,
|
||||
model: model.id,
|
||||
max_completion_tokens: model.info.maxTokens,
|
||||
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
// Only set temperature for non-reasoner models
|
||||
...(model.id === "deepseek-reasoner" ? {} : { temperature: 0 }),
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
|
|
|
|||
74
src/api/providers/mistral.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { Mistral } from "@mistralai/mistralai"
|
||||
import { ApiHandler } from "../"
|
||||
import {
|
||||
ApiHandlerOptions,
|
||||
mistralDefaultModelId,
|
||||
MistralModelId,
|
||||
mistralModels,
|
||||
ModelInfo,
|
||||
openAiNativeDefaultModelId,
|
||||
OpenAiNativeModelId,
|
||||
openAiNativeModels,
|
||||
} from "../../shared/api"
|
||||
import { convertToMistralMessages } from "../transform/mistral-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
export class MistralHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: Mistral
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new Mistral({
|
||||
serverURL: "https://codestral.mistral.ai",
|
||||
apiKey: this.options.mistralApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const stream = await this.client.chat.stream({
|
||||
model: this.getModel().id,
|
||||
// max_completion_tokens: this.getModel().info.maxTokens,
|
||||
temperature: 0,
|
||||
messages: [{ role: "system", content: systemPrompt }, ...convertToMistralMessages(messages)],
|
||||
stream: true,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.data.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
let content: string = ""
|
||||
if (typeof delta.content === "string") {
|
||||
content = delta.content
|
||||
} else if (Array.isArray(delta.content)) {
|
||||
content = delta.content.map((c) => (c.type === "text" ? c.text : "")).join("")
|
||||
}
|
||||
yield {
|
||||
type: "text",
|
||||
text: content,
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.data.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.data.usage.promptTokens || 0,
|
||||
outputTokens: chunk.data.usage.completionTokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: MistralModelId; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in mistralModels) {
|
||||
const id = modelId as MistralModelId
|
||||
return { id, info: mistralModels[id] }
|
||||
}
|
||||
return {
|
||||
id: mistralDefaultModelId,
|
||||
info: mistralModels[mistralDefaultModelId],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,8 @@ export class OpenRouterHandler implements ApiHandler {
|
|||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const model = this.getModel()
|
||||
|
||||
// Convert Anthropic messages to OpenAI format
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
|
|
@ -32,7 +34,7 @@ export class OpenRouterHandler implements ApiHandler {
|
|||
|
||||
// prompt caching: https://openrouter.ai/docs/prompt-caching
|
||||
// this is specifically for claude models (some models may 'support prompt caching' automatically without this)
|
||||
switch (this.getModel().id) {
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-3.5-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet-20240620":
|
||||
|
|
@ -83,7 +85,7 @@ export class OpenRouterHandler implements ApiHandler {
|
|||
// Not sure how openrouter defaults max tokens when no value is provided, but the anthropic api requires this value and since they offer both 4096 and 8192 variants, we should ensure 8192.
|
||||
// (models usually default to max tokens allowed)
|
||||
let maxTokens: number | undefined
|
||||
switch (this.getModel().id) {
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-3.5-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet-20240620":
|
||||
|
|
@ -97,15 +99,15 @@ export class OpenRouterHandler implements ApiHandler {
|
|||
}
|
||||
|
||||
// Removes messages in the middle when close to context window limit. Should not be applied to models that support prompt caching since it would continuously break the cache.
|
||||
let shouldApplyMiddleOutTransform = !this.getModel().info.supportsPromptCache
|
||||
let shouldApplyMiddleOutTransform = !model.info.supportsPromptCache
|
||||
// except for deepseek (which we set supportsPromptCache to true for), where because the context window is so small our truncation algo might miss and we should use openrouter's middle-out transform as a fallback to ensure we don't exceed the context window (FIXME: once we have a more robust token estimator we should not rely on this)
|
||||
if (this.getModel().id === "deepseek/deepseek-chat") {
|
||||
if (model.id === "deepseek/deepseek-chat") {
|
||||
shouldApplyMiddleOutTransform = true
|
||||
}
|
||||
|
||||
// @ts-ignore-next-line
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
model: model.id,
|
||||
max_tokens: maxTokens,
|
||||
temperature: 0,
|
||||
messages: openAiMessages,
|
||||
|
|
|
|||
639
src/api/providers/vscode-lm.ts
Normal file
|
|
@ -0,0 +1,639 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import * as vscode from "vscode"
|
||||
import { ApiHandler, SingleCompletionHandler } from "../"
|
||||
import { calculateApiCost } from "../../utils/cost"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToVsCodeLmMessages } from "../transform/vscode-lm-format"
|
||||
import { SELECTOR_SEPARATOR, stringifyVsCodeLmModelSelector } from "../../shared/vsCodeSelectorUtils"
|
||||
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
|
||||
|
||||
// Cline does not update VSCode type definitions or engine requirements to maintain compatibility.
|
||||
// This declaration (as seen in src/integrations/TerminalManager.ts) provides types for the Language Model API in newer versions of VSCode.
|
||||
// Extracted from https://github.com/microsoft/vscode/blob/131ee0ef660d600cd0a7e6058375b281553abe20/src/vscode-dts/vscode.d.ts
|
||||
declare module "vscode" {
|
||||
enum LanguageModelChatMessageRole {
|
||||
User = 1,
|
||||
Assistant = 2,
|
||||
}
|
||||
enum LanguageModelChatToolMode {
|
||||
Auto = 1,
|
||||
Required = 2,
|
||||
}
|
||||
interface LanguageModelChatSelector {
|
||||
vendor?: string
|
||||
family?: string
|
||||
version?: string
|
||||
id?: string
|
||||
}
|
||||
interface LanguageModelChatTool {
|
||||
name: string
|
||||
description: string
|
||||
inputSchema?: object
|
||||
}
|
||||
interface LanguageModelChatRequestOptions {
|
||||
justification?: string
|
||||
modelOptions?: { [name: string]: any }
|
||||
tools?: LanguageModelChatTool[]
|
||||
toolMode?: LanguageModelChatToolMode
|
||||
}
|
||||
class LanguageModelTextPart {
|
||||
value: string
|
||||
constructor(value: string)
|
||||
}
|
||||
class LanguageModelToolCallPart {
|
||||
callId: string
|
||||
name: string
|
||||
input: object
|
||||
constructor(callId: string, name: string, input: object)
|
||||
}
|
||||
interface LanguageModelChatResponse {
|
||||
stream: AsyncIterable<LanguageModelTextPart | LanguageModelToolCallPart | unknown>
|
||||
text: AsyncIterable<string>
|
||||
}
|
||||
interface LanguageModelChat {
|
||||
readonly name: string
|
||||
readonly id: string
|
||||
readonly vendor: string
|
||||
readonly family: string
|
||||
readonly version: string
|
||||
readonly maxInputTokens: number
|
||||
|
||||
sendRequest(
|
||||
messages: LanguageModelChatMessage[],
|
||||
options?: LanguageModelChatRequestOptions,
|
||||
token?: CancellationToken,
|
||||
): Thenable<LanguageModelChatResponse>
|
||||
countTokens(text: string | LanguageModelChatMessage, token?: CancellationToken): Thenable<number>
|
||||
}
|
||||
class LanguageModelPromptTsxPart {
|
||||
value: unknown
|
||||
constructor(value: unknown)
|
||||
}
|
||||
class LanguageModelToolResultPart {
|
||||
callId: string
|
||||
content: Array<LanguageModelTextPart | LanguageModelPromptTsxPart | unknown>
|
||||
constructor(callId: string, content: Array<LanguageModelTextPart | LanguageModelPromptTsxPart | unknown>)
|
||||
}
|
||||
class LanguageModelChatMessage {
|
||||
static User(
|
||||
content: string | Array<LanguageModelTextPart | LanguageModelToolResultPart>,
|
||||
name?: string,
|
||||
): LanguageModelChatMessage
|
||||
static Assistant(
|
||||
content: string | Array<LanguageModelTextPart | LanguageModelToolCallPart>,
|
||||
name?: string,
|
||||
): LanguageModelChatMessage
|
||||
|
||||
role: LanguageModelChatMessageRole
|
||||
content: Array<LanguageModelTextPart | LanguageModelToolResultPart | LanguageModelToolCallPart>
|
||||
name: string | undefined
|
||||
|
||||
constructor(
|
||||
role: LanguageModelChatMessageRole,
|
||||
content: string | Array<LanguageModelTextPart | LanguageModelToolResultPart | LanguageModelToolCallPart>,
|
||||
name?: string,
|
||||
)
|
||||
}
|
||||
namespace lm {
|
||||
function selectChatModels(selector?: LanguageModelChatSelector): Thenable<LanguageModelChat[]>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles interaction with VS Code's Language Model API for chat-based operations.
|
||||
* This handler implements the ApiHandler interface to provide VS Code LM specific functionality.
|
||||
*
|
||||
* @implements {ApiHandler}
|
||||
*
|
||||
* @remarks
|
||||
* The handler manages a VS Code language model chat client and provides methods to:
|
||||
* - Create and manage chat client instances
|
||||
* - Stream messages using VS Code's Language Model API
|
||||
* - Retrieve model information
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const options = {
|
||||
* vsCodeLmModelSelector: { vendor: "copilot", family: "gpt-4" }
|
||||
* };
|
||||
* const handler = new VsCodeLmHandler(options);
|
||||
*
|
||||
* // Stream a conversation
|
||||
* const systemPrompt = "You are a helpful assistant";
|
||||
* const messages = [{ role: "user", content: "Hello!" }];
|
||||
* for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
* console.log(chunk);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: vscode.LanguageModelChat | null
|
||||
private disposable: vscode.Disposable | null
|
||||
private currentRequestCancellation: vscode.CancellationTokenSource | null
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = null
|
||||
this.disposable = null
|
||||
this.currentRequestCancellation = null
|
||||
|
||||
try {
|
||||
// Listen for model changes and reset client
|
||||
this.disposable = vscode.workspace.onDidChangeConfiguration((event) => {
|
||||
if (event.affectsConfiguration("lm")) {
|
||||
try {
|
||||
this.client = null
|
||||
this.ensureCleanState()
|
||||
} catch (error) {
|
||||
console.error("Error during configuration change cleanup:", error)
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// Ensure cleanup if constructor fails
|
||||
this.dispose()
|
||||
|
||||
throw new Error(
|
||||
`Cline <Language Model API>: Failed to initialize handler: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a language model chat client based on the provided selector.
|
||||
*
|
||||
* @param selector - Selector criteria to filter language model chat instances
|
||||
* @returns Promise resolving to the first matching language model chat instance
|
||||
* @throws Error when no matching models are found with the given selector
|
||||
*
|
||||
* @example
|
||||
* const selector = { vendor: "copilot", family: "gpt-4o" };
|
||||
* const chatClient = await createClient(selector);
|
||||
*/
|
||||
async createClient(selector: vscode.LanguageModelChatSelector): Promise<vscode.LanguageModelChat> {
|
||||
try {
|
||||
const models = await vscode.lm.selectChatModels(selector)
|
||||
|
||||
// Use first available model or create a minimal model object
|
||||
if (models && Array.isArray(models) && models.length > 0) {
|
||||
return models[0]
|
||||
}
|
||||
|
||||
// Create a minimal model if no models are available
|
||||
return {
|
||||
id: "default-lm",
|
||||
name: "Default Language Model",
|
||||
vendor: "vscode",
|
||||
family: "lm",
|
||||
version: "1.0",
|
||||
maxInputTokens: 8192,
|
||||
sendRequest: async (messages, options, token) => {
|
||||
// Provide a minimal implementation
|
||||
return {
|
||||
stream: (async function* () {
|
||||
yield new vscode.LanguageModelTextPart(
|
||||
"Language model functionality is limited. Please check VS Code configuration.",
|
||||
)
|
||||
})(),
|
||||
text: (async function* () {
|
||||
yield "Language model functionality is limited. Please check VS Code configuration."
|
||||
})(),
|
||||
}
|
||||
},
|
||||
countTokens: async () => 0,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
throw new Error(`Cline <Language Model API>: Failed to select model: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and streams a message using the VS Code Language Model API.
|
||||
*
|
||||
* @param systemPrompt - The system prompt to initialize the conversation context
|
||||
* @param messages - An array of message parameters following the Anthropic message format
|
||||
*
|
||||
* @yields {ApiStream} An async generator that yields either text chunks or tool calls from the model response
|
||||
*
|
||||
* @throws {Error} When vsCodeLmModelSelector option is not provided
|
||||
* @throws {Error} When the response stream encounters an error
|
||||
*
|
||||
* @remarks
|
||||
* This method handles the initialization of the VS Code LM client if not already created,
|
||||
* converts the messages to VS Code LM format, and streams the response chunks.
|
||||
* Tool calls handling is currently a work in progress.
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.disposable) {
|
||||
this.disposable.dispose()
|
||||
}
|
||||
|
||||
if (this.currentRequestCancellation) {
|
||||
this.currentRequestCancellation.cancel()
|
||||
this.currentRequestCancellation.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private async countTokens(text: string | vscode.LanguageModelChatMessage): Promise<number> {
|
||||
// Check for required dependencies
|
||||
if (!this.client) {
|
||||
console.warn("Cline <Language Model API>: No client available for token counting")
|
||||
return 0
|
||||
}
|
||||
|
||||
if (!this.currentRequestCancellation) {
|
||||
console.warn("Cline <Language Model API>: No cancellation token available for token counting")
|
||||
return 0
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if (!text) {
|
||||
console.debug("Cline <Language Model API>: Empty text provided for token counting")
|
||||
return 0
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle different input types
|
||||
let tokenCount: number
|
||||
|
||||
if (typeof text === "string") {
|
||||
tokenCount = await this.client.countTokens(text, this.currentRequestCancellation.token)
|
||||
} else if (text instanceof vscode.LanguageModelChatMessage) {
|
||||
// For chat messages, ensure we have content
|
||||
if (!text.content || (Array.isArray(text.content) && text.content.length === 0)) {
|
||||
console.debug("Cline <Language Model API>: Empty chat message content")
|
||||
return 0
|
||||
}
|
||||
tokenCount = await this.client.countTokens(text, this.currentRequestCancellation.token)
|
||||
} else {
|
||||
console.warn("Cline <Language Model API>: Invalid input type for token counting")
|
||||
return 0
|
||||
}
|
||||
|
||||
// Validate the result
|
||||
if (typeof tokenCount !== "number") {
|
||||
console.warn("Cline <Language Model API>: Non-numeric token count received:", tokenCount)
|
||||
return 0
|
||||
}
|
||||
|
||||
if (tokenCount < 0) {
|
||||
console.warn("Cline <Language Model API>: Negative token count received:", tokenCount)
|
||||
return 0
|
||||
}
|
||||
|
||||
return tokenCount
|
||||
} catch (error) {
|
||||
// Handle specific error types
|
||||
if (error instanceof vscode.CancellationError) {
|
||||
console.debug("Cline <Language Model API>: Token counting cancelled by user")
|
||||
return 0
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.warn("Cline <Language Model API>: Token counting failed:", errorMessage)
|
||||
|
||||
// Log additional error details if available
|
||||
if (error instanceof Error && error.stack) {
|
||||
console.debug("Token counting error stack:", error.stack)
|
||||
}
|
||||
|
||||
return 0 // Fallback to prevent stream interruption
|
||||
}
|
||||
}
|
||||
|
||||
private async calculateTotalInputTokens(
|
||||
systemPrompt: string,
|
||||
vsCodeLmMessages: vscode.LanguageModelChatMessage[],
|
||||
): Promise<number> {
|
||||
const systemTokens: number = await this.countTokens(systemPrompt)
|
||||
|
||||
const messageTokens: number[] = await Promise.all(vsCodeLmMessages.map((msg) => this.countTokens(msg)))
|
||||
|
||||
return systemTokens + messageTokens.reduce((sum: number, tokens: number): number => sum + tokens, 0)
|
||||
}
|
||||
|
||||
private ensureCleanState(): void {
|
||||
if (this.currentRequestCancellation) {
|
||||
this.currentRequestCancellation.cancel()
|
||||
this.currentRequestCancellation.dispose()
|
||||
this.currentRequestCancellation = null
|
||||
}
|
||||
}
|
||||
|
||||
private async getClient(): Promise<vscode.LanguageModelChat> {
|
||||
if (!this.client) {
|
||||
console.debug("Cline <Language Model API>: Getting client with options:", {
|
||||
vsCodeLmModelSelector: this.options.vsCodeLmModelSelector,
|
||||
hasOptions: !!this.options,
|
||||
selectorKeys: this.options.vsCodeLmModelSelector ? Object.keys(this.options.vsCodeLmModelSelector) : [],
|
||||
})
|
||||
|
||||
try {
|
||||
// Use default empty selector if none provided to get all available models
|
||||
const selector = this.options?.vsCodeLmModelSelector || {}
|
||||
console.debug("Cline <Language Model API>: Creating client with selector:", selector)
|
||||
this.client = await this.createClient(selector)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Cline <Language Model API>: Client creation failed:", message)
|
||||
throw new Error(`Cline <Language Model API>: Failed to create client: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return this.client
|
||||
}
|
||||
|
||||
private cleanTerminalOutput(text: string): string {
|
||||
if (!text) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return (
|
||||
text
|
||||
// Normalize line breaks
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n")
|
||||
|
||||
// Remove ANSI escape sequences
|
||||
.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "") // Full set of ANSI sequences
|
||||
.replace(/\x9B[0-?]*[ -/]*[@-~]/g, "") // CSI sequences
|
||||
|
||||
// Remove terminal title setting sequences and other OSC sequences
|
||||
.replace(/\x1B\][0-9;]*(?:\x07|\x1B\\)/g, "")
|
||||
|
||||
// Remove control characters
|
||||
.replace(/[\x00-\x09\x0B-\x0C\x0E-\x1F\x7F]/g, "")
|
||||
|
||||
// Remove VS Code escape sequences
|
||||
.replace(/\x1B[PD].*?\x1B\\/g, "") // DCS sequences
|
||||
.replace(/\x1B_.*?\x1B\\/g, "") // APC sequences
|
||||
.replace(/\x1B\^.*?\x1B\\/g, "") // PM sequences
|
||||
.replace(/\x1B\[[\d;]*[HfABCDEFGJKST]/g, "") // Cursor movement and clear screen
|
||||
|
||||
// Remove Windows paths and service information
|
||||
.replace(/^(?:PS )?[A-Z]:\\[^\n]*$/gm, "")
|
||||
.replace(/^;?Cwd=.*$/gm, "")
|
||||
|
||||
// Clean escaped sequences
|
||||
.replace(/\\x[0-9a-fA-F]{2}/g, "")
|
||||
.replace(/\\u[0-9a-fA-F]{4}/g, "")
|
||||
|
||||
// Final cleanup
|
||||
.replace(/\n{3,}/g, "\n\n") // Remove multiple empty lines
|
||||
.trim()
|
||||
)
|
||||
}
|
||||
|
||||
private cleanMessageContent(content: any): any {
|
||||
if (!content) {
|
||||
return content
|
||||
}
|
||||
|
||||
if (typeof content === "string") {
|
||||
return this.cleanTerminalOutput(content)
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return content.map((item) => this.cleanMessageContent(item))
|
||||
}
|
||||
|
||||
if (typeof content === "object") {
|
||||
const cleaned: any = {}
|
||||
for (const [key, value] of Object.entries(content)) {
|
||||
cleaned[key] = this.cleanMessageContent(value)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
// Ensure clean state before starting a new request
|
||||
this.ensureCleanState()
|
||||
const client: vscode.LanguageModelChat = await this.getClient()
|
||||
|
||||
// Clean system prompt and messages
|
||||
const cleanedSystemPrompt = this.cleanTerminalOutput(systemPrompt)
|
||||
const cleanedMessages = messages.map((msg) => ({
|
||||
...msg,
|
||||
content: this.cleanMessageContent(msg.content),
|
||||
}))
|
||||
|
||||
// Convert Anthropic messages to VS Code LM messages
|
||||
const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [
|
||||
vscode.LanguageModelChatMessage.Assistant(cleanedSystemPrompt),
|
||||
...convertToVsCodeLmMessages(cleanedMessages),
|
||||
]
|
||||
|
||||
// Initialize cancellation token for the request
|
||||
this.currentRequestCancellation = new vscode.CancellationTokenSource()
|
||||
|
||||
// Calculate input tokens before starting the stream
|
||||
const totalInputTokens: number = await this.calculateTotalInputTokens(systemPrompt, vsCodeLmMessages)
|
||||
|
||||
// Accumulate the text and count at the end of the stream to reduce token counting overhead.
|
||||
let accumulatedText: string = ""
|
||||
|
||||
try {
|
||||
// Create the response stream with minimal required options
|
||||
const requestOptions: vscode.LanguageModelChatRequestOptions = {
|
||||
justification: `Cline would like to use '${client.name}' from '${client.vendor}', Click 'Allow' to proceed.`,
|
||||
}
|
||||
|
||||
// Note: Tool support is currently provided by the VSCode Language Model API directly
|
||||
// Extensions can register tools using vscode.lm.registerTool()
|
||||
|
||||
const response: vscode.LanguageModelChatResponse = await client.sendRequest(
|
||||
vsCodeLmMessages,
|
||||
requestOptions,
|
||||
this.currentRequestCancellation.token,
|
||||
)
|
||||
|
||||
// Consume the stream and handle both text and tool call chunks
|
||||
for await (const chunk of response.stream) {
|
||||
if (chunk instanceof vscode.LanguageModelTextPart) {
|
||||
// Validate text part value
|
||||
if (typeof chunk.value !== "string") {
|
||||
console.warn("Cline <Language Model API>: Invalid text part value received:", chunk.value)
|
||||
continue
|
||||
}
|
||||
|
||||
accumulatedText += chunk.value
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.value,
|
||||
}
|
||||
} else if (chunk instanceof vscode.LanguageModelToolCallPart) {
|
||||
try {
|
||||
// Validate tool call parameters
|
||||
if (!chunk.name || typeof chunk.name !== "string") {
|
||||
console.warn("Cline <Language Model API>: Invalid tool name received:", chunk.name)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!chunk.callId || typeof chunk.callId !== "string") {
|
||||
console.warn("Cline <Language Model API>: Invalid tool callId received:", chunk.callId)
|
||||
continue
|
||||
}
|
||||
|
||||
// Ensure input is a valid object
|
||||
if (!chunk.input || typeof chunk.input !== "object") {
|
||||
console.warn("Cline <Language Model API>: Invalid tool input received:", chunk.input)
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert tool calls to text format with proper error handling
|
||||
const toolCall = {
|
||||
type: "tool_call",
|
||||
name: chunk.name,
|
||||
arguments: chunk.input,
|
||||
callId: chunk.callId,
|
||||
}
|
||||
|
||||
const toolCallText = JSON.stringify(toolCall)
|
||||
accumulatedText += toolCallText
|
||||
|
||||
// Log tool call for debugging
|
||||
console.debug("Cline <Language Model API>: Processing tool call:", {
|
||||
name: chunk.name,
|
||||
callId: chunk.callId,
|
||||
inputSize: JSON.stringify(chunk.input).length,
|
||||
})
|
||||
|
||||
yield {
|
||||
type: "text",
|
||||
text: toolCallText,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Cline <Language Model API>: Failed to process tool call:", error)
|
||||
// Continue processing other chunks even if one fails
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
console.warn("Cline <Language Model API>: Unknown chunk type received:", chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// Count tokens in the accumulated text after stream completion
|
||||
const totalOutputTokens: number = await this.countTokens(accumulatedText)
|
||||
|
||||
// Report final usage after stream completion
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
totalCost: calculateApiCost(this.getModel().info, totalInputTokens, totalOutputTokens),
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
this.ensureCleanState()
|
||||
|
||||
if (error instanceof vscode.CancellationError) {
|
||||
throw new Error("Cline <Language Model API>: Request cancelled by user")
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
console.error("Cline <Language Model API>: Stream error details:", {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
})
|
||||
|
||||
// Return original error if it's already an Error instance
|
||||
throw error
|
||||
} else if (typeof error === "object" && error !== null) {
|
||||
// Handle error-like objects
|
||||
const errorDetails = JSON.stringify(error, null, 2)
|
||||
console.error("Cline <Language Model API>: Stream error object:", errorDetails)
|
||||
throw new Error(`Cline <Language Model API>: Response stream error: ${errorDetails}`)
|
||||
} else {
|
||||
// Fallback for unknown error types
|
||||
const errorMessage = String(error)
|
||||
console.error("Cline <Language Model API>: Unknown stream error:", errorMessage)
|
||||
throw new Error(`Cline <Language Model API>: Response stream error: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return model information based on the current client state
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
if (this.client) {
|
||||
// Validate client properties
|
||||
const requiredProps = {
|
||||
id: this.client.id,
|
||||
vendor: this.client.vendor,
|
||||
family: this.client.family,
|
||||
version: this.client.version,
|
||||
maxInputTokens: this.client.maxInputTokens,
|
||||
}
|
||||
|
||||
// Log any missing properties for debugging
|
||||
for (const [prop, value] of Object.entries(requiredProps)) {
|
||||
if (!value && value !== 0) {
|
||||
console.warn(`Cline <Language Model API>: Client missing ${prop} property`)
|
||||
}
|
||||
}
|
||||
|
||||
// Construct model ID using available information
|
||||
const modelParts = [this.client.vendor, this.client.family, this.client.version].filter(Boolean)
|
||||
|
||||
const modelId = this.client.id || modelParts.join(SELECTOR_SEPARATOR)
|
||||
|
||||
// Build model info with conservative defaults for missing values
|
||||
const modelInfo: ModelInfo = {
|
||||
maxTokens: -1, // Unlimited tokens by default
|
||||
contextWindow:
|
||||
typeof this.client.maxInputTokens === "number"
|
||||
? Math.max(0, this.client.maxInputTokens)
|
||||
: openAiModelInfoSaneDefaults.contextWindow,
|
||||
supportsImages: false, // VSCode Language Model API currently doesn't support image inputs
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: `VSCode Language Model: ${modelId}`,
|
||||
}
|
||||
|
||||
return { id: modelId, info: modelInfo }
|
||||
}
|
||||
|
||||
// Fallback when no client is available
|
||||
const fallbackId = this.options.vsCodeLmModelSelector
|
||||
? stringifyVsCodeLmModelSelector(this.options.vsCodeLmModelSelector)
|
||||
: "vscode-lm"
|
||||
|
||||
console.debug("Cline <Language Model API>: No client available, using fallback model info")
|
||||
|
||||
return {
|
||||
id: fallbackId,
|
||||
info: {
|
||||
...openAiModelInfoSaneDefaults,
|
||||
description: `VSCode Language Model (Fallback): ${fallbackId}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
try {
|
||||
const client = await this.getClient()
|
||||
const response = await client.sendRequest(
|
||||
[vscode.LanguageModelChatMessage.User(prompt)],
|
||||
{},
|
||||
new vscode.CancellationTokenSource().token,
|
||||
)
|
||||
let result = ""
|
||||
for await (const chunk of response.stream) {
|
||||
if (chunk instanceof vscode.LanguageModelTextPart) {
|
||||
result += chunk.value
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`VSCode LM completion error: ${error.message}`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
92
src/api/transform/mistral-format.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { Mistral } from "@mistralai/mistralai"
|
||||
import { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage"
|
||||
import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage"
|
||||
import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage"
|
||||
import { UserMessage } from "@mistralai/mistralai/models/components/usermessage"
|
||||
|
||||
export type MistralMessage =
|
||||
| (SystemMessage & { role: "system" })
|
||||
| (UserMessage & { role: "user" })
|
||||
| (AssistantMessage & { role: "assistant" })
|
||||
| (ToolMessage & { role: "tool" })
|
||||
|
||||
export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): MistralMessage[] {
|
||||
const mistralMessages: MistralMessage[] = []
|
||||
for (const anthropicMessage of anthropicMessages) {
|
||||
if (typeof anthropicMessage.content === "string") {
|
||||
mistralMessages.push({
|
||||
role: anthropicMessage.role,
|
||||
content: anthropicMessage.content,
|
||||
})
|
||||
} else {
|
||||
if (anthropicMessage.role === "user") {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
|
||||
toolMessages: Anthropic.ToolResultBlockParam[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_result") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
} // user cannot send tool_use messages
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
)
|
||||
|
||||
if (nonToolMessages.length > 0) {
|
||||
mistralMessages.push({
|
||||
role: "user",
|
||||
content: nonToolMessages.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return {
|
||||
type: "image_url",
|
||||
imageUrl: {
|
||||
url: `data:${part.source.media_type};base64,${part.source.data}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
return { type: "text", text: part.text }
|
||||
}),
|
||||
})
|
||||
}
|
||||
} else if (anthropicMessage.role === "assistant") {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
|
||||
toolMessages: Anthropic.ToolUseBlockParam[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_use") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
} // assistant cannot send tool_result messages
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
)
|
||||
|
||||
let content: string | undefined
|
||||
if (nonToolMessages.length > 0) {
|
||||
content = nonToolMessages
|
||||
.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return "" // impossible as the assistant cannot send images
|
||||
}
|
||||
return part.text
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
mistralMessages.push({
|
||||
role: "assistant",
|
||||
content,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mistralMessages
|
||||
}
|
||||
200
src/api/transform/vscode-lm-format.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
/**
|
||||
* Safely converts a value into a plain object.
|
||||
*/
|
||||
function asObjectSafe(value: any): object {
|
||||
// Handle null/undefined
|
||||
if (!value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle strings that might be JSON
|
||||
if (typeof value === "string") {
|
||||
return JSON.parse(value)
|
||||
}
|
||||
|
||||
// Handle pre-existing objects
|
||||
if (typeof value === "object") {
|
||||
return Object.assign({}, value)
|
||||
}
|
||||
|
||||
return {}
|
||||
} catch (error) {
|
||||
console.warn("Cline <Language Model API>: Failed to parse object:", error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function convertToVsCodeLmMessages(
|
||||
anthropicMessages: Anthropic.Messages.MessageParam[],
|
||||
): vscode.LanguageModelChatMessage[] {
|
||||
const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = []
|
||||
|
||||
for (const anthropicMessage of anthropicMessages) {
|
||||
// Handle simple string messages
|
||||
if (typeof anthropicMessage.content === "string") {
|
||||
vsCodeLmMessages.push(
|
||||
anthropicMessage.role === "assistant"
|
||||
? vscode.LanguageModelChatMessage.Assistant(anthropicMessage.content)
|
||||
: vscode.LanguageModelChatMessage.User(anthropicMessage.content),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle complex message structures
|
||||
switch (anthropicMessage.role) {
|
||||
case "user": {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
|
||||
toolMessages: Anthropic.ToolResultBlockParam[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_result") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
)
|
||||
|
||||
// Process tool messages first then non-tool messages
|
||||
const contentParts = [
|
||||
// Convert tool messages to ToolResultParts
|
||||
...toolMessages.map((toolMessage) => {
|
||||
// Process tool result content into TextParts
|
||||
const toolContentParts: vscode.LanguageModelTextPart[] =
|
||||
typeof toolMessage.content === "string"
|
||||
? [new vscode.LanguageModelTextPart(toolMessage.content)]
|
||||
: (toolMessage.content?.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return new vscode.LanguageModelTextPart(
|
||||
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
|
||||
)
|
||||
}
|
||||
return new vscode.LanguageModelTextPart(part.text)
|
||||
}) ?? [new vscode.LanguageModelTextPart("")])
|
||||
|
||||
return new vscode.LanguageModelToolResultPart(toolMessage.tool_use_id, toolContentParts)
|
||||
}),
|
||||
|
||||
// Convert non-tool messages to TextParts after tool messages
|
||||
...nonToolMessages.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return new vscode.LanguageModelTextPart(
|
||||
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
|
||||
)
|
||||
}
|
||||
return new vscode.LanguageModelTextPart(part.text)
|
||||
}),
|
||||
]
|
||||
|
||||
// Add single user message with all content parts
|
||||
vsCodeLmMessages.push(vscode.LanguageModelChatMessage.User(contentParts))
|
||||
break
|
||||
}
|
||||
|
||||
case "assistant": {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
|
||||
toolMessages: Anthropic.ToolUseBlockParam[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_use") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
)
|
||||
|
||||
// Process tool messages first then non-tool messages
|
||||
const contentParts = [
|
||||
// Convert tool messages to ToolCallParts first
|
||||
...toolMessages.map(
|
||||
(toolMessage) =>
|
||||
new vscode.LanguageModelToolCallPart(
|
||||
toolMessage.id,
|
||||
toolMessage.name,
|
||||
asObjectSafe(toolMessage.input),
|
||||
),
|
||||
),
|
||||
|
||||
// Convert non-tool messages to TextParts after tool messages
|
||||
...nonToolMessages.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return new vscode.LanguageModelTextPart("[Image generation not supported by VSCode LM API]")
|
||||
}
|
||||
return new vscode.LanguageModelTextPart(part.text)
|
||||
}),
|
||||
]
|
||||
|
||||
// Add the assistant message to the list of messages
|
||||
vsCodeLmMessages.push(vscode.LanguageModelChatMessage.Assistant(contentParts))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return vsCodeLmMessages
|
||||
}
|
||||
|
||||
export function convertToAnthropicRole(vsCodeLmMessageRole: vscode.LanguageModelChatMessageRole): string | null {
|
||||
switch (vsCodeLmMessageRole) {
|
||||
case vscode.LanguageModelChatMessageRole.Assistant:
|
||||
return "assistant"
|
||||
case vscode.LanguageModelChatMessageRole.User:
|
||||
return "user"
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function convertToAnthropicMessage(
|
||||
vsCodeLmMessage: vscode.LanguageModelChatMessage,
|
||||
): Promise<Anthropic.Messages.Message> {
|
||||
const anthropicRole: string | null = convertToAnthropicRole(vsCodeLmMessage.role)
|
||||
if (anthropicRole !== "assistant") {
|
||||
throw new Error("Cline <Language Model API>: Only assistant messages are supported.")
|
||||
}
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type: "message",
|
||||
model: "vscode-lm",
|
||||
role: anthropicRole,
|
||||
content: vsCodeLmMessage.content
|
||||
.map((part): Anthropic.ContentBlock | null => {
|
||||
if (part instanceof vscode.LanguageModelTextPart) {
|
||||
return {
|
||||
type: "text",
|
||||
text: part.value,
|
||||
}
|
||||
}
|
||||
|
||||
if (part instanceof vscode.LanguageModelToolCallPart) {
|
||||
return {
|
||||
type: "tool_use",
|
||||
id: part.callId || crypto.randomUUID(),
|
||||
name: part.name,
|
||||
input: asObjectSafe(part.input),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
.filter((part): part is Anthropic.ContentBlock => part !== null),
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -2,16 +2,18 @@ import { Anthropic } from "@anthropic-ai/sdk"
|
|||
import cloneDeep from "clone-deep"
|
||||
import delay from "delay"
|
||||
import fs from "fs/promises"
|
||||
import getFolderSize from "get-folder-size"
|
||||
import os from "os"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import * as path from "path"
|
||||
import { serializeError } from "serialize-error"
|
||||
import * as vscode from "vscode"
|
||||
import { ApiHandler, buildApiHandler } from "../api"
|
||||
import { ApiStream } from "../api/transform/stream"
|
||||
import CheckpointTracker from "../integrations/checkpoints/CheckpointTracker"
|
||||
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
|
||||
import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
|
||||
import { extractTextFromFile } from "../integrations/misc/extract-text"
|
||||
import { showSystemNotification } from "../integrations/notifications"
|
||||
import { TerminalManager } from "../integrations/terminal/TerminalManager"
|
||||
import { BrowserSession } from "../services/browser/BrowserSession"
|
||||
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
|
||||
|
|
@ -21,6 +23,8 @@ import { parseSourceCodeForDefinitionsTopLevel } from "../services/tree-sitter"
|
|||
import { ApiConfiguration } from "../shared/api"
|
||||
import { findLast, findLastIndex } from "../shared/array"
|
||||
import { AutoApprovalSettings } from "../shared/AutoApprovalSettings"
|
||||
import { BrowserSettings } from "../shared/BrowserSettings"
|
||||
import { ChatSettings } from "../shared/ChatSettings"
|
||||
import { combineApiRequests } from "../shared/combineApiRequests"
|
||||
import { combineCommandSequences, COMMAND_REQ_APP_STRING } from "../shared/combineCommandSequences"
|
||||
import {
|
||||
|
|
@ -43,19 +47,18 @@ import { ClineAskResponse, ClineCheckpointRestore } from "../shared/WebviewMessa
|
|||
import { calculateApiCost } from "../utils/cost"
|
||||
import { fileExistsAtPath } from "../utils/fs"
|
||||
import { arePathsEqual, getReadablePath } from "../utils/path"
|
||||
import { fixModelHtmlEscaping, removeInvalidChars } from "../utils/string"
|
||||
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
|
||||
import { constructNewFileContent } from "./assistant-message/diff"
|
||||
import { parseMentions } from "./mentions"
|
||||
import { formatResponse } from "./prompts/responses"
|
||||
import { addUserInstructions, SYSTEM_PROMPT } from "./prompts/system"
|
||||
import { getNextTruncationRange, getTruncatedMessages } from "./sliding-window"
|
||||
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
|
||||
import { showSystemNotification } from "../integrations/notifications"
|
||||
import { removeInvalidChars } from "../utils/string"
|
||||
import { fixModelHtmlEscaping } from "../utils/string"
|
||||
import { OpenRouterHandler } from "../api/providers/openrouter"
|
||||
import { getNextTruncationRange, getTruncatedMessages } from "./sliding-window"
|
||||
import { SYSTEM_PROMPT } from "./prompts/system"
|
||||
import { addUserInstructions } from "./prompts/system"
|
||||
import { OpenAiHandler } from "../api/providers/openai"
|
||||
import CheckpointTracker from "../integrations/checkpoints/CheckpointTracker"
|
||||
import getFolderSize from "get-folder-size"
|
||||
import { ApiStream } from "../api/transform/stream"
|
||||
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
|
||||
|
||||
|
|
@ -69,10 +72,12 @@ export class Cline {
|
|||
api: ApiHandler
|
||||
private terminalManager: TerminalManager
|
||||
private urlContentFetcher: UrlContentFetcher
|
||||
private browserSession: BrowserSession
|
||||
browserSession: BrowserSession
|
||||
private didEditFile: boolean = false
|
||||
customInstructions?: string
|
||||
autoApprovalSettings: AutoApprovalSettings
|
||||
private browserSettings: BrowserSettings
|
||||
private chatSettings: ChatSettings
|
||||
apiConversationHistory: Anthropic.MessageParam[] = []
|
||||
clineMessages: ClineMessage[] = []
|
||||
private askResponse?: ClineAskResponse
|
||||
|
|
@ -90,8 +95,11 @@ export class Cline {
|
|||
checkpointTrackerErrorMessage?: string
|
||||
conversationHistoryDeletedRange?: [number, number]
|
||||
isInitialized = false
|
||||
isAwaitingPlanResponse = false
|
||||
didRespondToPlanAskBySwitchingMode = false
|
||||
|
||||
// streaming
|
||||
isWaitingForFirstChunk = false
|
||||
isStreaming = false
|
||||
private currentStreamingContentIndex = 0
|
||||
private assistantMessageContent: AssistantMessageContent[] = []
|
||||
|
|
@ -102,11 +110,14 @@ export class Cline {
|
|||
private didRejectTool = false
|
||||
private didAlreadyUseTool = false
|
||||
private didCompleteReadingStream = false
|
||||
private didAutomaticallyRetryFailedApiRequest = false
|
||||
|
||||
constructor(
|
||||
provider: ClineProvider,
|
||||
apiConfiguration: ApiConfiguration,
|
||||
autoApprovalSettings: AutoApprovalSettings,
|
||||
browserSettings: BrowserSettings,
|
||||
chatSettings: ChatSettings,
|
||||
customInstructions?: string,
|
||||
task?: string,
|
||||
images?: string[],
|
||||
|
|
@ -116,10 +127,12 @@ export class Cline {
|
|||
this.api = buildApiHandler(apiConfiguration)
|
||||
this.terminalManager = new TerminalManager()
|
||||
this.urlContentFetcher = new UrlContentFetcher(provider.context)
|
||||
this.browserSession = new BrowserSession(provider.context)
|
||||
this.browserSession = new BrowserSession(provider.context, browserSettings)
|
||||
this.diffViewProvider = new DiffViewProvider(cwd)
|
||||
this.customInstructions = customInstructions
|
||||
this.autoApprovalSettings = autoApprovalSettings
|
||||
this.browserSettings = browserSettings
|
||||
this.chatSettings = chatSettings
|
||||
if (historyItem) {
|
||||
this.taskId = historyItem.id
|
||||
this.conversationHistoryDeletedRange = historyItem.conversationHistoryDeletedRange
|
||||
|
|
@ -132,6 +145,15 @@ export class Cline {
|
|||
}
|
||||
}
|
||||
|
||||
updateBrowserSettings(browserSettings: BrowserSettings) {
|
||||
this.browserSettings = browserSettings
|
||||
this.browserSession.browserSettings = browserSettings
|
||||
}
|
||||
|
||||
updateChatSettings(chatSettings: ChatSettings) {
|
||||
this.chatSettings = chatSettings
|
||||
}
|
||||
|
||||
// Storing task to disk for history
|
||||
|
||||
private async ensureTaskDirectoryExists(): Promise<string> {
|
||||
|
|
@ -968,14 +990,20 @@ export class Cline {
|
|||
newUserContent.push({
|
||||
type: "text",
|
||||
text:
|
||||
`[TASK RESUMPTION] This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.${
|
||||
`[TASK RESUMPTION] ${
|
||||
this.chatSettings?.mode === "plan"
|
||||
? `This task was interrupted ${agoText}. The conversation may have been incomplete. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful. However you are in PLAN MODE, so rather than continuing the task, you must respond to the user's message.`
|
||||
: `This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.`
|
||||
}${
|
||||
wasRecent
|
||||
? "\n\nIMPORTANT: If the last tool use was a replace_in_file or write_to_file that was interrupted, the file was reverted back to its original state before the interrupted edit, and you do NOT need to re-read the file as you already have its up-to-date contents."
|
||||
: ""
|
||||
}` +
|
||||
(responseText
|
||||
? `\n\nNew instructions for task continuation:\n<user_message>\n${responseText}\n</user_message>`
|
||||
: ""),
|
||||
? `\n\n${this.chatSettings?.mode === "plan" ? "New message to respond to with plan_mode_response tool (be sure to provide your response in the <response> parameter)" : "New instructions for task continuation"}:\n<user_message>\n${responseText}\n</user_message>`
|
||||
: this.chatSettings.mode === "plan"
|
||||
? "(The user did not provide a new message. Consider asking them how they'd like you to proceed, or to switch to Act mode to continue with the task.)"
|
||||
: ""),
|
||||
})
|
||||
|
||||
if (responseImages && responseImages.length > 0) {
|
||||
|
|
@ -1177,7 +1205,13 @@ export class Cline {
|
|||
throw new Error("MCP hub not available")
|
||||
}
|
||||
|
||||
let systemPrompt = await SYSTEM_PROMPT(cwd, this.api.getModel().info.supportsComputerUse ?? false, mcpHub)
|
||||
let systemPrompt = await SYSTEM_PROMPT(
|
||||
cwd,
|
||||
this.api.getModel().info.supportsComputerUse ?? false,
|
||||
mcpHub,
|
||||
this.browserSettings,
|
||||
)
|
||||
|
||||
let settingsCustomInstructions = this.customInstructions?.trim()
|
||||
const clineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
|
||||
let clineRulesFileInstructions: string | undefined
|
||||
|
|
@ -1242,21 +1276,35 @@ export class Cline {
|
|||
this.conversationHistoryDeletedRange,
|
||||
)
|
||||
|
||||
const stream = this.api.createMessage(systemPrompt, truncatedConversationHistory)
|
||||
let stream = this.api.createMessage(systemPrompt, truncatedConversationHistory)
|
||||
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
|
||||
try {
|
||||
// awaiting first chunk to see if it will throw an error
|
||||
this.isWaitingForFirstChunk = true
|
||||
const firstChunk = await iterator.next()
|
||||
yield firstChunk.value
|
||||
this.isWaitingForFirstChunk = false
|
||||
} catch (error) {
|
||||
// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
|
||||
const { response } = await this.ask("api_req_failed", error.message ?? JSON.stringify(serializeError(error), null, 2))
|
||||
if (response !== "yesButtonClicked") {
|
||||
// this will never happen since if noButtonClicked, we will clear current task, aborting this instance
|
||||
throw new Error("API request failed")
|
||||
const isOpenRouter = this.api instanceof OpenRouterHandler
|
||||
if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
|
||||
console.log("first chunk failed, waiting 1 second before retrying")
|
||||
await delay(1000)
|
||||
this.didAutomaticallyRetryFailedApiRequest = true
|
||||
} else {
|
||||
// request failed after retrying automatically once, ask user if they want to retry again
|
||||
// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
|
||||
const { response } = await this.ask(
|
||||
"api_req_failed",
|
||||
error.message ?? JSON.stringify(serializeError(error), null, 2),
|
||||
)
|
||||
if (response !== "yesButtonClicked") {
|
||||
// this will never happen since if noButtonClicked, we will clear current task, aborting this instance
|
||||
throw new Error("API request failed")
|
||||
}
|
||||
await this.say("api_req_retried")
|
||||
}
|
||||
await this.say("api_req_retried")
|
||||
// delegate generator output from the recursive call
|
||||
yield* this.attemptApiRequest(previousApiReqIndex)
|
||||
return
|
||||
|
|
@ -1376,6 +1424,8 @@ export class Cline {
|
|||
return `[${block.name} for '${block.params.server_name}']`
|
||||
case "ask_followup_question":
|
||||
return `[${block.name} for '${block.params.question}']`
|
||||
case "plan_mode_response":
|
||||
return `[${block.name}]`
|
||||
case "attempt_completion":
|
||||
return `[${block.name}]`
|
||||
}
|
||||
|
|
@ -2331,7 +2381,12 @@ export class Cline {
|
|||
arguments: mcp_arguments,
|
||||
} satisfies ClineAskUseMcpServer)
|
||||
|
||||
if (this.shouldAutoApproveTool(block.name)) {
|
||||
const isToolAutoApproved = this.providerRef
|
||||
.deref()
|
||||
?.mcpHub?.connections?.find((conn) => conn.server.name === server_name)
|
||||
?.server.tools?.find((tool) => tool.name === tool_name)?.autoApprove
|
||||
|
||||
if (this.shouldAutoApproveTool(block.name) && isToolAutoApproved) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "use_mcp_server")
|
||||
await this.say("use_mcp_server", completeMessage, undefined, false)
|
||||
this.consecutiveAutoApprovedRequestsCount++
|
||||
|
|
@ -2494,6 +2549,56 @@ export class Cline {
|
|||
break
|
||||
}
|
||||
}
|
||||
case "plan_mode_response": {
|
||||
const response: string | undefined = block.params.response
|
||||
try {
|
||||
if (block.partial) {
|
||||
await this.ask("plan_mode_response", removeClosingTag("response", response), block.partial).catch(
|
||||
() => {},
|
||||
)
|
||||
break
|
||||
} else {
|
||||
if (!response) {
|
||||
this.consecutiveMistakeCount++
|
||||
pushToolResult(await this.sayAndCreateMissingParamError("plan_mode_response", "response"))
|
||||
// await this.saveCheckpoint()
|
||||
break
|
||||
}
|
||||
this.consecutiveMistakeCount = 0
|
||||
|
||||
// if (this.autoApprovalSettings.enabled && this.autoApprovalSettings.enableNotifications) {
|
||||
// showSystemNotification({
|
||||
// subtitle: "Cline has a response...",
|
||||
// message: response.replace(/\n/g, " "),
|
||||
// })
|
||||
// }
|
||||
|
||||
this.isAwaitingPlanResponse = true
|
||||
const { text, images } = await this.ask("plan_mode_response", response, false)
|
||||
this.isAwaitingPlanResponse = false
|
||||
|
||||
if (this.didRespondToPlanAskBySwitchingMode) {
|
||||
// await this.say("user_feedback", text ?? "", images)
|
||||
pushToolResult(
|
||||
formatResponse.toolResult(
|
||||
`[The user has switched to ACT MODE, so you may now proceed with the task.]`,
|
||||
images,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
await this.say("user_feedback", text ?? "", images)
|
||||
pushToolResult(formatResponse.toolResult(`<user_message>\n${text}\n</user_message>`, images))
|
||||
}
|
||||
|
||||
// await this.saveCheckpoint()
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
await handleError("responding to inquiry", error)
|
||||
// await this.saveCheckpoint()
|
||||
break
|
||||
}
|
||||
}
|
||||
case "attempt_completion": {
|
||||
/*
|
||||
this.consecutiveMistakeCount = 0
|
||||
|
|
@ -2863,6 +2968,7 @@ export class Cline {
|
|||
this.didAlreadyUseTool = false
|
||||
this.presentAssistantMessageLocked = false
|
||||
this.presentAssistantMessageHasPendingUpdates = false
|
||||
this.didAutomaticallyRetryFailedApiRequest = false
|
||||
await this.diffViewProvider.reset()
|
||||
|
||||
const stream = this.attemptApiRequest(previousApiReqIndex) // yields only if the first chunk is successful, otherwise will allow the user to retry the request (most likely due to rate limit error, which gets thrown on the first chunk)
|
||||
|
|
@ -2973,7 +3079,9 @@ export class Cline {
|
|||
|
||||
// if the model did not tool use, then we need to tell it to either use a tool or attempt_completion
|
||||
const didToolUse = this.assistantMessageContent.some((block) => block.type === "tool_use")
|
||||
|
||||
if (!didToolUse) {
|
||||
// normal request where tool use is required
|
||||
this.userMessageContent.push({
|
||||
type: "text",
|
||||
text: formatResponse.noToolsUsed(),
|
||||
|
|
@ -3150,20 +3258,20 @@ export class Cline {
|
|||
}
|
||||
|
||||
// Add current time information with timezone
|
||||
// const now = new Date()
|
||||
// const formatter = new Intl.DateTimeFormat(undefined, {
|
||||
// year: "numeric",
|
||||
// month: "numeric",
|
||||
// day: "numeric",
|
||||
// hour: "numeric",
|
||||
// minute: "numeric",
|
||||
// second: "numeric",
|
||||
// hour12: true,
|
||||
// })
|
||||
// const timeZone = formatter.resolvedOptions().timeZone
|
||||
// const timeZoneOffset = -now.getTimezoneOffset() / 60 // Convert to hours and invert sign to match conventional notation
|
||||
// const timeZoneOffsetStr = `${timeZoneOffset >= 0 ? "+" : ""}${timeZoneOffset}:00`
|
||||
// details += `\n\n# Current Time\n${formatter.format(now)} (${timeZone}, UTC${timeZoneOffsetStr})`
|
||||
const now = new Date()
|
||||
const formatter = new Intl.DateTimeFormat(undefined, {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
hour12: true,
|
||||
})
|
||||
const timeZone = formatter.resolvedOptions().timeZone
|
||||
const timeZoneOffset = -now.getTimezoneOffset() / 60 // Convert to hours and invert sign to match conventional notation
|
||||
const timeZoneOffsetStr = `${timeZoneOffset >= 0 ? "+" : ""}${timeZoneOffset}:00`
|
||||
details += `\n\n# Current Time\n${formatter.format(now)} (${timeZone}, UTC${timeZoneOffsetStr})`
|
||||
|
||||
if (includeFileDetails) {
|
||||
details += `\n\n# Current Working Directory (${cwd.toPosix()}) Files\n`
|
||||
|
|
@ -3178,6 +3286,17 @@ export class Cline {
|
|||
}
|
||||
}
|
||||
|
||||
details += "\n\n# Current Mode"
|
||||
if (this.chatSettings.mode === "plan") {
|
||||
details += "\nPLAN MODE"
|
||||
details +=
|
||||
"\nIn this mode you should focus on information gathering, asking questions, and architecting a solution. Once you have a plan, use the plan_mode_response tool to engage in a conversational back and forth with the user. Do not use the plan_mode_response tool until you've gathered all the information you need e.g. with read_file or ask_followup_question."
|
||||
details +=
|
||||
'\n(Remember: If it seems the user wants you to use tools only available in Act Mode, you should ask the user to "toggle to Act mode" (use those words) - they will have to manually do this themselves with the Plan/Act toggle button below. You do not have the ability to switch to Act Mode yourself, and must wait for the user to do it themselves once they are satisfied with the plan.)'
|
||||
} else {
|
||||
details += "\nACT MODE"
|
||||
}
|
||||
|
||||
return `<environment_details>\n${details.trim()}\n</environment_details>`
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const toolUseNames = [
|
|||
"use_mcp_tool",
|
||||
"access_mcp_resource",
|
||||
"ask_followup_question",
|
||||
"plan_mode_response",
|
||||
"attempt_completion",
|
||||
] as const
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ export const toolParamNames = [
|
|||
"arguments",
|
||||
"uri",
|
||||
"question",
|
||||
"response",
|
||||
"result",
|
||||
] as const
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ import defaultShell from "default-shell"
|
|||
import os from "os"
|
||||
import osName from "os-name"
|
||||
import { McpHub } from "../../services/mcp/McpHub"
|
||||
import { BrowserSettings } from "../../shared/BrowserSettings"
|
||||
|
||||
export const SYSTEM_PROMPT = async (
|
||||
cwd: string,
|
||||
supportsComputerUse: boolean,
|
||||
mcpHub: McpHub,
|
||||
browserSettings: BrowserSettings,
|
||||
) => `You are Cline, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.
|
||||
|
||||
====
|
||||
|
|
@ -143,7 +145,7 @@ Usage:
|
|||
Description: Request to interact with a Puppeteer-controlled browser. Every action, except \`close\`, will be responded to with a screenshot of the browser's current state, along with any new console logs. You may only perform one browser action per message, and wait for the user's response including a screenshot and logs to determine the next action.
|
||||
- The sequence of actions **must always start with** launching the browser at a URL, and **must always end with** closing the browser. If you need to visit a new URL that is not possible to navigate to from the current webpage, you must first close the browser, then launch again at the new URL.
|
||||
- While the browser is active, only the \`browser_action\` tool can be used. No other tools should be called during this time. You may proceed to use other tools only after closing the browser. For example if you run into an error and need to fix a file, you must close the browser, then use other tools to make the necessary changes, then re-launch the browser to verify the result.
|
||||
- The browser window has a resolution of **900x600** pixels. When performing any click actions, ensure the coordinates are within this resolution range.
|
||||
- The browser window has a resolution of **${browserSettings.viewport.width}x${browserSettings.viewport.height}** pixels. When performing any click actions, ensure the coordinates are within this resolution range.
|
||||
- Before clicking on any elements such as icons, links, or buttons, you must consult the provided screenshot of the page to determine the coordinates of the element. The click should be targeted at the **center of the element**, not on its edges.
|
||||
Parameters:
|
||||
- action: (required) The action to perform. The available actions are:
|
||||
|
|
@ -161,7 +163,7 @@ Parameters:
|
|||
- Example: \`<action>close</action>\`
|
||||
- url: (optional) Use this for providing the URL for the \`launch\` action.
|
||||
* Example: <url>https://example.com</url>
|
||||
- coordinate: (optional) The X and Y coordinates for the \`click\` action. Coordinates should be within the **900x600** resolution.
|
||||
- coordinate: (optional) The X and Y coordinates for the \`click\` action. Coordinates should be within the **${browserSettings.viewport.width}x${browserSettings.viewport.height}** resolution.
|
||||
* Example: <coordinate>450,300</coordinate>
|
||||
- text: (optional) Use this for providing the text for the \`type\` action.
|
||||
* Example: <text>Hello, world!</text>
|
||||
|
|
@ -175,6 +177,9 @@ Usage:
|
|||
: ""
|
||||
}
|
||||
|
||||
${
|
||||
mcpHub.isMcpEnabled()
|
||||
? `
|
||||
## use_mcp_tool
|
||||
Description: Request to use a tool provided by a connected MCP server. Each MCP server can provide multiple tools with different capabilities. Tools have defined input schemas that specify required and optional parameters.
|
||||
Parameters:
|
||||
|
|
@ -203,6 +208,9 @@ Usage:
|
|||
<server_name>server name here</server_name>
|
||||
<uri>resource URI here</uri>
|
||||
</access_mcp_resource>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
## ask_followup_question
|
||||
Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
|
||||
|
|
@ -227,6 +235,15 @@ Your final result description here
|
|||
<command>Command to demonstrate result (optional)</command>
|
||||
</attempt_completion>
|
||||
|
||||
## plan_mode_response
|
||||
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should be used when you need to provide a response to a question or statement from the user about how you plan to accomplish the task. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN MODE then you should not use this tool. Depending on the user's message, you may ask questions to get clarification about the user's request, architect a solution to the task, and to brainstorm ideas with the user. For example, if the user's task is to create a website, you may start by asking some clarifying questions, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT MODE to implement the solution.
|
||||
Parameters:
|
||||
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response.
|
||||
Usage:
|
||||
<plan_mode_response>
|
||||
<response>Your response here</response>
|
||||
</plan_mode_response>
|
||||
|
||||
# Tool Use Examples
|
||||
|
||||
## Example 1: Requesting to execute a command
|
||||
|
|
@ -236,27 +253,7 @@ Your final result description here
|
|||
<requires_approval>false</requires_approval>
|
||||
</execute_command>
|
||||
|
||||
## Example 2: Requesting to use an MCP tool
|
||||
|
||||
<use_mcp_tool>
|
||||
<server_name>weather-server</server_name>
|
||||
<tool_name>get_forecast</tool_name>
|
||||
<arguments>
|
||||
{
|
||||
"city": "San Francisco",
|
||||
"days": 5
|
||||
}
|
||||
</arguments>
|
||||
</use_mcp_tool>
|
||||
|
||||
## Example 3: Requesting to access an MCP resource
|
||||
|
||||
<access_mcp_resource>
|
||||
<server_name>weather-server</server_name>
|
||||
<uri>weather://san-francisco/current</uri>
|
||||
</access_mcp_resource>
|
||||
|
||||
## Example 4: Requesting to create a new file
|
||||
## Example 2: Requesting to create a new file
|
||||
|
||||
<write_to_file>
|
||||
<path>src/frontend-config.json</path>
|
||||
|
|
@ -278,7 +275,7 @@ Your final result description here
|
|||
</content>
|
||||
</write_to_file>
|
||||
|
||||
## Example 6: Requesting to make targeted edits to a file
|
||||
## Example 3: Requesting to make targeted edits to a file
|
||||
|
||||
<replace_in_file>
|
||||
<path>src/components/App.tsx</path>
|
||||
|
|
@ -312,6 +309,31 @@ return (
|
|||
>>>>>>> REPLACE
|
||||
</diff>
|
||||
</replace_in_file>
|
||||
${
|
||||
mcpHub.isMcpEnabled()
|
||||
? `
|
||||
|
||||
## Example 4: Requesting to use an MCP tool
|
||||
|
||||
<use_mcp_tool>
|
||||
<server_name>weather-server</server_name>
|
||||
<tool_name>get_forecast</tool_name>
|
||||
<arguments>
|
||||
{
|
||||
"city": "San Francisco",
|
||||
"days": 5
|
||||
}
|
||||
</arguments>
|
||||
</use_mcp_tool>
|
||||
|
||||
## Example 5: Requesting to access an MCP resource
|
||||
|
||||
<access_mcp_resource>
|
||||
<server_name>weather-server</server_name>
|
||||
<uri>weather://san-francisco/current</uri>
|
||||
</access_mcp_resource>`
|
||||
: ""
|
||||
}
|
||||
|
||||
# Tool Use Guidelines
|
||||
|
||||
|
|
@ -334,6 +356,9 @@ It is crucial to proceed step-by-step, waiting for the user's message after each
|
|||
|
||||
By waiting for and carefully considering the user's response after each tool use, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work.
|
||||
|
||||
${
|
||||
mcpHub.isMcpEnabled()
|
||||
? `
|
||||
====
|
||||
|
||||
MCP SERVERS
|
||||
|
|
@ -701,6 +726,8 @@ npm run build
|
|||
|
||||
5. Install the MCP Server by adding the MCP server configuration to the settings file located at '${await mcpHub.getMcpSettingsFilePath()}'. The settings file may have other MCP servers already configured, so you would read it first and then add your new server to the existing \`mcpServers\` object.
|
||||
|
||||
IMPORTANT: Regardless of what else you see in the MCP settings file, you must default any new MCP servers you create to disabled=false and autoApprove=[].
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"mcpServers": {
|
||||
|
|
@ -724,12 +751,13 @@ npm run build
|
|||
|
||||
## Editing MCP Servers
|
||||
|
||||
The user may ask to add tools or resources that may make sense to add to an existing MCP server (listed under 'Connected MCP Servers' above: ${
|
||||
mcpHub
|
||||
.getServers()
|
||||
.map((server) => server.name)
|
||||
.join(", ") || "(None running currently)"
|
||||
}, e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use replace_in_file to make changes to the files.
|
||||
The user may ask to add tools or resources that may make sense to add to an existing MCP server (listed under 'Connected MCP Servers' below: ${
|
||||
mcpHub
|
||||
.getServers()
|
||||
.filter((server) => server.status === "connected")
|
||||
.map((server) => server.name)
|
||||
.join(", ") || "(None running currently)"
|
||||
}, e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use replace_in_file to make changes to the files.
|
||||
|
||||
However some MCP servers may be running from installed packages rather than a local repository, in which case it may make more sense to create a new MCP server.
|
||||
|
||||
|
|
@ -738,7 +766,9 @@ However some MCP servers may be running from installed packages rather than a lo
|
|||
The user may not always request the use or creation of MCP servers. Instead, they might provide tasks that can be completed with existing tools. While using the MCP SDK to extend your capabilities can be useful, it's important to understand that this is just one specialized type of task you can accomplish. You should only implement MCP servers when the user explicitly requests it (e.g., "add a tool that...").
|
||||
|
||||
Remember: The MCP documentation and example provided above are to help you understand and work with existing MCP servers or create new ones when requested by the user. You already have access to tools and capabilities that can be used to accomplish a wide range of tasks.
|
||||
|
||||
`
|
||||
: ""
|
||||
}
|
||||
====
|
||||
|
||||
EDITING FILES
|
||||
|
|
@ -816,6 +846,26 @@ By thoughtfully selecting between write_to_file and replace_in_file, you can mak
|
|||
|
||||
====
|
||||
|
||||
ACT MODE V.S. PLAN MODE
|
||||
|
||||
In each user message, the environment_details will specify the current mode. There are two modes:
|
||||
|
||||
- ACT MODE: In this mode, you have access to all tools EXCEPT the plan_mode_response tool.
|
||||
- In ACT MODE, you use tools to accomplish the user's task. Once you've completed the user's task, you use the attempt_completion tool to present the result of the task to the user.
|
||||
- PLAN MODE: In this special mode, you have access to the plan_mode_response tool.
|
||||
- In PLAN MODE, the goal is to gather information and get context to create a detailed plan for accomplishing the task, which the user will review and approve before they switch you to ACT MODE to implement the solution.
|
||||
- In PLAN MODE, when you need to converse with the user or present a plan, you should use the plan_mode_response tool to deliver your response directly, rather than using <thinking> tags to analyze when to respond. Do not talk about using plan_mode_response - just use it directly to share your thoughts and provide helpful answers.
|
||||
|
||||
## What is PLAN MODE?
|
||||
|
||||
- While you are usually in ACT MODE, the user may switch to PLAN MODE in order to have a back and forth with you to plan how to best accomplish the task.
|
||||
- When starting in PLAN MODE, depending on the user's request, you may need to do some information gathering e.g. using read_file or search_files to get more context about the task. You may also ask the user clarifying questions to get a better understanding of the task.
|
||||
- Once you've gained more context about the user's request, you should architect a detailed plan for how you will accomplish the task.
|
||||
- Then you might ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and plan the best way to accomplish it.
|
||||
- Finally once it seems like you've reached a good plan, ask the user to switch you back to ACT MODE to implement the solution.
|
||||
|
||||
====
|
||||
|
||||
CAPABILITIES
|
||||
|
||||
- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search${
|
||||
|
|
@ -830,7 +880,13 @@ CAPABILITIES
|
|||
? "\n- You can use the browser_action tool to interact with websites (including html files and locally running development servers) through a Puppeteer-controlled browser when you feel it is necessary in accomplishing the user's task. This tool is particularly useful for web development tasks as it allows you to launch a browser, navigate to pages, interact with elements through clicks and keyboard input, and capture the results through screenshots and console logs. This tool may be useful at key stages of web development tasks-such as after implementing new features, making substantial changes, when troubleshooting issues, or to verify the result of your work. You can analyze the provided screenshots to ensure correct rendering or identify errors, and review console logs for runtime issues.\n - For example, if asked to add a component to a react website, you might create the necessary files, use execute_command to run the site locally, then use browser_action to launch the browser, navigate to the local server, and verify the component renders & functions correctly before closing the browser."
|
||||
: ""
|
||||
}
|
||||
${
|
||||
mcpHub.isMcpEnabled()
|
||||
? `
|
||||
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
====
|
||||
|
||||
|
|
@ -851,7 +907,7 @@ RULES
|
|||
- The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it.
|
||||
- Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation.${
|
||||
supportsComputerUse
|
||||
? '\n- The user may ask generic non-development tasks, such as "what\'s the latest news" or "look up the weather in San Diego", in which case you might use the browser_action tool to complete the task if it makes sense to do so, rather than trying to create a website or using curl to answer the question. However, if an available MCP server tool or resource can be used instead, you should prefer to use it over browser_action.'
|
||||
? `\n- The user may ask generic non-development tasks, such as "what\'s the latest news" or "look up the weather in San Diego", in which case you might use the browser_action tool to complete the task if it makes sense to do so, rather than trying to create a website or using curl to answer the question.${mcpHub.isMcpEnabled() ? "However, if an available MCP server tool or resource can be used instead, you should prefer to use it over browser_action." : ""}`
|
||||
: ""
|
||||
}
|
||||
- NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user.
|
||||
|
|
@ -859,7 +915,6 @@ RULES
|
|||
- When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task.
|
||||
- At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details.
|
||||
- Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn't need to start it again. If no active terminals are listed, proceed with command execution as normal.
|
||||
- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations.
|
||||
- When using the replace_in_file tool, you must include complete lines in your SEARCH blocks, not partial lines. The system requires exact line matches and cannot match partial lines. For example, if you want to match a line containing "const x = 5;", your SEARCH block must include the entire line, not just "x = 5" or other fragments.
|
||||
- When using the replace_in_file tool, if you use multiple SEARCH/REPLACE blocks, list them in the order they appear in the file. For example if you need to make changes to both line 10 and line 50, first include the SEARCH/REPLACE block for line 10, followed by the SEARCH/REPLACE block for line 50.
|
||||
- It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc.${
|
||||
|
|
@ -867,6 +922,13 @@ RULES
|
|||
? " Then if you want to test your work, you might use browser_action to launch the site, wait for the user's response confirming the site was launched along with a screenshot, then perhaps e.g., click a button to test functionality if needed, wait for the user's response confirming the button was clicked along with a screenshot of the new state, before finally closing the browser."
|
||||
: ""
|
||||
}
|
||||
${
|
||||
mcpHub.isMcpEnabled()
|
||||
? `
|
||||
- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations.
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
====
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
|
|||
import axios from "axios"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import crypto from "crypto"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
|
|
@ -12,6 +13,7 @@ import { selectImages } from "../../integrations/misc/process-images"
|
|||
import { getTheme } from "../../integrations/theme/getTheme"
|
||||
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
|
||||
import { McpHub } from "../../services/mcp/McpHub"
|
||||
import { FirebaseAuthManager, UserInfo } from "../../services/auth/FirebaseAuthManager"
|
||||
import { ApiProvider, ModelInfo } from "../../shared/api"
|
||||
import { findLast } from "../../shared/array"
|
||||
import { ExtensionMessage, ExtensionState } from "../../shared/ExtensionMessage"
|
||||
|
|
@ -23,6 +25,8 @@ import { openMention } from "../mentions"
|
|||
import { getNonce } from "./getNonce"
|
||||
import { getUri } from "./getUri"
|
||||
import { AutoApprovalSettings, DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../shared/AutoApprovalSettings"
|
||||
import { BrowserSettings, DEFAULT_BROWSER_SETTINGS } from "../../shared/BrowserSettings"
|
||||
import { ChatSettings, DEFAULT_CHAT_SETTINGS } from "../../shared/ChatSettings"
|
||||
|
||||
/*
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
|
|
@ -40,6 +44,9 @@ type SecretKey =
|
|||
| "geminiApiKey"
|
||||
| "openAiNativeApiKey"
|
||||
| "deepSeekApiKey"
|
||||
| "mistralApiKey"
|
||||
| "authToken"
|
||||
| "authNonce"
|
||||
type GlobalStateKey =
|
||||
| "apiProvider"
|
||||
| "apiModelId"
|
||||
|
|
@ -61,6 +68,10 @@ type GlobalStateKey =
|
|||
| "openRouterModelId"
|
||||
| "openRouterModelInfo"
|
||||
| "autoApprovalSettings"
|
||||
| "browserSettings"
|
||||
| "chatSettings"
|
||||
| "vsCodeLmModelSelector"
|
||||
| "userInfo"
|
||||
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
|
|
@ -79,7 +90,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
private cline?: Cline
|
||||
private workspaceTracker?: WorkspaceTracker
|
||||
mcpHub?: McpHub
|
||||
private latestAnnouncementId = "jan-6-2025" // update to some unique identifier when we add a new announcement
|
||||
private authManager: FirebaseAuthManager
|
||||
private latestAnnouncementId = "jan-20-2025" // update to some unique identifier when we add a new announcement
|
||||
|
||||
constructor(
|
||||
readonly context: vscode.ExtensionContext,
|
||||
|
|
@ -89,6 +101,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
ClineProvider.activeInstances.add(this)
|
||||
this.workspaceTracker = new WorkspaceTracker(this)
|
||||
this.mcpHub = new McpHub(this)
|
||||
this.authManager = new FirebaseAuthManager(this)
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -114,10 +127,29 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
this.workspaceTracker = undefined
|
||||
this.mcpHub?.dispose()
|
||||
this.mcpHub = undefined
|
||||
this.authManager.dispose()
|
||||
this.outputChannel.appendLine("Disposed all disposables")
|
||||
ClineProvider.activeInstances.delete(this)
|
||||
}
|
||||
|
||||
// Auth methods
|
||||
async handleSignOut() {
|
||||
try {
|
||||
await this.authManager.signOut()
|
||||
vscode.window.showInformationMessage("Successfully logged out of Cline")
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage("Logout failed")
|
||||
}
|
||||
}
|
||||
|
||||
async setAuthToken(token?: string) {
|
||||
await this.storeSecret("authToken", token)
|
||||
}
|
||||
|
||||
async setUserInfo(info?: { displayName: string | null; email: string | null; photoURL: string | null }) {
|
||||
await this.updateGlobalState("userInfo", info)
|
||||
}
|
||||
|
||||
public static getVisibleInstance(): ClineProvider | undefined {
|
||||
return findLast(Array.from(this.activeInstances), (instance) => instance.view?.visible === true)
|
||||
}
|
||||
|
|
@ -210,17 +242,30 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
|
||||
async initClineWithTask(task?: string, images?: string[]) {
|
||||
await this.clearTask() // ensures that an exising task doesn't exist before starting a new one, although this shouldn't be possible since user must clear task before starting a new one
|
||||
const { apiConfiguration, customInstructions, autoApprovalSettings } = await this.getState()
|
||||
this.cline = new Cline(this, apiConfiguration, autoApprovalSettings, customInstructions, task, images)
|
||||
}
|
||||
|
||||
async initClineWithHistoryItem(historyItem: HistoryItem) {
|
||||
await this.clearTask()
|
||||
const { apiConfiguration, customInstructions, autoApprovalSettings } = await this.getState()
|
||||
const { apiConfiguration, customInstructions, autoApprovalSettings, browserSettings, chatSettings } =
|
||||
await this.getState()
|
||||
this.cline = new Cline(
|
||||
this,
|
||||
apiConfiguration,
|
||||
autoApprovalSettings,
|
||||
browserSettings,
|
||||
chatSettings,
|
||||
customInstructions,
|
||||
task,
|
||||
images,
|
||||
)
|
||||
}
|
||||
|
||||
async initClineWithHistoryItem(historyItem: HistoryItem) {
|
||||
await this.clearTask()
|
||||
const { apiConfiguration, customInstructions, autoApprovalSettings, browserSettings, chatSettings } =
|
||||
await this.getState()
|
||||
this.cline = new Cline(
|
||||
this,
|
||||
apiConfiguration,
|
||||
autoApprovalSettings,
|
||||
browserSettings,
|
||||
chatSettings,
|
||||
customInstructions,
|
||||
undefined,
|
||||
undefined,
|
||||
|
|
@ -294,7 +339,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource}; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} data:; script-src 'nonce-${nonce}';">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource}; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https: data:; script-src 'nonce-${nonce}';">
|
||||
<link rel="stylesheet" type="text/css" href="${stylesUri}">
|
||||
<link href="${codiconsUri}" rel="stylesheet" />
|
||||
<title>Cline</title>
|
||||
|
|
@ -389,9 +434,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
mistralApiKey,
|
||||
azureApiVersion,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
vsCodeLmModelSelector,
|
||||
} = message.apiConfiguration
|
||||
await this.updateGlobalState("apiProvider", apiProvider)
|
||||
await this.updateGlobalState("apiModelId", apiModelId)
|
||||
|
|
@ -415,9 +462,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
await this.storeSecret("geminiApiKey", geminiApiKey)
|
||||
await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey)
|
||||
await this.storeSecret("deepSeekApiKey", deepSeekApiKey)
|
||||
await this.storeSecret("mistralApiKey", mistralApiKey)
|
||||
await this.updateGlobalState("azureApiVersion", azureApiVersion)
|
||||
await this.updateGlobalState("openRouterModelId", openRouterModelId)
|
||||
await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
|
||||
await this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector)
|
||||
if (this.cline) {
|
||||
this.cline.api = buildApiHandler(message.apiConfiguration)
|
||||
}
|
||||
|
|
@ -436,6 +485,41 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
await this.postStateToWebview()
|
||||
}
|
||||
break
|
||||
case "browserSettings":
|
||||
if (message.browserSettings) {
|
||||
await this.updateGlobalState("browserSettings", message.browserSettings)
|
||||
if (this.cline) {
|
||||
this.cline.updateBrowserSettings(message.browserSettings)
|
||||
}
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
break
|
||||
case "chatSettings":
|
||||
if (message.chatSettings) {
|
||||
const didSwitchToActMode = message.chatSettings.mode === "act"
|
||||
await this.updateGlobalState("chatSettings", message.chatSettings)
|
||||
await this.postStateToWebview()
|
||||
if (this.cline) {
|
||||
this.cline.updateChatSettings(message.chatSettings)
|
||||
if (this.cline.isAwaitingPlanResponse && didSwitchToActMode) {
|
||||
this.cline.didRespondToPlanAskBySwitchingMode = true
|
||||
// this is necessary for the webview to update accordingly, but Cline instance will not send text back as feedback message
|
||||
await this.postMessageToWebview({
|
||||
type: "invoke",
|
||||
invoke: "sendMessage",
|
||||
text: "[Proceeding with the task...]",
|
||||
})
|
||||
} else {
|
||||
this.cancelTask()
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
// case "relaunchChromeDebugMode":
|
||||
// if (this.cline) {
|
||||
// this.cline.browserSession.relaunchChromeDebugMode()
|
||||
// }
|
||||
// break
|
||||
case "askResponse":
|
||||
this.cline?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
|
||||
break
|
||||
|
|
@ -487,6 +571,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
lmStudioModels,
|
||||
})
|
||||
break
|
||||
case "requestVsCodeLmModels":
|
||||
const vsCodeLmModels = await this.getVsCodeLmModels()
|
||||
this.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels })
|
||||
break
|
||||
case "refreshOpenRouterModels":
|
||||
await this.refreshOpenRouterModels()
|
||||
break
|
||||
|
|
@ -529,6 +617,30 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
case "cancelTask":
|
||||
this.cancelTask()
|
||||
break
|
||||
case "getLatestState":
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "accountLoginClicked": {
|
||||
// Generate nonce for state validation
|
||||
const nonce = crypto.randomBytes(32).toString("hex")
|
||||
await this.storeSecret("authNonce", nonce)
|
||||
|
||||
// Open browser for authentication with state param
|
||||
console.log("Login button clicked in account page")
|
||||
console.log("Opening auth page with state param")
|
||||
|
||||
const uriScheme = vscode.env.uriScheme
|
||||
|
||||
const authUrl = vscode.Uri.parse(
|
||||
`https://app.cline.bot/auth?state=${encodeURIComponent(nonce)}&callback_url=${encodeURIComponent(`${uriScheme || "vscode"}://saoudrizwan.claude-dev/auth`)}`,
|
||||
)
|
||||
vscode.env.openExternal(authUrl)
|
||||
break
|
||||
}
|
||||
case "accountLogoutClicked": {
|
||||
await this.handleSignOut()
|
||||
break
|
||||
}
|
||||
case "openMcpSettings": {
|
||||
const mcpSettingsFilePath = await this.mcpHub?.getMcpSettingsFilePath()
|
||||
if (mcpSettingsFilePath) {
|
||||
|
|
@ -536,6 +648,22 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
}
|
||||
break
|
||||
}
|
||||
case "toggleMcpServer": {
|
||||
try {
|
||||
await this.mcpHub?.toggleServerDisabled(message.serverName!, message.disabled!)
|
||||
} catch (error) {
|
||||
console.error(`Failed to toggle MCP server ${message.serverName}:`, error)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "toggleToolAutoApprove": {
|
||||
try {
|
||||
await this.mcpHub?.toggleToolAutoApprove(message.serverName!, message.toolName!, message.autoApprove!)
|
||||
} catch (error) {
|
||||
console.error(`Failed to toggle auto-approve for tool ${message.toolName}:`, error)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "restartMcpServer": {
|
||||
try {
|
||||
await this.mcpHub?.restartConnection(message.text!)
|
||||
|
|
@ -544,6 +672,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
}
|
||||
break
|
||||
}
|
||||
case "openExtensionSettings": {
|
||||
await vscode.commands.executeCommand("workbench.action.openSettings", "@ext:saoudrizwan.claude-dev")
|
||||
break
|
||||
}
|
||||
// Add more switch case statements here as more webview message commands
|
||||
// are created within the webview context (i.e. inside media/main.js)
|
||||
}
|
||||
|
|
@ -562,7 +694,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
console.error("Failed to abort task", error)
|
||||
}
|
||||
await pWaitFor(
|
||||
() => this.cline === undefined || this.cline.isStreaming === false || this.cline.didFinishAbortingStream,
|
||||
() =>
|
||||
this.cline === undefined ||
|
||||
this.cline.isStreaming === false ||
|
||||
this.cline.didFinishAbortingStream ||
|
||||
this.cline.isWaitingForFirstChunk, // if only first chunk is processed, then there's no need to wait for graceful abort (closes edits, browser, etc)
|
||||
{
|
||||
timeout: 3_000,
|
||||
},
|
||||
|
|
@ -605,6 +741,18 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
return settingsDir
|
||||
}
|
||||
|
||||
// VSCode LM API
|
||||
|
||||
private async getVsCodeLmModels() {
|
||||
try {
|
||||
const models = await vscode.lm.selectChatModels({})
|
||||
return models || []
|
||||
} catch (error) {
|
||||
console.error("Error fetching VS Code LM models:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Ollama
|
||||
|
||||
async getOllamaModels(baseUrl?: string) {
|
||||
|
|
@ -643,6 +791,32 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
}
|
||||
}
|
||||
|
||||
// Auth
|
||||
|
||||
public async validateAuthState(state: string | null): Promise<boolean> {
|
||||
const storedNonce = await this.getSecret("authNonce")
|
||||
if (!state || state !== storedNonce) {
|
||||
return false
|
||||
}
|
||||
await this.storeSecret("authNonce", undefined) // Clear after use
|
||||
return true
|
||||
}
|
||||
|
||||
async handleAuthCallback(token: string) {
|
||||
try {
|
||||
// First sign in with Firebase to trigger auth state change
|
||||
await this.authManager.signInWithCustomToken(token)
|
||||
|
||||
// Then store the token securely
|
||||
await this.storeSecret("authToken", token)
|
||||
await this.postStateToWebview()
|
||||
vscode.window.showInformationMessage("Successfully logged in to Cline")
|
||||
} catch (error) {
|
||||
console.error("Failed to handle auth callback:", error)
|
||||
vscode.window.showErrorMessage("Failed to log in to Cline")
|
||||
}
|
||||
}
|
||||
|
||||
// OpenRouter
|
||||
|
||||
async handleOpenRouterCallback(code: string) {
|
||||
|
|
@ -908,8 +1082,18 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
}
|
||||
|
||||
async getStateToPostToWebview(): Promise<ExtensionState> {
|
||||
const { apiConfiguration, lastShownAnnouncementId, customInstructions, taskHistory, autoApprovalSettings } =
|
||||
await this.getState()
|
||||
const {
|
||||
apiConfiguration,
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
taskHistory,
|
||||
autoApprovalSettings,
|
||||
browserSettings,
|
||||
chatSettings,
|
||||
userInfo,
|
||||
} = await this.getState()
|
||||
|
||||
const authToken = await this.getSecret("authToken")
|
||||
return {
|
||||
version: this.context.extension?.packageJSON?.version ?? "",
|
||||
apiConfiguration,
|
||||
|
|
@ -921,6 +1105,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
taskHistory: (taskHistory || []).filter((item) => item.ts && item.task).sort((a, b) => b.ts - a.ts),
|
||||
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
|
||||
autoApprovalSettings,
|
||||
browserSettings,
|
||||
chatSettings,
|
||||
isLoggedIn: !!authToken,
|
||||
userInfo,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -999,6 +1187,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
mistralApiKey,
|
||||
azureApiVersion,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
|
|
@ -1006,6 +1195,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
customInstructions,
|
||||
taskHistory,
|
||||
autoApprovalSettings,
|
||||
browserSettings,
|
||||
chatSettings,
|
||||
vsCodeLmModelSelector,
|
||||
userInfo,
|
||||
] = await Promise.all([
|
||||
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
|
||||
this.getGlobalState("apiModelId") as Promise<string | undefined>,
|
||||
|
|
@ -1029,6 +1222,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
this.getSecret("geminiApiKey") as Promise<string | undefined>,
|
||||
this.getSecret("openAiNativeApiKey") as Promise<string | undefined>,
|
||||
this.getSecret("deepSeekApiKey") as Promise<string | undefined>,
|
||||
this.getSecret("mistralApiKey") as Promise<string | undefined>,
|
||||
this.getGlobalState("azureApiVersion") as Promise<string | undefined>,
|
||||
this.getGlobalState("openRouterModelId") as Promise<string | undefined>,
|
||||
this.getGlobalState("openRouterModelInfo") as Promise<ModelInfo | undefined>,
|
||||
|
|
@ -1036,6 +1230,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
this.getGlobalState("customInstructions") as Promise<string | undefined>,
|
||||
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
|
||||
this.getGlobalState("autoApprovalSettings") as Promise<AutoApprovalSettings | undefined>,
|
||||
this.getGlobalState("browserSettings") as Promise<BrowserSettings | undefined>,
|
||||
this.getGlobalState("chatSettings") as Promise<ChatSettings | undefined>,
|
||||
this.getGlobalState("vsCodeLmModelSelector") as Promise<vscode.LanguageModelChatSelector | undefined>,
|
||||
this.getGlobalState("userInfo") as Promise<UserInfo | undefined>,
|
||||
])
|
||||
|
||||
let apiProvider: ApiProvider
|
||||
|
|
@ -1076,14 +1274,19 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
mistralApiKey,
|
||||
azureApiVersion,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
vsCodeLmModelSelector,
|
||||
},
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
taskHistory,
|
||||
autoApprovalSettings: autoApprovalSettings || DEFAULT_AUTO_APPROVAL_SETTINGS, // default value can be 0 or empty string
|
||||
browserSettings: browserSettings || DEFAULT_BROWSER_SETTINGS,
|
||||
chatSettings: chatSettings || DEFAULT_CHAT_SETTINGS,
|
||||
userInfo,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1139,7 +1342,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
}
|
||||
}
|
||||
|
||||
private async getSecret(key: SecretKey) {
|
||||
async getSecret(key: SecretKey) {
|
||||
return await this.context.secrets.get(key)
|
||||
}
|
||||
|
||||
|
|
@ -1160,6 +1363,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
"geminiApiKey",
|
||||
"openAiNativeApiKey",
|
||||
"deepSeekApiKey",
|
||||
"mistralApiKey",
|
||||
"authToken",
|
||||
]
|
||||
for (const key of secretKeys) {
|
||||
await this.storeSecret(key, undefined)
|
||||
|
|
|
|||
|
|
@ -110,6 +110,15 @@ export function activate(context: vscode.ExtensionContext) {
|
|||
}),
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.accountLoginClicked", () => {
|
||||
sidebarProvider.postMessageToWebview({
|
||||
type: "action",
|
||||
action: "accountLoginClicked",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
/*
|
||||
We use the text document content provider API to show the left side for diff view by creating a virtual document for the original content. This makes it readonly so users know to edit the right side if they want to keep their changes.
|
||||
|
||||
|
|
@ -126,6 +135,12 @@ export function activate(context: vscode.ExtensionContext) {
|
|||
|
||||
// URI Handler
|
||||
const handleUri = async (uri: vscode.Uri) => {
|
||||
console.log("URI Handler called with:", {
|
||||
path: uri.path,
|
||||
query: uri.query,
|
||||
scheme: uri.scheme,
|
||||
})
|
||||
|
||||
const path = uri.path
|
||||
const query = new URLSearchParams(uri.query.replace(/\+/g, "%2B"))
|
||||
const visibleProvider = ClineProvider.getVisibleInstance()
|
||||
|
|
@ -140,6 +155,26 @@ export function activate(context: vscode.ExtensionContext) {
|
|||
}
|
||||
break
|
||||
}
|
||||
case "/auth": {
|
||||
const token = query.get("token")
|
||||
const state = query.get("state")
|
||||
|
||||
console.log("Auth callback received:", {
|
||||
token: token,
|
||||
state: state,
|
||||
})
|
||||
|
||||
// Validate state parameter
|
||||
if (!(await visibleProvider.validateAuthState(state))) {
|
||||
vscode.window.showErrorMessage("Invalid auth state")
|
||||
return
|
||||
}
|
||||
|
||||
if (token) {
|
||||
await visibleProvider.handleAuthCallback(token)
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
|
|||
73
src/integrations/debug/DebugConsoleManager.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import * as vscode from "vscode"
|
||||
|
||||
interface DebugSession {
|
||||
id: string
|
||||
name: string
|
||||
output: string[]
|
||||
lastRetrievedIndex: number
|
||||
}
|
||||
|
||||
export class DebugConsoleManager {
|
||||
private sessions: Map<string, DebugSession> = new Map()
|
||||
private disposables: vscode.Disposable[] = []
|
||||
|
||||
constructor() {
|
||||
// Listen for debug session start events
|
||||
this.disposables.push(
|
||||
vscode.debug.onDidStartDebugSession((session) => {
|
||||
this.sessions.set(session.id, {
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
output: [],
|
||||
lastRetrievedIndex: -1,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
// Listen for debug session end events
|
||||
this.disposables.push(
|
||||
vscode.debug.onDidTerminateDebugSession((session) => {
|
||||
this.sessions.delete(session.id)
|
||||
}),
|
||||
)
|
||||
|
||||
// Listen for debug console output
|
||||
this.disposables.push(
|
||||
vscode.debug.onDidReceiveDebugSessionCustomEvent((e: vscode.DebugSessionCustomEvent) => {
|
||||
if (e.event === "output" && e.body?.output) {
|
||||
const session = this.sessions.get(e.session.id)
|
||||
if (session) {
|
||||
session.output.push(e.body.output)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active debug sessions
|
||||
*/
|
||||
getActiveSessions(): { id: string; name: string }[] {
|
||||
return Array.from(this.sessions.values()).map(({ id, name }) => ({ id, name }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get any new output since the last retrieval for a specific debug session
|
||||
*/
|
||||
getUnretrievedOutput(sessionId: string): string | undefined {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) return undefined
|
||||
|
||||
const newOutput = session.output.slice(session.lastRetrievedIndex + 1).join("")
|
||||
session.lastRetrievedIndex = session.output.length - 1
|
||||
return newOutput || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
dispose() {
|
||||
this.disposables.forEach((d) => d.dispose())
|
||||
this.sessions.clear()
|
||||
}
|
||||
}
|
||||
99
src/services/auth/FirebaseAuthManager.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { initializeApp } from "firebase/app"
|
||||
import { Auth, User, getAuth, onAuthStateChanged, signInWithCustomToken, signOut } from "firebase/auth"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { firebaseConfig } from "./config"
|
||||
|
||||
export interface UserInfo {
|
||||
displayName: string | null
|
||||
email: string | null
|
||||
photoURL: string | null
|
||||
}
|
||||
|
||||
export class FirebaseAuthManager {
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
private auth: Auth
|
||||
private disposables: vscode.Disposable[] = []
|
||||
|
||||
constructor(provider: ClineProvider) {
|
||||
console.log("Initializing FirebaseAuthManager", { provider })
|
||||
this.providerRef = new WeakRef(provider)
|
||||
const app = initializeApp(firebaseConfig)
|
||||
this.auth = getAuth(app)
|
||||
console.log("Firebase app initialized", { appConfig: firebaseConfig })
|
||||
|
||||
// Auth state listener
|
||||
onAuthStateChanged(this.auth, this.handleAuthStateChange.bind(this))
|
||||
console.log("Auth state change listener added")
|
||||
|
||||
// Try to restore session
|
||||
this.restoreSession()
|
||||
}
|
||||
|
||||
private async restoreSession() {
|
||||
console.log("Attempting to restore session")
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
console.log("Provider reference lost during session restore")
|
||||
return
|
||||
}
|
||||
|
||||
const storedToken = await provider.getSecret("authToken")
|
||||
if (storedToken) {
|
||||
console.log("Found stored auth token, attempting to restore session")
|
||||
try {
|
||||
await this.signInWithCustomToken(storedToken)
|
||||
console.log("Session restored successfully")
|
||||
} catch (error) {
|
||||
console.error("Failed to restore session, clearing token:", error)
|
||||
await provider.setAuthToken(undefined)
|
||||
await provider.setUserInfo(undefined)
|
||||
}
|
||||
} else {
|
||||
console.log("No stored auth token found")
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAuthStateChange(user: User | null) {
|
||||
console.log("Auth state changed", { user })
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
console.log("Provider reference lost")
|
||||
return
|
||||
}
|
||||
|
||||
if (user) {
|
||||
console.log("User signed in", { userId: user.uid })
|
||||
const idToken = await user.getIdToken()
|
||||
await provider.setAuthToken(idToken)
|
||||
// Store public user info in state
|
||||
await provider.setUserInfo({
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
photoURL: user.photoURL,
|
||||
})
|
||||
console.log("User info set in provider", { user })
|
||||
} else {
|
||||
console.log("User signed out")
|
||||
await provider.setAuthToken(undefined)
|
||||
await provider.setUserInfo(undefined)
|
||||
}
|
||||
await provider.postStateToWebview()
|
||||
console.log("Webview state updated")
|
||||
}
|
||||
|
||||
async signInWithCustomToken(token: string) {
|
||||
console.log("Signing in with custom token", { token })
|
||||
await signInWithCustomToken(this.auth, token)
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
console.log("Signing out")
|
||||
await signOut(this.auth)
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposables.forEach((d) => d.dispose())
|
||||
console.log("Disposables disposed", { count: this.disposables.length })
|
||||
}
|
||||
}
|
||||
10
src/services/auth/config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// Public Firebase config (safe for open source)
|
||||
export const firebaseConfig = {
|
||||
apiKey: "AIzaSyDcXAaanNgR2_T0dq2oOl5XyKPksYHppVo",
|
||||
authDomain: "cline-bot.firebaseapp.com",
|
||||
projectId: "cline-bot",
|
||||
storageBucket: "cline-bot.firebasestorage.app",
|
||||
messagingSenderId: "364369702101",
|
||||
appId: "1:364369702101:web:0013885dcf20b43799c65c",
|
||||
measurementId: "G-MDPRELSCD1",
|
||||
}
|
||||
|
|
@ -8,20 +8,26 @@ import pWaitFor from "p-wait-for"
|
|||
import delay from "delay"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { BrowserActionResult } from "../../shared/ExtensionMessage"
|
||||
import { BrowserSettings } from "../../shared/BrowserSettings"
|
||||
// import * as chromeLauncher from "chrome-launcher"
|
||||
|
||||
interface PCRStats {
|
||||
puppeteer: { launch: typeof launch }
|
||||
executablePath: string
|
||||
}
|
||||
|
||||
// const DEBUG_PORT = 9222 // Chrome's default debugging port
|
||||
|
||||
export class BrowserSession {
|
||||
private context: vscode.ExtensionContext
|
||||
private browser?: Browser
|
||||
private page?: Page
|
||||
private currentMousePosition?: string
|
||||
browserSettings: BrowserSettings
|
||||
|
||||
constructor(context: vscode.ExtensionContext) {
|
||||
constructor(context: vscode.ExtensionContext, browserSettings: BrowserSettings) {
|
||||
this.context = context
|
||||
this.browserSettings = browserSettings
|
||||
}
|
||||
|
||||
private async ensureChromiumExists(): Promise<PCRStats> {
|
||||
|
|
@ -45,6 +51,70 @@ export class BrowserSession {
|
|||
return stats
|
||||
}
|
||||
|
||||
// private async checkExistingChromeDebugger(): Promise<boolean> {
|
||||
// try {
|
||||
// // Try to connect to existing debugger
|
||||
// const response = await fetch(`http://localhost:${DEBUG_PORT}/json/version`)
|
||||
// return response.ok
|
||||
// } catch {
|
||||
// return false
|
||||
// }
|
||||
// }
|
||||
|
||||
// async relaunchChromeDebugMode() {
|
||||
// const result = await vscode.window.showWarningMessage(
|
||||
// "This will close your existing Chrome tabs and relaunch Chrome in debug mode. Are you sure?",
|
||||
// { modal: true },
|
||||
// "Yes",
|
||||
// )
|
||||
|
||||
// if (result !== "Yes") {
|
||||
// return
|
||||
// }
|
||||
|
||||
// // // Kill any existing Chrome instances
|
||||
// // await chromeLauncher.killAll()
|
||||
|
||||
// // // Launch Chrome with debug port
|
||||
// // const launcher = new chromeLauncher.Launcher({
|
||||
// // port: DEBUG_PORT,
|
||||
// // chromeFlags: ["--remote-debugging-port=" + DEBUG_PORT, "--no-first-run", "--no-default-browser-check"],
|
||||
// // })
|
||||
|
||||
// // await launcher.launch()
|
||||
// const installation = chromeLauncher.Launcher.getFirstInstallation()
|
||||
// if (!installation) {
|
||||
// throw new Error("Could not find Chrome installation on this system")
|
||||
// }
|
||||
// console.log("chrome installation", installation)
|
||||
// }
|
||||
|
||||
// private async getSystemChromeExecutablePath(): Promise<string> {
|
||||
// // Find installed Chrome
|
||||
// const installation = chromeLauncher.Launcher.getFirstInstallation()
|
||||
// if (!installation) {
|
||||
// throw new Error("Could not find Chrome installation on this system")
|
||||
// }
|
||||
// console.log("chrome installation", installation)
|
||||
// return installation
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Helper to detect user’s default Chrome data dir.
|
||||
// * Adjust for OS if needed.
|
||||
// */
|
||||
// private getDefaultChromeUserDataDir(): string {
|
||||
// const homedir = require("os").homedir()
|
||||
// switch (process.platform) {
|
||||
// case "win32":
|
||||
// return path.join(homedir, "AppData", "Local", "Google", "Chrome", "User Data")
|
||||
// case "darwin":
|
||||
// return path.join(homedir, "Library", "Application Support", "Google", "Chrome")
|
||||
// default:
|
||||
// return path.join(homedir, ".config", "google-chrome")
|
||||
// }
|
||||
// }
|
||||
|
||||
async launchBrowser() {
|
||||
console.log("launch browser called")
|
||||
if (this.browser) {
|
||||
|
|
@ -58,12 +128,29 @@ export class BrowserSession {
|
|||
"--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
||||
],
|
||||
executablePath: stats.executablePath,
|
||||
defaultViewport: {
|
||||
width: 900,
|
||||
height: 600,
|
||||
},
|
||||
// headless: false,
|
||||
defaultViewport: this.browserSettings.viewport,
|
||||
headless: this.browserSettings.headless,
|
||||
})
|
||||
|
||||
// if (this.browserSettings.chromeType === "system") {
|
||||
// const userDataDir = this.getDefaultChromeUserDataDir()
|
||||
// this.browser = await stats.puppeteer.launch({
|
||||
// args: [`--user-data-dir=${userDataDir}`, "--profile-directory=Default"],
|
||||
// executablePath: await this.getSystemChromeExecutablePath(),
|
||||
// defaultViewport: this.browserSettings.viewport,
|
||||
// headless: this.browserSettings.headless,
|
||||
// })
|
||||
// } else {
|
||||
// this.browser = await stats.puppeteer.launch({
|
||||
// args: [
|
||||
// "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
||||
// ],
|
||||
// executablePath: stats.executablePath,
|
||||
// defaultViewport: this.browserSettings.viewport,
|
||||
// headless: this.browserSettings.headless,
|
||||
// })
|
||||
// }
|
||||
|
||||
// (latest version of puppeteer does not add headless to user agent)
|
||||
this.page = await this.browser?.newPage()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,11 +25,15 @@ export type McpConnection = {
|
|||
transport: StdioClientTransport
|
||||
}
|
||||
|
||||
const AutoApproveSchema = z.array(z.string()).default([])
|
||||
|
||||
// StdioServerParameters
|
||||
const StdioConfigSchema = z.object({
|
||||
command: z.string(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
autoApprove: AutoApproveSchema.optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const McpSettingsSchema = z.object({
|
||||
|
|
@ -51,7 +55,12 @@ export class McpHub {
|
|||
}
|
||||
|
||||
getServers(): McpServer[] {
|
||||
return this.connections.map((conn) => conn.server)
|
||||
// Only return enabled servers
|
||||
return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server)
|
||||
}
|
||||
|
||||
isMcpEnabled(): boolean {
|
||||
return vscode.workspace.getConfiguration("cline.mcp").get("enabled") ?? true
|
||||
}
|
||||
|
||||
async getMcpServersPath(): Promise<string> {
|
||||
|
|
@ -189,11 +198,13 @@ export class McpHub {
|
|||
}
|
||||
|
||||
// valid schema
|
||||
const parsedConfig = StdioConfigSchema.parse(config)
|
||||
const connection: McpConnection = {
|
||||
server: {
|
||||
name,
|
||||
config: JSON.stringify(config),
|
||||
status: "connecting",
|
||||
disabled: parsedConfig.disabled,
|
||||
},
|
||||
client,
|
||||
transport,
|
||||
|
|
@ -275,7 +286,21 @@ export class McpHub {
|
|||
const response = await this.connections
|
||||
.find((conn) => conn.server.name === serverName)
|
||||
?.client.request({ method: "tools/list" }, ListToolsResultSchema)
|
||||
return response?.tools || []
|
||||
|
||||
// Get autoApprove settings
|
||||
const settingsPath = await this.getMcpSettingsFilePath()
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const config = JSON.parse(content)
|
||||
const autoApproveConfig = config.mcpServers[serverName]?.autoApprove || []
|
||||
|
||||
// Mark tools as always allowed based on settings
|
||||
const tools = (response?.tools || []).map((tool) => ({
|
||||
...tool,
|
||||
autoApprove: autoApproveConfig.includes(tool.name),
|
||||
}))
|
||||
|
||||
// console.log(`[MCP] Fetched tools for ${serverName}:`, tools)
|
||||
return tools
|
||||
} catch (error) {
|
||||
// console.error(`Failed to fetch tools for ${serverName}:`, error)
|
||||
return []
|
||||
|
|
@ -441,11 +466,91 @@ export class McpHub {
|
|||
|
||||
// Using server
|
||||
|
||||
// Public methods for server management
|
||||
|
||||
public async toggleServerDisabled(serverName: string, disabled: boolean): Promise<void> {
|
||||
let settingsPath: string
|
||||
try {
|
||||
settingsPath = await this.getMcpSettingsFilePath()
|
||||
|
||||
// Ensure the settings file exists and is accessible
|
||||
try {
|
||||
await fs.access(settingsPath)
|
||||
} catch (error) {
|
||||
console.error("Settings file not accessible:", error)
|
||||
throw new Error("Settings file not accessible")
|
||||
}
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const config = JSON.parse(content)
|
||||
|
||||
// Validate the config structure
|
||||
if (!config || typeof config !== "object") {
|
||||
throw new Error("Invalid config structure")
|
||||
}
|
||||
|
||||
if (!config.mcpServers || typeof config.mcpServers !== "object") {
|
||||
config.mcpServers = {}
|
||||
}
|
||||
|
||||
if (config.mcpServers[serverName]) {
|
||||
// Create a new server config object to ensure clean structure
|
||||
const serverConfig = {
|
||||
...config.mcpServers[serverName],
|
||||
disabled,
|
||||
}
|
||||
|
||||
// Ensure required fields exist
|
||||
if (!serverConfig.autoApprove) {
|
||||
serverConfig.autoApprove = []
|
||||
}
|
||||
|
||||
config.mcpServers[serverName] = serverConfig
|
||||
|
||||
// Write the entire config back
|
||||
const updatedConfig = {
|
||||
mcpServers: config.mcpServers,
|
||||
}
|
||||
|
||||
await fs.writeFile(settingsPath, JSON.stringify(updatedConfig, null, 2))
|
||||
|
||||
const connection = this.connections.find((conn) => conn.server.name === serverName)
|
||||
if (connection) {
|
||||
try {
|
||||
connection.server.disabled = disabled
|
||||
|
||||
// Only refresh capabilities if connected
|
||||
if (connection.server.status === "connected") {
|
||||
connection.server.tools = await this.fetchToolsList(serverName)
|
||||
connection.server.resources = await this.fetchResourcesList(serverName)
|
||||
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(serverName)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh capabilities for ${serverName}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update server disabled state:", error)
|
||||
if (error instanceof Error) {
|
||||
console.error("Error details:", error.message, error.stack)
|
||||
}
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to update server state: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async readResource(serverName: string, uri: string): Promise<McpResourceResponse> {
|
||||
const connection = this.connections.find((conn) => conn.server.name === serverName)
|
||||
if (!connection) {
|
||||
throw new Error(`No connection found for server: ${serverName}`)
|
||||
}
|
||||
if (connection.server.disabled) {
|
||||
throw new Error(`Server "${serverName}" is disabled`)
|
||||
}
|
||||
return await connection.client.request(
|
||||
{
|
||||
method: "resources/read",
|
||||
|
|
@ -464,6 +569,11 @@ export class McpHub {
|
|||
`No connection found for server: ${serverName}. Please make sure to use MCP servers available under 'Connected MCP Servers'.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (connection.server.disabled) {
|
||||
throw new Error(`Server "${serverName}" is disabled and cannot be used`)
|
||||
}
|
||||
|
||||
return await connection.client.request(
|
||||
{
|
||||
method: "tools/call",
|
||||
|
|
@ -476,6 +586,44 @@ export class McpHub {
|
|||
)
|
||||
}
|
||||
|
||||
async toggleToolAutoApprove(serverName: string, toolName: string, shouldAllow: boolean): Promise<void> {
|
||||
try {
|
||||
const settingsPath = await this.getMcpSettingsFilePath()
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const config = JSON.parse(content)
|
||||
|
||||
// Initialize autoApprove if it doesn't exist
|
||||
if (!config.mcpServers[serverName].autoApprove) {
|
||||
config.mcpServers[serverName].autoApprove = []
|
||||
}
|
||||
|
||||
const autoApprove = config.mcpServers[serverName].autoApprove
|
||||
const toolIndex = autoApprove.indexOf(toolName)
|
||||
|
||||
if (shouldAllow && toolIndex === -1) {
|
||||
// Add tool to autoApprove list
|
||||
autoApprove.push(toolName)
|
||||
} else if (!shouldAllow && toolIndex !== -1) {
|
||||
// Remove tool from autoApprove list
|
||||
autoApprove.splice(toolIndex, 1)
|
||||
}
|
||||
|
||||
// Write updated config back to file
|
||||
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
|
||||
|
||||
// Update the tools list to reflect the change
|
||||
const connection = this.connections.find((conn) => conn.server.name === serverName)
|
||||
if (connection) {
|
||||
connection.server.tools = await this.fetchToolsList(serverName)
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update autoApprove settings:", error)
|
||||
vscode.window.showErrorMessage("Failed to update autoApprove settings")
|
||||
throw error // Re-throw to ensure the error is properly handled
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.removeAllFileWatchers()
|
||||
for (const connection of this.connections) {
|
||||
|
|
|
|||
27
src/shared/BrowserSettings.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export interface BrowserSettings {
|
||||
// Viewport size settings
|
||||
viewport: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
// Browser mode settings
|
||||
headless: boolean
|
||||
// Chrome installation to use
|
||||
// chromeType: "chromium" | "system"
|
||||
}
|
||||
|
||||
export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = {
|
||||
viewport: {
|
||||
width: 900,
|
||||
height: 600,
|
||||
},
|
||||
headless: true,
|
||||
// chromeType: "chromium",
|
||||
}
|
||||
|
||||
export const BROWSER_VIEWPORT_PRESETS = {
|
||||
"Large Desktop (1280x800)": { width: 1280, height: 800 },
|
||||
"Small Desktop (900x600)": { width: 900, height: 600 },
|
||||
"Tablet (768x1024)": { width: 768, height: 1024 },
|
||||
"Mobile (360x640)": { width: 360, height: 640 },
|
||||
} as const
|
||||
7
src/shared/ChatSettings.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export interface ChatSettings {
|
||||
mode: "plan" | "act"
|
||||
}
|
||||
|
||||
export const DEFAULT_CHAT_SETTINGS: ChatSettings = {
|
||||
mode: "act",
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
import { ApiConfiguration, ModelInfo } from "./api"
|
||||
import { AutoApprovalSettings } from "./AutoApprovalSettings"
|
||||
import { BrowserSettings } from "./BrowserSettings"
|
||||
import { ChatSettings } from "./ChatSettings"
|
||||
import { HistoryItem } from "./HistoryItem"
|
||||
import { McpServer } from "./mcp"
|
||||
|
||||
|
|
@ -20,13 +22,23 @@ export interface ExtensionMessage {
|
|||
| "openRouterModels"
|
||||
| "mcpServers"
|
||||
| "relinquishControl"
|
||||
| "vsCodeLmModels"
|
||||
| "requestVsCodeLmModels"
|
||||
text?: string
|
||||
action?: "chatButtonClicked" | "mcpButtonClicked" | "settingsButtonClicked" | "historyButtonClicked" | "didBecomeVisible"
|
||||
action?:
|
||||
| "chatButtonClicked"
|
||||
| "mcpButtonClicked"
|
||||
| "settingsButtonClicked"
|
||||
| "historyButtonClicked"
|
||||
| "didBecomeVisible"
|
||||
| "accountLoginClicked"
|
||||
| "accountLogoutClicked"
|
||||
invoke?: "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
|
||||
state?: ExtensionState
|
||||
images?: string[]
|
||||
ollamaModels?: string[]
|
||||
lmStudioModels?: string[]
|
||||
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
|
||||
filePaths?: string[]
|
||||
partialMessage?: ClineMessage
|
||||
openRouterModels?: Record<string, ModelInfo>
|
||||
|
|
@ -44,6 +56,14 @@ export interface ExtensionState {
|
|||
taskHistory: HistoryItem[]
|
||||
shouldShowAnnouncement: boolean
|
||||
autoApprovalSettings: AutoApprovalSettings
|
||||
browserSettings: BrowserSettings
|
||||
chatSettings: ChatSettings
|
||||
isLoggedIn: boolean
|
||||
userInfo?: {
|
||||
displayName: string | null
|
||||
email: string | null
|
||||
photoURL: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClineMessage {
|
||||
|
|
@ -61,6 +81,7 @@ export interface ClineMessage {
|
|||
|
||||
export type ClineAsk =
|
||||
| "followup"
|
||||
| "plan_mode_response"
|
||||
| "command"
|
||||
| "command_output"
|
||||
| "completion_result"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { ApiConfiguration } from "./api"
|
||||
import { AutoApprovalSettings } from "./AutoApprovalSettings"
|
||||
import { BrowserSettings } from "./BrowserSettings"
|
||||
import { ChatSettings } from "./ChatSettings"
|
||||
|
||||
export interface WebviewMessage {
|
||||
type:
|
||||
|
|
@ -26,16 +28,34 @@ export interface WebviewMessage {
|
|||
| "openMcpSettings"
|
||||
| "restartMcpServer"
|
||||
| "autoApprovalSettings"
|
||||
| "browserSettings"
|
||||
| "chatSettings"
|
||||
| "checkpointDiff"
|
||||
| "checkpointRestore"
|
||||
| "taskCompletionViewChanges"
|
||||
| "openExtensionSettings"
|
||||
| "requestVsCodeLmModels"
|
||||
| "toggleToolAutoApprove"
|
||||
| "toggleMcpServer"
|
||||
| "getLatestState"
|
||||
| "accountLoginClicked"
|
||||
| "accountLogoutClicked"
|
||||
// | "relaunchChromeDebugMode"
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
askResponse?: ClineAskResponse
|
||||
apiConfiguration?: ApiConfiguration
|
||||
images?: string[]
|
||||
bool?: boolean
|
||||
number?: number
|
||||
autoApprovalSettings?: AutoApprovalSettings
|
||||
browserSettings?: BrowserSettings
|
||||
chatSettings?: ChatSettings
|
||||
|
||||
// For toggleToolAutoApprove
|
||||
serverName?: string
|
||||
toolName?: string
|
||||
autoApprove?: boolean
|
||||
}
|
||||
|
||||
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ export type ApiProvider =
|
|||
| "gemini"
|
||||
| "openai-native"
|
||||
| "deepseek"
|
||||
| "mistral"
|
||||
| "vscode-lm"
|
||||
|
||||
export interface ApiHandlerOptions {
|
||||
apiModelId?: string
|
||||
|
|
@ -34,7 +36,9 @@ export interface ApiHandlerOptions {
|
|||
geminiApiKey?: string
|
||||
openAiNativeApiKey?: string
|
||||
deepSeekApiKey?: string
|
||||
mistralApiKey?: string
|
||||
azureApiVersion?: string
|
||||
vsCodeLmModelSelector?: any
|
||||
}
|
||||
|
||||
export type ApiConfiguration = ApiHandlerOptions & {
|
||||
|
|
@ -239,6 +243,14 @@ export const openAiModelInfoSaneDefaults: ModelInfo = {
|
|||
export type GeminiModelId = keyof typeof geminiModels
|
||||
export const geminiDefaultModelId: GeminiModelId = "gemini-2.0-flash-thinking-exp-1219"
|
||||
export const geminiModels = {
|
||||
"gemini-2.0-flash-thinking-exp-01-21": {
|
||||
maxTokens: 65536,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"gemini-2.0-flash-thinking-exp-1219": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 32_767,
|
||||
|
|
@ -373,4 +385,29 @@ export const deepSeekModels = {
|
|||
cacheWritesPrice: 0.14,
|
||||
cacheReadsPrice: 0.014,
|
||||
},
|
||||
"deepseek-reasoner": {
|
||||
maxTokens: 8_000,
|
||||
contextWindow: 64_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true, // supports context caching, but not in the way anthropic does it (deepseek reports input tokens and reads/writes in the same usage report) FIXME: we need to show users cache stats how deepseek does it
|
||||
inputPrice: 0, // technically there is no input price, it's all either a cache hit or miss (ApiOptions will not show this)
|
||||
outputPrice: 2.19,
|
||||
cacheWritesPrice: 0.55,
|
||||
cacheReadsPrice: 0.14,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// Mistral
|
||||
// https://docs.mistral.ai/getting-started/models/models_overview/
|
||||
export type MistralModelId = keyof typeof mistralModels
|
||||
export const mistralDefaultModelId: MistralModelId = "codestral-latest"
|
||||
export const mistralModels = {
|
||||
"codestral-latest": {
|
||||
maxTokens: 32_768,
|
||||
contextWindow: 256_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.3,
|
||||
outputPrice: 0.9,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ export type McpServer = {
|
|||
tools?: McpTool[]
|
||||
resources?: McpResource[]
|
||||
resourceTemplates?: McpResourceTemplate[]
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export type McpTool = {
|
||||
name: string
|
||||
description?: string
|
||||
inputSchema?: object
|
||||
autoApprove?: boolean
|
||||
}
|
||||
|
||||
export type McpResource = {
|
||||
|
|
|
|||
7
src/shared/vsCodeSelectorUtils.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { LanguageModelChatSelector } from "vscode"
|
||||
|
||||
export const SELECTOR_SEPARATOR = "/"
|
||||
|
||||
export function stringifyVsCodeLmModelSelector(selector: LanguageModelChatSelector): string {
|
||||
return [selector.vendor, selector.family, selector.version, selector.id].filter(Boolean).join(SELECTOR_SEPARATOR)
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import path from "path"
|
|||
import "should"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
const packagePath = path.join(__dirname, "..", "..", "..", "package.json")
|
||||
const packagePath = path.join(__dirname, "..", "..", "package.json")
|
||||
|
||||
describe("Cline Extension", () => {
|
||||
after(() => {
|
||||
|
|
@ -23,4 +23,69 @@ describe("Cline Extension", () => {
|
|||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
await vscode.commands.executeCommand("cline.plusButtonClicked")
|
||||
})
|
||||
|
||||
// New test to verify xvfb and webview functionality
|
||||
it("should create and display a webview panel", async () => {
|
||||
// Create a webview panel
|
||||
const panel = vscode.window.createWebviewPanel("testWebview", "CI/CD Test", vscode.ViewColumn.One, {
|
||||
enableScripts: true,
|
||||
})
|
||||
|
||||
// Set some HTML content
|
||||
panel.webview.html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>xvfb Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="test">Testing xvfb display server</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
// Verify panel exists
|
||||
should.exist(panel)
|
||||
panel.visible.should.be.true()
|
||||
|
||||
// Clean up
|
||||
panel.dispose()
|
||||
})
|
||||
|
||||
// Test webview message passing
|
||||
it("should handle webview messages", async () => {
|
||||
const panel = vscode.window.createWebviewPanel("testWebview", "Message Test", vscode.ViewColumn.One, {
|
||||
enableScripts: true,
|
||||
})
|
||||
|
||||
// Set up message handling
|
||||
const messagePromise = new Promise<string>((resolve) => {
|
||||
panel.webview.onDidReceiveMessage((message) => resolve(message.text), undefined)
|
||||
})
|
||||
|
||||
// Add message sending script
|
||||
panel.webview.html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Message Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
vscode.postMessage({ text: 'test-message' });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
// Wait for message
|
||||
const message = await messagePromise
|
||||
message.should.equal("test-message")
|
||||
|
||||
// Clean up
|
||||
panel.dispose()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
37
src/test/suite/extension.test.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
const { expect } = require("chai")
|
||||
const vscode = require("vscode")
|
||||
|
||||
describe("Extension Tests", function () {
|
||||
this.timeout(60000) // Increased timeout for extension operations
|
||||
|
||||
it("should activate extension successfully", async () => {
|
||||
// Get the extension
|
||||
const extension = vscode.extensions.getExtension("saoudrizwan.claude-dev")
|
||||
expect(extension).to.not.be.undefined
|
||||
|
||||
// Activate the extension if not already activated
|
||||
if (!extension.isActive) {
|
||||
await extension.activate()
|
||||
}
|
||||
expect(extension.isActive).to.be.true
|
||||
})
|
||||
|
||||
it("should open sidebar view", async () => {
|
||||
// Execute the command to open sidebar
|
||||
await vscode.commands.executeCommand("cline.plusButtonClicked")
|
||||
|
||||
// Wait for sidebar to be visible
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// Get all views
|
||||
const views = vscode.window.visibleTextEditors
|
||||
// Just verify the command executed without error
|
||||
// The actual view verification is handled in the TypeScript tests
|
||||
})
|
||||
|
||||
it("should handle basic commands", async () => {
|
||||
// Test basic command execution
|
||||
await vscode.commands.executeCommand("cline.historyButtonClicked")
|
||||
// Success if no error thrown
|
||||
})
|
||||
})
|
||||
43
src/test/suite/index.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
const path = require("path")
|
||||
const Mocha = require("mocha")
|
||||
const glob = require("glob")
|
||||
|
||||
async function run() {
|
||||
// Create the mocha test
|
||||
const mocha = new Mocha({
|
||||
ui: "bdd",
|
||||
color: true,
|
||||
timeout: 60000, // Increased timeout for extension operations
|
||||
})
|
||||
|
||||
const testsRoot = path.resolve(__dirname, ".")
|
||||
|
||||
try {
|
||||
// Find all test files
|
||||
const files = await glob("*.test.js", { cwd: testsRoot })
|
||||
|
||||
// Add files to the test suite
|
||||
files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f)))
|
||||
|
||||
// Run the mocha test
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Run the tests
|
||||
mocha.run((failures) => {
|
||||
if (failures > 0) {
|
||||
reject(new Error(`${failures} tests failed.`))
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Failed to run tests:", err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { run }
|
||||
116
src/test/webview/chat-native.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import * as vscode from "vscode"
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import { strict as assert } from "assert"
|
||||
import { join } from "path"
|
||||
describe("Chat Integration Tests", () => {
|
||||
let panel: vscode.WebviewPanel
|
||||
let disposables: vscode.Disposable[] = []
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create VSCode webview panel
|
||||
panel = vscode.window.createWebviewPanel("testWebview", "Chat Test", vscode.ViewColumn.One, {
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
})
|
||||
|
||||
// Set up minimal test webview
|
||||
panel.webview.html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
window.addEventListener('message', event => {
|
||||
const message = event.data;
|
||||
switch (message.type) {
|
||||
case 'sendMessage':
|
||||
vscode.postMessage({ type: 'newTask', text: message.text });
|
||||
break;
|
||||
case 'toggleMode':
|
||||
vscode.postMessage({ type: 'chatSettings', chatSettings: { mode: 'act' } });
|
||||
break;
|
||||
case 'invoke':
|
||||
if (message.invoke === 'primaryButtonClick') {
|
||||
vscode.postMessage({ type: 'askResponse', askResponse: 'yesButtonClicked' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="test-webview"></div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
panel.dispose()
|
||||
disposables.forEach((d) => d.dispose())
|
||||
disposables = []
|
||||
})
|
||||
|
||||
it("should send chat messages", async () => {
|
||||
// Set up message listener
|
||||
const messagePromise = new Promise<any>((resolve) => {
|
||||
panel.webview.onDidReceiveMessage((message) => {
|
||||
if (message.type === "newTask") {
|
||||
resolve(message)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Trigger send message
|
||||
await panel.webview.postMessage({
|
||||
type: "sendMessage",
|
||||
text: "Create a hello world app",
|
||||
})
|
||||
|
||||
// Verify message was sent
|
||||
const message = await messagePromise
|
||||
assert.equal(message.type, "newTask")
|
||||
assert.equal(message.text, "Create a hello world app")
|
||||
})
|
||||
|
||||
it("should toggle between plan and act modes", async () => {
|
||||
// Set up state change listener
|
||||
const stateChangePromise = new Promise<any>((resolve) => {
|
||||
panel.webview.onDidReceiveMessage((message) => {
|
||||
if (message.type === "chatSettings") {
|
||||
resolve(message)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Trigger mode toggle
|
||||
await panel.webview.postMessage({ type: "toggleMode" })
|
||||
|
||||
// Verify mode changed
|
||||
const stateChange = await stateChangePromise
|
||||
assert.equal(stateChange.chatSettings.mode, "act")
|
||||
})
|
||||
|
||||
it("should handle tool approval flow", async () => {
|
||||
// Set up approval listener
|
||||
const approvalPromise = new Promise<any>((resolve) => {
|
||||
panel.webview.onDidReceiveMessage((message) => {
|
||||
if (message.type === "askResponse") {
|
||||
resolve(message)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Trigger tool approval
|
||||
await panel.webview.postMessage({
|
||||
type: "invoke",
|
||||
invoke: "primaryButtonClick",
|
||||
})
|
||||
|
||||
// Verify approval was sent
|
||||
const response = await approvalPromise
|
||||
assert.equal(response.type, "askResponse")
|
||||
assert.equal(response.askResponse, "yesButtonClicked")
|
||||
})
|
||||
})
|
||||
|
|
@ -8,7 +8,11 @@
|
|||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"types": ["node", "mocha", "should", "vscode"]
|
||||
"types": ["node", "mocha", "should", "vscode", "chai"],
|
||||
"typeRoots": ["./node_modules/@types", "./src/test/types"],
|
||||
"outDir": "out",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.test.ts"]
|
||||
"include": ["src/**/*.test.ts"],
|
||||
"exclude": ["src/test/**/*.js"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import ChatView from "./components/chat/ChatView"
|
|||
import HistoryView from "./components/history/HistoryView"
|
||||
import SettingsView from "./components/settings/SettingsView"
|
||||
import WelcomeView from "./components/welcome/WelcomeView"
|
||||
import AccountView from "./components/account/AccountView"
|
||||
import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext"
|
||||
import { vscode } from "./utils/vscode"
|
||||
import McpView from "./components/mcp/McpView"
|
||||
|
|
@ -14,6 +15,7 @@ const AppContent = () => {
|
|||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showMcp, setShowMcp] = useState(false)
|
||||
const [showAccount, setShowAccount] = useState(false)
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(false)
|
||||
|
||||
const handleMessage = useCallback((e: MessageEvent) => {
|
||||
|
|
@ -25,21 +27,31 @@ const AppContent = () => {
|
|||
setShowSettings(true)
|
||||
setShowHistory(false)
|
||||
setShowMcp(false)
|
||||
setShowAccount(false)
|
||||
break
|
||||
case "historyButtonClicked":
|
||||
setShowSettings(false)
|
||||
setShowHistory(true)
|
||||
setShowMcp(false)
|
||||
setShowAccount(false)
|
||||
break
|
||||
case "mcpButtonClicked":
|
||||
setShowSettings(false)
|
||||
setShowHistory(false)
|
||||
setShowMcp(true)
|
||||
setShowAccount(false)
|
||||
break
|
||||
case "accountLoginClicked":
|
||||
setShowSettings(false)
|
||||
setShowHistory(false)
|
||||
setShowMcp(false)
|
||||
setShowAccount(true)
|
||||
break
|
||||
case "chatButtonClicked":
|
||||
setShowSettings(false)
|
||||
setShowHistory(false)
|
||||
setShowMcp(false)
|
||||
setShowAccount(false)
|
||||
break
|
||||
}
|
||||
break
|
||||
|
|
@ -68,6 +80,7 @@ const AppContent = () => {
|
|||
{showSettings && <SettingsView onDone={() => setShowSettings(false)} />}
|
||||
{showHistory && <HistoryView onDone={() => setShowHistory(false)} />}
|
||||
{showMcp && <McpView onDone={() => setShowMcp(false)} />}
|
||||
{showAccount && <AccountView onDone={() => setShowAccount(false)} />}
|
||||
{/* Do not conditionally load ChatView, it's expensive and there's state we don't want to lose (user input, disableInput, askResponse promise, etc.) */}
|
||||
<ChatView
|
||||
showHistoryView={() => {
|
||||
|
|
@ -75,7 +88,7 @@ const AppContent = () => {
|
|||
setShowMcp(false)
|
||||
setShowHistory(true)
|
||||
}}
|
||||
isHidden={showSettings || showHistory || showMcp}
|
||||
isHidden={showSettings || showHistory || showMcp || showAccount}
|
||||
showAnnouncement={showAnnouncement}
|
||||
hideAnnouncement={() => {
|
||||
setShowAnnouncement(false)
|
||||
|
|
|
|||
15
webview-ui/src/components/account/AccountOptions.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { memo } from "react"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
|
||||
const AccountOptions = () => {
|
||||
const handleAccountClick = () => {
|
||||
vscode.postMessage({ type: "accountLoginClicked" })
|
||||
}
|
||||
|
||||
// Call handleAccountClick immediately when component mounts
|
||||
handleAccountClick()
|
||||
|
||||
return null // This component doesn't render anything
|
||||
}
|
||||
|
||||
export default memo(AccountOptions)
|
||||
83
webview-ui/src/components/account/AccountView.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { memo } from "react"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
|
||||
type AccountViewProps = {
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
const AccountView = ({ onDone }: AccountViewProps) => {
|
||||
const { isLoggedIn, userInfo } = useExtensionState()
|
||||
|
||||
const handleLogin = () => {
|
||||
vscode.postMessage({ type: "accountLoginClicked" })
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
vscode.postMessage({ type: "accountLogoutClicked" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: "10px 0px 0px 20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "17px",
|
||||
paddingRight: 17,
|
||||
}}>
|
||||
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Account</h3>
|
||||
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
overflowY: "scroll",
|
||||
paddingRight: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}>
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
{userInfo?.photoURL && (
|
||||
<img
|
||||
src={userInfo.photoURL}
|
||||
alt="Profile"
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: "50%",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ fontSize: "14px", marginBottom: 10 }}>
|
||||
{userInfo?.displayName && <div>Name: {userInfo.displayName}</div>}
|
||||
{userInfo?.email && <div>Email: {userInfo.email}</div>}
|
||||
</div>
|
||||
<VSCodeButton onClick={handleLogout}>Log out</VSCodeButton>
|
||||
</>
|
||||
) : (
|
||||
<VSCodeButton onClick={handleLogin}>Log in to Cline</VSCodeButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(AccountView)
|
||||
235
webview-ui/src/components/browser/BrowserSettingsMenu.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { VSCodeButton, VSCodeCheckbox, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { useRef, useState } from "react"
|
||||
import { useClickAway } from "react-use"
|
||||
import styled from "styled-components"
|
||||
import { BROWSER_VIEWPORT_PRESETS } from "../../../../src/shared/BrowserSettings"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
|
||||
interface BrowserSettingsMenuProps {
|
||||
disabled?: boolean
|
||||
maxWidth?: number
|
||||
}
|
||||
|
||||
export const BrowserSettingsMenu: React.FC<BrowserSettingsMenuProps> = ({ disabled = false, maxWidth }) => {
|
||||
const { browserSettings } = useExtensionState()
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
const [hasMouseEntered, setHasMouseEntered] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useClickAway(containerRef, () => {
|
||||
if (showMenu) {
|
||||
setShowMenu(false)
|
||||
setHasMouseEntered(false)
|
||||
}
|
||||
})
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHasMouseEntered(true)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (hasMouseEntered) {
|
||||
setShowMenu(false)
|
||||
setHasMouseEntered(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleControlsMouseLeave = (e: React.MouseEvent) => {
|
||||
const menuElement = menuRef.current
|
||||
|
||||
if (menuElement && showMenu) {
|
||||
const menuRect = menuElement.getBoundingClientRect()
|
||||
|
||||
// If mouse is moving towards the menu, don't close it
|
||||
if (
|
||||
e.clientY >= menuRect.top &&
|
||||
e.clientY <= menuRect.bottom &&
|
||||
e.clientX >= menuRect.left &&
|
||||
e.clientX <= menuRect.right
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setShowMenu(false)
|
||||
setHasMouseEntered(false)
|
||||
}
|
||||
|
||||
const handleViewportChange = (event: Event) => {
|
||||
const target = event.target as HTMLSelectElement
|
||||
const selectedSize = BROWSER_VIEWPORT_PRESETS[target.value as keyof typeof BROWSER_VIEWPORT_PRESETS]
|
||||
if (selectedSize) {
|
||||
vscode.postMessage({
|
||||
type: "browserSettings",
|
||||
browserSettings: {
|
||||
...browserSettings,
|
||||
viewport: selectedSize,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updateHeadless = (headless: boolean) => {
|
||||
vscode.postMessage({
|
||||
type: "browserSettings",
|
||||
browserSettings: {
|
||||
...browserSettings,
|
||||
headless,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// const updateChromeType = (chromeType: BrowserSettings["chromeType"]) => {
|
||||
// vscode.postMessage({
|
||||
// type: "browserSettings",
|
||||
// browserSettings: {
|
||||
// ...browserSettings,
|
||||
// chromeType,
|
||||
// },
|
||||
// })
|
||||
// }
|
||||
|
||||
// const relaunchChromeDebugMode = () => {
|
||||
// vscode.postMessage({
|
||||
// type: "relaunchChromeDebugMode",
|
||||
// })
|
||||
// }
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ position: "relative", marginTop: "-1px" }} onMouseLeave={handleControlsMouseLeave}>
|
||||
<VSCodeButton appearance="icon" onClick={() => setShowMenu(!showMenu)} disabled={disabled}>
|
||||
<i className="codicon codicon-settings-gear" style={{ fontSize: "14.5px" }} />
|
||||
</VSCodeButton>
|
||||
{showMenu && (
|
||||
<SettingsMenu ref={menuRef} maxWidth={maxWidth} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
<SettingsGroup>
|
||||
{/* <SettingsHeader>Headless Mode</SettingsHeader> */}
|
||||
<VSCodeCheckbox
|
||||
style={{ marginBottom: "8px", marginTop: -1 }}
|
||||
checked={browserSettings.headless}
|
||||
onChange={(e) => updateHeadless((e.target as HTMLInputElement).checked)}>
|
||||
Run in headless mode
|
||||
</VSCodeCheckbox>
|
||||
<SettingsDescription>When enabled, Chrome will run in the background.</SettingsDescription>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* <SettingsGroup>
|
||||
<SettingsHeader>Chrome Executable</SettingsHeader>
|
||||
<VSCodeDropdown
|
||||
style={{ width: "100%", marginBottom: "8px" }}
|
||||
value={browserSettings.chromeType}
|
||||
onChange={(e) =>
|
||||
updateChromeType((e.target as HTMLSelectElement).value as BrowserSettings["chromeType"])
|
||||
}>
|
||||
<VSCodeOption value="chromium">Chromium (Auto-downloaded)</VSCodeOption>
|
||||
<VSCodeOption value="system">System Chrome</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
<SettingsDescription>
|
||||
{browserSettings.chromeType === "system" ? (
|
||||
<>
|
||||
Cline will use your personal browser. You must{" "}
|
||||
<VSCodeLink
|
||||
href="#"
|
||||
style={{ fontSize: "inherit" }}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
relaunchChromeDebugMode()
|
||||
}}>
|
||||
relaunch Chrome in debug mode
|
||||
</VSCodeLink>{" "}
|
||||
to use this setting.
|
||||
</>
|
||||
) : (
|
||||
"Cline will use a Chromium browser bundled with the extension."
|
||||
)}
|
||||
</SettingsDescription>
|
||||
</SettingsGroup> */}
|
||||
|
||||
<SettingsGroup>
|
||||
<SettingsHeader>Viewport Size</SettingsHeader>
|
||||
<VSCodeDropdown
|
||||
style={{ width: "100%" }}
|
||||
value={
|
||||
Object.entries(BROWSER_VIEWPORT_PRESETS).find(
|
||||
([_, size]) =>
|
||||
size.width === browserSettings.viewport.width &&
|
||||
size.height === browserSettings.viewport.height,
|
||||
)?.[0]
|
||||
}
|
||||
onChange={(event) => handleViewportChange(event as Event)}>
|
||||
{Object.entries(BROWSER_VIEWPORT_PRESETS).map(([name]) => (
|
||||
<VSCodeOption key={name} value={name}>
|
||||
{name}
|
||||
</VSCodeOption>
|
||||
))}
|
||||
</VSCodeDropdown>
|
||||
</SettingsGroup>
|
||||
</SettingsMenu>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingsMenu = styled.div<{ maxWidth?: number }>`
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: -2px;
|
||||
background: ${CODE_BLOCK_BG_COLOR};
|
||||
border: 1px solid var(--vscode-editorGroup-border);
|
||||
padding: 8px;
|
||||
border-radius: 3px;
|
||||
z-index: 1000;
|
||||
width: calc(100vw - 57px);
|
||||
min-width: 0px;
|
||||
max-width: ${(props) => (props.maxWidth ? `${props.maxWidth - 23}px` : "100vw")};
|
||||
|
||||
// Add invisible padding to create a safe hover zone
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -14px; // Same as margin-top in the parent's top property
|
||||
left: 0;
|
||||
right: -6px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: 6px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: ${CODE_BLOCK_BG_COLOR};
|
||||
border-left: 1px solid var(--vscode-editorGroup-border);
|
||||
border-top: 1px solid var(--vscode-editorGroup-border);
|
||||
transform: rotate(45deg);
|
||||
z-index: 1; // Ensure arrow stays above the padding
|
||||
}
|
||||
`
|
||||
|
||||
const SettingsGroup = styled.div`
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
// padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--vscode-editorGroup-border);
|
||||
}
|
||||
`
|
||||
|
||||
const SettingsHeader = styled.div`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: var(--vscode-foreground);
|
||||
`
|
||||
|
||||
const SettingsDescription = styled.div<{ isLast?: boolean }>`
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
margin-bottom: ${(props) => (props.isLast ? "0" : "8px")};
|
||||
`
|
||||
|
||||
export default BrowserSettingsMenu
|
||||
|
|
@ -31,39 +31,29 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
|
|||
</h3>
|
||||
<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
|
||||
<li>
|
||||
<b>Checkpoints are here!</b> Cline now saves a snapshot of your workspace at each step of the task. Hover over
|
||||
any message to see two new buttons:
|
||||
<ul style={{ margin: "4px 0", paddingLeft: 22 }}>
|
||||
<li>
|
||||
<span
|
||||
className="codicon codicon-diff-multiple"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginRight: "4px",
|
||||
}}></span>
|
||||
<b>Compare</b> shows you a diff between the snapshot and your current workspace
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
className="codicon codicon-discard"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginRight: "4px",
|
||||
}}></span>
|
||||
<b>Restore</b> lets you revert your project's files back to that point in the task
|
||||
</li>
|
||||
</ul>
|
||||
<b>Plan/Act mode toggle:</b> Plan mode turns Cline into an architect that gathers information, asks clarifying
|
||||
questions, and designs a solution. Switch back to Act mode to let him execute the plan!{" "}
|
||||
<VSCodeLink href="https://x.com/sdrzn/status/1881761978986934582" style={{ display: "inline" }}>
|
||||
See a demo here.
|
||||
</VSCodeLink>
|
||||
</li>
|
||||
<li>
|
||||
<b>'See new changes' button</b> when a task is completed, showing you an overview of all the changes Cline
|
||||
made to your workspace throughout the task
|
||||
<b>Quick API/model switching</b> with a new popup menu under the chat field
|
||||
</li>
|
||||
<li>
|
||||
<b>VS Code LM API</b> lets you use models from other extensions like GitHub Copilot
|
||||
</li>
|
||||
<li>
|
||||
<b>MCP server improvements:</b> On/off toggle to disable servers when not in use, and Auto-approve option for
|
||||
individual tools
|
||||
</li>
|
||||
<li>
|
||||
In case you missed it, Cline now supports Checkpoints!{" "}
|
||||
<VSCodeLink href="https://x.com/sdrzn/status/1876378124126236949" style={{ display: "inline" }}>
|
||||
See it in action here.
|
||||
</VSCodeLink>
|
||||
</li>
|
||||
</ul>
|
||||
<p style={{ margin: "8px 0" }}>
|
||||
<VSCodeLink href="https://x.com/sdrzn/status/1876378124126236949" style={{ display: "inline" }}>
|
||||
See a demo of Checkpoints here!
|
||||
</VSCodeLink>
|
||||
</p>
|
||||
{/*<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
|
||||
<li>
|
||||
OpenRouter now supports prompt caching! They also have much higher rate limits than other providers,
|
||||
|
|
@ -120,9 +110,13 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
|
|||
}}
|
||||
/>
|
||||
<p style={{ margin: "0" }}>
|
||||
Join
|
||||
Join our{" "}
|
||||
<VSCodeLink style={{ display: "inline" }} href="https://discord.gg/cline">
|
||||
discord.gg/cline
|
||||
discord
|
||||
</VSCodeLink>{" "}
|
||||
or{" "}
|
||||
<VSCodeLink style={{ display: "inline" }} href="https://www.reddit.com/r/cline/">
|
||||
r/cline
|
||||
</VSCodeLink>
|
||||
for more updates!
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
|||
import styled from "styled-components"
|
||||
import { CheckpointControls, CheckpointOverlay } from "../common/CheckpointControls"
|
||||
import { findLast } from "../../../../src/shared/array"
|
||||
import { BrowserSettingsMenu } from "../browser/BrowserSettingsMenu"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { BROWSER_VIEWPORT_PRESETS } from "../../../../src/shared/BrowserSettings"
|
||||
|
||||
interface BrowserSessionRowProps {
|
||||
messages: ClineMessage[]
|
||||
|
|
@ -21,6 +24,7 @@ interface BrowserSessionRowProps {
|
|||
|
||||
const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
|
||||
const { messages, isLast, onHeightChange, lastModifiedMessage } = props
|
||||
const { browserSettings } = useExtensionState()
|
||||
const prevHeightRef = useRef(0)
|
||||
const [maxActionHeight, setMaxActionHeight] = useState(0)
|
||||
const [consoleLogsExpanded, setConsoleLogsExpanded] = useState(false)
|
||||
|
|
@ -169,17 +173,19 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
|
|||
const currentPage = pages[currentPageIndex]
|
||||
const isLastPage = currentPageIndex === pages.length - 1
|
||||
|
||||
const defaultMousePosition = `${browserSettings.viewport.width * 0.7},${browserSettings.viewport.height * 0.5}`
|
||||
|
||||
// Use latest state if we're on the last page and don't have a state yet
|
||||
const displayState = isLastPage
|
||||
? {
|
||||
url: currentPage?.currentState.url || latestState.url || initialUrl,
|
||||
mousePosition: currentPage?.currentState.mousePosition || latestState.mousePosition || "700,400",
|
||||
mousePosition: currentPage?.currentState.mousePosition || latestState.mousePosition || defaultMousePosition,
|
||||
consoleLogs: currentPage?.currentState.consoleLogs,
|
||||
screenshot: currentPage?.currentState.screenshot || latestState.screenshot,
|
||||
}
|
||||
: {
|
||||
url: currentPage?.currentState.url || initialUrl,
|
||||
mousePosition: currentPage?.currentState.mousePosition || "700,400",
|
||||
mousePosition: currentPage?.currentState.mousePosition || defaultMousePosition,
|
||||
consoleLogs: currentPage?.currentState.consoleLogs,
|
||||
screenshot: currentPage?.currentState.screenshot,
|
||||
}
|
||||
|
|
@ -230,6 +236,14 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
|
|||
shouldShowCheckpoints = lastModifiedMessage?.ask === "resume_completed_task" || lastModifiedMessage?.ask === "resume_task"
|
||||
}
|
||||
|
||||
const shouldShowSettings = useMemo(() => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
return lastMessage?.ask === "browser_action_launch" || lastMessage?.say === "browser_action_launch"
|
||||
}, [messages])
|
||||
|
||||
// Calculate maxWidth
|
||||
const maxWidth = browserSettings.viewport.width < BROWSER_VIEWPORT_PRESETS["Small Desktop (900x600)"].width ? 200 : undefined
|
||||
|
||||
const [browserSessionRow, { height }] = useSize(
|
||||
<BrowserSessionRowContainer style={{ marginBottom: -10 }}>
|
||||
<div
|
||||
|
|
@ -257,43 +271,51 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
|
|||
style={{
|
||||
borderRadius: 3,
|
||||
border: "1px solid var(--vscode-editorGroup-border)",
|
||||
overflow: "hidden",
|
||||
// overflow: "hidden",
|
||||
backgroundColor: CODE_BLOCK_BG_COLOR,
|
||||
marginBottom: 10,
|
||||
// marginBottom: 10,
|
||||
maxWidth,
|
||||
margin: "0 auto 10px auto", // Center the container
|
||||
}}>
|
||||
{/* URL Bar */}
|
||||
<div
|
||||
style={{
|
||||
margin: "5px auto",
|
||||
width: "calc(100% - 10px)",
|
||||
boxSizing: "border-box", // includes padding in width calculation
|
||||
backgroundColor: "var(--vscode-input-background)",
|
||||
border: "1px solid var(--vscode-input-border)",
|
||||
borderRadius: "4px",
|
||||
padding: "3px 5px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: displayState.url ? "var(--vscode-input-foreground)" : "var(--vscode-descriptionForeground)",
|
||||
fontSize: "12px",
|
||||
gap: "4px",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
width: "100%",
|
||||
textAlign: "center",
|
||||
flex: 1,
|
||||
backgroundColor: "var(--vscode-input-background)",
|
||||
border: "1px solid var(--vscode-input-border)",
|
||||
borderRadius: "4px",
|
||||
padding: "3px 5px",
|
||||
minWidth: 0,
|
||||
color: displayState.url ? "var(--vscode-input-foreground)" : "var(--vscode-descriptionForeground)",
|
||||
fontSize: "12px",
|
||||
}}>
|
||||
{displayState.url || "http"}
|
||||
<div
|
||||
style={{
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
width: "100%",
|
||||
textAlign: "center",
|
||||
}}>
|
||||
{displayState.url || "http"}
|
||||
</div>
|
||||
</div>
|
||||
<BrowserSettingsMenu disabled={!shouldShowSettings} maxWidth={maxWidth} />
|
||||
</div>
|
||||
|
||||
{/* Screenshot Area */}
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
paddingBottom: "calc(200%/3)",
|
||||
paddingBottom: `${(browserSettings.viewport.height / browserSettings.viewport.width) * 100}%`,
|
||||
position: "relative",
|
||||
backgroundColor: "var(--vscode-input-background)",
|
||||
}}>
|
||||
|
|
@ -338,8 +360,8 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
|
|||
<BrowserCursor
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `${(parseInt(mousePosition.split(",")[1]) / 600) * 100}%`,
|
||||
left: `${(parseInt(mousePosition.split(",")[0]) / 900) * 100}%`,
|
||||
top: `${(parseInt(mousePosition.split(",")[1]) / browserSettings.viewport.height) * 100}%`,
|
||||
left: `${(parseInt(mousePosition.split(",")[0]) / browserSettings.viewport.width) * 100}%`,
|
||||
transition: "top 0.3s ease-out, left 0.3s ease-out",
|
||||
}}
|
||||
/>
|
||||
|
|
@ -355,7 +377,7 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
|
|||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
width: "100%",
|
||||
// width: "100%",
|
||||
justifyContent: "flex-start",
|
||||
cursor: "pointer",
|
||||
padding: `9px 8px ${consoleLogsExpanded ? 0 : 8}px 8px`,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import {
|
|||
ClineAskUseMcpServer,
|
||||
ClineMessage,
|
||||
ClineSayTool,
|
||||
ExtensionMessage,
|
||||
COMPLETION_RESULT_CHANGES_FLAG,
|
||||
ExtensionMessage,
|
||||
} from "../../../../src/shared/ExtensionMessage"
|
||||
import { COMMAND_OUTPUT_STRING, COMMAND_REQ_APP_STRING } from "../../../../src/shared/combineCommandSequences"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
|
|
@ -685,13 +685,19 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
|||
|
||||
{useMcpServer.type === "use_mcp_tool" && (
|
||||
<>
|
||||
<McpToolRow
|
||||
tool={{
|
||||
name: useMcpServer.toolName || "",
|
||||
description:
|
||||
server?.tools?.find((tool) => tool.name === useMcpServer.toolName)?.description || "",
|
||||
}}
|
||||
/>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<McpToolRow
|
||||
tool={{
|
||||
name: useMcpServer.toolName || "",
|
||||
description:
|
||||
server?.tools?.find((tool) => tool.name === useMcpServer.toolName)?.description || "",
|
||||
autoApprove:
|
||||
server?.tools?.find((tool) => tool.name === useMcpServer.toolName)?.autoApprove ||
|
||||
false,
|
||||
}}
|
||||
serverName={useMcpServer.serverName}
|
||||
/>
|
||||
</div>
|
||||
{useMcpServer.arguments && useMcpServer.arguments !== "{}" && (
|
||||
<div style={{ marginTop: "8px" }}>
|
||||
<div
|
||||
|
|
@ -1154,6 +1160,12 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
|||
</div>
|
||||
</>
|
||||
)
|
||||
case "plan_mode_response":
|
||||
return (
|
||||
<div style={{}}>
|
||||
<Markdown markdown={message.text} />
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
|
||||
import DynamicTextArea from "react-textarea-autosize"
|
||||
import { useClickAway, useWindowSize } from "react-use"
|
||||
import styled from "styled-components"
|
||||
import {
|
||||
anthropicDefaultModelId,
|
||||
bedrockDefaultModelId,
|
||||
deepSeekDefaultModelId,
|
||||
geminiDefaultModelId,
|
||||
mistralDefaultModelId,
|
||||
openAiNativeDefaultModelId,
|
||||
openRouterDefaultModelId,
|
||||
vertexDefaultModelId,
|
||||
} from "../../../../src/shared/api"
|
||||
import { mentionRegex, mentionRegexGlobal } from "../../../../src/shared/context-mentions"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import {
|
||||
|
|
@ -9,9 +22,13 @@ import {
|
|||
removeMention,
|
||||
shouldShowContextMenu,
|
||||
} from "../../utils/context-mentions"
|
||||
import { validateApiConfiguration, validateModelId } from "../../utils/validate"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
import Thumbnails from "../common/Thumbnails"
|
||||
import ApiOptions from "../settings/ApiOptions"
|
||||
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
|
||||
import ContextMenu from "./ContextMenu"
|
||||
import Thumbnails from "../common/Thumbnails"
|
||||
|
||||
interface ChatTextAreaProps {
|
||||
inputValue: string
|
||||
|
|
@ -26,6 +43,167 @@ interface ChatTextAreaProps {
|
|||
onHeightChange?: (height: number) => void
|
||||
}
|
||||
|
||||
const PLAN_MODE_COLOR = "var(--vscode-inputValidation-warningBorder)"
|
||||
|
||||
const SwitchOption = styled.div<{ isActive: boolean }>`
|
||||
padding: 2px 8px;
|
||||
color: ${(props) => (props.isActive ? "white" : "var(--vscode-input-foreground)")};
|
||||
z-index: 1;
|
||||
transition: color 0.2s ease;
|
||||
font-size: 12px;
|
||||
width: 50%;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => (!props.isActive ? "var(--vscode-toolbar-hoverBackground)" : "transparent")};
|
||||
}
|
||||
`
|
||||
|
||||
const SwitchContainer = styled.div<{ disabled: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--vscode-editor-background);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
|
||||
opacity: ${(props) => (props.disabled ? 0.5 : 1)};
|
||||
transform: scale(0.85);
|
||||
transform-origin: right center;
|
||||
margin-left: -10px; // compensate for the transform so flex spacing works
|
||||
user-select: none; // Prevent text selection
|
||||
`
|
||||
|
||||
const Slider = styled.div<{ isAct: boolean; isPlan?: boolean }>`
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 50%;
|
||||
background-color: ${(props) => (props.isPlan ? PLAN_MODE_COLOR : "var(--vscode-focusBorder)")};
|
||||
transition: transform 0.2s ease;
|
||||
transform: translateX(${(props) => (props.isAct ? "100%" : "0%")});
|
||||
`
|
||||
|
||||
const ButtonGroup = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`
|
||||
|
||||
const ButtonContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: -5px;
|
||||
padding: 0px 15px 5px 15px;
|
||||
`
|
||||
|
||||
const ModelSelectorTooltip = styled.div<ModelSelectorTooltipProps>`
|
||||
position: fixed;
|
||||
bottom: calc(100% + 9px);
|
||||
left: 15px;
|
||||
right: 15px;
|
||||
background: ${CODE_BLOCK_BG_COLOR};
|
||||
border: 1px solid var(--vscode-editorGroup-border);
|
||||
padding: 12px;
|
||||
border-radius: 3px;
|
||||
z-index: 1000;
|
||||
max-height: calc(100vh - 100px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
// Add invisible padding for hover zone
|
||||
&::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
bottom: ${(props) => `calc(100vh - ${props.menuPosition}px - 2px)`};
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
// Arrow pointing down
|
||||
&::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
bottom: ${(props) => `calc(100vh - ${props.menuPosition}px)`};
|
||||
right: ${(props) => props.arrowPosition}px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: ${CODE_BLOCK_BG_COLOR};
|
||||
border-right: 1px solid var(--vscode-editorGroup-border);
|
||||
border-bottom: 1px solid var(--vscode-editorGroup-border);
|
||||
transform: rotate(45deg);
|
||||
z-index: -1;
|
||||
}
|
||||
`
|
||||
|
||||
const ModelContainer = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`
|
||||
|
||||
const ModelButtonWrapper = styled.div`
|
||||
display: inline-flex; // Make it shrink to content
|
||||
min-width: 0; // Allow shrinking
|
||||
max-width: 100%; // Don't overflow parent
|
||||
`
|
||||
|
||||
const ModelDisplayButton = styled.a<{ isActive?: boolean; disabled?: boolean }>`
|
||||
padding: 0px 0px;
|
||||
height: 20px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
|
||||
text-decoration: ${(props) => (props.isActive ? "underline" : "none")};
|
||||
color: ${(props) => (props.isActive ? "var(--vscode-foreground)" : "var(--vscode-descriptionForeground)")};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
opacity: ${(props) => (props.disabled ? 0.5 : 1)};
|
||||
pointer-events: ${(props) => (props.disabled ? "none" : "auto")};
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: ${(props) => (props.disabled ? "var(--vscode-descriptionForeground)" : "var(--vscode-foreground)")};
|
||||
text-decoration: ${(props) => (props.disabled ? "none" : "underline")};
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: ${(props) => (props.disabled ? "var(--vscode-descriptionForeground)" : "var(--vscode-foreground)")};
|
||||
text-decoration: ${(props) => (props.disabled ? "none" : "underline")};
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
`
|
||||
|
||||
const ModelButtonContent = styled.div`
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`
|
||||
|
||||
const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
(
|
||||
{
|
||||
|
|
@ -42,7 +220,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
},
|
||||
ref,
|
||||
) => {
|
||||
const { filePaths } = useExtensionState()
|
||||
const { filePaths, chatSettings, apiConfiguration, openRouterModels } = useExtensionState()
|
||||
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
||||
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
|
||||
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
|
||||
|
|
@ -57,6 +235,15 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false)
|
||||
const [intendedCursorPosition, setIntendedCursorPosition] = useState<number | null>(null)
|
||||
const contextMenuContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [showModelSelector, setShowModelSelector] = useState(false)
|
||||
const modelSelectorRef = useRef<HTMLDivElement>(null)
|
||||
const { width: viewportWidth, height: viewportHeight } = useWindowSize()
|
||||
const buttonRef = useRef<HTMLDivElement>(null)
|
||||
const [arrowPosition, setArrowPosition] = useState(0)
|
||||
const [menuPosition, setMenuPosition] = useState(0)
|
||||
|
||||
// Add a ref to track previous menu state
|
||||
const prevShowModelSelector = useRef(showModelSelector)
|
||||
|
||||
const queryItems = useMemo(() => {
|
||||
return [
|
||||
|
|
@ -188,6 +375,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
const isComposing = event.nativeEvent?.isComposing ?? false
|
||||
if (event.key === "Enter" && !event.shiftKey && !isComposing) {
|
||||
event.preventDefault()
|
||||
setIsTextAreaFocused(false)
|
||||
onSend()
|
||||
}
|
||||
|
||||
|
|
@ -406,184 +594,513 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
[updateCursorPosition],
|
||||
)
|
||||
|
||||
const onModeToggle = useCallback(() => {
|
||||
if (textAreaDisabled) return
|
||||
const newMode = chatSettings.mode === "plan" ? "act" : "plan"
|
||||
vscode.postMessage({
|
||||
type: "chatSettings",
|
||||
chatSettings: {
|
||||
mode: newMode,
|
||||
},
|
||||
})
|
||||
// Focus the textarea after mode toggle with slight delay
|
||||
setTimeout(() => {
|
||||
textAreaRef.current?.focus()
|
||||
}, 100)
|
||||
}, [chatSettings.mode, textAreaDisabled])
|
||||
|
||||
const handleContextButtonClick = useCallback(() => {
|
||||
if (textAreaDisabled) return
|
||||
|
||||
// Focus the textarea first
|
||||
textAreaRef.current?.focus()
|
||||
|
||||
// If input is empty, just insert @
|
||||
if (!inputValue.trim()) {
|
||||
const event = {
|
||||
target: {
|
||||
value: "@",
|
||||
selectionStart: 1,
|
||||
},
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>
|
||||
handleInputChange(event)
|
||||
updateHighlights()
|
||||
return
|
||||
}
|
||||
|
||||
// If input ends with space or is empty, just append @
|
||||
if (inputValue.endsWith(" ")) {
|
||||
const event = {
|
||||
target: {
|
||||
value: inputValue + "@",
|
||||
selectionStart: inputValue.length + 1,
|
||||
},
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>
|
||||
handleInputChange(event)
|
||||
updateHighlights()
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise add space then @
|
||||
const event = {
|
||||
target: {
|
||||
value: inputValue + " @",
|
||||
selectionStart: inputValue.length + 2,
|
||||
},
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>
|
||||
handleInputChange(event)
|
||||
updateHighlights()
|
||||
}, [inputValue, textAreaDisabled, handleInputChange, updateHighlights])
|
||||
|
||||
// Separate the API config submission logic
|
||||
const submitApiConfig = useCallback(() => {
|
||||
const apiValidationResult = validateApiConfiguration(apiConfiguration)
|
||||
const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels)
|
||||
|
||||
if (!apiValidationResult && !modelIdValidationResult) {
|
||||
vscode.postMessage({ type: "apiConfiguration", apiConfiguration })
|
||||
} else {
|
||||
vscode.postMessage({ type: "getLatestState" })
|
||||
}
|
||||
}, [apiConfiguration, openRouterModels])
|
||||
|
||||
// Use an effect to detect menu close
|
||||
useEffect(() => {
|
||||
if (prevShowModelSelector.current && !showModelSelector) {
|
||||
// Menu was just closed
|
||||
submitApiConfig()
|
||||
}
|
||||
prevShowModelSelector.current = showModelSelector
|
||||
}, [showModelSelector, submitApiConfig])
|
||||
|
||||
// Remove the handleApiConfigSubmit callback
|
||||
// Update click handler to just toggle the menu
|
||||
const handleModelButtonClick = () => {
|
||||
setShowModelSelector(!showModelSelector)
|
||||
}
|
||||
|
||||
// Update click away handler to just close menu
|
||||
useClickAway(modelSelectorRef, () => {
|
||||
setShowModelSelector(false)
|
||||
})
|
||||
|
||||
// Get model display name
|
||||
const modelDisplayName = useMemo(() => {
|
||||
const unknownModel = "unknown"
|
||||
if (!apiConfiguration) return unknownModel
|
||||
switch (apiConfiguration.apiProvider) {
|
||||
case "anthropic":
|
||||
return `anthropic:${apiConfiguration.apiModelId || anthropicDefaultModelId}`
|
||||
case "openai":
|
||||
return `openai:${apiConfiguration.openAiModelId || unknownModel}`
|
||||
case "openrouter":
|
||||
return `openrouter:${apiConfiguration.openRouterModelId || openRouterDefaultModelId}`
|
||||
case "bedrock":
|
||||
return `bedrock:${apiConfiguration.apiModelId || bedrockDefaultModelId}`
|
||||
case "vertex":
|
||||
return `vertex:${apiConfiguration.apiModelId || vertexDefaultModelId}`
|
||||
case "ollama":
|
||||
return `ollama:${apiConfiguration.ollamaModelId || unknownModel}`
|
||||
case "lmstudio":
|
||||
return `lmstudio:${apiConfiguration.lmStudioModelId || unknownModel}`
|
||||
case "gemini":
|
||||
return `gemini:${apiConfiguration.apiModelId || geminiDefaultModelId}`
|
||||
case "openai-native":
|
||||
return `openai-native:${apiConfiguration.apiModelId || openAiNativeDefaultModelId}`
|
||||
case "deepseek":
|
||||
return `deepseek:${apiConfiguration.apiModelId || deepSeekDefaultModelId}`
|
||||
case "mistral":
|
||||
return `mistral:${apiConfiguration.apiModelId || mistralDefaultModelId}`
|
||||
case "vscode-lm":
|
||||
return `vscode-lm:${apiConfiguration.vsCodeLmModelSelector ? `${apiConfiguration.vsCodeLmModelSelector.vendor ?? ""}/${apiConfiguration.vsCodeLmModelSelector.family ?? ""}` : unknownModel}`
|
||||
default:
|
||||
return unknownModel
|
||||
}
|
||||
}, [apiConfiguration])
|
||||
|
||||
// Calculate arrow position and menu position based on button location
|
||||
useEffect(() => {
|
||||
if (showModelSelector && buttonRef.current) {
|
||||
const buttonRect = buttonRef.current.getBoundingClientRect()
|
||||
const buttonCenter = buttonRect.left + buttonRect.width / 2
|
||||
|
||||
// Calculate distance from right edge of viewport using viewport coordinates
|
||||
const rightPosition = document.documentElement.clientWidth - buttonCenter - 5
|
||||
|
||||
setArrowPosition(rightPosition)
|
||||
setMenuPosition(buttonRect.top + 1) // Added +1 to move menu down by 1px
|
||||
}
|
||||
}, [showModelSelector, viewportWidth, viewportHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showModelSelector) {
|
||||
// Attempt to save if possible
|
||||
// NOTE: we cannot call this here since it will create an infinite loop between this effect and the callback since getLatestState will update state. Instead we should submitapiconfig when the menu is explicitly closed, rather than as an effect of showModelSelector changing.
|
||||
// handleApiConfigSubmit()
|
||||
|
||||
// Reset any active styling by blurring the button
|
||||
const button = buttonRef.current?.querySelector("a")
|
||||
if (button) {
|
||||
button.blur()
|
||||
}
|
||||
}
|
||||
}, [showModelSelector])
|
||||
|
||||
/**
|
||||
* Handles the drag over event to allow dropping.
|
||||
* Prevents the default behavior to enable drop.
|
||||
*
|
||||
* @param {React.DragEvent} e - The drag event.
|
||||
*/
|
||||
const onDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the drop event for files and text.
|
||||
* Processes dropped images and text, updating the state accordingly.
|
||||
*
|
||||
* @param {React.DragEvent} e - The drop event.
|
||||
*/
|
||||
const onDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
const text = e.dataTransfer.getData("text")
|
||||
|
||||
if (text) {
|
||||
handleTextDrop(text)
|
||||
return
|
||||
}
|
||||
|
||||
const acceptedTypes = ["png", "jpeg", "webp"]
|
||||
const imageFiles = files.filter((file) => {
|
||||
const [type, subtype] = file.type.split("/")
|
||||
return type === "image" && acceptedTypes.includes(subtype)
|
||||
})
|
||||
|
||||
if (shouldDisableImages || imageFiles.length === 0) return
|
||||
|
||||
const imageDataArray = await readImageFiles(imageFiles)
|
||||
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
|
||||
|
||||
if (dataUrls.length > 0) {
|
||||
setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
|
||||
} else {
|
||||
console.warn("No valid images were processed")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the drop event for text.
|
||||
* Inserts the dropped text at the current cursor position.
|
||||
*
|
||||
* @param {string} text - The dropped text.
|
||||
*/
|
||||
const handleTextDrop = (text: string) => {
|
||||
const newValue = inputValue.slice(0, cursorPosition) + text + inputValue.slice(cursorPosition)
|
||||
setInputValue(newValue)
|
||||
const newCursorPosition = cursorPosition + text.length
|
||||
setCursorPosition(newCursorPosition)
|
||||
setIntendedCursorPosition(newCursorPosition)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads image files and returns their data URLs.
|
||||
* Uses FileReader to read the files as data URLs.
|
||||
*
|
||||
* @param {File[]} imageFiles - The image files to read.
|
||||
* @returns {Promise<(string | null)[]>} - A promise that resolves to an array of data URLs or null values.
|
||||
*/
|
||||
const readImageFiles = (imageFiles: File[]): Promise<(string | null)[]> => {
|
||||
return Promise.all(
|
||||
imageFiles.map(
|
||||
(file) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
if (reader.error) {
|
||||
console.error("Error reading file:", reader.error)
|
||||
resolve(null)
|
||||
} else {
|
||||
const result = reader.result
|
||||
resolve(typeof result === "string" ? result : null)
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 15px",
|
||||
opacity: textAreaDisabled ? 0.5 : 1,
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
}}>
|
||||
{showContextMenu && (
|
||||
<div ref={contextMenuContainerRef}>
|
||||
<ContextMenu
|
||||
onSelect={handleMentionSelect}
|
||||
searchQuery={searchQuery}
|
||||
onMouseDown={handleMenuMouseDown}
|
||||
selectedIndex={selectedMenuIndex}
|
||||
setSelectedIndex={setSelectedMenuIndex}
|
||||
selectedType={selectedType}
|
||||
queryItems={queryItems}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isTextAreaFocused && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: "10px 15px",
|
||||
border: "1px solid var(--vscode-input-border)",
|
||||
borderRadius: 2,
|
||||
pointerEvents: "none",
|
||||
zIndex: 5,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={highlightLayerRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
left: 15,
|
||||
right: 15,
|
||||
bottom: 10,
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordWrap: "break-word",
|
||||
color: "transparent",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "var(--vscode-input-background)",
|
||||
fontFamily: "var(--vscode-font-family)",
|
||||
fontSize: "var(--vscode-editor-font-size)",
|
||||
lineHeight: "var(--vscode-editor-line-height)",
|
||||
borderRadius: 2,
|
||||
borderLeft: 0,
|
||||
borderRight: 0,
|
||||
borderTop: 0,
|
||||
borderColor: "transparent",
|
||||
borderBottom: `${thumbnailsHeight + 6}px solid transparent`,
|
||||
padding: "9px 49px 3px 9px",
|
||||
}}
|
||||
/>
|
||||
<DynamicTextArea
|
||||
ref={(el) => {
|
||||
if (typeof ref === "function") {
|
||||
ref(el)
|
||||
} else if (ref) {
|
||||
ref.current = el
|
||||
}
|
||||
textAreaRef.current = el
|
||||
}}
|
||||
value={inputValue}
|
||||
disabled={textAreaDisabled}
|
||||
onChange={(e) => {
|
||||
handleInputChange(e)
|
||||
updateHighlights()
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onFocus={() => setIsTextAreaFocused(true)}
|
||||
onBlur={handleBlur}
|
||||
onPaste={handlePaste}
|
||||
onSelect={updateCursorPosition}
|
||||
onMouseUp={updateCursorPosition}
|
||||
onHeightChange={(height) => {
|
||||
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
|
||||
setTextAreaBaseHeight(height)
|
||||
}
|
||||
onHeightChange?.(height)
|
||||
}}
|
||||
placeholder={placeholderText}
|
||||
maxRows={10}
|
||||
autoFocus={true}
|
||||
style={{
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--vscode-input-foreground)",
|
||||
//border: "1px solid var(--vscode-input-border)",
|
||||
borderRadius: 2,
|
||||
fontFamily: "var(--vscode-font-family)",
|
||||
fontSize: "var(--vscode-editor-font-size)",
|
||||
lineHeight: "var(--vscode-editor-line-height)",
|
||||
resize: "none",
|
||||
overflowX: "hidden",
|
||||
overflowY: "scroll",
|
||||
scrollbarWidth: "none",
|
||||
// Since we have maxRows, when text is long enough it starts to overflow the bottom padding, appearing behind the thumbnails. To fix this, we use a transparent border to push the text up instead. (https://stackoverflow.com/questions/42631947/maintaining-a-padding-inside-of-text-area/52538410#52538410)
|
||||
// borderTop: "9px solid transparent",
|
||||
borderLeft: 0,
|
||||
borderRight: 0,
|
||||
borderTop: 0,
|
||||
borderBottom: `${thumbnailsHeight + 6}px solid transparent`,
|
||||
borderColor: "transparent",
|
||||
// borderRight: "54px solid transparent",
|
||||
// borderLeft: "9px solid transparent", // NOTE: react-textarea-autosize doesn't calculate correct height when using borderLeft/borderRight so we need to use horizontal padding instead
|
||||
// Instead of using boxShadow, we use a div with a border to better replicate the behavior when the textarea is focused
|
||||
// boxShadow: "0px 0px 0px 1px var(--vscode-input-border)",
|
||||
padding: "9px 49px 3px 9px",
|
||||
cursor: textAreaDisabled ? "not-allowed" : undefined,
|
||||
flex: 1,
|
||||
zIndex: 1,
|
||||
}}
|
||||
onScroll={() => updateHighlights()}
|
||||
/>
|
||||
{selectedImages.length > 0 && (
|
||||
<Thumbnails
|
||||
images={selectedImages}
|
||||
setImages={setSelectedImages}
|
||||
onHeightChange={handleThumbnailsHeightChange}
|
||||
style={{
|
||||
position: "absolute",
|
||||
paddingTop: 4,
|
||||
bottom: 14,
|
||||
left: 22,
|
||||
right: 67, // (54 + 9) + 4 extra padding
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 23,
|
||||
padding: "10px 15px",
|
||||
opacity: textAreaDisabled ? 0.5 : 1,
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "flex-center",
|
||||
height: textAreaBaseHeight || 31,
|
||||
bottom: 9.5, // should be 10 but doesnt look good on mac
|
||||
zIndex: 2,
|
||||
}}>
|
||||
}}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}>
|
||||
{showContextMenu && (
|
||||
<div ref={contextMenuContainerRef}>
|
||||
<ContextMenu
|
||||
onSelect={handleMentionSelect}
|
||||
searchQuery={searchQuery}
|
||||
onMouseDown={handleMenuMouseDown}
|
||||
selectedIndex={selectedMenuIndex}
|
||||
setSelectedIndex={setSelectedMenuIndex}
|
||||
selectedType={selectedType}
|
||||
queryItems={queryItems}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isTextAreaFocused && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: "10px 15px",
|
||||
border: "1px solid var(--vscode-input-border)",
|
||||
borderRadius: 2,
|
||||
pointerEvents: "none",
|
||||
zIndex: 5,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={highlightLayerRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
left: 15,
|
||||
right: 15,
|
||||
bottom: 10,
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordWrap: "break-word",
|
||||
color: "transparent",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "var(--vscode-input-background)",
|
||||
fontFamily: "var(--vscode-font-family)",
|
||||
fontSize: "var(--vscode-editor-font-size)",
|
||||
lineHeight: "var(--vscode-editor-line-height)",
|
||||
borderRadius: 2,
|
||||
borderLeft: 0,
|
||||
borderRight: 0,
|
||||
borderTop: 0,
|
||||
borderColor: "transparent",
|
||||
borderBottom: `${thumbnailsHeight + 6}px solid transparent`,
|
||||
padding: "9px 49px 3px 9px",
|
||||
}}
|
||||
/>
|
||||
<DynamicTextArea
|
||||
data-testid="chat-input"
|
||||
ref={(el) => {
|
||||
if (typeof ref === "function") {
|
||||
ref(el)
|
||||
} else if (ref) {
|
||||
ref.current = el
|
||||
}
|
||||
textAreaRef.current = el
|
||||
}}
|
||||
value={inputValue}
|
||||
disabled={textAreaDisabled}
|
||||
onChange={(e) => {
|
||||
handleInputChange(e)
|
||||
updateHighlights()
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onFocus={() => setIsTextAreaFocused(true)}
|
||||
onBlur={handleBlur}
|
||||
onPaste={handlePaste}
|
||||
onSelect={updateCursorPosition}
|
||||
onMouseUp={updateCursorPosition}
|
||||
onHeightChange={(height) => {
|
||||
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
|
||||
setTextAreaBaseHeight(height)
|
||||
}
|
||||
onHeightChange?.(height)
|
||||
}}
|
||||
placeholder={placeholderText}
|
||||
maxRows={10}
|
||||
autoFocus={true}
|
||||
style={{
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--vscode-input-foreground)",
|
||||
//border: "1px solid var(--vscode-input-border)",
|
||||
borderRadius: 2,
|
||||
fontFamily: "var(--vscode-font-family)",
|
||||
fontSize: "var(--vscode-editor-font-size)",
|
||||
lineHeight: "var(--vscode-editor-line-height)",
|
||||
resize: "none",
|
||||
overflowX: "hidden",
|
||||
overflowY: "scroll",
|
||||
scrollbarWidth: "none",
|
||||
// Since we have maxRows, when text is long enough it starts to overflow the bottom padding, appearing behind the thumbnails. To fix this, we use a transparent border to push the text up instead. (https://stackoverflow.com/questions/42631947/maintaining-a-padding-inside-of-text-area/52538410#52538410)
|
||||
// borderTop: "9px solid transparent",
|
||||
borderLeft: 0,
|
||||
borderRight: 0,
|
||||
borderTop: 0,
|
||||
borderBottom: `${thumbnailsHeight + 6}px solid transparent`,
|
||||
borderColor: "transparent",
|
||||
// borderRight: "54px solid transparent",
|
||||
// borderLeft: "9px solid transparent", // NOTE: react-textarea-autosize doesn't calculate correct height when using borderLeft/borderRight so we need to use horizontal padding instead
|
||||
// Instead of using boxShadow, we use a div with a border to better replicate the behavior when the textarea is focused
|
||||
// boxShadow: "0px 0px 0px 1px var(--vscode-input-border)",
|
||||
padding: "9px 28px 3px 9px",
|
||||
cursor: textAreaDisabled ? "not-allowed" : undefined,
|
||||
flex: 1,
|
||||
zIndex: 1,
|
||||
outline: isTextAreaFocused
|
||||
? `1px solid ${chatSettings.mode === "plan" ? PLAN_MODE_COLOR : "var(--vscode-focusBorder)"}`
|
||||
: "none",
|
||||
}}
|
||||
onScroll={() => updateHighlights()}
|
||||
/>
|
||||
{selectedImages.length > 0 && (
|
||||
<Thumbnails
|
||||
images={selectedImages}
|
||||
setImages={setSelectedImages}
|
||||
onHeightChange={handleThumbnailsHeightChange}
|
||||
style={{
|
||||
position: "absolute",
|
||||
paddingTop: 4,
|
||||
bottom: 14,
|
||||
left: 22,
|
||||
right: 47, // (54 + 9) + 4 extra padding
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 23,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignItems: "flex-center",
|
||||
height: textAreaBaseHeight || 31,
|
||||
bottom: 9.5, // should be 10 but doesnt look good on mac
|
||||
zIndex: 2,
|
||||
}}>
|
||||
<div
|
||||
className={`input-icon-button ${shouldDisableImages ? "disabled" : ""} codicon codicon-device-camera`}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
{/* <div
|
||||
className={`input-icon-button ${shouldDisableImages ? "disabled" : ""} codicon codicon-device-camera`}
|
||||
onClick={() => {
|
||||
if (!shouldDisableImages) {
|
||||
onSelectImages()
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
marginRight: 5.5,
|
||||
fontSize: 16.5,
|
||||
}}
|
||||
/> */}
|
||||
<div
|
||||
data-testid="send-button"
|
||||
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`}
|
||||
onClick={() => {
|
||||
if (!textAreaDisabled) {
|
||||
setIsTextAreaFocused(false)
|
||||
onSend()
|
||||
}
|
||||
}}
|
||||
style={{ fontSize: 15 }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ControlsContainer>
|
||||
<ButtonGroup>
|
||||
<VSCodeButton
|
||||
data-testid="context-button"
|
||||
appearance="icon"
|
||||
aria-label="Add Context"
|
||||
disabled={textAreaDisabled}
|
||||
onClick={handleContextButtonClick}
|
||||
style={{ padding: "0px 0px", height: "20px" }}>
|
||||
<ButtonContainer>
|
||||
<span style={{ fontSize: "13px", marginBottom: 1 }}>@</span>
|
||||
{/* {showButtonText && <span style={{ fontSize: "10px" }}>Context</span>} */}
|
||||
</ButtonContainer>
|
||||
</VSCodeButton>
|
||||
|
||||
<VSCodeButton
|
||||
data-testid="images-button"
|
||||
appearance="icon"
|
||||
aria-label="Add Images"
|
||||
disabled={shouldDisableImages}
|
||||
onClick={() => {
|
||||
if (!shouldDisableImages) {
|
||||
onSelectImages()
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
marginRight: 5.5,
|
||||
fontSize: 16.5,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`}
|
||||
onClick={() => {
|
||||
if (!textAreaDisabled) {
|
||||
onSend()
|
||||
}
|
||||
}}
|
||||
style={{ fontSize: 15 }}></div>
|
||||
</div>
|
||||
</div>
|
||||
style={{ padding: "0px 0px", height: "20px" }}>
|
||||
<ButtonContainer>
|
||||
<span className="codicon codicon-device-camera" style={{ fontSize: "14px", marginBottom: -3 }} />
|
||||
{/* {showButtonText && <span style={{ fontSize: "10px" }}>Images</span>} */}
|
||||
</ButtonContainer>
|
||||
</VSCodeButton>
|
||||
|
||||
<ModelContainer ref={modelSelectorRef}>
|
||||
<ModelButtonWrapper ref={buttonRef}>
|
||||
<ModelDisplayButton
|
||||
role="button"
|
||||
isActive={showModelSelector}
|
||||
disabled={textAreaDisabled}
|
||||
onClick={handleModelButtonClick}
|
||||
// onKeyDown={(e) => {
|
||||
// if (e.key === "Enter" || e.key === " ") {
|
||||
// e.preventDefault()
|
||||
// handleModelButtonClick()
|
||||
// }
|
||||
// }}
|
||||
tabIndex={0}>
|
||||
<ModelButtonContent>{modelDisplayName}</ModelButtonContent>
|
||||
</ModelDisplayButton>
|
||||
</ModelButtonWrapper>
|
||||
{showModelSelector && (
|
||||
<ModelSelectorTooltip
|
||||
arrowPosition={arrowPosition}
|
||||
menuPosition={menuPosition}
|
||||
style={{
|
||||
bottom: `calc(100vh - ${menuPosition}px + 6px)`,
|
||||
}}>
|
||||
<ApiOptions
|
||||
showModelOptions={true}
|
||||
apiErrorMessage={undefined}
|
||||
modelIdErrorMessage={undefined}
|
||||
isPopup={true}
|
||||
/>
|
||||
</ModelSelectorTooltip>
|
||||
)}
|
||||
</ModelContainer>
|
||||
</ButtonGroup>
|
||||
|
||||
<SwitchContainer data-testid="mode-switch" disabled={textAreaDisabled} onClick={onModeToggle}>
|
||||
<Slider isAct={chatSettings.mode === "act"} isPlan={chatSettings.mode === "plan"} />
|
||||
<SwitchOption isActive={chatSettings.mode === "plan"}>Plan</SwitchOption>
|
||||
<SwitchOption isActive={chatSettings.mode === "act"}>Act</SwitchOption>
|
||||
</SwitchContainer>
|
||||
</ControlsContainer>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
// Update TypeScript interface for styled-component props
|
||||
interface ModelSelectorTooltipProps {
|
||||
arrowPosition: number
|
||||
menuPosition: number
|
||||
}
|
||||
|
||||
export default ChatTextArea
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@ import { vscode } from "../../utils/vscode"
|
|||
import HistoryPreview from "../history/HistoryPreview"
|
||||
import { normalizeApiConfiguration } from "../settings/ApiOptions"
|
||||
import Announcement from "./Announcement"
|
||||
import AutoApproveMenu from "./AutoApproveMenu"
|
||||
import BrowserSessionRow from "./BrowserSessionRow"
|
||||
import ChatRow from "./ChatRow"
|
||||
import ChatTextArea from "./ChatTextArea"
|
||||
import TaskHeader from "./TaskHeader"
|
||||
import AutoApproveMenu from "./AutoApproveMenu"
|
||||
|
||||
interface ChatViewProps {
|
||||
isHidden: boolean
|
||||
|
|
@ -99,7 +99,14 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
|||
case "followup":
|
||||
setTextAreaDisabled(isPartial)
|
||||
setClineAsk("followup")
|
||||
setEnableButtons(isPartial)
|
||||
setEnableButtons(false)
|
||||
// setPrimaryButtonText(undefined)
|
||||
// setSecondaryButtonText(undefined)
|
||||
break
|
||||
case "plan_mode_response":
|
||||
setTextAreaDisabled(isPartial)
|
||||
setClineAsk("plan_mode_response")
|
||||
setEnableButtons(false)
|
||||
// setPrimaryButtonText(undefined)
|
||||
// setSecondaryButtonText(undefined)
|
||||
break
|
||||
|
|
@ -262,6 +269,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
|||
} else if (clineAsk) {
|
||||
switch (clineAsk) {
|
||||
case "followup":
|
||||
case "plan_mode_response":
|
||||
case "tool":
|
||||
case "browser_action_launch":
|
||||
case "command": // user can provide feedback to a tool or command use
|
||||
|
|
@ -658,7 +666,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
|||
useEvent("wheel", handleWheel, window, { passive: true }) // passive improves scrolling performance
|
||||
|
||||
const placeholderText = useMemo(() => {
|
||||
const text = task ? "Type a message (@ to add context)..." : "Type your task here (@ to add context)..."
|
||||
const text = task ? "Type a message..." : "Type your task here..."
|
||||
return text
|
||||
}, [task])
|
||||
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
const isCostAvailable = useMemo(() => {
|
||||
return (
|
||||
apiConfiguration?.apiProvider !== "openai" &&
|
||||
apiConfiguration?.apiProvider !== "vscode-lm" &&
|
||||
apiConfiguration?.apiProvider !== "ollama" &&
|
||||
apiConfiguration?.apiProvider !== "lmstudio" &&
|
||||
apiConfiguration?.apiProvider !== "gemini"
|
||||
|
|
@ -155,7 +156,10 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
flexGrow: 1,
|
||||
minWidth: 0, // This allows the div to shrink below its content size
|
||||
}}>
|
||||
<span style={{ fontWeight: "bold" }}>Task{!isTaskExpanded && ":"}</span>
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
Task
|
||||
{!isTaskExpanded && ":"}
|
||||
</span>
|
||||
{!isTaskExpanded && <span style={{ marginLeft: 4 }}>{highlightMentions(task.text, false)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -259,6 +263,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
height: 17,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -355,6 +360,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
height: 17,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
36
webview-ui/src/components/common/SettingsButton.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import styled from "styled-components"
|
||||
|
||||
const StyledButton = styled(VSCodeButton)`
|
||||
--settings-button-bg: var(--vscode-button-secondaryBackground);
|
||||
--settings-button-hover: var(--vscode-button-secondaryHoverBackground);
|
||||
--settings-button-active: var(--vscode-button-secondaryBackground);
|
||||
|
||||
background-color: var(--settings-button-bg) !important;
|
||||
border-color: var(--settings-button-bg) !important;
|
||||
width: 100% !important;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--settings-button-hover) !important;
|
||||
border-color: var(--settings-button-hover) !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--settings-button-active) !important;
|
||||
border-color: var(--settings-button-active) !important;
|
||||
}
|
||||
|
||||
i.codicon {
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
`
|
||||
|
||||
interface SettingsButtonProps extends React.ComponentProps<typeof VSCodeButton> {}
|
||||
|
||||
const SettingsButton: React.FC<SettingsButtonProps> = (props) => {
|
||||
return <StyledButton appearance="secondary" {...props} />
|
||||
}
|
||||
|
||||
export default SettingsButton
|
||||
|
|
@ -1,19 +1,45 @@
|
|||
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
|
||||
import { McpTool } from "../../../../src/shared/mcp"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
|
||||
type McpToolRowProps = {
|
||||
tool: McpTool
|
||||
serverName?: string
|
||||
}
|
||||
|
||||
const McpToolRow = ({ tool }: McpToolRowProps) => {
|
||||
const McpToolRow = ({ tool, serverName }: McpToolRowProps) => {
|
||||
const { autoApprovalSettings } = useExtensionState()
|
||||
|
||||
const handleAutoApproveChange = () => {
|
||||
if (!serverName) return
|
||||
|
||||
vscode.postMessage({
|
||||
type: "toggleToolAutoApprove",
|
||||
serverName,
|
||||
toolName: tool.name,
|
||||
autoApprove: !tool.autoApprove,
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
style={{
|
||||
padding: "3px 0",
|
||||
}}>
|
||||
<div style={{ display: "flex" }}>
|
||||
<span className="codicon codicon-symbol-method" style={{ marginRight: "6px" }}></span>
|
||||
<span style={{ fontWeight: 500 }}>{tool.name}</span>
|
||||
<div
|
||||
data-testid="tool-row-container"
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<span className="codicon codicon-symbol-method" style={{ marginRight: "6px" }}></span>
|
||||
<span style={{ fontWeight: 500 }}>{tool.name}</span>
|
||||
</div>
|
||||
{serverName && autoApprovalSettings.enabled && autoApprovalSettings.actions.useMcp && (
|
||||
<VSCodeCheckbox checked={tool.autoApprove} onChange={handleAutoApproveChange} data-tool={tool.name}>
|
||||
Auto-approve
|
||||
</VSCodeCheckbox>
|
||||
)}
|
||||
</div>
|
||||
{tool.description && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ type McpViewProps = {
|
|||
|
||||
const McpView = ({ onDone }: McpViewProps) => {
|
||||
const { mcpServers: servers } = useExtensionState()
|
||||
|
||||
// const [servers, setServers] = useState<McpServer[]>([
|
||||
// // Add some mock servers for testing
|
||||
// {
|
||||
|
|
@ -100,7 +101,7 @@ const McpView = ({ onDone }: McpViewProps) => {
|
|||
style={{
|
||||
color: "var(--vscode-foreground)",
|
||||
fontSize: "13px",
|
||||
marginBottom: "20px",
|
||||
marginBottom: "16px",
|
||||
marginTop: "5px",
|
||||
}}>
|
||||
The{" "}
|
||||
|
|
@ -118,7 +119,6 @@ const McpView = ({ onDone }: McpViewProps) => {
|
|||
</VSCodeLink>
|
||||
</div>
|
||||
|
||||
{/* Server List */}
|
||||
{servers.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -132,7 +132,8 @@ const McpView = ({ onDone }: McpViewProps) => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Settings Button */}
|
||||
{/* Server Configuration Button */}
|
||||
|
||||
<div style={{ marginTop: "10px", width: "100%" }}>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
|
|
@ -140,11 +141,25 @@ const McpView = ({ onDone }: McpViewProps) => {
|
|||
onClick={() => {
|
||||
vscode.postMessage({ type: "openMcpSettings" })
|
||||
}}>
|
||||
<span className="codicon codicon-edit" style={{ marginRight: "6px" }}></span>
|
||||
Edit MCP Settings
|
||||
<span className="codicon codicon-server" style={{ marginRight: "6px" }}></span>
|
||||
Configure MCP Servers
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
|
||||
{/* Advanced Settings Link */}
|
||||
<div style={{ textAlign: "center", marginTop: "5px" }}>
|
||||
<VSCodeLink
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openExtensionSettings",
|
||||
text: "cline.mcp",
|
||||
})
|
||||
}}
|
||||
style={{ fontSize: "12px" }}>
|
||||
Advanced MCP Settings
|
||||
</VSCodeLink>
|
||||
</div>
|
||||
|
||||
{/* Bottom padding */}
|
||||
<div style={{ height: "20px" }} />
|
||||
</div>
|
||||
|
|
@ -190,12 +205,62 @@ const ServerRow = ({ server }: { server: McpServer }) => {
|
|||
background: "var(--vscode-textCodeBlock-background)",
|
||||
cursor: server.error ? "default" : "pointer",
|
||||
borderRadius: isExpanded || server.error ? "4px 4px 0 0" : "4px",
|
||||
opacity: server.disabled ? 0.6 : 1,
|
||||
}}
|
||||
onClick={handleRowClick}>
|
||||
{!server.error && (
|
||||
<span className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`} style={{ marginRight: "8px" }} />
|
||||
)}
|
||||
<span style={{ flex: 1 }}>{server.name}</span>
|
||||
<div style={{ display: "flex", alignItems: "center", marginRight: "8px" }} onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
role="switch"
|
||||
aria-checked={!server.disabled}
|
||||
tabIndex={0}
|
||||
style={{
|
||||
width: "20px",
|
||||
height: "10px",
|
||||
backgroundColor: server.disabled
|
||||
? "var(--vscode-titleBar-inactiveForeground)"
|
||||
: "var(--vscode-testing-iconPassed)",
|
||||
borderRadius: "5px",
|
||||
position: "relative",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 0.2s",
|
||||
opacity: server.disabled ? 0.5 : 0.9,
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "toggleMcpServer",
|
||||
serverName: server.name,
|
||||
disabled: !server.disabled,
|
||||
})
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
vscode.postMessage({
|
||||
type: "toggleMcpServer",
|
||||
serverName: server.name,
|
||||
disabled: !server.disabled,
|
||||
})
|
||||
}
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
backgroundColor: "white",
|
||||
border: "1px solid color-mix(in srgb, #666666 65%, transparent)",
|
||||
borderRadius: "50%",
|
||||
position: "absolute",
|
||||
top: "1px",
|
||||
left: server.disabled ? "2px" : "12px",
|
||||
transition: "left 0.2s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: "8px",
|
||||
|
|
@ -261,7 +326,7 @@ const ServerRow = ({ server }: { server: McpServer }) => {
|
|||
width: "100%",
|
||||
}}>
|
||||
{server.tools.map((tool) => (
|
||||
<McpToolRow key={tool.name} tool={tool} />
|
||||
<McpToolRow key={tool.name} tool={tool} serverName={server.name} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Fragment, memo, useCallback, useEffect, useMemo, useState } from "react
|
|||
import { useEvent, useInterval } from "react-use"
|
||||
import {
|
||||
ApiConfiguration,
|
||||
ApiProvider,
|
||||
ModelInfo,
|
||||
anthropicDefaultModelId,
|
||||
anthropicModels,
|
||||
|
|
@ -21,6 +22,8 @@ import {
|
|||
deepSeekModels,
|
||||
geminiDefaultModelId,
|
||||
geminiModels,
|
||||
mistralDefaultModelId,
|
||||
mistralModels,
|
||||
openAiModelInfoSaneDefaults,
|
||||
openAiNativeDefaultModelId,
|
||||
openAiNativeModels,
|
||||
|
|
@ -33,18 +36,46 @@ import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
|
|||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import VSCodeButtonLink from "../common/VSCodeButtonLink"
|
||||
import OpenRouterModelPicker, { ModelDescriptionMarkdown, OPENROUTER_MODEL_PICKER_Z_INDEX } from "./OpenRouterModelPicker"
|
||||
import OpenRouterModelPicker, { ModelDescriptionMarkdown } from "./OpenRouterModelPicker"
|
||||
import styled from "styled-components"
|
||||
import * as vscodemodels from "vscode"
|
||||
|
||||
interface ApiOptionsProps {
|
||||
showModelOptions: boolean
|
||||
apiErrorMessage?: string
|
||||
modelIdErrorMessage?: string
|
||||
isPopup?: boolean
|
||||
}
|
||||
|
||||
const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => {
|
||||
// This is necessary to ensure dropdown opens downward, important for when this is used in popup
|
||||
const DROPDOWN_Z_INDEX = 1001 // Higher than the OpenRouterModelPicker's and ModelSelectorTooltip's z-index
|
||||
|
||||
const DropdownContainer = styled.div<{ zIndex?: number }>`
|
||||
position: relative;
|
||||
z-index: ${(props) => props.zIndex || DROPDOWN_Z_INDEX};
|
||||
|
||||
// Force dropdowns to open downward
|
||||
& vscode-dropdown::part(listbox) {
|
||||
position: absolute !important;
|
||||
top: 100% !important;
|
||||
bottom: auto !important;
|
||||
}
|
||||
`
|
||||
|
||||
declare module "vscode" {
|
||||
interface LanguageModelChatSelector {
|
||||
vendor?: string
|
||||
family?: string
|
||||
version?: string
|
||||
id?: string
|
||||
}
|
||||
}
|
||||
|
||||
const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, isPopup }: ApiOptionsProps) => {
|
||||
const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState()
|
||||
const [ollamaModels, setOllamaModels] = useState<string[]>([])
|
||||
const [lmStudioModels, setLmStudioModels] = useState<string[]>([])
|
||||
const [vsCodeLmModels, setVsCodeLmModels] = useState<vscodemodels.LanguageModelChatSelector[]>([])
|
||||
const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
|
||||
const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion)
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
||||
|
|
@ -72,14 +103,19 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
|||
type: "requestLmStudioModels",
|
||||
text: apiConfiguration?.lmStudioBaseUrl,
|
||||
})
|
||||
} else if (selectedProvider === "vscode-lm") {
|
||||
vscode.postMessage({ type: "requestVsCodeLmModels" })
|
||||
}
|
||||
}, [selectedProvider, apiConfiguration?.ollamaBaseUrl, apiConfiguration?.lmStudioBaseUrl])
|
||||
useEffect(() => {
|
||||
if (selectedProvider === "ollama" || selectedProvider === "lmstudio") {
|
||||
if (selectedProvider === "ollama" || selectedProvider === "lmstudio" || selectedProvider === "vscode-lm") {
|
||||
requestLocalModels()
|
||||
}
|
||||
}, [selectedProvider, requestLocalModels])
|
||||
useInterval(requestLocalModels, selectedProvider === "ollama" || selectedProvider === "lmstudio" ? 2000 : null)
|
||||
useInterval(
|
||||
requestLocalModels,
|
||||
selectedProvider === "ollama" || selectedProvider === "lmstudio" || selectedProvider === "vscode-lm" ? 2000 : null,
|
||||
)
|
||||
|
||||
const handleMessage = useCallback((event: MessageEvent) => {
|
||||
const message: ExtensionMessage = event.data
|
||||
|
|
@ -87,6 +123,8 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
|||
setOllamaModels(message.ollamaModels)
|
||||
} else if (message.type === "lmStudioModels" && message.lmStudioModels) {
|
||||
setLmStudioModels(message.lmStudioModels)
|
||||
} else if (message.type === "vsCodeLmModels" && message.vsCodeLmModels) {
|
||||
setVsCodeLmModels(message.vsCodeLmModels)
|
||||
}
|
||||
}, [])
|
||||
useEvent("message", handleMessage)
|
||||
|
|
@ -124,8 +162,8 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||
<div className="dropdown-container">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5, marginBottom: isPopup ? -10 : 0 }}>
|
||||
<DropdownContainer className="dropdown-container">
|
||||
<label htmlFor="api-provider">
|
||||
<span style={{ fontWeight: 500 }}>API Provider</span>
|
||||
</label>
|
||||
|
|
@ -136,20 +174,21 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
|||
style={{
|
||||
minWidth: 130,
|
||||
position: "relative",
|
||||
zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 1,
|
||||
}}>
|
||||
<VSCodeOption value="openrouter">OpenRouter</VSCodeOption>
|
||||
<VSCodeOption value="anthropic">Anthropic</VSCodeOption>
|
||||
<VSCodeOption value="gemini">Google Gemini</VSCodeOption>
|
||||
<VSCodeOption value="deepseek">DeepSeek</VSCodeOption>
|
||||
<VSCodeOption value="mistral">Mistral</VSCodeOption>
|
||||
<VSCodeOption value="vertex">GCP Vertex AI</VSCodeOption>
|
||||
<VSCodeOption value="bedrock">AWS Bedrock</VSCodeOption>
|
||||
<VSCodeOption value="openai-native">OpenAI</VSCodeOption>
|
||||
<VSCodeOption value="openai">OpenAI Compatible</VSCodeOption>
|
||||
<VSCodeOption value="vscode-lm">VS Code LM API</VSCodeOption>
|
||||
<VSCodeOption value="lmstudio">LM Studio</VSCodeOption>
|
||||
<VSCodeOption value="ollama">Ollama</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
</div>
|
||||
</DropdownContainer>
|
||||
|
||||
{selectedProvider === "anthropic" && (
|
||||
<div>
|
||||
|
|
@ -270,6 +309,37 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
|||
</div>
|
||||
)}
|
||||
|
||||
{selectedProvider === "mistral" && (
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.mistralApiKey || ""}
|
||||
style={{ width: "100%" }}
|
||||
type="password"
|
||||
onInput={handleInputChange("mistralApiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<span style={{ fontWeight: 500 }}>Mistral API Key</span>
|
||||
</VSCodeTextField>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: 3,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
This key is stored locally and only used to make API requests from this extension.
|
||||
{!apiConfiguration?.mistralApiKey && (
|
||||
<VSCodeLink
|
||||
href="https://console.mistral.ai/codestral"
|
||||
style={{
|
||||
display: "inline",
|
||||
fontSize: "inherit",
|
||||
}}>
|
||||
You can get a Mistral API key by signing up here.
|
||||
</VSCodeLink>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedProvider === "openrouter" && (
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
|
|
@ -336,7 +406,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
|||
placeholder="Enter Session Token...">
|
||||
<span style={{ fontWeight: 500 }}>AWS Session Token</span>
|
||||
</VSCodeTextField>
|
||||
<div className="dropdown-container">
|
||||
<DropdownContainer zIndex={DROPDOWN_Z_INDEX - 1} className="dropdown-container">
|
||||
<label htmlFor="aws-region-dropdown">
|
||||
<span style={{ fontWeight: 500 }}>AWS Region</span>
|
||||
</label>
|
||||
|
|
@ -372,7 +442,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
|||
<VSCodeOption value="us-gov-west-1">us-gov-west-1</VSCodeOption>
|
||||
{/* <VSCodeOption value="us-gov-east-1">us-gov-east-1</VSCodeOption> */}
|
||||
</VSCodeDropdown>
|
||||
</div>
|
||||
</DropdownContainer>
|
||||
<VSCodeCheckbox
|
||||
checked={apiConfiguration?.awsUseCrossRegionInference || false}
|
||||
onChange={(e: any) => {
|
||||
|
|
@ -411,7 +481,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
|||
placeholder="Enter Project ID...">
|
||||
<span style={{ fontWeight: 500 }}>Google Cloud Project ID</span>
|
||||
</VSCodeTextField>
|
||||
<div className="dropdown-container">
|
||||
<DropdownContainer zIndex={DROPDOWN_Z_INDEX - 2} className="dropdown-container">
|
||||
<label htmlFor="vertex-region-dropdown">
|
||||
<span style={{ fontWeight: 500 }}>Google Cloud Region</span>
|
||||
</label>
|
||||
|
|
@ -427,7 +497,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
|||
<VSCodeOption value="europe-west4">europe-west4</VSCodeOption>
|
||||
<VSCodeOption value="asia-southeast1">asia-southeast1</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
</div>
|
||||
</DropdownContainer>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
|
|
@ -541,6 +611,68 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
|||
</div>
|
||||
)}
|
||||
|
||||
{selectedProvider === "vscode-lm" && (
|
||||
<div>
|
||||
<DropdownContainer zIndex={DROPDOWN_Z_INDEX - 2} className="dropdown-container">
|
||||
<label htmlFor="vscode-lm-model">
|
||||
<span style={{ fontWeight: 500 }}>Language Model</span>
|
||||
</label>
|
||||
{vsCodeLmModels.length > 0 ? (
|
||||
<VSCodeDropdown
|
||||
id="vscode-lm-model"
|
||||
value={
|
||||
apiConfiguration?.vsCodeLmModelSelector
|
||||
? `${apiConfiguration.vsCodeLmModelSelector.vendor ?? ""}/${apiConfiguration.vsCodeLmModelSelector.family ?? ""}`
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = (e.target as HTMLInputElement).value
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
const [vendor, family] = value.split("/")
|
||||
handleInputChange("vsCodeLmModelSelector")({
|
||||
target: {
|
||||
value: { vendor, family },
|
||||
},
|
||||
})
|
||||
}}
|
||||
style={{ width: "100%" }}>
|
||||
<VSCodeOption value="">Select a model...</VSCodeOption>
|
||||
{vsCodeLmModels.map((model) => (
|
||||
<VSCodeOption
|
||||
key={`${model.vendor}/${model.family}`}
|
||||
value={`${model.vendor}/${model.family}`}>
|
||||
{model.vendor} - {model.family}
|
||||
</VSCodeOption>
|
||||
))}
|
||||
</VSCodeDropdown>
|
||||
) : (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
The VS Code Language Model API allows you to run models provided by other VS Code extensions
|
||||
(including but not limited to GitHub Copilot). The easiest way to get started is to install the
|
||||
Copilot extension from the VS Marketplace and enabling Claude 3.5 Sonnet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
Note: This is a very experimental integration and may not work as expected.
|
||||
</p>
|
||||
</DropdownContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedProvider === "lmstudio" && (
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
|
|
@ -679,15 +811,14 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
|||
</p>
|
||||
)}
|
||||
|
||||
{selectedProvider === "openrouter" && showModelOptions && <OpenRouterModelPicker />}
|
||||
|
||||
{selectedProvider !== "openrouter" &&
|
||||
selectedProvider !== "openai" &&
|
||||
selectedProvider !== "ollama" &&
|
||||
selectedProvider !== "lmstudio" &&
|
||||
selectedProvider !== "vscode-lm" &&
|
||||
showModelOptions && (
|
||||
<>
|
||||
<div className="dropdown-container">
|
||||
<DropdownContainer zIndex={DROPDOWN_Z_INDEX - 2} className="dropdown-container">
|
||||
<label htmlFor="model-id">
|
||||
<span style={{ fontWeight: 500 }}>Model</span>
|
||||
</label>
|
||||
|
|
@ -697,17 +828,21 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
|||
{selectedProvider === "gemini" && createDropdown(geminiModels)}
|
||||
{selectedProvider === "openai-native" && createDropdown(openAiNativeModels)}
|
||||
{selectedProvider === "deepseek" && createDropdown(deepSeekModels)}
|
||||
</div>
|
||||
{selectedProvider === "mistral" && createDropdown(mistralModels)}
|
||||
</DropdownContainer>
|
||||
|
||||
<ModelInfoView
|
||||
selectedModelId={selectedModelId}
|
||||
modelInfo={selectedModelInfo}
|
||||
isDescriptionExpanded={isDescriptionExpanded}
|
||||
setIsDescriptionExpanded={setIsDescriptionExpanded}
|
||||
isPopup={isPopup}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedProvider === "openrouter" && showModelOptions && <OpenRouterModelPicker isPopup={isPopup} />}
|
||||
|
||||
{modelIdErrorMessage && (
|
||||
<p
|
||||
style={{
|
||||
|
|
@ -725,7 +860,6 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
|||
export function getOpenRouterAuthUrl(uriScheme?: string) {
|
||||
return `https://openrouter.ai/auth?callback_url=${uriScheme || "vscode"}://saoudrizwan.claude-dev/openrouter`
|
||||
}
|
||||
|
||||
export const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
|
|
@ -740,11 +874,13 @@ export const ModelInfoView = ({
|
|||
modelInfo,
|
||||
isDescriptionExpanded,
|
||||
setIsDescriptionExpanded,
|
||||
isPopup,
|
||||
}: {
|
||||
selectedModelId: string
|
||||
modelInfo: ModelInfo
|
||||
isDescriptionExpanded: boolean
|
||||
setIsDescriptionExpanded: (isExpanded: boolean) => void
|
||||
isPopup?: boolean
|
||||
}) => {
|
||||
const isGemini = Object.keys(geminiModels).includes(selectedModelId)
|
||||
|
||||
|
|
@ -755,6 +891,7 @@ export const ModelInfoView = ({
|
|||
markdown={modelInfo.description}
|
||||
isExpanded={isDescriptionExpanded}
|
||||
setIsExpanded={setIsDescriptionExpanded}
|
||||
isPopup={isPopup}
|
||||
/>
|
||||
),
|
||||
<ModelInfoSupportsItem
|
||||
|
|
@ -860,7 +997,11 @@ const ModelInfoSupportsItem = ({
|
|||
</span>
|
||||
)
|
||||
|
||||
export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
|
||||
export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration): {
|
||||
selectedProvider: ApiProvider
|
||||
selectedModelId: string
|
||||
selectedModelInfo: ModelInfo
|
||||
} {
|
||||
const provider = apiConfiguration?.apiProvider || "anthropic"
|
||||
const modelId = apiConfiguration?.apiModelId
|
||||
|
||||
|
|
@ -893,6 +1034,8 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
|
|||
return getProviderData(openAiNativeModels, openAiNativeDefaultModelId)
|
||||
case "deepseek":
|
||||
return getProviderData(deepSeekModels, deepSeekDefaultModelId)
|
||||
case "mistral":
|
||||
return getProviderData(mistralModels, mistralDefaultModelId)
|
||||
case "openrouter":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
|
|
@ -917,6 +1060,17 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
|
|||
selectedModelId: apiConfiguration?.lmStudioModelId || "",
|
||||
selectedModelInfo: openAiModelInfoSaneDefaults,
|
||||
}
|
||||
case "vscode-lm":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.vsCodeLmModelSelector
|
||||
? `${apiConfiguration.vsCodeLmModelSelector.vendor}/${apiConfiguration.vsCodeLmModelSelector.family}`
|
||||
: "",
|
||||
selectedModelInfo: {
|
||||
...openAiModelInfoSaneDefaults,
|
||||
supportsImages: false, // VSCode LM API currently doesn't support images
|
||||
},
|
||||
}
|
||||
default:
|
||||
return getProviderData(anthropicModels, anthropicDefaultModelId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,13 @@ import { useExtensionState } from "../../context/ExtensionStateContext"
|
|||
import { vscode } from "../../utils/vscode"
|
||||
import { highlight } from "../history/HistoryView"
|
||||
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
|
||||
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
|
||||
const OpenRouterModelPicker: React.FC = () => {
|
||||
export interface OpenRouterModelPickerProps {
|
||||
isPopup?: boolean
|
||||
}
|
||||
|
||||
const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup }) => {
|
||||
const { apiConfiguration, setApiConfiguration, openRouterModels } = useExtensionState()
|
||||
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openRouterModelId || openRouterDefaultModelId)
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
|
||||
|
|
@ -24,8 +29,10 @@ const OpenRouterModelPicker: React.FC = () => {
|
|||
// could be setting invalid model id/undefined info but validation will catch it
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
openRouterModelId: newModelId,
|
||||
openRouterModelInfo: openRouterModels[newModelId],
|
||||
...{
|
||||
openRouterModelId: newModelId,
|
||||
openRouterModelInfo: openRouterModels[newModelId],
|
||||
},
|
||||
})
|
||||
setSearchTerm(newModelId)
|
||||
}
|
||||
|
|
@ -129,7 +136,7 @@ const OpenRouterModelPicker: React.FC = () => {
|
|||
}, [selectedIndex])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ width: "100%" }}>
|
||||
<style>
|
||||
{`
|
||||
.model-item-highlight {
|
||||
|
|
@ -138,7 +145,7 @@ const OpenRouterModelPicker: React.FC = () => {
|
|||
}
|
||||
`}
|
||||
</style>
|
||||
<div>
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<label htmlFor="model-search">
|
||||
<span style={{ fontWeight: 500 }}>Model</span>
|
||||
</label>
|
||||
|
|
@ -204,6 +211,7 @@ const OpenRouterModelPicker: React.FC = () => {
|
|||
modelInfo={selectedModelInfo}
|
||||
isDescriptionExpanded={isDescriptionExpanded}
|
||||
setIsDescriptionExpanded={setIsDescriptionExpanded}
|
||||
isPopup={isPopup}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
|
|
@ -212,20 +220,22 @@ const OpenRouterModelPicker: React.FC = () => {
|
|||
marginTop: 0,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
The extension automatically fetches the latest list of models available on{" "}
|
||||
<VSCodeLink style={{ display: "inline", fontSize: "inherit" }} href="https://openrouter.ai/models">
|
||||
OpenRouter.
|
||||
</VSCodeLink>
|
||||
If you're unsure which model to choose, Cline works best with{" "}
|
||||
<VSCodeLink
|
||||
style={{ display: "inline", fontSize: "inherit" }}
|
||||
onClick={() => handleModelChange("anthropic/claude-3.5-sonnet:beta")}>
|
||||
anthropic/claude-3.5-sonnet:beta.
|
||||
</VSCodeLink>
|
||||
You can also try searching "free" for no-cost options currently available.
|
||||
<>
|
||||
The extension automatically fetches the latest list of models available on{" "}
|
||||
<VSCodeLink style={{ display: "inline", fontSize: "inherit" }} href="https://openrouter.ai/models">
|
||||
OpenRouter.
|
||||
</VSCodeLink>
|
||||
If you're unsure which model to choose, Cline works best with{" "}
|
||||
<VSCodeLink
|
||||
style={{ display: "inline", fontSize: "inherit" }}
|
||||
onClick={() => handleModelChange("anthropic/claude-3.5-sonnet:beta")}>
|
||||
anthropic/claude-3.5-sonnet:beta.
|
||||
</VSCodeLink>
|
||||
You can also try searching "free" for no-cost options currently available.
|
||||
</>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -320,11 +330,13 @@ export const ModelDescriptionMarkdown = memo(
|
|||
key,
|
||||
isExpanded,
|
||||
setIsExpanded,
|
||||
isPopup,
|
||||
}: {
|
||||
markdown?: string
|
||||
key: string
|
||||
isExpanded: boolean
|
||||
setIsExpanded: (isExpanded: boolean) => void
|
||||
isPopup?: boolean
|
||||
}) => {
|
||||
const [reactContent, setMarkdown] = useRemark()
|
||||
// const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
|
@ -394,7 +406,7 @@ export const ModelDescriptionMarkdown = memo(
|
|||
fontSize: "inherit",
|
||||
paddingRight: 0,
|
||||
paddingLeft: 3,
|
||||
backgroundColor: "var(--vscode-sideBar-background)",
|
||||
backgroundColor: isPopup ? CODE_BLOCK_BG_COLOR : "var(--vscode-sideBar-background)",
|
||||
}}
|
||||
onClick={() => setIsExpanded(true)}>
|
||||
See more
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useExtensionState } from "../../context/ExtensionStateContext"
|
|||
import { validateApiConfiguration, validateModelId } from "../../utils/validate"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import ApiOptions from "./ApiOptions"
|
||||
import SettingsButton from "../common/SettingsButton"
|
||||
|
||||
const IS_DEV = false // FIXME: use flags when packaging
|
||||
|
||||
|
|
@ -15,12 +16,14 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
|
|||
const { apiConfiguration, version, customInstructions, setCustomInstructions, openRouterModels } = useExtensionState()
|
||||
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
|
||||
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
|
||||
|
||||
const handleSubmit = () => {
|
||||
const apiValidationResult = validateApiConfiguration(apiConfiguration)
|
||||
const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels)
|
||||
|
||||
setApiErrorMessage(apiValidationResult)
|
||||
setModelIdErrorMessage(modelIdValidationResult)
|
||||
|
||||
if (!apiValidationResult && !modelIdValidationResult) {
|
||||
vscode.postMessage({ type: "apiConfiguration", apiConfiguration })
|
||||
vscode.postMessage({
|
||||
|
|
@ -96,6 +99,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
|
|||
<VSCodeTextArea
|
||||
value={customInstructions ?? ""}
|
||||
style={{ width: "100%" }}
|
||||
resize="vertical"
|
||||
rows={4}
|
||||
placeholder={'e.g. "Run unit tests at the end", "Use TypeScript with async/await", "Speak in Spanish"'}
|
||||
onInput={(e: any) => setCustomInstructions(e.target?.value ?? "")}>
|
||||
|
|
@ -128,14 +132,29 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
|
|||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "auto",
|
||||
paddingRight: 8,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}>
|
||||
<SettingsButton
|
||||
onClick={() => vscode.postMessage({ type: "openExtensionSettings" })}
|
||||
style={{
|
||||
margin: "0 0 16px 0",
|
||||
}}>
|
||||
<i className="codicon codicon-settings-gear" />
|
||||
Advanced Settings
|
||||
</SettingsButton>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
fontSize: "12px",
|
||||
lineHeight: "1.2",
|
||||
marginTop: "auto",
|
||||
padding: "10px 8px 15px 0px",
|
||||
padding: "0 8px 15px 0",
|
||||
}}>
|
||||
<p
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { findLastIndex } from "../../../src/shared/array"
|
|||
import { McpServer } from "../../../src/shared/mcp"
|
||||
import { convertTextMateToHljs } from "../utils/textMateToHljs"
|
||||
import { vscode } from "../utils/vscode"
|
||||
import { DEFAULT_BROWSER_SETTINGS } from "../../../src/shared/BrowserSettings"
|
||||
import { DEFAULT_CHAT_SETTINGS } from "../../../src/shared/ChatSettings"
|
||||
|
||||
interface ExtensionStateContextType extends ExtensionState {
|
||||
didHydrateState: boolean
|
||||
|
|
@ -31,6 +33,9 @@ export const ExtensionStateContextProvider: React.FC<{
|
|||
taskHistory: [],
|
||||
shouldShowAnnouncement: false,
|
||||
autoApprovalSettings: DEFAULT_AUTO_APPROVAL_SETTINGS,
|
||||
browserSettings: DEFAULT_BROWSER_SETTINGS,
|
||||
chatSettings: DEFAULT_CHAT_SETTINGS,
|
||||
isLoggedIn: false,
|
||||
})
|
||||
const [didHydrateState, setDidHydrateState] = useState(false)
|
||||
const [showWelcome, setShowWelcome] = useState(false)
|
||||
|
|
@ -59,6 +64,8 @@ export const ExtensionStateContextProvider: React.FC<{
|
|||
config.geminiApiKey,
|
||||
config.openAiNativeApiKey,
|
||||
config.deepSeekApiKey,
|
||||
config.mistralApiKey,
|
||||
config.vsCodeLmModelSelector,
|
||||
].some((key) => key !== undefined)
|
||||
: false
|
||||
setShowWelcome(!hasKey)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): s
|
|||
return "You must provide a valid API key or choose a different provider."
|
||||
}
|
||||
break
|
||||
case "mistral":
|
||||
if (!apiConfiguration.mistralApiKey) {
|
||||
return "You must provide a valid API key or choose a different provider."
|
||||
}
|
||||
break
|
||||
case "openai":
|
||||
if (!apiConfiguration.openAiBaseUrl || !apiConfiguration.openAiApiKey || !apiConfiguration.openAiModelId) {
|
||||
return "You must provide a valid base URL, API key, and model ID."
|
||||
|
|
@ -53,6 +58,11 @@ export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): s
|
|||
return "You must provide a valid model ID."
|
||||
}
|
||||
break
|
||||
case "vscode-lm":
|
||||
if (!apiConfiguration.vsCodeLmModelSelector) {
|
||||
return "You must provide a valid model selector."
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
|
|
|
|||