Compare commits

..

20 Commits

Author SHA1 Message Date
Andrei Edell 98b5285628 run prettier correctly on generated protos 2025-05-08 13:10:14 -10:00
canvrno 7b416ccc70 Feat: Task Favorites ️ (#3392)
* Task Favorites

* Task management docs
2025-05-08 15:49:05 -07:00
Alex 29f3cfa894 Update index.css (#3367) 2025-05-09 03:18:53 +05:30
Ara 978f34e30b Supporting implicit Caching in Gemini (#3394)
* Refactor GeminiHandler to remove caching logic and update pricing structure

* Removed the enhanced caching system and related logic from GeminiHandler.
* Updated the pricing structure for cache reads in both geminiModels and vertexModels.
* Simplified the message creation process by eliminating unnecessary cache checks and operations.

* Fixing Gemini and vertex cache pricing

* Fixing Gemini and vertex cache pricing

* Update src/api/providers/gemini.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-05-09 03:17:41 +05:30
canvrno 445e25221a [PROTOBUS] Move requestVsCodeLmModels to protobus (#3344)
* Task Favorites

* getOllamaModels protobus migration

* VsCodeLmModels protobus migration

* cleanup
2025-05-08 10:50:23 -10:00
Ara 489a05117c Increasing file sizes for files that can be read by cline (#3396)
* Increasing file sizes for files that can be read by cline

* Update src/integrations/misc/extract-text.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Increasing file sizes for files that can be read by cline

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-05-08 12:55:40 -07:00
WinterYukky e572ee44f9 fix(bedrock): application inference profile is not work (#3388)
* fix(bedrock): application inference profile is not work

* chore: add change set

* chore: change the encoding condition to whether it contains a slash
2025-05-09 00:26:05 +05:30
pashpashpash f4e14bfe3b removing sparkle from command name (#3395)
Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-09 00:16:40 +05:30
watany bddc1b5e96 fix(bedrock); update bedrock api (#3157)
* fix nova

* haiku

* changeset

* changeset

* clean up duplicate changeset

* commented caching write
2025-05-08 10:52:08 -07:00
pashpashpash cb0de8f17e tracking models in diff edit failures (#3297)
* tracking models in diff edit failures

* prettier

---------

Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-08 12:33:29 -05:00
Evan e1a0b244de Conditionally initialize posthog webview (#3381)
* conditionally initialize posthog client webview

* changeset

---------

Co-authored-by: Elephant Lumps <celestial_vault@Elephants-MacBook-Pro.local>
2025-05-08 12:08:40 -05:00
Dennis Bartlett a9d5411bf0 Revert "Update deployer team name (#3377)" 2025-05-08 06:57:18 -05:00
Dennis Bartlett 16af9125ec Update variable name (#3384) 2025-05-08 06:51:55 -05:00
Ara 2792e7698f Raise Errors when users try to upload images larger than 7500x7500 pixels (#3336)
* Adding iamge dimension check

* Adding iamge dimension check

* Adding iamge dimension check

* Adding iamge dimension check

* Adding iamge dimension check

* Adding iamge dimension check
2025-05-07 23:48:04 -07:00
Wesley Smith d02e5a89e5 fix excessive markdown format character escaping (#3355)
* fix excessive markdown format character escaping

* add changeset

* made it a little more robust

---------

Co-authored-by: Wesley Smith <wes@neofactory.ai>
Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-07 22:31:31 -07:00
Dennis Bartlett 20f19917d3 Add org to team affiliation check (#3380) 2025-05-08 00:01:42 -05:00
Evan 7e5cd52864 Always allow textarea typing (#3356)
* enable text area while cline is doing stuff

* changeset

* add sendingDisabled to dependency array

---------

Co-authored-by: Elephant Lumps <celestial_vault@Elephants-MacBook-Pro.local>
2025-05-07 22:01:07 -07:00
pashpashpash 4622ad767b Copy buttons (#3373)
* copy button in task header

* changeset

* added copy buttons to assistant messages that show up on hover

* added aria

---------

Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-07 21:59:42 -07:00
Dennis Bartlett 96048d5ac5 Update deployer team name (#3377)
* Update deployer team name

* Create clever-balloons-wave.md
2025-05-07 23:37:33 -05:00
Ara facec93082 Adding Mistral 3 medium model (#3366)
* Fixing Gemini and vertex cache pricing

* Fixing Gemini and vertex cache pricing
2025-05-08 05:46:09 +05:30
52 changed files with 2361 additions and 583 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
added copy buttons to task header and assistant messages
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Increasing file size that can be read by cline
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add org to team affiliation check
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix-bedrock-api
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix for markdown copy excessive escape characters
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Only initialize posthog in the webview if the user has opted into telemetry
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
requestVsCodeLmModels protobus migration
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Allow the user to form their next message while Cline is taking action
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Adding mistral medium-3 model
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Add Task Favorites and several proto messages related to tasks
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Raise Errors when users try to upload images larger than 7500x7500 pixels
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fixed an issue where loading never finished when using an application inference profile for the model ID
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add support for Gemini Implicit Caching
+2 -1
View File
@@ -33,7 +33,8 @@ jobs:
uses: morfien101/actions-authorized-user@4a3cfbf0bcb3cafe4a71710a278920c5d94bb38b
with:
username: ${{ github.actor }}
team: "Deployer"
org: ${{ github.repository_owner }}
team: "deployer"
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Check if user is authorized
+1
View File
@@ -62,6 +62,7 @@
"getting-started/installing-dev-essentials",
"getting-started/model-selection-guide",
"getting-started/our-favorite-tech-stack",
"getting-started/task-management",
"getting-started/understanding-context-management",
"getting-started/what-is-cline"
]
+67
View File
@@ -0,0 +1,67 @@
---
title: "Task Management in Cline"
description: "Learn how to effectively manage your task history, use favorites, and organize your work in Cline."
---
# Task Management
As you use Cline, you'll accumulate many tasks over time. The task management system helps you organize, filter, search, and clean up your task history to keep your workspace efficient.
## Accessing Task History
You can access your task history by:
1. Clicking on the "History" button in the Cline sidebar
2. Using the command palette to search for "Cline: Show Task History"
## Task History Features
The task history view provides several powerful features:
### Searching and Filtering
- **Search Bar**: Use the fuzzy search at the top to quickly find tasks by content
- **Sort Options**: Sort tasks by:
- Newest (default)
- Oldest
- Most Expensive (highest API cost)
- Most Tokens (highest token usage)
- Most Relevant (when searching)
- **Favorites Filter**: Toggle to show only favorited tasks
### Task Actions
Each task in the history view has several actions available:
- **Open**: Click on a task to reopen it in the Cline chat
- **Favorite**: Click the star icon to mark a task as a favorite
- **Delete**: Remove individual tasks (favorites are protected from deletion)
- **Export**: Export a task's conversation to markdown
## ⭐ Task Favorites
The favorites feature allows you to mark important tasks that you want to preserve and find quickly.
### How Favorites Work
- **Marking Favorites**: Click the star icon next to any task to toggle its favorite status
- **Protection**: Favorited tasks are protected from individual and bulk deletion operations (can be overridden)
- **Filtering**: Use the favorites filter to quickly access your important tasks
## Batch Operations
The task history view supports several batch operations:
- **Select Multiple**: Use the checkboxes to select multiple tasks
- **Select All/None**: Quickly select or deselect all tasks
- **Delete Selected**: Remove all selected tasks
- **Delete All**: Remove all tasks from history (favorites are preserved unless you choose to include them)
## Best Practices
1. **Favorite Important Tasks**: Mark reference tasks or frequently accessed conversations as favorites
2. **Regular Cleanup**: Periodically remove old or unused tasks to improve performance
3. **Use Search**: Leverage the fuzzy search to quickly find specific conversations
4. **Export Valuable Tasks**: Export important tasks to markdown for external reference
Task management helps you maintain an organized workflow when using Cline, allowing you to quickly find past conversations, preserve important work, and keep your history clean and efficient.
+20 -35
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.14.0",
"version": "3.14.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.14.0",
"version": "3.14.1",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.12.4",
@@ -42,11 +42,11 @@
"globby": "^14.0.2",
"iconv-lite": "^0.6.3",
"ignore": "^7.0.3",
"image-size": "^2.0.2",
"isbinaryfile": "^5.0.2",
"jschardet": "^3.1.4",
"mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
"node-cache": "^5.1.2",
"ollama": "^0.5.13",
"open-graph-scraper": "^6.9.0",
"openai": "^4.83.0",
@@ -12866,14 +12866,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clone-deep": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
@@ -16579,6 +16571,18 @@
"node": ">= 4"
}
},
"node_modules/image-size": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
"integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
"license": "MIT",
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@@ -19760,17 +19764,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/node-cache": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz",
"integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==",
"dependencies": {
"clone": "2.x"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -34886,11 +34879,6 @@
}
}
},
"clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="
},
"clone-deep": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
@@ -37429,6 +37417,11 @@
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz",
"integrity": "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA=="
},
"image-size": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
"integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="
},
"immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@@ -39565,14 +39558,6 @@
"@types/nlcst": "^2.0.0"
}
},
"node-cache": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz",
"integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==",
"requires": {
"clone": "2.x"
}
},
"node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+3 -3
View File
@@ -121,7 +121,7 @@
},
{
"command": "cline.generateGitCommitMessage",
"title": "Generate Commit Message with Cline",
"title": "Generate Commit Message with Cline",
"category": "Cline",
"icon": "$(robot)"
}
@@ -314,7 +314,7 @@
"watch:esbuild": "node esbuild.js --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
"protos": "node proto/build-proto.js && prettier src/shared/proto --write && prettier src/core/controller --write",
"protos": "node proto/build-proto.js && prettier src/shared/proto src/core/controller webview-ui/src/services --write",
"compile-tests": "node ./scripts/build-tests.js",
"watch-tests": "tsc -p . -w --outDir out",
"pretest": "npm run compile-tests && npm run compile && npm run lint",
@@ -411,11 +411,11 @@
"globby": "^14.0.2",
"iconv-lite": "^0.6.3",
"ignore": "^7.0.3",
"image-size": "^2.0.2",
"isbinaryfile": "^5.0.2",
"jschardet": "^3.1.4",
"mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
"node-cache": "^5.1.2",
"ollama": "^0.5.13",
"open-graph-scraper": "^6.9.0",
"openai": "^4.83.0",
+15 -1
View File
@@ -10,7 +10,21 @@ import "common.proto";
service ModelsService {
// Fetches available models from Ollama
rpc getOllamaModels(StringRequest) returns (StringArray);
// Fetches available models from LM Studio
rpc getLmStudioModels(StringRequest) returns (StringArray);
// Fetches available models from VS Code LM API
rpc getVsCodeLmModels(EmptyRequest) returns (VsCodeLmModelsArray);
}
// List of VS Code LM models
message VsCodeLmModelsArray {
repeated VsCodeLmModel models = 1;
}
// Structure representing a VS Code LM model
message VsCodeLmModel {
string vendor = 1;
string family = 2;
string version = 3;
string id = 4;
}
+61 -1
View File
@@ -16,9 +16,15 @@ service TaskService {
// Creates a new task with the given text and optional images
rpc newTask(NewTaskRequest) returns (Empty);
// Shows a task with the specified ID
rpc showTaskWithId(StringRequest) returns (Empty);
rpc showTaskWithId(StringRequest) returns (TaskResponse);
// Exports a task with the given ID to markdown
rpc exportTaskWithId(StringRequest) returns (Empty);
// Toggles the favorite status of a task
rpc toggleTaskFavorite(TaskFavoriteRequest) returns (Empty);
// Deletes all non-favorited tasks
rpc deleteNonFavoritedTasks(EmptyRequest) returns (DeleteNonFavoritedTasksResults);
// Gets filtered task history
rpc getTaskHistory(GetTaskHistoryRequest) returns (TaskHistoryArray);
}
// Request message for creating a new task
@@ -28,3 +34,57 @@ message NewTaskRequest {
repeated string images = 3;
}
// Request message for toggling task favorite status
message TaskFavoriteRequest {
Metadata metadata = 1;
string task_id = 2;
bool is_favorited = 3;
}
// Response for task details
message TaskResponse {
string id = 1;
string task = 2;
int64 ts = 3;
bool is_favorited = 4;
int64 size = 5;
double total_cost = 6;
int32 tokens_in = 7;
int32 tokens_out = 8;
int32 cache_writes = 9;
int32 cache_reads = 10;
}
// Results returned when deleting non-favorited tasks
message DeleteNonFavoritedTasksResults {
int32 tasks_preserved = 1;
int32 tasks_deleted = 2;
}
// Request for getting task history with filtering
message GetTaskHistoryRequest {
Metadata metadata = 1;
bool favorites_only = 2;
string search_query = 3;
string sort_by = 4;
}
// Response for task history
message TaskHistoryArray {
repeated TaskItem tasks = 1;
int32 total_count = 2;
}
// Task item details for history list
message TaskItem {
string id = 1;
string task = 2;
int64 ts = 3;
bool is_favorited = 4;
int64 size = 5;
double total_cost = 6;
int32 tokens_in = 7;
int32 tokens_out = 8;
int32 cache_writes = 9;
int32 cache_reads = 10;
}
+6 -2
View File
@@ -272,10 +272,14 @@ export class AwsBedrockHandler implements ApiHandler {
}
/**
* Gets the appropriate model ID, accounting for cross-region inference if enabled
* Gets the appropriate model ID, accounting for cross-region inference if enabled.
* If the model ID is an ARN that contains a slash, you will get the URL encoded ARN.
*/
async getModelId(): Promise<string> {
if (this.options.awsUseCrossRegionInference) {
if (this.options.awsBedrockCustomSelected && this.getModel().id.includes("/")) {
return encodeURIComponent(this.getModel().id)
}
if (!this.options.awsBedrockCustomSelected && this.options.awsUseCrossRegionInference) {
const regionPrefix = this.getRegion().slice(0, 3)
switch (regionPrefix) {
case "us-":
+6 -308
View File
@@ -1,7 +1,6 @@
import type { Anthropic } from "@anthropic-ai/sdk"
// Restore GenerateContentConfig import and add GenerateContentResponseUsageMetadata
import { GoogleGenAI, type Content, type GenerateContentConfig, type GenerateContentResponseUsageMetadata } from "@google/genai"
import NodeCache from "node-cache"
import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { ApiHandlerOptions, geminiDefaultModelId, GeminiModelId, geminiModels, ModelInfo } from "@shared/api"
@@ -39,12 +38,6 @@ export class GeminiHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: GoogleGenAI
// Enhanced caching system
private contentCaches: NodeCache // Stores cache details (key, count, etc.)
private isCacheBusy = false
private taskCacheNames: Map<string, string> = new Map() // Maps taskId to cache name for stable lookup
private taskCacheTokens: Map<string, number> = new Map() // Maps taskId to total tokens in cache
constructor(options: GeminiHandlerOptions) {
// Store the options
this.options = options
@@ -67,26 +60,13 @@ export class GeminiHandler implements ApiHandler {
this.client = new GoogleGenAI({ apiKey: options.geminiApiKey })
}
// Initialize cache with TTL and check period
this.contentCaches = new NodeCache({
stdTTL: DEFAULT_CACHE_TTL_SECONDS,
checkperiod: DEFAULT_CACHE_TTL_SECONDS,
})
}
/**
* Creates a message using the Gemini API with optimized caching and split cost accounting.
*
* This method implements a task-based caching strategy:
* 1. Each task gets its own cache, identified by taskId
* 2. On first call for a task, a new cache is created
* 3. On subsequent calls, the existing cache is reused and only new messages are sent
* 4. Cache operations are tracked for accurate cost accounting
* Creates a message using the Gemini API with implicit caching.
*
* Cost accounting:
* - Immediate costs (returned in the usage object): Input tokens, output tokens, cache read costs
* - Ongoing costs (tracked at task level): Cache storage costs for the TTL period
*
* @param systemPrompt The system prompt to use for the message
* @param messages The conversation history to include in the message
@@ -97,54 +77,6 @@ export class GeminiHandler implements ApiHandler {
const { id: model, info } = this.getModel()
const contents = messages.map(convertAnthropicMessageToGemini)
// Ensure we have a stable cache key (taskId)
if (!this.options.taskId) {
console.warn("[GeminiHandler] No taskId provided, caching will be disabled")
}
const taskId = this.options.taskId
// Calculate total content length for cache eligibility check
const contentsLength = systemPrompt.length + this.getMessagesLength(contents)
// Minimum token threshold for caching (approx 4096 tokens)
const CONTEXT_CACHE_TOKEN_MINIMUM = 4096
let uncachedContent: Content[] | undefined = undefined
let cachedContent: string | undefined = undefined
// Check if caching is available and content is large enough to benefit from caching
// We only enable caching for conversations above a certain size to avoid overhead for small requests
const isCacheAvailable = info.supportsPromptCache && contentsLength > 4 * CONTEXT_CACHE_TOKEN_MINIMUM && taskId
// This flag tracks whether this operation involves a cache write/update
// It's used to track task-level ongoing costs, not immediate costs
let cacheWrite = false
if (isCacheAvailable) {
// Check if we already have a cache for this task
const existingCacheName = this.taskCacheNames.get(taskId)
const cacheEntry = existingCacheName ? this.contentCaches.get<{ key: string; count: number }>(taskId) : undefined
if (cacheEntry) {
// Use existing cache
uncachedContent = contents.slice(cacheEntry.count, contents.length)
cachedContent = cacheEntry.key
console.log(
`[GeminiHandler] using existing cache for task ${taskId}: ${cacheEntry.count} cached messages (${cacheEntry.key}) and ${uncachedContent.length} uncached messages`,
)
}
// Create or update cache only if there's new content to add
const shouldUpdateCache = !existingCacheName || (cacheEntry && uncachedContent && uncachedContent.length > 0)
if (shouldUpdateCache) {
// If we should update the cache, then there will be a cache write
cacheWrite = true
}
}
const isCacheUsed = !!cachedContent
// Configure thinking budget if supported
const thinkingBudget = this.options.thinkingBudgetTokens ?? 0
const maxBudget = info.thinkingConfig?.maxBudget ?? 0
@@ -153,10 +85,7 @@ export class GeminiHandler implements ApiHandler {
const requestConfig: GenerateContentConfig = {
// Add base URL if configured
httpOptions: this.options.geminiBaseUrl ? { baseUrl: this.options.geminiBaseUrl } : undefined,
// Only include systemInstruction if NOT using the cache
...(isCacheUsed ? {} : { systemInstruction: systemPrompt }),
...{ systemInstruction: systemPrompt },
// Set temperature (default to 0)
temperature: 0,
}
@@ -171,19 +100,12 @@ export class GeminiHandler implements ApiHandler {
// Generate content using the configured parameters
const result = await this.client.models.generateContentStream({
model,
contents: uncachedContent ?? contents,
contents: contents,
config: {
...requestConfig,
...(isCacheUsed ? { cachedContent } : {}),
},
})
// Update the cache after the LLM request is already sent to avoid blocking
// We only update the cache if we have a taskId and the cache write flag is set
// This is a non-blocking operation and will not affect the response time
if (cacheWrite && taskId) {
this.updateCacheContent(taskId, model, contents, systemPrompt)
}
// Track usage metadata
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined
@@ -207,7 +129,7 @@ export class GeminiHandler implements ApiHandler {
const outputTokens = lastUsageMetadata.candidatesTokenCount ?? 0
const cacheReadTokens = lastUsageMetadata.cachedContentTokenCount
// Calculate immediate costs only (excluding cache write/storage costs)
// Calculate immediate costs
const totalCost = this.calculateCost({
info,
inputTokens,
@@ -215,214 +137,17 @@ export class GeminiHandler implements ApiHandler {
cacheReadTokens,
})
// Store the token count for task-level ongoing cost tracking
// This is not included in the immediate costs returned to the user
const cacheWriteTokens = cacheWrite ? inputTokens : undefined
// If this is a cache write operation, update the task's ongoing costs
if (cacheWrite && this.options.taskId && inputTokens > 0) {
// Log the ongoing costs for debugging
const ongoingCosts = this.getTaskOngoingCosts(this.options.taskId)
console.log(
`[GeminiHandler] Task ${this.options.taskId} ongoing costs: $${ongoingCosts?.toFixed(6) ?? "unknown"}`,
)
}
yield {
type: "usage",
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
cacheWriteTokens: 0,
totalCost,
}
}
}
/**
* Lists all caches for the current API key.
*
* According to the Gemini API documentation, you can retrieve metadata for all uploaded caches
* using the caches.list() method. This is useful for monitoring cache usage and cleanup.
*
* @param pageSize Optional number of caches to return per page (default: 10)
* @returns A promise that resolves to an array of cache metadata objects
*/
public async listCaches(pageSize: number = 10): Promise<any[]> {
try {
const caches: any[] = []
const pager = await this.client.caches.list({ config: { pageSize } })
let page = pager.page
while (true) {
for (const cache of page) {
caches.push(cache)
}
if (!pager.hasNextPage()) {
break
}
page = await pager.nextPage()
}
return caches
} catch (error) {
console.error(`[GeminiHandler] Failed to list caches:`, error)
return []
}
}
/**
* Updates the content of a cache for a specific task.
*
* Since the Gemini API doesn't support incremental updates to cache content,
* this method:
* 1. Creates a new cache with the full content (old + new)
* 2. Deletes the old cache if it exists
* 3. Updates our local tracking to point to the new cache
*
* @param taskId The ID of the task whose cache should be updated
* @param model The model to use for the cache
* @param contents The full content to cache (including both old and new messages)
* @param systemInstruction The system instruction to include in the cache
*/
private async updateCacheContent(
taskId: string,
model: string,
contents: Content[],
systemInstruction: string,
): Promise<void> {
if (this.isCacheBusy) {
console.log(`[GeminiHandler] Cache is busy, skipping update for task ${taskId}`)
return
}
this.isCacheBusy = true
const timestamp = Date.now()
const existingCacheName = this.taskCacheNames.get(taskId)
try {
// 1. Create a new cache with the full content
const result = await this.client.caches.create({
model,
config: {
contents,
systemInstruction,
ttl: `${DEFAULT_CACHE_TTL_SECONDS}s`,
httpOptions: { timeout: 120_000 },
},
})
const { name, usageMetadata } = result
if (name) {
// 2. Delete the old cache if it exists (non-blocking)
// We don't await this operation to avoid blocking the main flow if deletion fails
if (existingCacheName) {
// Schedule cache deletion in the background
setTimeout(() => {
this.client.caches
.delete({ name: existingCacheName })
.then(() => {
console.log(`[GeminiHandler] Deleted old cache ${existingCacheName} for task ${taskId}`)
})
.catch((error) => {
console.error(`[GeminiHandler] Failed to delete old cache ${existingCacheName}:`, error)
console.log(`[GeminiHandler] Continuing without deleting old cache. It will expire after TTL.`)
})
}, 1000)
}
// 3. Update our local tracking
this.contentCaches.set<{ key: string; count: number }>(taskId, {
key: name,
count: contents.length,
})
this.taskCacheNames.set(taskId, name)
// Track total tokens in cache for ongoing cost calculation
const totalTokens = usageMetadata?.totalTokenCount ?? 0
this.taskCacheTokens.set(taskId, totalTokens)
const operation = existingCacheName ? "Updated" : "Created new"
console.log(
`[GeminiHandler] ${operation} cache for task ${taskId}: ${contents.length} messages (${totalTokens} tokens) in ${Date.now() - timestamp}ms`,
)
return // Indicate that a cache write occurred
}
return
} catch (error) {
console.error(`[GeminiHandler] Failed to update cache for task ${taskId}:`, error)
return
} finally {
this.isCacheBusy = false
}
}
/**
* Updates the TTL of an existing cache.
*
* According to the Gemini API documentation, you can update the TTL of a cache
* using the caches.update() method. This is useful for extending the lifetime
* of a cache that's still being used.
*
* @param taskId The ID of the task whose cache TTL should be updated
* @param ttlSeconds The new TTL in seconds
* @returns A promise that resolves to the updated cache, or undefined if the update fails
*/
public async updateCacheTTL(taskId: string, ttlSeconds: number = DEFAULT_CACHE_TTL_SECONDS): Promise<any> {
const cacheName = this.taskCacheNames.get(taskId)
if (!cacheName) {
console.warn(`[GeminiHandler] No cache found for task ${taskId}, cannot update TTL`)
return
}
try {
const updatedCache = await this.client.caches.update({
name: cacheName,
config: { ttl: `${ttlSeconds}s` },
})
console.log(`[GeminiHandler] Updated TTL for cache ${cacheName} to ${ttlSeconds}s`)
return updatedCache
} catch (error) {
console.error(`[GeminiHandler] Failed to update TTL for cache ${cacheName}:`, error)
}
}
/**
* Calculate the ongoing costs for a task based on cache storage.
*
* This method calculates the cost of holding tokens in cache for the TTL period.
* These costs are separate from the immediate costs of API calls and should be
* tracked at the task level rather than the message level.
*
* TODO: Surface these ongoing costs to the user in the UI, possibly in:
* - The task header/summary
* - A dedicated "costs" panel or tooltip
* - As part of the total cost calculation for the task
*
* @param taskId The ID of the task to calculate ongoing costs for
* @returns The ongoing cost in dollars, or undefined if no cache exists for the task
*/
public getTaskOngoingCosts(taskId: string): number | undefined {
const tokens = this.taskCacheTokens.get(taskId)
if (!tokens) {
return undefined
}
const { info } = this.getModel()
if (!info.cacheWritesPrice) {
return undefined
}
// Calculate the cost of holding tokens in cache for the TTL period
// (tokens / 1M) * (price per 1M tokens) * (cache TTL in hours)
return info.cacheWritesPrice * (tokens / 1_000_000) * (DEFAULT_CACHE_TTL_SECONDS / 3600)
}
/**
* Calculate the immediate dollar cost of the API call based on token usage and model pricing.
*
@@ -430,21 +155,18 @@ export class GeminiHandler implements ApiHandler {
* - Input token costs (for uncached tokens)
* - Output token costs
* - Cache read costs
* - Gemini implicit caching has no write costs
*
* It does NOT include ongoing costs like cache storage, which are tracked separately
* at the task level through getTaskOngoingCosts().
*/
public calculateCost({
info,
inputTokens,
outputTokens,
cacheWriteTokens = 0,
cacheReadTokens = 0,
}: {
info: ModelInfo
inputTokens: number
outputTokens: number
cacheWriteTokens?: number
cacheReadTokens?: number
}) {
// Exit early if any required pricing information is missing
@@ -454,9 +176,7 @@ export class GeminiHandler implements ApiHandler {
let inputPrice = info.inputPrice
let outputPrice = info.outputPrice
let cacheWritesPrice = info.cacheWritesPrice ?? 0
// Right now, we only show the immediate costs of caching and not the ongoing costs of storing the cache
cacheWritesPrice = 0
let cacheReadsPrice = info.cacheReadsPrice ?? 0
// If there's tiered pricing then adjust prices based on the input tokens used
@@ -465,7 +185,6 @@ export class GeminiHandler implements ApiHandler {
if (tier) {
inputPrice = tier.inputPrice ?? inputPrice
outputPrice = tier.outputPrice ?? outputPrice
cacheWritesPrice = tier.cacheWritesPrice ?? cacheWritesPrice
cacheReadsPrice = tier.cacheReadsPrice ?? cacheReadsPrice
}
}
@@ -502,27 +221,6 @@ export class GeminiHandler implements ApiHandler {
return totalCost
}
/**
* Calculate the total length of all messages for cache eligibility check
*/
private getMessagesLength(contents: Content[]): number {
return contents.reduce((total, content) => {
if (!content.parts) {
return total
}
return (
total +
content.parts.reduce((partTotal, part) => {
if (typeof part.text === "string") {
return partTotal + part.text.length
}
return partTotal
}, 0)
)
}, 0)
}
/**
* Get the model ID and info for the current configuration
*/
+49 -23
View File
@@ -19,7 +19,6 @@ import WorkspaceTracker from "@integrations/workspace/WorkspaceTracker"
import { ClineAccountService } from "@services/account/ClineAccountService"
import { BrowserSession } from "@services/browser/BrowserSession"
import { McpHub } from "@services/mcp/McpHub"
import { searchWorkspaceFiles } from "@services/search/file-search"
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
import { ApiProvider, ModelInfo } from "@shared/api"
import { ChatContent } from "@shared/ChatContent"
@@ -28,11 +27,10 @@ import { ExtensionMessage, ExtensionState, Invoke, Platform } from "@shared/Exte
import { HistoryItem } from "@shared/HistoryItem"
import { McpDownloadResponse, McpMarketplaceCatalog, McpServer } from "@shared/mcp"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { ClineCheckpointRestore, WebviewMessage } from "@shared/WebviewMessage"
import { WebviewMessage } from "@shared/WebviewMessage"
import { fileExistsAtPath } from "@utils/fs"
import { searchCommits, getWorkingState } from "@utils/git"
import { getWorkingState } from "@utils/git"
import { extractCommitMessage } from "@integrations/git/commit-message-generator"
import { getWorkspacePath } from "@utils/path"
import { getTotalTasksSize } from "@utils/storage"
import { openMention } from "../mentions"
import { ensureMcpServersDirectoryExists, ensureSettingsDirectoryExists, GlobalFileNames } from "../storage/disk"
@@ -332,10 +330,6 @@ export class Controller {
case "resetState":
await this.resetState()
break
case "requestVsCodeLmModels":
const vsCodeLmModels = await this.getVsCodeLmModels()
this.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels })
break
case "refreshOpenRouterModels":
await this.refreshOpenRouterModels()
break
@@ -603,11 +597,18 @@ export class Controller {
}
case "clearAllTaskHistory": {
const answer = await vscode.window.showWarningMessage(
"Are you sure you want to delete all history?",
"Delete",
"What would you like to delete?",
{ modal: true },
"Delete All Except Favorites",
"Delete Everything",
"Cancel",
)
if (answer === "Delete") {
if (answer === "Delete All Except Favorites") {
await this.deleteNonFavoriteTaskHistory()
await this.postStateToWebview()
this.refreshTotalTasksSize()
} else if (answer === "Delete Everything") {
await this.deleteAllTaskHistory()
await this.postStateToWebview()
this.refreshTotalTasksSize()
@@ -884,18 +885,6 @@ export class Controller {
}
}
// 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 []
}
}
// Account
async fetchUserCreditsData() {
@@ -1556,6 +1545,43 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
// await this.postStateToWebview()
}
async deleteNonFavoriteTaskHistory() {
await this.clearTask()
const taskHistory = ((await getGlobalState(this.context, "taskHistory")) as HistoryItem[]) || []
const favoritedTasks = taskHistory.filter((task) => task.isFavorited === true)
// If user has no favorited tasks, show a warning message
if (favoritedTasks.length === 0) {
vscode.window.showWarningMessage("No favorited tasks found. Please favorite tasks before using this option.")
await this.postStateToWebview()
return
}
await updateGlobalState(this.context, "taskHistory", favoritedTasks)
// Delete non-favorited task directories
try {
const preserveTaskIds = favoritedTasks.map((task) => task.id)
const taskDirPath = path.join(this.context.globalStorageUri.fsPath, "tasks")
if (await fileExistsAtPath(taskDirPath)) {
const taskDirs = await fs.readdir(taskDirPath)
for (const taskDir of taskDirs) {
if (!preserveTaskIds.includes(taskDir)) {
await fs.rm(path.join(taskDirPath, taskDir), { recursive: true, force: true })
}
}
}
} catch (error) {
vscode.window.showErrorMessage(
`Error deleting task history: ${error instanceof Error ? error.message : String(error)}`,
)
}
await this.postStateToWebview()
}
async refreshTotalTasksSize() {
getTotalTasksSize(this.context.globalStorageUri.fsPath)
.then((newTotalSize) => {
@@ -0,0 +1,24 @@
import { Controller } from ".."
import { EmptyRequest } from "../../../shared/proto/common"
import { VsCodeLmModelsArray } from "../../../shared/proto/models"
import * as vscode from "vscode"
import { convertVsCodeNativeModelsToProtoModels } from "../../../shared/proto-conversions/models/vscode-lm-models-conversion"
/**
* Fetches available models from VS Code LM API
* @param controller The controller instance
* @param request Empty request
* @returns Array of VS Code LM models
*/
export async function getVsCodeLmModels(controller: Controller, request: EmptyRequest): Promise<VsCodeLmModelsArray> {
try {
const models = await vscode.lm.selectChatModels({})
const protoModels = convertVsCodeNativeModelsToProtoModels(models || [])
return VsCodeLmModelsArray.create({ models: protoModels })
} catch (error) {
console.error("Error fetching VS Code LM models:", error)
return VsCodeLmModelsArray.create({ models: [] })
}
}
+2
View File
@@ -5,10 +5,12 @@
import { registerMethod } from "./index"
import { getLmStudioModels } from "./getLmStudioModels"
import { getOllamaModels } from "./getOllamaModels"
import { getVsCodeLmModels } from "./getVsCodeLmModels"
// Register all models service methods
export function registerAllMethods(): void {
// Register each method with the registry
registerMethod("getLmStudioModels", getLmStudioModels)
registerMethod("getOllamaModels", getOllamaModels)
registerMethod("getVsCodeLmModels", getVsCodeLmModels)
}
@@ -0,0 +1,88 @@
import path from "path"
import fs from "fs/promises"
import { Controller } from ".."
import { EmptyRequest } from "../../../shared/proto/common"
import { DeleteNonFavoritedTasksResults } from "../../../shared/proto/task"
import { getGlobalState, updateGlobalState } from "../../storage/state"
import { fileExistsAtPath } from "../../../utils/fs"
/**
* Deletes all non-favorited tasks, preserving only favorited ones
* @param controller The controller instance
* @param request Empty request
* @returns DeleteNonFavoritedTasksResults with counts of preserved and deleted tasks
*/
export async function deleteNonFavoritedTasks(
controller: Controller,
_request: EmptyRequest,
): Promise<DeleteNonFavoritedTasksResults> {
try {
// Clear current task first
await controller.clearTask()
// Get existing task history
const taskHistory = ((await getGlobalState(controller.context, "taskHistory")) as any[]) || []
// Filter out non-favorited tasks
const favoritedTasks = taskHistory.filter((task) => task.isFavorited === true)
const deletedCount = taskHistory.length - favoritedTasks.length
console.log(`[deleteNonFavoritedTasks] Found ${favoritedTasks.length} favorited tasks to preserve`)
// Update global state
if (favoritedTasks.length > 0) {
await updateGlobalState(controller.context, "taskHistory", favoritedTasks)
} else {
await updateGlobalState(controller.context, "taskHistory", undefined)
}
// Handle file system cleanup for deleted tasks
const preserveTaskIds = favoritedTasks.map((task) => task.id)
await cleanupTaskFiles(controller, preserveTaskIds)
// Update webview
try {
await controller.postStateToWebview()
} catch (webviewErr) {
console.error("Error posting to webview:", webviewErr)
}
return {
tasksPreserved: favoritedTasks.length,
tasksDeleted: deletedCount,
}
} catch (error) {
console.error("Error in deleteNonFavoritedTasks:", error)
throw error
}
}
/**
* Helper function to cleanup task files while preserving specified tasks
*/
async function cleanupTaskFiles(controller: Controller, preserveTaskIds: string[]) {
const taskDirPath = path.join(controller.context.globalStorageUri.fsPath, "tasks")
try {
if (await fileExistsAtPath(taskDirPath)) {
if (preserveTaskIds.length > 0) {
const taskDirs = await fs.readdir(taskDirPath)
console.debug(`[cleanupTaskFiles] Found ${taskDirs.length} task directories`)
// Delete only non-preserved task directories
for (const dir of taskDirs) {
if (!preserveTaskIds.includes(dir)) {
await fs.rm(path.join(taskDirPath, dir), { recursive: true, force: true })
}
}
} else {
// No tasks to preserve, delete everything
await fs.rm(taskDirPath, { recursive: true, force: true })
}
}
} catch (error) {
console.error("Error cleaning up task files:", error)
}
return true
}
@@ -0,0 +1,89 @@
import { Controller } from ".."
import { GetTaskHistoryRequest, TaskHistoryArray } from "../../../shared/proto/task"
import { getGlobalState } from "../../storage/state"
/**
* Gets filtered task history
* @param controller The controller instance
* @param request Filter parameters for task history
* @returns TaskHistoryArray with filtered task list
*/
export async function getTaskHistory(controller: Controller, request: GetTaskHistoryRequest): Promise<TaskHistoryArray> {
try {
const { favoritesOnly, searchQuery, sortBy } = request
// Get task history from global state
const taskHistory = ((await getGlobalState(controller.context, "taskHistory")) as any[]) || []
// Apply filters
let filteredTasks = taskHistory.filter((item) => {
// Basic filter: must have timestamp and task content
const hasRequiredFields = item.ts && item.task
// Apply favorites filter if requested
if (favoritesOnly && hasRequiredFields) {
return item.isFavorited === true
}
return hasRequiredFields
})
// Apply search if provided
if (searchQuery) {
// Simple search implementation
const query = searchQuery.toLowerCase()
filteredTasks = filteredTasks.filter((item) => item.task.toLowerCase().includes(query))
}
// Calculate total count before sorting
const totalCount = filteredTasks.length
// Apply sorting
if (sortBy) {
filteredTasks.sort((a, b) => {
switch (sortBy) {
case "oldest":
return a.ts - b.ts
case "mostExpensive":
return (b.totalCost || 0) - (a.totalCost || 0)
case "mostTokens":
return (
(b.tokensIn || 0) +
(b.tokensOut || 0) +
(b.cacheWrites || 0) +
(b.cacheReads || 0) -
((a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0))
)
case "newest":
default:
return b.ts - a.ts
}
})
} else {
// Default sort by newest
filteredTasks.sort((a, b) => b.ts - a.ts)
}
// Map to response format
const tasks = filteredTasks.map((item) => ({
id: item.id,
task: item.task,
ts: item.ts,
isFavorited: item.isFavorited || false,
size: item.size || 0,
totalCost: item.totalCost || 0,
tokensIn: item.tokensIn || 0,
tokensOut: item.tokensOut || 0,
cacheWrites: item.cacheWrites || 0,
cacheReads: item.cacheReads || 0,
}))
return {
tasks,
totalCount,
}
} catch (error) {
console.error("Error in getTaskHistory:", error)
throw error
}
}
+6
View File
@@ -5,18 +5,24 @@
import { registerMethod } from "./index"
import { cancelTask } from "./cancelTask"
import { clearTask } from "./clearTask"
import { deleteNonFavoritedTasks } from "./deleteNonFavoritedTasks"
import { deleteTasksWithIds } from "./deleteTasksWithIds"
import { exportTaskWithId } from "./exportTaskWithId"
import { getTaskHistory } from "./getTaskHistory"
import { newTask } from "./newTask"
import { showTaskWithId } from "./showTaskWithId"
import { toggleTaskFavorite } from "./toggleTaskFavorite"
// Register all task service methods
export function registerAllMethods(): void {
// Register each method with the registry
registerMethod("cancelTask", cancelTask)
registerMethod("clearTask", clearTask)
registerMethod("deleteNonFavoritedTasks", deleteNonFavoritedTasks)
registerMethod("deleteTasksWithIds", deleteTasksWithIds)
registerMethod("exportTaskWithId", exportTaskWithId)
registerMethod("getTaskHistory", getTaskHistory)
registerMethod("newTask", newTask)
registerMethod("showTaskWithId", showTaskWithId)
registerMethod("toggleTaskFavorite", toggleTaskFavorite)
}
+61 -5
View File
@@ -1,17 +1,73 @@
import { Controller } from ".."
import { Empty, StringRequest } from "../../../shared/proto/common"
import { StringRequest } from "../../../shared/proto/common"
import { TaskResponse } from "../../../shared/proto/task"
/**
* Shows a task with the specified ID
* @param controller The controller instance
* @param request The request containing the task ID
* @returns Empty response
* @returns TaskResponse with task details
*/
export async function showTaskWithId(controller: Controller, request: StringRequest): Promise<Empty> {
export async function showTaskWithId(controller: Controller, request: StringRequest): Promise<TaskResponse> {
try {
await controller.showTaskWithId(request.value)
return Empty.create()
const id = request.value
// First check if task exists in global state for faster access
const taskHistory = ((await controller.context.globalState.get("taskHistory")) as any[]) || []
const historyItem = taskHistory.find((item) => item.id === id)
// We need to initialize the task before returning data
if (historyItem) {
// Always initialize the task with the history item
await controller.initTask(undefined, undefined, historyItem)
// Send UI update to show the chat view
await controller.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
// Return task data for gRPC response
return {
id: historyItem.id,
task: historyItem.task || "",
ts: historyItem.ts || 0,
isFavorited: historyItem.isFavorited || false,
size: historyItem.size || 0,
totalCost: historyItem.totalCost || 0,
tokensIn: historyItem.tokensIn || 0,
tokensOut: historyItem.tokensOut || 0,
cacheWrites: historyItem.cacheWrites || 0,
cacheReads: historyItem.cacheReads || 0,
}
}
// If not in global state, fetch from storage
const { historyItem: fetchedItem } = await controller.getTaskWithId(id)
// Initialize the task with the fetched item
await controller.initTask(undefined, undefined, fetchedItem)
// Send UI update to show the chat view
await controller.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
return {
id: fetchedItem.id,
task: fetchedItem.task || "",
ts: fetchedItem.ts || 0,
isFavorited: fetchedItem.isFavorited || false,
size: fetchedItem.size || 0,
totalCost: fetchedItem.totalCost || 0,
tokensIn: fetchedItem.tokensIn || 0,
tokensOut: fetchedItem.tokensOut || 0,
cacheWrites: fetchedItem.cacheWrites || 0,
cacheReads: fetchedItem.cacheReads || 0,
}
} catch (error) {
console.error("Error in showTaskWithId:", error)
throw error
}
}
@@ -0,0 +1,51 @@
import { Controller } from "../"
import { Empty } from "../../../shared/proto/common"
import { TaskFavoriteRequest } from "../../../shared/proto/task"
export async function toggleTaskFavorite(controller: Controller, request: TaskFavoriteRequest): Promise<Empty> {
if (!request.taskId || request.isFavorited === undefined) {
const errorMsg = `[toggleTaskFavorite] Invalid request: taskId or isFavorited missing`
console.error(errorMsg)
return {}
}
try {
// Update in-memory state only
try {
const history = ((await controller.context.globalState.get("taskHistory")) as any[]) || []
const taskIndex = history.findIndex((item) => item.id === request.taskId)
if (taskIndex === -1) {
console.log(`[toggleTaskFavorite] Task not found in history array!`)
} else {
// Create a new array instead of modifying in place to ensure state change
const updatedHistory = [...history]
updatedHistory[taskIndex] = {
...updatedHistory[taskIndex],
isFavorited: request.isFavorited,
}
// Update global state and wait for it to complete
try {
await controller.context.globalState.update("taskHistory", updatedHistory)
} catch (stateErr) {
console.error("Error updating global state:", stateErr)
}
}
} catch (historyErr) {
console.error("Error processing task history:", historyErr)
}
// Post to webview
try {
await controller.postStateToWebview()
} catch (webviewErr) {
console.error("Error posting to webview:", webviewErr)
}
} catch (error) {
console.error("Error in toggleTaskFavorite:", error)
}
return {}
}
+4 -1
View File
@@ -118,6 +118,7 @@ export class Task {
private cancelTask: () => Promise<void>
readonly taskId: string
private taskIsFavorited?: boolean
api: ApiHandler
private terminalManager: TerminalManager
private urlContentFetcher: UrlContentFetcher
@@ -211,6 +212,7 @@ export class Task {
// Initialize taskId first
if (historyItem) {
this.taskId = historyItem.id
this.taskIsFavorited = historyItem.isFavorited
this.conversationHistoryDeletedRange = historyItem.conversationHistoryDeletedRange
} else if (task || images) {
this.taskId = Date.now().toString()
@@ -314,6 +316,7 @@ export class Task {
size: taskDirSize,
shadowGitConfigWorkTree: await this.checkpointTracker?.getShadowGitConfigWorkTree(),
conversationHistoryDeletedRange: this.conversationHistoryDeletedRange,
isFavorited: this.taskIsFavorited,
})
} catch (error) {
console.error("Failed to save cline messages:", error)
@@ -1916,7 +1919,7 @@ export class Task {
: "other_diff_error"
// Add telemetry for diff edit failure
telemetryService.captureDiffEditFailure(this.taskId, errorType)
telemetryService.captureDiffEditFailure(this.taskId, this.api.getModel().id, errorType)
pushToolResult(
formatResponse.toolError(
+2 -1
View File
@@ -40,7 +40,8 @@ export async function extractTextFromFile(filePath: string): Promise<string> {
return extractTextFromIPYNB(filePath)
default:
const fileBuffer = await fs.readFile(filePath)
if (fileBuffer.byteLength > 300 * 1024) {
if (fileBuffer.byteLength > 20 * 1000 * 1024) {
// 20MB limit (20 * 1000 * 1024 bytes, decimal MB)
throw new Error(`File is too large to read into context.`)
}
const encoding = await detectEncoding(fileBuffer, fileExtension)
+31 -10
View File
@@ -1,6 +1,7 @@
import * as vscode from "vscode"
import fs from "fs/promises"
import * as path from "path"
import sizeOf from "image-size"
export async function selectImages(): Promise<string[]> {
const options: vscode.OpenDialogOptions = {
@@ -17,16 +18,36 @@ export async function selectImages(): Promise<string[]> {
return []
}
return await Promise.all(
fileUris.map(async (uri) => {
const imagePath = uri.fsPath
const buffer = await fs.readFile(imagePath)
const base64 = buffer.toString("base64")
const mimeType = getMimeType(imagePath)
const dataUrl = `data:${mimeType};base64,${base64}`
return dataUrl
}),
)
const processedImagePromises = fileUris.map(async (uri) => {
const imagePath = uri.fsPath
let buffer: Buffer
try {
// Read the file into a buffer first
buffer = await fs.readFile(imagePath)
// Convert Node.js Buffer to Uint8Array
const uint8Array = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
const dimensions = sizeOf(uint8Array) // Get dimensions from Uint8Array
if (dimensions.width! > 7500 || dimensions.height! > 7500) {
console.warn(`Image dimensions exceed 7500px, skipping: ${imagePath}`)
vscode.window.showErrorMessage(
`Image too large: ${path.basename(imagePath)} was skipped (dimensions exceed 7500px).`,
)
return null
}
} catch (error) {
console.error(`Error reading file or getting dimensions for ${imagePath}:`, error)
vscode.window.showErrorMessage(`Could not read dimensions for ${path.basename(imagePath)}, skipping.`)
return null
}
// If dimensions are valid, proceed to convert the existing buffer to base64
const base64 = buffer.toString("base64")
const mimeType = getMimeType(imagePath)
return `data:${mimeType};base64,${base64}`
})
const dataUrlsWithNulls = await Promise.all(processedImagePromises)
return dataUrlsWithNulls.filter((url) => url !== null) as string[] // Filter out skipped images
}
function getMimeType(filePath: string): string {
@@ -512,13 +512,14 @@ class PostHogClient {
* @param taskId Unique identifier for the task
* @param errorType Type of error that occurred (e.g., "search_not_found", "invalid_format")
*/
public captureDiffEditFailure(taskId: string, errorType?: string, collect: boolean = false) {
public captureDiffEditFailure(taskId: string, modelId: string, errorType?: string, collect: boolean = false) {
this.capture(
{
event: PostHogClient.EVENTS.TASK.DIFF_EDIT_FAILED,
properties: {
taskId,
errorType,
modelId,
},
},
collect,
-2
View File
@@ -28,8 +28,6 @@ export interface ExtensionMessage {
| "requestyModels"
| "mcpServers"
| "relinquishControl"
| "vsCodeLmModels"
| "requestVsCodeLmModels"
| "authCallback"
| "mcpMarketplaceCatalog"
| "mcpDownloadDetails"
+1
View File
@@ -11,4 +11,5 @@ export type HistoryItem = {
size?: number
shadowGitConfigWorkTree?: string
conversationHistoryDeletedRange?: [number, number]
isFavorited?: boolean
}
+26 -6
View File
@@ -204,27 +204,33 @@ export const bedrockModels = {
contextWindow: 300_000,
supportsImages: true,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 0.8,
outputPrice: 3.2,
// cacheWritesPrice: 3.2, // not written
cacheReadsPrice: 0.2,
},
"amazon.nova-lite-v1:0": {
maxTokens: 5000,
contextWindow: 300_000,
supportsImages: true,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 0.06,
outputPrice: 0.24,
// cacheWritesPrice: 0.24, // not written
cacheReadsPrice: 0.015,
},
"amazon.nova-micro-v1:0": {
maxTokens: 5000,
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 0.035,
outputPrice: 0.14,
// cacheWritesPrice: 0.14, // not written
cacheReadsPrice: 0.00875,
},
"anthropic.claude-3-7-sonnet-20250219-v1:0": {
maxTokens: 8192,
@@ -251,10 +257,10 @@ export const bedrockModels = {
"anthropic.claude-3-5-haiku-20241022-v1:0": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: false,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.0,
outputPrice: 5.0,
inputPrice: 0.8,
outputPrice: 4.0,
cacheWritesPrice: 1.0,
cacheReadsPrice: 0.08,
},
@@ -436,16 +442,19 @@ export const vertexModels = {
supportsPromptCache: true,
inputPrice: 2.5,
outputPrice: 15,
cacheReadsPrice: 0.31,
tiers: [
{
contextWindow: 200000,
inputPrice: 1.25,
outputPrice: 10,
cacheReadsPrice: 0.31,
},
{
contextWindow: Infinity,
inputPrice: 2.5,
outputPrice: 15,
cacheReadsPrice: 0.625,
},
],
},
@@ -566,16 +575,19 @@ export const geminiModels = {
supportsPromptCache: true,
inputPrice: 2.5,
outputPrice: 15,
cacheReadsPrice: 0.31,
tiers: [
{
contextWindow: 200000,
inputPrice: 1.25,
outputPrice: 10,
cacheReadsPrice: 0.31,
},
{
contextWindow: Infinity,
inputPrice: 2.5,
outputPrice: 15,
cacheReadsPrice: 0.625,
},
],
},
@@ -1387,6 +1399,14 @@ export const mistralModels = {
inputPrice: 0.1,
outputPrice: 0.3,
},
"mistral-medium-latest": {
maxTokens: 128_000,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.4,
outputPrice: 2.0,
},
"mistral-small-2501": {
maxTokens: 32_000,
contextWindow: 32_000,
@@ -0,0 +1,23 @@
import { VsCodeLmModel } from "../../proto/models"
/**
* Represents a VS Code language model in the native VS Code format
*/
export interface VsCodeNativeModel {
vendor?: string
family?: string
version?: string
id?: string
}
/**
* Converts VS Code native model format to protobuf format
*/
export function convertVsCodeNativeModelsToProtoModels(models: VsCodeNativeModel[]): VsCodeLmModel[] {
return (models || []).map((model) => ({
vendor: model.vendor || "",
family: model.family || "",
version: model.version || "",
id: model.id || "",
}))
}
+222 -1
View File
@@ -5,10 +5,192 @@
// source: models.proto
/* eslint-disable */
import { StringArray, StringRequest } from "./common"
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"
import { EmptyRequest, StringArray, StringRequest } from "./common"
export const protobufPackage = "cline"
/** List of VS Code LM models */
export interface VsCodeLmModelsArray {
models: VsCodeLmModel[]
}
/** Structure representing a VS Code LM model */
export interface VsCodeLmModel {
vendor: string
family: string
version: string
id: string
}
function createBaseVsCodeLmModelsArray(): VsCodeLmModelsArray {
return { models: [] }
}
export const VsCodeLmModelsArray: MessageFns<VsCodeLmModelsArray> = {
encode(message: VsCodeLmModelsArray, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
for (const v of message.models) {
VsCodeLmModel.encode(v!, writer.uint32(10).fork()).join()
}
return writer
},
decode(input: BinaryReader | Uint8Array, length?: number): VsCodeLmModelsArray {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input)
let end = length === undefined ? reader.len : reader.pos + length
const message = createBaseVsCodeLmModelsArray()
while (reader.pos < end) {
const tag = reader.uint32()
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break
}
message.models.push(VsCodeLmModel.decode(reader, reader.uint32()))
continue
}
}
if ((tag & 7) === 4 || tag === 0) {
break
}
reader.skip(tag & 7)
}
return message
},
fromJSON(object: any): VsCodeLmModelsArray {
return {
models: globalThis.Array.isArray(object?.models) ? object.models.map((e: any) => VsCodeLmModel.fromJSON(e)) : [],
}
},
toJSON(message: VsCodeLmModelsArray): unknown {
const obj: any = {}
if (message.models?.length) {
obj.models = message.models.map((e) => VsCodeLmModel.toJSON(e))
}
return obj
},
create<I extends Exact<DeepPartial<VsCodeLmModelsArray>, I>>(base?: I): VsCodeLmModelsArray {
return VsCodeLmModelsArray.fromPartial(base ?? ({} as any))
},
fromPartial<I extends Exact<DeepPartial<VsCodeLmModelsArray>, I>>(object: I): VsCodeLmModelsArray {
const message = createBaseVsCodeLmModelsArray()
message.models = object.models?.map((e) => VsCodeLmModel.fromPartial(e)) || []
return message
},
}
function createBaseVsCodeLmModel(): VsCodeLmModel {
return { vendor: "", family: "", version: "", id: "" }
}
export const VsCodeLmModel: MessageFns<VsCodeLmModel> = {
encode(message: VsCodeLmModel, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.vendor !== "") {
writer.uint32(10).string(message.vendor)
}
if (message.family !== "") {
writer.uint32(18).string(message.family)
}
if (message.version !== "") {
writer.uint32(26).string(message.version)
}
if (message.id !== "") {
writer.uint32(34).string(message.id)
}
return writer
},
decode(input: BinaryReader | Uint8Array, length?: number): VsCodeLmModel {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input)
let end = length === undefined ? reader.len : reader.pos + length
const message = createBaseVsCodeLmModel()
while (reader.pos < end) {
const tag = reader.uint32()
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break
}
message.vendor = reader.string()
continue
}
case 2: {
if (tag !== 18) {
break
}
message.family = reader.string()
continue
}
case 3: {
if (tag !== 26) {
break
}
message.version = reader.string()
continue
}
case 4: {
if (tag !== 34) {
break
}
message.id = reader.string()
continue
}
}
if ((tag & 7) === 4 || tag === 0) {
break
}
reader.skip(tag & 7)
}
return message
},
fromJSON(object: any): VsCodeLmModel {
return {
vendor: isSet(object.vendor) ? globalThis.String(object.vendor) : "",
family: isSet(object.family) ? globalThis.String(object.family) : "",
version: isSet(object.version) ? globalThis.String(object.version) : "",
id: isSet(object.id) ? globalThis.String(object.id) : "",
}
},
toJSON(message: VsCodeLmModel): unknown {
const obj: any = {}
if (message.vendor !== "") {
obj.vendor = message.vendor
}
if (message.family !== "") {
obj.family = message.family
}
if (message.version !== "") {
obj.version = message.version
}
if (message.id !== "") {
obj.id = message.id
}
return obj
},
create<I extends Exact<DeepPartial<VsCodeLmModel>, I>>(base?: I): VsCodeLmModel {
return VsCodeLmModel.fromPartial(base ?? ({} as any))
},
fromPartial<I extends Exact<DeepPartial<VsCodeLmModel>, I>>(object: I): VsCodeLmModel {
const message = createBaseVsCodeLmModel()
message.vendor = object.vendor ?? ""
message.family = object.family ?? ""
message.version = object.version ?? ""
message.id = object.id ?? ""
return message
},
}
/** Service for model-related operations */
export type ModelsServiceDefinition = typeof ModelsServiceDefinition
export const ModelsServiceDefinition = {
@@ -33,5 +215,44 @@ export const ModelsServiceDefinition = {
responseStream: false,
options: {},
},
/** Fetches available models from VS Code LM API */
getVsCodeLmModels: {
name: "getVsCodeLmModels",
requestType: EmptyRequest,
requestStream: false,
responseType: VsCodeLmModelsArray,
responseStream: false,
options: {},
},
},
} as const
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined
export type DeepPartial<T> = T extends Builtin
? T
: T extends globalThis.Array<infer U>
? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends {}
? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>
type KeysOfUnion<T> = T extends T ? keyof T : never
export type Exact<P, I extends P> = P extends Builtin
? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never }
function isSet(value: any): boolean {
return value !== null && value !== undefined
}
export interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter
decode(input: BinaryReader | Uint8Array, length?: number): T
fromJSON(object: any): T
toJSON(message: T): unknown
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T
}
+878 -1
View File
@@ -17,6 +17,61 @@ export interface NewTaskRequest {
images: string[]
}
/** Request message for toggling task favorite status */
export interface TaskFavoriteRequest {
metadata?: Metadata | undefined
taskId: string
isFavorited: boolean
}
/** Response for task details */
export interface TaskResponse {
id: string
task: string
ts: number
isFavorited: boolean
size: number
totalCost: number
tokensIn: number
tokensOut: number
cacheWrites: number
cacheReads: number
}
/** Results returned when deleting non-favorited tasks */
export interface DeleteNonFavoritedTasksResults {
tasksPreserved: number
tasksDeleted: number
}
/** Request for getting task history with filtering */
export interface GetTaskHistoryRequest {
metadata?: Metadata | undefined
favoritesOnly: boolean
searchQuery: string
sortBy: string
}
/** Response for task history */
export interface TaskHistoryArray {
tasks: TaskItem[]
totalCount: number
}
/** Task item details for history list */
export interface TaskItem {
id: string
task: string
ts: number
isFavorited: boolean
size: number
totalCost: number
tokensIn: number
tokensOut: number
cacheWrites: number
cacheReads: number
}
function createBaseNewTaskRequest(): NewTaskRequest {
return { metadata: undefined, text: "", images: [] }
}
@@ -110,6 +165,790 @@ export const NewTaskRequest: MessageFns<NewTaskRequest> = {
},
}
function createBaseTaskFavoriteRequest(): TaskFavoriteRequest {
return { metadata: undefined, taskId: "", isFavorited: false }
}
export const TaskFavoriteRequest: MessageFns<TaskFavoriteRequest> = {
encode(message: TaskFavoriteRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.metadata !== undefined) {
Metadata.encode(message.metadata, writer.uint32(10).fork()).join()
}
if (message.taskId !== "") {
writer.uint32(18).string(message.taskId)
}
if (message.isFavorited !== false) {
writer.uint32(24).bool(message.isFavorited)
}
return writer
},
decode(input: BinaryReader | Uint8Array, length?: number): TaskFavoriteRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input)
let end = length === undefined ? reader.len : reader.pos + length
const message = createBaseTaskFavoriteRequest()
while (reader.pos < end) {
const tag = reader.uint32()
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break
}
message.metadata = Metadata.decode(reader, reader.uint32())
continue
}
case 2: {
if (tag !== 18) {
break
}
message.taskId = reader.string()
continue
}
case 3: {
if (tag !== 24) {
break
}
message.isFavorited = reader.bool()
continue
}
}
if ((tag & 7) === 4 || tag === 0) {
break
}
reader.skip(tag & 7)
}
return message
},
fromJSON(object: any): TaskFavoriteRequest {
return {
metadata: isSet(object.metadata) ? Metadata.fromJSON(object.metadata) : undefined,
taskId: isSet(object.taskId) ? globalThis.String(object.taskId) : "",
isFavorited: isSet(object.isFavorited) ? globalThis.Boolean(object.isFavorited) : false,
}
},
toJSON(message: TaskFavoriteRequest): unknown {
const obj: any = {}
if (message.metadata !== undefined) {
obj.metadata = Metadata.toJSON(message.metadata)
}
if (message.taskId !== "") {
obj.taskId = message.taskId
}
if (message.isFavorited !== false) {
obj.isFavorited = message.isFavorited
}
return obj
},
create<I extends Exact<DeepPartial<TaskFavoriteRequest>, I>>(base?: I): TaskFavoriteRequest {
return TaskFavoriteRequest.fromPartial(base ?? ({} as any))
},
fromPartial<I extends Exact<DeepPartial<TaskFavoriteRequest>, I>>(object: I): TaskFavoriteRequest {
const message = createBaseTaskFavoriteRequest()
message.metadata =
object.metadata !== undefined && object.metadata !== null ? Metadata.fromPartial(object.metadata) : undefined
message.taskId = object.taskId ?? ""
message.isFavorited = object.isFavorited ?? false
return message
},
}
function createBaseTaskResponse(): TaskResponse {
return {
id: "",
task: "",
ts: 0,
isFavorited: false,
size: 0,
totalCost: 0,
tokensIn: 0,
tokensOut: 0,
cacheWrites: 0,
cacheReads: 0,
}
}
export const TaskResponse: MessageFns<TaskResponse> = {
encode(message: TaskResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.id !== "") {
writer.uint32(10).string(message.id)
}
if (message.task !== "") {
writer.uint32(18).string(message.task)
}
if (message.ts !== 0) {
writer.uint32(24).int64(message.ts)
}
if (message.isFavorited !== false) {
writer.uint32(32).bool(message.isFavorited)
}
if (message.size !== 0) {
writer.uint32(40).int64(message.size)
}
if (message.totalCost !== 0) {
writer.uint32(49).double(message.totalCost)
}
if (message.tokensIn !== 0) {
writer.uint32(56).int32(message.tokensIn)
}
if (message.tokensOut !== 0) {
writer.uint32(64).int32(message.tokensOut)
}
if (message.cacheWrites !== 0) {
writer.uint32(72).int32(message.cacheWrites)
}
if (message.cacheReads !== 0) {
writer.uint32(80).int32(message.cacheReads)
}
return writer
},
decode(input: BinaryReader | Uint8Array, length?: number): TaskResponse {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input)
let end = length === undefined ? reader.len : reader.pos + length
const message = createBaseTaskResponse()
while (reader.pos < end) {
const tag = reader.uint32()
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break
}
message.id = reader.string()
continue
}
case 2: {
if (tag !== 18) {
break
}
message.task = reader.string()
continue
}
case 3: {
if (tag !== 24) {
break
}
message.ts = longToNumber(reader.int64())
continue
}
case 4: {
if (tag !== 32) {
break
}
message.isFavorited = reader.bool()
continue
}
case 5: {
if (tag !== 40) {
break
}
message.size = longToNumber(reader.int64())
continue
}
case 6: {
if (tag !== 49) {
break
}
message.totalCost = reader.double()
continue
}
case 7: {
if (tag !== 56) {
break
}
message.tokensIn = reader.int32()
continue
}
case 8: {
if (tag !== 64) {
break
}
message.tokensOut = reader.int32()
continue
}
case 9: {
if (tag !== 72) {
break
}
message.cacheWrites = reader.int32()
continue
}
case 10: {
if (tag !== 80) {
break
}
message.cacheReads = reader.int32()
continue
}
}
if ((tag & 7) === 4 || tag === 0) {
break
}
reader.skip(tag & 7)
}
return message
},
fromJSON(object: any): TaskResponse {
return {
id: isSet(object.id) ? globalThis.String(object.id) : "",
task: isSet(object.task) ? globalThis.String(object.task) : "",
ts: isSet(object.ts) ? globalThis.Number(object.ts) : 0,
isFavorited: isSet(object.isFavorited) ? globalThis.Boolean(object.isFavorited) : false,
size: isSet(object.size) ? globalThis.Number(object.size) : 0,
totalCost: isSet(object.totalCost) ? globalThis.Number(object.totalCost) : 0,
tokensIn: isSet(object.tokensIn) ? globalThis.Number(object.tokensIn) : 0,
tokensOut: isSet(object.tokensOut) ? globalThis.Number(object.tokensOut) : 0,
cacheWrites: isSet(object.cacheWrites) ? globalThis.Number(object.cacheWrites) : 0,
cacheReads: isSet(object.cacheReads) ? globalThis.Number(object.cacheReads) : 0,
}
},
toJSON(message: TaskResponse): unknown {
const obj: any = {}
if (message.id !== "") {
obj.id = message.id
}
if (message.task !== "") {
obj.task = message.task
}
if (message.ts !== 0) {
obj.ts = Math.round(message.ts)
}
if (message.isFavorited !== false) {
obj.isFavorited = message.isFavorited
}
if (message.size !== 0) {
obj.size = Math.round(message.size)
}
if (message.totalCost !== 0) {
obj.totalCost = message.totalCost
}
if (message.tokensIn !== 0) {
obj.tokensIn = Math.round(message.tokensIn)
}
if (message.tokensOut !== 0) {
obj.tokensOut = Math.round(message.tokensOut)
}
if (message.cacheWrites !== 0) {
obj.cacheWrites = Math.round(message.cacheWrites)
}
if (message.cacheReads !== 0) {
obj.cacheReads = Math.round(message.cacheReads)
}
return obj
},
create<I extends Exact<DeepPartial<TaskResponse>, I>>(base?: I): TaskResponse {
return TaskResponse.fromPartial(base ?? ({} as any))
},
fromPartial<I extends Exact<DeepPartial<TaskResponse>, I>>(object: I): TaskResponse {
const message = createBaseTaskResponse()
message.id = object.id ?? ""
message.task = object.task ?? ""
message.ts = object.ts ?? 0
message.isFavorited = object.isFavorited ?? false
message.size = object.size ?? 0
message.totalCost = object.totalCost ?? 0
message.tokensIn = object.tokensIn ?? 0
message.tokensOut = object.tokensOut ?? 0
message.cacheWrites = object.cacheWrites ?? 0
message.cacheReads = object.cacheReads ?? 0
return message
},
}
function createBaseDeleteNonFavoritedTasksResults(): DeleteNonFavoritedTasksResults {
return { tasksPreserved: 0, tasksDeleted: 0 }
}
export const DeleteNonFavoritedTasksResults: MessageFns<DeleteNonFavoritedTasksResults> = {
encode(message: DeleteNonFavoritedTasksResults, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.tasksPreserved !== 0) {
writer.uint32(8).int32(message.tasksPreserved)
}
if (message.tasksDeleted !== 0) {
writer.uint32(16).int32(message.tasksDeleted)
}
return writer
},
decode(input: BinaryReader | Uint8Array, length?: number): DeleteNonFavoritedTasksResults {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input)
let end = length === undefined ? reader.len : reader.pos + length
const message = createBaseDeleteNonFavoritedTasksResults()
while (reader.pos < end) {
const tag = reader.uint32()
switch (tag >>> 3) {
case 1: {
if (tag !== 8) {
break
}
message.tasksPreserved = reader.int32()
continue
}
case 2: {
if (tag !== 16) {
break
}
message.tasksDeleted = reader.int32()
continue
}
}
if ((tag & 7) === 4 || tag === 0) {
break
}
reader.skip(tag & 7)
}
return message
},
fromJSON(object: any): DeleteNonFavoritedTasksResults {
return {
tasksPreserved: isSet(object.tasksPreserved) ? globalThis.Number(object.tasksPreserved) : 0,
tasksDeleted: isSet(object.tasksDeleted) ? globalThis.Number(object.tasksDeleted) : 0,
}
},
toJSON(message: DeleteNonFavoritedTasksResults): unknown {
const obj: any = {}
if (message.tasksPreserved !== 0) {
obj.tasksPreserved = Math.round(message.tasksPreserved)
}
if (message.tasksDeleted !== 0) {
obj.tasksDeleted = Math.round(message.tasksDeleted)
}
return obj
},
create<I extends Exact<DeepPartial<DeleteNonFavoritedTasksResults>, I>>(base?: I): DeleteNonFavoritedTasksResults {
return DeleteNonFavoritedTasksResults.fromPartial(base ?? ({} as any))
},
fromPartial<I extends Exact<DeepPartial<DeleteNonFavoritedTasksResults>, I>>(object: I): DeleteNonFavoritedTasksResults {
const message = createBaseDeleteNonFavoritedTasksResults()
message.tasksPreserved = object.tasksPreserved ?? 0
message.tasksDeleted = object.tasksDeleted ?? 0
return message
},
}
function createBaseGetTaskHistoryRequest(): GetTaskHistoryRequest {
return { metadata: undefined, favoritesOnly: false, searchQuery: "", sortBy: "" }
}
export const GetTaskHistoryRequest: MessageFns<GetTaskHistoryRequest> = {
encode(message: GetTaskHistoryRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.metadata !== undefined) {
Metadata.encode(message.metadata, writer.uint32(10).fork()).join()
}
if (message.favoritesOnly !== false) {
writer.uint32(16).bool(message.favoritesOnly)
}
if (message.searchQuery !== "") {
writer.uint32(26).string(message.searchQuery)
}
if (message.sortBy !== "") {
writer.uint32(34).string(message.sortBy)
}
return writer
},
decode(input: BinaryReader | Uint8Array, length?: number): GetTaskHistoryRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input)
let end = length === undefined ? reader.len : reader.pos + length
const message = createBaseGetTaskHistoryRequest()
while (reader.pos < end) {
const tag = reader.uint32()
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break
}
message.metadata = Metadata.decode(reader, reader.uint32())
continue
}
case 2: {
if (tag !== 16) {
break
}
message.favoritesOnly = reader.bool()
continue
}
case 3: {
if (tag !== 26) {
break
}
message.searchQuery = reader.string()
continue
}
case 4: {
if (tag !== 34) {
break
}
message.sortBy = reader.string()
continue
}
}
if ((tag & 7) === 4 || tag === 0) {
break
}
reader.skip(tag & 7)
}
return message
},
fromJSON(object: any): GetTaskHistoryRequest {
return {
metadata: isSet(object.metadata) ? Metadata.fromJSON(object.metadata) : undefined,
favoritesOnly: isSet(object.favoritesOnly) ? globalThis.Boolean(object.favoritesOnly) : false,
searchQuery: isSet(object.searchQuery) ? globalThis.String(object.searchQuery) : "",
sortBy: isSet(object.sortBy) ? globalThis.String(object.sortBy) : "",
}
},
toJSON(message: GetTaskHistoryRequest): unknown {
const obj: any = {}
if (message.metadata !== undefined) {
obj.metadata = Metadata.toJSON(message.metadata)
}
if (message.favoritesOnly !== false) {
obj.favoritesOnly = message.favoritesOnly
}
if (message.searchQuery !== "") {
obj.searchQuery = message.searchQuery
}
if (message.sortBy !== "") {
obj.sortBy = message.sortBy
}
return obj
},
create<I extends Exact<DeepPartial<GetTaskHistoryRequest>, I>>(base?: I): GetTaskHistoryRequest {
return GetTaskHistoryRequest.fromPartial(base ?? ({} as any))
},
fromPartial<I extends Exact<DeepPartial<GetTaskHistoryRequest>, I>>(object: I): GetTaskHistoryRequest {
const message = createBaseGetTaskHistoryRequest()
message.metadata =
object.metadata !== undefined && object.metadata !== null ? Metadata.fromPartial(object.metadata) : undefined
message.favoritesOnly = object.favoritesOnly ?? false
message.searchQuery = object.searchQuery ?? ""
message.sortBy = object.sortBy ?? ""
return message
},
}
function createBaseTaskHistoryArray(): TaskHistoryArray {
return { tasks: [], totalCount: 0 }
}
export const TaskHistoryArray: MessageFns<TaskHistoryArray> = {
encode(message: TaskHistoryArray, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
for (const v of message.tasks) {
TaskItem.encode(v!, writer.uint32(10).fork()).join()
}
if (message.totalCount !== 0) {
writer.uint32(16).int32(message.totalCount)
}
return writer
},
decode(input: BinaryReader | Uint8Array, length?: number): TaskHistoryArray {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input)
let end = length === undefined ? reader.len : reader.pos + length
const message = createBaseTaskHistoryArray()
while (reader.pos < end) {
const tag = reader.uint32()
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break
}
message.tasks.push(TaskItem.decode(reader, reader.uint32()))
continue
}
case 2: {
if (tag !== 16) {
break
}
message.totalCount = reader.int32()
continue
}
}
if ((tag & 7) === 4 || tag === 0) {
break
}
reader.skip(tag & 7)
}
return message
},
fromJSON(object: any): TaskHistoryArray {
return {
tasks: globalThis.Array.isArray(object?.tasks) ? object.tasks.map((e: any) => TaskItem.fromJSON(e)) : [],
totalCount: isSet(object.totalCount) ? globalThis.Number(object.totalCount) : 0,
}
},
toJSON(message: TaskHistoryArray): unknown {
const obj: any = {}
if (message.tasks?.length) {
obj.tasks = message.tasks.map((e) => TaskItem.toJSON(e))
}
if (message.totalCount !== 0) {
obj.totalCount = Math.round(message.totalCount)
}
return obj
},
create<I extends Exact<DeepPartial<TaskHistoryArray>, I>>(base?: I): TaskHistoryArray {
return TaskHistoryArray.fromPartial(base ?? ({} as any))
},
fromPartial<I extends Exact<DeepPartial<TaskHistoryArray>, I>>(object: I): TaskHistoryArray {
const message = createBaseTaskHistoryArray()
message.tasks = object.tasks?.map((e) => TaskItem.fromPartial(e)) || []
message.totalCount = object.totalCount ?? 0
return message
},
}
function createBaseTaskItem(): TaskItem {
return {
id: "",
task: "",
ts: 0,
isFavorited: false,
size: 0,
totalCost: 0,
tokensIn: 0,
tokensOut: 0,
cacheWrites: 0,
cacheReads: 0,
}
}
export const TaskItem: MessageFns<TaskItem> = {
encode(message: TaskItem, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.id !== "") {
writer.uint32(10).string(message.id)
}
if (message.task !== "") {
writer.uint32(18).string(message.task)
}
if (message.ts !== 0) {
writer.uint32(24).int64(message.ts)
}
if (message.isFavorited !== false) {
writer.uint32(32).bool(message.isFavorited)
}
if (message.size !== 0) {
writer.uint32(40).int64(message.size)
}
if (message.totalCost !== 0) {
writer.uint32(49).double(message.totalCost)
}
if (message.tokensIn !== 0) {
writer.uint32(56).int32(message.tokensIn)
}
if (message.tokensOut !== 0) {
writer.uint32(64).int32(message.tokensOut)
}
if (message.cacheWrites !== 0) {
writer.uint32(72).int32(message.cacheWrites)
}
if (message.cacheReads !== 0) {
writer.uint32(80).int32(message.cacheReads)
}
return writer
},
decode(input: BinaryReader | Uint8Array, length?: number): TaskItem {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input)
let end = length === undefined ? reader.len : reader.pos + length
const message = createBaseTaskItem()
while (reader.pos < end) {
const tag = reader.uint32()
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break
}
message.id = reader.string()
continue
}
case 2: {
if (tag !== 18) {
break
}
message.task = reader.string()
continue
}
case 3: {
if (tag !== 24) {
break
}
message.ts = longToNumber(reader.int64())
continue
}
case 4: {
if (tag !== 32) {
break
}
message.isFavorited = reader.bool()
continue
}
case 5: {
if (tag !== 40) {
break
}
message.size = longToNumber(reader.int64())
continue
}
case 6: {
if (tag !== 49) {
break
}
message.totalCost = reader.double()
continue
}
case 7: {
if (tag !== 56) {
break
}
message.tokensIn = reader.int32()
continue
}
case 8: {
if (tag !== 64) {
break
}
message.tokensOut = reader.int32()
continue
}
case 9: {
if (tag !== 72) {
break
}
message.cacheWrites = reader.int32()
continue
}
case 10: {
if (tag !== 80) {
break
}
message.cacheReads = reader.int32()
continue
}
}
if ((tag & 7) === 4 || tag === 0) {
break
}
reader.skip(tag & 7)
}
return message
},
fromJSON(object: any): TaskItem {
return {
id: isSet(object.id) ? globalThis.String(object.id) : "",
task: isSet(object.task) ? globalThis.String(object.task) : "",
ts: isSet(object.ts) ? globalThis.Number(object.ts) : 0,
isFavorited: isSet(object.isFavorited) ? globalThis.Boolean(object.isFavorited) : false,
size: isSet(object.size) ? globalThis.Number(object.size) : 0,
totalCost: isSet(object.totalCost) ? globalThis.Number(object.totalCost) : 0,
tokensIn: isSet(object.tokensIn) ? globalThis.Number(object.tokensIn) : 0,
tokensOut: isSet(object.tokensOut) ? globalThis.Number(object.tokensOut) : 0,
cacheWrites: isSet(object.cacheWrites) ? globalThis.Number(object.cacheWrites) : 0,
cacheReads: isSet(object.cacheReads) ? globalThis.Number(object.cacheReads) : 0,
}
},
toJSON(message: TaskItem): unknown {
const obj: any = {}
if (message.id !== "") {
obj.id = message.id
}
if (message.task !== "") {
obj.task = message.task
}
if (message.ts !== 0) {
obj.ts = Math.round(message.ts)
}
if (message.isFavorited !== false) {
obj.isFavorited = message.isFavorited
}
if (message.size !== 0) {
obj.size = Math.round(message.size)
}
if (message.totalCost !== 0) {
obj.totalCost = message.totalCost
}
if (message.tokensIn !== 0) {
obj.tokensIn = Math.round(message.tokensIn)
}
if (message.tokensOut !== 0) {
obj.tokensOut = Math.round(message.tokensOut)
}
if (message.cacheWrites !== 0) {
obj.cacheWrites = Math.round(message.cacheWrites)
}
if (message.cacheReads !== 0) {
obj.cacheReads = Math.round(message.cacheReads)
}
return obj
},
create<I extends Exact<DeepPartial<TaskItem>, I>>(base?: I): TaskItem {
return TaskItem.fromPartial(base ?? ({} as any))
},
fromPartial<I extends Exact<DeepPartial<TaskItem>, I>>(object: I): TaskItem {
const message = createBaseTaskItem()
message.id = object.id ?? ""
message.task = object.task ?? ""
message.ts = object.ts ?? 0
message.isFavorited = object.isFavorited ?? false
message.size = object.size ?? 0
message.totalCost = object.totalCost ?? 0
message.tokensIn = object.tokensIn ?? 0
message.tokensOut = object.tokensOut ?? 0
message.cacheWrites = object.cacheWrites ?? 0
message.cacheReads = object.cacheReads ?? 0
return message
},
}
export type TaskServiceDefinition = typeof TaskServiceDefinition
export const TaskServiceDefinition = {
name: "TaskService",
@@ -156,7 +995,7 @@ export const TaskServiceDefinition = {
name: "showTaskWithId",
requestType: StringRequest,
requestStream: false,
responseType: Empty,
responseType: TaskResponse,
responseStream: false,
options: {},
},
@@ -169,6 +1008,33 @@ export const TaskServiceDefinition = {
responseStream: false,
options: {},
},
/** Toggles the favorite status of a task */
toggleTaskFavorite: {
name: "toggleTaskFavorite",
requestType: TaskFavoriteRequest,
requestStream: false,
responseType: Empty,
responseStream: false,
options: {},
},
/** Deletes all non-favorited tasks */
deleteNonFavoritedTasks: {
name: "deleteNonFavoritedTasks",
requestType: EmptyRequest,
requestStream: false,
responseType: DeleteNonFavoritedTasksResults,
responseStream: false,
options: {},
},
/** Gets filtered task history */
getTaskHistory: {
name: "getTaskHistory",
requestType: GetTaskHistoryRequest,
requestStream: false,
responseType: TaskHistoryArray,
responseStream: false,
options: {},
},
},
} as const
@@ -189,6 +1055,17 @@ export type Exact<P, I extends P> = P extends Builtin
? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never }
function longToNumber(int64: { toString(): string }): number {
const num = globalThis.Number(int64.toString())
if (num > globalThis.Number.MAX_SAFE_INTEGER) {
throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER")
}
if (num < globalThis.Number.MIN_SAFE_INTEGER) {
throw new globalThis.Error("Value is smaller than Number.MIN_SAFE_INTEGER")
}
return num
}
function isSet(value: any): boolean {
return value !== null && value !== undefined
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, type ReactNode } from "react"
import { PostHogProvider } from "posthog-js/react"
import posthog from "posthog-js"
import { posthogConfig } from "@shared/services/config/posthog-config"
import { useExtensionState } from "./context/ExtensionStateContext"
export function CustomPostHogProvider({ children }: { children: ReactNode }) {
const { telemetrySetting } = useExtensionState()
const isTelemetryEnabled = telemetrySetting === "enabled"
useEffect(() => {
if (isTelemetryEnabled) {
posthog.init(posthogConfig.apiKey, {
api_host: posthogConfig.host,
autocapture: false,
disable_session_recording: true,
})
} else {
posthog.opt_out_capturing()
}
}, [isTelemetryEnabled])
return <PostHogProvider client={posthog}>{children}</PostHogProvider>
}
+4 -11
View File
@@ -1,25 +1,18 @@
import type { ReactNode } from "react"
import { type ReactNode } from "react"
import { ExtensionStateContextProvider } from "./context/ExtensionStateContext"
import { FirebaseAuthProvider } from "./context/FirebaseAuthContext"
import { HeroUIProvider } from "@heroui/react"
import { PostHogProvider } from "posthog-js/react"
import { posthogConfig } from "@shared/services/config/posthog-config"
import posthog from "posthog-js"
posthog.init(posthogConfig.apiKey, {
api_host: posthogConfig.host,
autocapture: false,
})
import { CustomPostHogProvider } from "./CustomPostHogProvider"
export function Providers({ children }: { children: ReactNode }) {
return (
<ExtensionStateContextProvider>
<PostHogProvider client={posthog}>
<CustomPostHogProvider>
<FirebaseAuthProvider>
<HeroUIProvider>{children}</HeroUIProvider>
</FirebaseAuthProvider>
</PostHogProvider>
</CustomPostHogProvider>
</ExtensionStateContextProvider>
)
}
+101 -40
View File
@@ -1,4 +1,4 @@
import { VSCodeBadge, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
import { VSCodeBadge, VSCodeProgressRing, VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import deepEqual from "fast-deep-equal"
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, MouseEvent } from "react"
@@ -20,6 +20,62 @@ import { findMatchingResourceOrTemplate, getMcpServerDisplayName } from "@/utils
import { vscode } from "@/utils/vscode"
import { FileServiceClient } from "@/services/grpc-client"
import { CheckmarkControl } from "@/components/common/CheckmarkControl"
interface CopyButtonProps {
textToCopy: string | undefined
}
const CopyButtonStyled = styled(VSCodeButton)`
position: absolute;
bottom: 2px;
right: 2px;
z-index: 1;
opacity: 0;
`
interface WithCopyButtonProps {
children: React.ReactNode
textToCopy?: string
style?: React.CSSProperties
ref?: React.Ref<HTMLDivElement>
onMouseUp?: (event: MouseEvent<HTMLDivElement>) => void
}
const StyledContainer = styled.div`
position: relative;
&:hover ${CopyButtonStyled} {
opacity: 1;
}
`
const WithCopyButton = React.forwardRef<HTMLDivElement, WithCopyButtonProps>(
({ children, textToCopy, style, onMouseUp, ...props }, ref) => {
const [copied, setCopied] = useState(false)
const handleCopy = () => {
if (!textToCopy) return
navigator.clipboard.writeText(textToCopy).then(() => {
setCopied(true)
setTimeout(() => {
setCopied(false)
}, 1500)
})
}
return (
<StyledContainer ref={ref} onMouseUp={onMouseUp} style={style} {...props}>
{children}
{textToCopy && (
<CopyButtonStyled appearance="icon" onClick={handleCopy} aria-label={copied ? "Copied" : "Copy"}>
<span className={`codicon codicon-${copied ? "check" : "copy"}`}></span>
</CopyButtonStyled>
)}
</StyledContainer>
)
},
)
import { CheckpointControls, CheckpointOverlay } from "../common/CheckpointControls"
import CodeAccordian, { cleanPathPrefix } from "../common/CodeAccordian"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
@@ -90,6 +146,7 @@ const Markdown = memo(({ markdown }: { markdown?: string }) => {
overflowWrap: "anywhere",
marginBottom: -15,
marginTop: -15,
overflow: "hidden", // contain child margins so that parent diff matches height of children
}}>
<MarkdownBlock markdown={markdown} />
</div>
@@ -901,7 +958,7 @@ export const ChatRowContent = ({
return <McpResponseDisplay responseText={message.text || ""} />
case "text":
return (
<div ref={contentRef} onMouseUp={handleMouseUp} style={{ position: "relative" }}>
<WithCopyButton ref={contentRef} onMouseUp={handleMouseUp} textToCopy={message.text}>
<Markdown markdown={message.text} />
{quoteButtonState.visible && (
<QuoteButton
@@ -912,7 +969,7 @@ export const ChatRowContent = ({
}}
/>
)}
</div>
</WithCopyButton>
)
case "reasoning":
return (
@@ -1132,13 +1189,13 @@ export const ChatRowContent = ({
}}
/>
</div>
<div
ref={contentRef} // Added ref
onMouseUp={handleMouseUp} // Added handler
<WithCopyButton
ref={contentRef}
onMouseUp={handleMouseUp}
textToCopy={text}
style={{
color: "var(--vscode-charts-green)",
paddingTop: 10,
position: "relative", // Added position
}}>
<Markdown markdown={text} />
{quoteButtonState.visible && (
@@ -1148,7 +1205,7 @@ export const ChatRowContent = ({
onClick={handleQuoteClick}
/>
)}
</div>
</WithCopyButton>
{message.partial !== true && hasChanges && (
<div style={{ paddingTop: 17 }}>
<SuccessButton
@@ -1295,13 +1352,13 @@ export const ChatRowContent = ({
}}
/>
</div>
<div
ref={contentRef} // Added ref
onMouseUp={handleMouseUp} // Added handler
<WithCopyButton
ref={contentRef}
onMouseUp={handleMouseUp}
textToCopy={text}
style={{
color: "var(--vscode-charts-green)",
paddingTop: 10,
position: "relative", // Added position
}}>
<Markdown markdown={text} />
{quoteButtonState.visible && (
@@ -1311,30 +1368,30 @@ export const ChatRowContent = ({
onClick={handleQuoteClick}
/>
)}
{message.partial !== true && hasChanges && (
<div style={{ marginTop: 15 }}>
<SuccessButton
appearance="secondary"
disabled={seeNewChangesDisabled}
onClick={() => {
setSeeNewChangesDisabled(true)
vscode.postMessage({
type: "taskCompletionViewChanges",
number: message.ts,
})
}}>
<i
className="codicon codicon-new-file"
style={{
marginRight: 6,
cursor: seeNewChangesDisabled ? "wait" : "pointer",
}}
/>
See new changes
</SuccessButton>
</div>
)}
</div>
</WithCopyButton>
{message.partial !== true && hasChanges && (
<div style={{ marginTop: 15 }}>
<SuccessButton
appearance="secondary"
disabled={seeNewChangesDisabled}
onClick={() => {
setSeeNewChangesDisabled(true)
vscode.postMessage({
type: "taskCompletionViewChanges",
number: message.ts,
})
}}>
<i
className="codicon codicon-new-file"
style={{
marginRight: 6,
cursor: seeNewChangesDisabled ? "wait" : "pointer",
}}
/>
See new changes
</SuccessButton>
</div>
)}
</div>
)
} else {
@@ -1362,7 +1419,11 @@ export const ChatRowContent = ({
{title}
</div>
)}
<div ref={contentRef} onMouseUp={handleMouseUp} style={{ position: "relative", paddingTop: 10 }}>
<WithCopyButton
ref={contentRef}
onMouseUp={handleMouseUp}
textToCopy={question}
style={{ paddingTop: 10 }}>
<Markdown markdown={question} />
<OptionsButtons
options={options}
@@ -1379,7 +1440,7 @@ export const ChatRowContent = ({
}}
/>
)}
</div>
</WithCopyButton>
</>
)
case "new_task":
@@ -1428,7 +1489,7 @@ export const ChatRowContent = ({
response = message.text
}
return (
<div ref={contentRef} onMouseUp={handleMouseUp} style={{ position: "relative" }}>
<WithCopyButton ref={contentRef} onMouseUp={handleMouseUp} textToCopy={response}>
<Markdown markdown={response} />
<OptionsButtons
options={options}
@@ -1445,7 +1506,7 @@ export const ChatRowContent = ({
}}
/>
)}
</div>
</WithCopyButton>
)
}
default:
+98 -19
View File
@@ -40,11 +40,29 @@ import { ChatSettings } from "@shared/ChatSettings"
import ServersToggleModal from "./ServersToggleModal"
import ClineRulesToggleModal from "../cline-rules/ClineRulesToggleModal"
const getImageDimensions = (dataUrl: string): Promise<{ width: number; height: number }> => {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
if (img.naturalWidth > 7500 || img.naturalHeight > 7500) {
reject(new Error("Image dimensions exceed maximum allowed size of 7500px."))
} else {
resolve({ width: img.naturalWidth, height: img.naturalHeight })
}
}
img.onerror = (err) => {
console.error("Failed to load image for dimension check:", err)
reject(new Error("Failed to load image to check dimensions."))
}
img.src = dataUrl
})
}
interface ChatTextAreaProps {
inputValue: string
activeQuote: string | null
setInputValue: (value: string) => void
textAreaDisabled: boolean
sendingDisabled: boolean
placeholderText: string
selectedImages: string[]
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>
@@ -229,7 +247,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
inputValue,
activeQuote,
setInputValue,
textAreaDisabled,
sendingDisabled,
placeholderText,
selectedImages,
setSelectedImages,
@@ -276,6 +294,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const shiftHoldTimerRef = useRef<NodeJS.Timeout | null>(null)
const [showUnsupportedFileError, setShowUnsupportedFileError] = useState(false)
const unsupportedFileTimerRef = useRef<NodeJS.Timeout | null>(null)
const [showDimensionError, setShowDimensionError] = useState(false)
const dimensionErrorTimerRef = useRef<NodeJS.Timeout | null>(null)
const [fileSearchResults, setFileSearchResults] = useState<SearchResult[]>([])
const [searchLoading, setSearchLoading] = useState(false)
@@ -519,8 +539,11 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const isComposing = event.nativeEvent?.isComposing ?? false
if (event.key === "Enter" && !event.shiftKey && !isComposing) {
event.preventDefault()
setIsTextAreaFocused(false)
onSend()
if (!sendingDisabled) {
setIsTextAreaFocused(false)
onSend()
}
}
if (event.key === "Backspace" && !isComposing) {
@@ -604,6 +627,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
selectedSlashCommandsIndex,
slashCommandsQuery,
handleSlashCommandsSelect,
sendingDisabled,
],
)
@@ -729,6 +753,17 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
onFocusChange?.(false) // Call prop on blur
}, [isMouseDownOnMenu, onFocusChange])
const showDimensionErrorMessage = useCallback(() => {
setShowDimensionError(true)
if (dimensionErrorTimerRef.current) {
clearTimeout(dimensionErrorTimerRef.current)
}
dimensionErrorTimerRef.current = setTimeout(() => {
setShowDimensionError(false)
dimensionErrorTimerRef.current = null
}, 3000)
}, [])
const handlePaste = useCallback(
async (e: React.ClipboardEvent) => {
const items = e.clipboardData.items
@@ -774,13 +809,24 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
return
}
const reader = new FileReader()
reader.onloadend = () => {
reader.onloadend = async () => {
if (reader.error) {
console.error("Error reading file:", reader.error)
resolve(null)
} else {
const result = reader.result
resolve(typeof result === "string" ? result : null)
if (typeof result === "string") {
try {
await getImageDimensions(result)
resolve(result)
} catch (error) {
console.warn((error as Error).message)
showDimensionErrorMessage()
resolve(null)
}
} else {
resolve(null)
}
}
}
reader.readAsDataURL(blob)
@@ -796,7 +842,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
}
},
[shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue],
[shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue, showDimensionErrorMessage],
)
const handleThumbnailsHeightChange = useCallback((height: number) => {
@@ -916,8 +962,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
useShortcut("Meta+Shift+a", onModeToggle, { disableTextInputs: false }) // important that we don't disable the text input here
const handleContextButtonClick = useCallback(() => {
if (textAreaDisabled) return
// Focus the textarea first
textAreaRef.current?.focus()
@@ -956,7 +1000,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
} as React.ChangeEvent<HTMLTextAreaElement>
handleInputChange(event)
updateHighlights()
}, [inputValue, textAreaDisabled, handleInputChange, updateHighlights])
}, [inputValue, handleInputChange, updateHighlights])
// Use an effect to detect menu close
useEffect(() => {
@@ -1228,13 +1272,25 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
(file) =>
new Promise<string | null>((resolve) => {
const reader = new FileReader()
reader.onloadend = () => {
reader.onloadend = async () => {
// Make async
if (reader.error) {
console.error("Error reading file:", reader.error)
resolve(null)
} else {
const result = reader.result
resolve(typeof result === "string" ? result : null)
if (typeof result === "string") {
try {
await getImageDimensions(result) // Check dimensions
resolve(result)
} catch (error) {
console.warn((error as Error).message)
showDimensionErrorMessage() // Show error to user
resolve(null) // Don't add this image
}
} else {
resolve(null)
}
}
}
reader.readAsDataURL(file)
@@ -1248,7 +1304,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
<div
style={{
padding: "10px 15px",
opacity: textAreaDisabled ? 0.5 : 1,
opacity: 1,
position: "relative",
display: "flex",
// Drag-over styles moved to DynamicTextArea
@@ -1258,6 +1314,31 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
onDragOver={onDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}>
{showDimensionError && (
<div
style={{
position: "absolute",
inset: "10px 15px",
backgroundColor: "rgba(var(--vscode-errorForeground-rgb), 0.1)",
border: "2px solid var(--vscode-errorForeground)",
borderRadius: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 10, // Ensure it's above other elements
pointerEvents: "none",
}}>
<span
style={{
color: "var(--vscode-errorForeground)",
fontWeight: "bold",
fontSize: "12px",
textAlign: "center",
}}>
Image dimensions exceed 7500px
</span>
</div>
)}
{showUnsupportedFileError && (
<div
style={{
@@ -1358,7 +1439,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
textAreaRef.current = el
}}
value={inputValue}
disabled={textAreaDisabled}
onChange={(e) => {
handleInputChange(e)
updateHighlights()
@@ -1379,7 +1459,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
onHeightChange?.(height)
}}
placeholder={showUnsupportedFileError ? "" : placeholderText}
placeholder={showUnsupportedFileError || showDimensionError ? "" : placeholderText}
maxRows={10}
autoFocus={true}
style={{
@@ -1408,7 +1488,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
// 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,
cursor: "text",
flex: 1,
zIndex: 1,
outline:
@@ -1466,9 +1546,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
/> */}
<div
data-testid="send-button"
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`}
className={`input-icon-button ${sendingDisabled ? "disabled" : ""} codicon codicon-send`}
onClick={() => {
if (!textAreaDisabled) {
if (!sendingDisabled) {
setIsTextAreaFocused(false)
onSend()
}
@@ -1504,7 +1584,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
data-testid="context-button"
appearance="icon"
aria-label="Add Context"
disabled={textAreaDisabled}
onClick={handleContextButtonClick}
style={{ padding: "0px 0px", height: "20px" }}>
<ButtonContainer>
+53 -30
View File
@@ -41,6 +41,27 @@ interface ChatViewProps {
showHistoryView: () => void
}
// Function to clean up markdown escape characters
function cleanupMarkdownEscapes(markdown: string): string {
return (
markdown
// Handle underscores and asterisks (single or multiple)
.replace(/\\([_*]+)/g, "$1")
// Handle angle brackets (for generics and XML)
.replace(/\\([<>])/g, "$1")
// Handle backticks (for code)
.replace(/\\(`)/g, "$1")
// Handle other common markdown special characters
.replace(/\\([[\]()#.!])/g, "$1")
// Fix multiple consecutive backslashes
.replace(/\\{2,}([_*`<>[\]()#.!])/g, "$1")
)
}
async function convertHtmlToMarkdown(html: string) {
// Process the HTML to Markdown
const result = await unified()
@@ -55,10 +76,14 @@ async function convertHtmlToMarkdown(html: string) {
rule: "-", // Use - for horizontal rules
ruleSpaces: false, // No spaces in horizontal rules
fences: true,
escape: false,
entities: false,
})
.process(html)
return String(result)
const md = String(result)
// Apply comprehensive cleanup of escape characters
return cleanupMarkdownEscapes(md)
}
export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
@@ -90,7 +115,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const [activeQuote, setActiveQuote] = useState<string | null>(null)
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const [textAreaDisabled, setTextAreaDisabled] = useState(false)
const [sendingDisabled, setSendingDisabled] = useState(false)
const [selectedImages, setSelectedImages] = useState<string[]>([])
// we need to hold on to the ask because useEffect > lastMessage will always let us know when an ask comes in and handle it, but by the time handleMessage is called, the last message might not be the ask anymore (it could be a say that followed)
@@ -120,7 +145,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// Convert HTML to Markdown
const markdown = await convertHtmlToMarkdown(selectedHtml)
vscode.postMessage({ type: "copyToClipboard", text: markdown })
e.preventDefault()
}
@@ -147,42 +171,42 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const isPartial = lastMessage.partial === true
switch (lastMessage.ask) {
case "api_req_failed":
setTextAreaDisabled(true)
setSendingDisabled(true)
setClineAsk("api_req_failed")
setEnableButtons(true)
setPrimaryButtonText("Retry")
setSecondaryButtonText("Start New Task")
break
case "mistake_limit_reached":
setTextAreaDisabled(false)
setSendingDisabled(false)
setClineAsk("mistake_limit_reached")
setEnableButtons(true)
setPrimaryButtonText("Proceed Anyways")
setSecondaryButtonText("Start New Task")
break
case "auto_approval_max_req_reached":
setTextAreaDisabled(true)
setSendingDisabled(true)
setClineAsk("auto_approval_max_req_reached")
setEnableButtons(true)
setPrimaryButtonText("Proceed")
setSecondaryButtonText("Start New Task")
break
case "followup":
setTextAreaDisabled(isPartial)
setSendingDisabled(isPartial)
setClineAsk("followup")
setEnableButtons(false)
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
break
case "plan_mode_respond":
setTextAreaDisabled(isPartial)
setSendingDisabled(isPartial)
setClineAsk("plan_mode_respond")
setEnableButtons(false)
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
break
case "tool":
setTextAreaDisabled(isPartial)
setSendingDisabled(isPartial)
setClineAsk("tool")
setEnableButtons(!isPartial)
const tool = JSON.parse(lastMessage.text || "{}") as ClineSayTool
@@ -199,28 +223,28 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}
break
case "browser_action_launch":
setTextAreaDisabled(isPartial)
setSendingDisabled(isPartial)
setClineAsk("browser_action_launch")
setEnableButtons(!isPartial)
setPrimaryButtonText("Approve")
setSecondaryButtonText("Reject")
break
case "command":
setTextAreaDisabled(isPartial)
setSendingDisabled(isPartial)
setClineAsk("command")
setEnableButtons(!isPartial)
setPrimaryButtonText("Run Command")
setSecondaryButtonText("Reject")
break
case "command_output":
setTextAreaDisabled(false)
setSendingDisabled(false)
setClineAsk("command_output")
setEnableButtons(true)
setPrimaryButtonText("Proceed While Running")
setSecondaryButtonText(undefined)
break
case "use_mcp_server":
setTextAreaDisabled(isPartial)
setSendingDisabled(isPartial)
setClineAsk("use_mcp_server")
setEnableButtons(!isPartial)
setPrimaryButtonText("Approve")
@@ -228,14 +252,14 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
break
case "completion_result":
// extension waiting for feedback. but we can just present a new task button
setTextAreaDisabled(isPartial)
setSendingDisabled(isPartial)
setClineAsk("completion_result")
setEnableButtons(!isPartial)
setPrimaryButtonText("Start New Task")
setSecondaryButtonText(undefined)
break
case "resume_task":
setTextAreaDisabled(false)
setSendingDisabled(false)
setClineAsk("resume_task")
setEnableButtons(true)
setPrimaryButtonText("Resume Task")
@@ -243,7 +267,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setDidClickCancel(false) // special case where we reset the cancel button state
break
case "resume_completed_task":
setTextAreaDisabled(false)
setSendingDisabled(false)
setClineAsk("resume_completed_task")
setEnableButtons(true)
setPrimaryButtonText("Start New Task")
@@ -251,14 +275,14 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setDidClickCancel(false)
break
case "new_task":
setTextAreaDisabled(isPartial)
setSendingDisabled(isPartial)
setClineAsk("new_task")
setEnableButtons(!isPartial)
setPrimaryButtonText("Start New Task with Context")
setSecondaryButtonText(undefined)
break
case "condense":
setTextAreaDisabled(isPartial)
setSendingDisabled(isPartial)
setClineAsk("condense")
setEnableButtons(!isPartial)
setPrimaryButtonText("Condense Conversation")
@@ -273,7 +297,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
if (secondLastMessage?.ask === "command_output") {
// if the last ask is a command_output, and we receive an api_req_started, then that means the command has finished and we don't need input from the user anymore (in every other case, the user has to interact with input field or buttons to continue, which does the following automatically)
setInputValue("")
setTextAreaDisabled(true)
setSendingDisabled(true)
setSelectedImages([])
setClineAsk(undefined)
setEnableButtons(false)
@@ -310,7 +334,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
useEffect(() => {
if (messages.length === 0) {
setTextAreaDisabled(false)
setSendingDisabled(false)
setClineAsk(undefined)
setEnableButtons(false)
setPrimaryButtonText("Approve")
@@ -397,7 +421,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}
setInputValue("")
setActiveQuote(null) // Clear quote when sending message
setTextAreaDisabled(true)
setSendingDisabled(true)
setSelectedImages([])
setClineAsk(undefined)
setEnableButtons(false)
@@ -467,7 +491,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
})
break
}
setTextAreaDisabled(true)
setSendingDisabled(true)
setClineAsk(undefined)
setEnableButtons(false)
// setPrimaryButtonText(undefined)
@@ -516,7 +540,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSelectedImages([])
break
}
setTextAreaDisabled(true)
setSendingDisabled(true)
setClineAsk(undefined)
setEnableButtons(false)
// setPrimaryButtonText(undefined)
@@ -542,8 +566,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
vscode.postMessage({ type: "selectImages" })
}, [])
const shouldDisableImages =
!selectedModelInfo.supportsImages || textAreaDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE
const shouldDisableImages = !selectedModelInfo.supportsImages || selectedImages.length >= MAX_IMAGES_PER_MESSAGE
const handleMessage = useCallback(
(e: MessageEvent) => {
@@ -552,7 +575,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
case "action":
switch (message.action!) {
case "didBecomeVisible":
if (!isHidden && !textAreaDisabled && !enableButtons) {
if (!isHidden && !sendingDisabled && !enableButtons) {
textAreaRef.current?.focus()
}
break
@@ -601,7 +624,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}
// textAreaRef.current is not explicitly required here since react guarantees that ref will be stable across re-renders, and we're not using its value but its reference.
},
[isHidden, textAreaDisabled, enableButtons, handleSendMessage, handlePrimaryButtonClick, handleSecondaryButtonClick],
[isHidden, sendingDisabled, enableButtons, handleSendMessage, handlePrimaryButtonClick, handleSecondaryButtonClick],
)
useEvent("message", handleMessage)
@@ -613,14 +636,14 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
useEffect(() => {
const timer = setTimeout(() => {
if (!isHidden && !textAreaDisabled && !enableButtons) {
if (!isHidden && !sendingDisabled && !enableButtons) {
textAreaRef.current?.focus()
}
}, 50)
return () => {
clearTimeout(timer)
}
}, [isHidden, textAreaDisabled, enableButtons])
}, [isHidden, sendingDisabled, enableButtons])
const visibleMessages = useMemo(() => {
return modifiedMessages.filter((message) => {
@@ -1096,7 +1119,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
activeQuote={activeQuote}
inputValue={inputValue}
setInputValue={setInputValue}
textAreaDisabled={textAreaDisabled}
sendingDisabled={sendingDisabled}
placeholderText={placeholderText}
selectedImages={selectedImages}
setSelectedImages={setSelectedImages}
+38 -2
View File
@@ -400,7 +400,10 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
</HeroTooltip>
</div>
{!shouldShowPromptCacheInfo() && (
<DeleteButton taskSize={formatSize(currentTaskItem?.size)} taskId={currentTaskItem?.id} />
<div className="flex items-center flex-wrap">
<CopyButton taskText={task.text} />
<DeleteButton taskSize={formatSize(currentTaskItem?.size)} taskId={currentTaskItem?.id} />
</div>
)}
</div>
{shouldShowPromptCacheInfo() && (
@@ -452,7 +455,10 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
</HeroTooltip>
)}
</div>
<DeleteButton taskSize={formatSize(currentTaskItem?.size)} taskId={currentTaskItem?.id} />
<div className="flex items-center flex-wrap">
<CopyButton taskText={task.text} />
<DeleteButton taskSize={formatSize(currentTaskItem?.size)} taskId={currentTaskItem?.id} />
</div>
</div>
)}
<div className="flex flex-col">
@@ -619,6 +625,36 @@ export const highlightText = (text?: string, withShadow = true) => {
return [text]
}
const CopyButton: React.FC<{
taskText?: string
}> = ({ taskText }) => {
const [copied, setCopied] = useState(false)
const handleCopy = () => {
if (!taskText) return
navigator.clipboard.writeText(taskText).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}
return (
<HeroTooltip content="Copy Task">
<VSCodeButton
appearance="icon"
onClick={handleCopy}
style={{ padding: "0px 0px" }}
className="p-0"
aria-label="Copy Task">
<div className="flex items-center gap-[3px] text-[8px] font-bold opacity-60">
<i className={`codicon codicon-${copied ? "check" : "copy"}`} />
</div>
</VSCodeButton>
</HeroTooltip>
)
}
const DeleteButton: React.FC<{
taskSize: string
taskId?: string
@@ -92,6 +92,18 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
{formatDate(item.ts)}
</span>
</div>
{item.isFavorited && (
<div
style={{
position: "absolute",
top: "12px",
right: "12px",
color: "var(--vscode-button-background)",
}}>
<span className="codicon codicon-star-full" aria-label="Favorited" />
</div>
)}
<div
style={{
fontSize: "var(--vscode-font-size)",
+185 -39
View File
@@ -24,6 +24,76 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
const [lastNonRelevantSort, setLastNonRelevantSort] = useState<SortOption | null>("newest")
const [deleteAllDisabled, setDeleteAllDisabled] = useState(false)
const [selectedItems, setSelectedItems] = useState<string[]>([])
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false)
// Keep track of pending favorite toggle operations
const [pendingFavoriteToggles, setPendingFavoriteToggles] = useState<Record<string, boolean>>({})
// Load filtered task history with gRPC
const [filteredTasks, setFilteredTasks] = useState<any[]>([])
// Load and refresh task history
const loadTaskHistory = useCallback(async () => {
try {
const response = await TaskServiceClient.getTaskHistory({
favoritesOnly: showFavoritesOnly,
searchQuery: searchQuery || undefined,
sortBy: sortOption,
})
setFilteredTasks(response.tasks || [])
} catch (error) {
console.error("Error loading task history:", error)
// Fallback to client-side filtering
setFilteredTasks(
taskHistory.filter((item) => {
const valid = item.ts && item.task
return valid && (!showFavoritesOnly || item.isFavorited)
}),
)
}
}, [showFavoritesOnly, searchQuery, sortOption, taskHistory])
// Load when filters change
useEffect(() => {
loadTaskHistory()
}, [loadTaskHistory])
const toggleFavorite = useCallback(
async (taskId: string, currentValue: boolean) => {
// Optimistic UI update
setPendingFavoriteToggles((prev) => ({ ...prev, [taskId]: !currentValue }))
try {
await TaskServiceClient.toggleTaskFavorite({
taskId,
isFavorited: !currentValue,
})
// Refresh if favorites filter is active
if (showFavoritesOnly) {
loadTaskHistory()
}
} catch (err) {
console.error(`[FAVORITE_TOGGLE_UI] Error for task ${taskId}:`, err)
// Revert optimistic update
setPendingFavoriteToggles((prev) => {
const updated = { ...prev }
delete updated[taskId]
return updated
})
} finally {
// Clean up pending state after 1 second
setTimeout(() => {
setPendingFavoriteToggles((prev) => {
const updated = { ...prev }
delete updated[taskId]
return updated
})
}, 1000)
}
},
[showFavoritesOnly, loadTaskHistory],
)
const handleMessage = useCallback((event: MessageEvent<ExtensionMessage>) => {
if (event.data.type === "relinquishControl") {
@@ -87,9 +157,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
.toUpperCase()
}, [])
const presentableTasks = useMemo(() => {
return taskHistory.filter((item) => item.ts && item.task)
}, [taskHistory])
const presentableTasks = useMemo(() => filteredTasks, [filteredTasks])
const fuse = useMemo(() => {
return new Fuse(presentableTasks, {
@@ -252,7 +320,48 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
<VSCodeRadio value="mostRelevant" disabled={!searchQuery} style={{ opacity: searchQuery ? 1 : 0.5 }}>
Most Relevant
</VSCodeRadio>
<div
onClick={() => setShowFavoritesOnly(!showFavoritesOnly)}
style={{
display: "flex",
alignItems: "center",
marginLeft: "6px",
cursor: "pointer",
}}>
<div
style={{
width: "14px",
height: "14px",
borderRadius: "50%",
border: "1px solid var(--vscode-checkbox-border)",
backgroundColor: showFavoritesOnly ? "var(--vscode-checkbox-background)" : "transparent",
position: "relative",
display: "flex",
justifyContent: "center",
alignItems: "center",
marginRight: "6px",
}}>
{showFavoritesOnly && (
<div
style={{
width: "6px",
height: "6px",
borderRadius: "50%",
backgroundColor: "var(--vscode-checkbox-foreground)",
}}
/>
)}
</div>
<span style={{ display: "flex", alignItems: "center", gap: "6px", userSelect: "none" }}>
<div
className="codicon codicon-star-full"
style={{ color: "var(--vscode-button-background)", fontSize: "14px" }}
/>
Favorites
</span>
</div>
</VSCodeRadioGroup>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "10px" }}>
<VSCodeButton
onClick={() => {
@@ -337,43 +446,80 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
}}>
{formatDate(item.ts)}
</span>
<VSCodeButton
appearance="icon"
onClick={(e) => {
e.stopPropagation()
handleDeleteHistoryItem(item.id)
}}
className="delete-button"
style={{ padding: "0px 0px" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "3px",
fontSize: "11px",
// fontWeight: "bold",
}}>
<span className="codicon codicon-trash"></span>
{formatSize(item.size)}
</div>
</VSCodeButton>
<div style={{ display: "flex", gap: "4px" }}>
{/* only show delete button if task not favorited */}
{!(pendingFavoriteToggles[item.id] ?? item.isFavorited) && (
<VSCodeButton
appearance="icon"
onClick={(e) => {
e.stopPropagation()
handleDeleteHistoryItem(item.id)
}}
className="delete-button"
style={{ padding: "0px 0px" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "3px",
fontSize: "11px",
}}>
<span className="codicon codicon-trash"></span>
{formatSize(item.size)}
</div>
</VSCodeButton>
)}
<VSCodeButton
appearance="icon"
onClick={(e) => {
e.stopPropagation()
toggleFavorite(item.id, item.isFavorited || false)
}}
style={{ padding: "0px" }}>
<div
className={`codicon ${
pendingFavoriteToggles[item.id] !== undefined
? pendingFavoriteToggles[item.id]
? "codicon-star-full"
: "codicon-star-empty"
: item.isFavorited
? "codicon-star-full"
: "codicon-star-empty"
}`}
style={{
color:
(pendingFavoriteToggles[item.id] ?? item.isFavorited)
? "var(--vscode-button-background)"
: "inherit",
opacity: (pendingFavoriteToggles[item.id] ?? item.isFavorited) ? 1 : 0.7,
display:
(pendingFavoriteToggles[item.id] ?? item.isFavorited)
? "block"
: undefined,
}}
/>
</VSCodeButton>
</div>
</div>
<div style={{ marginBottom: "8px", position: "relative" }}>
<div
style={{
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-foreground)",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflowWrap: "anywhere",
}}
dangerouslySetInnerHTML={{
__html: item.task,
}}
/>
</div>
<div
style={{
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-foreground)",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflowWrap: "anywhere",
}}
dangerouslySetInnerHTML={{
__html: item.task,
}}
/>
<div
style={{
display: "flex",
@@ -209,7 +209,15 @@ const ApiOptions = ({
setLmStudioModels([])
}
} else if (selectedProvider === "vscode-lm") {
vscode.postMessage({ type: "requestVsCodeLmModels" })
try {
const response = await ModelsServiceClient.getVsCodeLmModels({})
if (response && response.models) {
setVsCodeLmModels(response.models)
}
} catch (error) {
console.error("Failed to fetch VS Code LM models:", error)
setVsCodeLmModels([])
}
}
}, [selectedProvider, apiConfiguration?.ollamaBaseUrl, apiConfiguration?.lmStudioBaseUrl])
useEffect(() => {
@@ -222,14 +230,6 @@ const ApiOptions = ({
selectedProvider === "ollama" || selectedProvider === "lmstudio" || selectedProvider === "vscode-lm" ? 2000 : null,
)
const handleMessage = useCallback((event: MessageEvent) => {
const message: ExtensionMessage = event.data
if (message.type === "vsCodeLmModels" && message.vsCodeLmModels) {
setVsCodeLmModels(message.vsCodeLmModels)
}
}, [])
useEvent("message", handleMessage)
/*
VSCodeDropdown has an open bug where dynamically rendered options don't auto select the provided value prop. You can see this for yourself by comparing it with normal select/option elements, which work as expected.
https://github.com/microsoft/vscode-webview-ui-toolkit/issues/433
@@ -822,10 +822,7 @@ const ApiOptions = ({
color: "var(--vscode-descriptionForeground)",
}}>
Select "Custom" when using the Application Inference Profile in Bedrock. Enter the Application
Inference Profile ID in the Model ID field. However, be sure to encode the / in the ARN as %2F.
<br />
Example: arn:aws:bedrock:us-west-2:&lt;AWS Account
ID&gt;:application-inference-profile%2Fxxxxxxxxxxxx
Inference Profile ARN in the Model ID field.
</p>
<label htmlFor="bedrock-model-input">
<span style={{ fontWeight: 500 }}>Model ID</span>
@@ -10,7 +10,6 @@ import { useEvent } from "react-use"
import { ExtensionMessage } from "@shared/ExtensionMessage"
import BrowserSettingsSection from "./BrowserSettingsSection"
import TerminalSettingsSection from "./TerminalSettingsSection"
import { useFeatureFlag } from "@/hooks/useFeatureFlag"
import { FEATURE_FLAGS } from "@shared/services/feature-flags/feature-flags"
const { IS_DEV } = process.env
@@ -146,8 +145,6 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
handleSubmit(true)
}
const showCustomInstructions = useFeatureFlag(FEATURE_FLAGS.CUSTOM_INSTRUCTIONS)
return (
<div className="fixed top-0 left-0 right-0 bottom-0 pt-[10px] pr-0 pb-0 pl-5 flex flex-col overflow-hidden">
<div className="flex justify-between items-center mb-[13px] pr-[17px]">
@@ -186,24 +183,20 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
/>
)}
{showCustomInstructions && (
<div className="mb-[5px]">
<VSCodeTextArea
value={customInstructions ?? ""}
className="w-full"
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 ?? "")}>
<span className="font-medium">Custom Instructions</span>
</VSCodeTextArea>
<p className="text-xs mt-[5px] text-[var(--vscode-descriptionForeground)]">
These instructions are added to the end of the system prompt sent with every request.
</p>
</div>
)}
<div className="mb-[5px]">
<VSCodeTextArea
value={customInstructions ?? ""}
className="w-full"
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 ?? "")}>
<span className="font-medium">Custom Instructions</span>
</VSCodeTextArea>
<p className="text-xs mt-[5px] text-[var(--vscode-descriptionForeground)]">
These instructions are added to the end of the system prompt sent with every request.
</p>
</div>
<div className="mb-[5px]">
<VSCodeCheckbox
+17 -4
View File
@@ -1,10 +1,23 @@
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useFeatureFlagPayload } from "posthog-js/react"
export const useFeatureFlag = (flagName: string): boolean => {
const payload = useFeatureFlagPayload(flagName) as { enabled: boolean }
if (payload && payload.enabled) {
return payload.enabled
const { telemetrySetting } = useExtensionState()
try {
const payload = useFeatureFlagPayload(flagName) as { enabled: boolean }
if (payload && typeof payload === "object") {
// Check if the enabled property exists and is a boolean
if ("enabled" in payload && typeof payload.enabled === "boolean") {
return payload.enabled
}
}
if (telemetrySetting === "enabled") {
console.warn(`Feature flag ${flagName} not found or missing enabled property.`)
}
} catch (error) {
console.error(`Error retrieving feature flag "${flagName}":`, error)
}
console.warn(`Feature flag ${flagName} not found or missing enabled property.`)
return false
}
-2
View File
@@ -22,8 +22,6 @@ https://github.com/gitkraken/vscode-gitlens/blob/b1d71d4844523e8b2ef16f9e007068e
html {
height: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {