Compare commits

...
Author SHA1 Message Date
Saoud Rizwan f978ecce52 Prepare for release 2025-04-10 02:13:04 -07:00
Saoud Rizwan 4c28760557 fix: update ErrorService to use isEnabled() consistently 2025-04-10 02:07:00 -07:00
ba79a51dd7 Error Service Respects Telemetry (#2780)
* Refactor Service to be able to be disabled based on telemetry settings

* Add telemetryService enabled check

* Create wet-avocados-kiss.md

* Update src/services/error/ErrorService.ts

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

* Update src/services/error/ErrorService.ts

* Update src/services/error/ErrorService.ts

* Update src/services/error/ErrorService.ts

* Update src/services/error/ErrorService.ts

* Update src/services/error/ErrorService.ts

* Apply suggestions from code review

* Add comment about opt in

* Fixes

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-04-10 02:04:54 -07:00
Saoud RizwanandDennis Bartlett 4e5cc92065 Fixes issue where deleting tasks wasn't clearing the task metadata or context history files; let model recording fail gracefully (#2778)
* Fixes issue where deleting tasks wasn't clearing the task metadata or context history files; let model recording fail gracefully

* Create clean-boats-film.md

---------

Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>
2025-04-10 02:04:10 -07:00
Saoud Rizwan b42c0f2571 Use legacy context manager (#2786)
* Use legacy context manager

* Revert changes post new context management
2025-04-10 01:51:50 -07:00
Saoud Rizwan 386d5e41e7 Use line indicators for checkpoint markers (#2785)
* Use line indicators for checkpoint markers

* revert comment removal

* Create unlucky-dragons-fly.md
2025-04-10 01:07:42 -07:00
Dennis Bartlett 2823e6c845 Update list of models and set new defualt for xAI (#2777) 2025-04-09 17:32:35 -07:00
Dennis Bartlettandarafatkatze cef9af16a4 Remove stream options from XAIHandler and add new Grok-3 model variants to API configuration (#2776)
Co-authored-by: arafatkatze <arafat.da.khan@gmail.com>
2025-04-09 17:20:06 -07:00
Dennis Bartlett 49d3bcfedc Update sentry in extension to add traceability and version (#2775)
* Add Sentry package

* Setup Logger to use Error Service

* Enhance traceability and message information
2025-04-09 16:51:58 -07:00
pashpashpash d36a44ec38 Cline Task Server (#2773)
* added cline task server

* moving test server into separate file

* removed redundant test server in extension
2025-04-09 16:24:47 -07:00
Dennis Bartlettandellipsis-dev[bot] cbcf89d634 Add sentry to extension (#2766)
* Add Sentry package

* Setup Logger to use Error Service

* Update src/services/error/ErrorService.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-04-09 16:01:10 -07:00
pashpashpash 6a10e30436 linebreak (#2765) 2025-04-09 15:33:42 -07:00
21 changed files with 1739 additions and 1091 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
update ordering of messages during task restore
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
added IS_TEST build flag
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Include model and apiProvider in metadata for cline to read
+7 -1
View File
@@ -1,10 +1,16 @@
# Changelog
## [3.11.0]
- Redesign checkpoint UI to declutter chat view by using a subtle indicator line that expands to a popover on hover, with a new date indicator for when it was created
- Add support for xAI's provider's Grok 3 models
- Add more robust error tracking for users opted in to telemetry (thank you for helping us make Cline better!)
## [3.10.1]
- Add CMD+' keyboard shortcut to add selected text to Cline
- Cline now auto focuses the text field when using 'Add to Cline' shortcut
- Add new 'Create New Task' tool to let Cline start a new task autonomously!
- Add new 'Create New Task' tool to let Cline start a new task autonomously!
- Fix Mermaid diagram issues
- Fix Gemini provider cost calculation to take new tiered pricing structure into account
+78 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.10.0",
"version": "3.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.10.0",
"version": "3.10.1",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.12.4",
@@ -23,6 +23,7 @@
"@opentelemetry/sdk-node": "^0.39.1",
"@opentelemetry/sdk-trace-node": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.30.0",
"@sentry/browser": "^9.12.0",
"@vscode/codicons": "^0.0.36",
"axios": "^1.8.2",
"cheerio": "^1.0.0",
@@ -6856,6 +6857,81 @@
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
"license": "MIT"
},
"node_modules/@sentry-internal/browser-utils": {
"version": "9.12.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.12.0.tgz",
"integrity": "sha512-GXuDEG2Ix8DmVtTkjsItWdusk2CvJ6EPWKYVqFKifxt+IAT3ZbhGZd99Rg3wdRmt9xhCNuS4QrDzDTPMPgfdCw==",
"license": "MIT",
"dependencies": {
"@sentry/core": "9.12.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "9.12.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.12.0.tgz",
"integrity": "sha512-3+UxoT97QIXNSUQS4ATL1FFws0RkUb6PeaQN8CPndI6mFlqTW5tuVVLNg9Eo1seNg7R/dfk6WHCWrYN1NbFFKQ==",
"license": "MIT",
"dependencies": {
"@sentry/core": "9.12.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "9.12.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.12.0.tgz",
"integrity": "sha512-njEQosFeO/UX+gG+DMRANkPUuz6OIJLb+A1GVylhq9adUgFQydQ9Ay3v7/x1gMhdfHVP6Jeb27qkti0BWYbzBQ==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "9.12.0",
"@sentry/core": "9.12.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "9.12.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.12.0.tgz",
"integrity": "sha512-p8LuKZgWT/CoQBbDOXkSGjWWnc8WsnAayWgna8M/ZFWNITCNEM2rCuqZOyWOElIlrni+M7qoEA3jS7MZe8Ejxw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "9.12.0",
"@sentry/core": "9.12.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser": {
"version": "9.12.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.12.0.tgz",
"integrity": "sha512-4xQYoZqi+VVhNvlhWiwRd57+SMr3Og4sLjuayAA+zIp1Wx/bDcIld697cugLwml/BR+mVJI2eokkgh1CBl6zag==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "9.12.0",
"@sentry-internal/feedback": "9.12.0",
"@sentry-internal/replay": "9.12.0",
"@sentry-internal/replay-canvas": "9.12.0",
"@sentry/core": "9.12.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/core": {
"version": "9.12.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.12.0.tgz",
"integrity": "sha512-jOqQK/90uzHmsBvkPTj/DAEFvA5poX4ZRyC7LE1zjg4F5jdOp3+M4W3qCy0CkSTu88Zu5VWBoppCU2Bs34XEqg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
+2 -1
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.10.1",
"version": "3.11.0",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -375,6 +375,7 @@
"@opentelemetry/sdk-node": "^0.39.1",
"@opentelemetry/sdk-trace-node": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.30.0",
"@sentry/browser": "^9.12.0",
"@vscode/codicons": "^0.0.36",
"axios": "^1.8.2",
"cheerio": "^1.0.0",
-1
View File
@@ -24,7 +24,6 @@ export class XAIHandler implements ApiHandler {
temperature: 0,
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
stream_options: { include_usage: true },
})
for await (const chunk of stream) {
@@ -0,0 +1,834 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineApiReqInfo, ClineMessage } from "../../shared/ExtensionMessage"
import { ApiHandler } from "../../api"
import { getContextWindowInfo } from "./context-window-utils"
import { formatResponse } from "../prompts/responses"
import { GlobalFileNames } from "../storage/disk"
import { fileExistsAtPath } from "../../utils/fs"
import * as path from "path"
import fs from "fs/promises"
import cloneDeep from "clone-deep"
enum EditType {
UNDEFINED = 0,
NO_FILE_READ = 1,
READ_FILE_TOOL = 2,
ALTER_FILE_TOOL = 3,
FILE_MENTION = 4,
}
// array of string values allows us to cover all changes for message types currently supported
type MessageContent = string[]
type MessageMetadata = string[][]
// Type for a single context update
type ContextUpdate = [number, string, MessageContent, MessageMetadata] // [timestamp, updateType, update, metadata]
// Type for the serialized format of our nested maps
type SerializedContextHistory = Array<
[
number, // messageIndex
[
number, // EditType (message type)
Array<
[
number, // blockIndex
ContextUpdate[], // updates array (now with 4 elements including metadata)
]
>,
],
]
>
export class ContextManager {
// mapping from the apiMessages outer index to the inner message index to a list of actual changes, ordered by timestamp
// timestamp is required in order to support full checkpointing, where the changes we apply need to be able to be undone when
// moving to an earlier conversation history checkpoint - this ordering intuitively allows for binary search on truncation
// there is also a number stored for each (EditType) which defines which message type it is, for custom handling
// format: { outerIndex => [EditType, { innerIndex => [[timestamp, updateType, update], ...] }] }
// example: { 1 => { [0, 0 => [[<timestamp>, "text", "[NOTE] Some previous conversation history with the user has been removed ..."], ...] }] }
// the above example would be how we update the first assistant message to indicate we truncated text
private contextHistoryUpdates: Map<number, [number, Map<number, ContextUpdate[]>]>
constructor() {
this.contextHistoryUpdates = new Map()
}
/**
* public function for loading contextHistoryUpdates from disk, if it exists
*/
async initializeContextHistory(taskDirectory: string) {
this.contextHistoryUpdates = await this.getSavedContextHistory(taskDirectory)
}
/**
* get the stored context history updates from disk
*/
private async getSavedContextHistory(taskDirectory: string): Promise<Map<number, [number, Map<number, ContextUpdate[]>]>> {
try {
const filePath = path.join(taskDirectory, GlobalFileNames.contextHistory)
if (await fileExistsAtPath(filePath)) {
const data = await fs.readFile(filePath, "utf8")
const serializedUpdates = JSON.parse(data) as SerializedContextHistory
// Update to properly reconstruct the tuple structure
return new Map(
serializedUpdates.map(([messageIndex, [numberValue, innerMapArray]]) => [
messageIndex,
[numberValue, new Map(innerMapArray)],
]),
)
}
} catch (error) {
console.error("Failed to load context history:", error)
}
return new Map()
}
/**
* save the context history updates to disk
*/
private async saveContextHistory(taskDirectory: string) {
try {
const serializedUpdates: SerializedContextHistory = Array.from(this.contextHistoryUpdates.entries()).map(
([messageIndex, [numberValue, innerMap]]) => [messageIndex, [numberValue, Array.from(innerMap.entries())]],
)
await fs.writeFile(
path.join(taskDirectory, GlobalFileNames.contextHistory),
JSON.stringify(serializedUpdates),
"utf8",
)
} catch (error) {
console.error("Failed to save context history:", error)
}
}
/**
* primary entry point for getting up to date context & truncating when required
*/
async getNewContextMessagesAndMetadata(
apiConversationHistory: Anthropic.Messages.MessageParam[],
clineMessages: ClineMessage[],
api: ApiHandler,
conversationHistoryDeletedRange: [number, number] | undefined,
previousApiReqIndex: number,
taskDirectory: string,
) {
let updatedConversationHistoryDeletedRange = false
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
if (previousApiReqIndex >= 0) {
const previousRequest = clineMessages[previousApiReqIndex]
if (previousRequest && previousRequest.text) {
const timestamp = previousRequest.ts
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
const { maxAllowedSize } = getContextWindowInfo(api)
// This is the most reliable way to know when we're close to hitting the context window.
if (totalTokens >= maxAllowedSize) {
// Since the user may switch between models with different context windows, truncating half may not be enough (ie if switching from claude 200k to deepseek 64k, half truncation will only remove 100k tokens, but we need to remove much more)
// So if totalTokens/2 is greater than maxAllowedSize, we truncate 3/4 instead of 1/2
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
// we later check how many chars we trim to determine if we should still truncate history
let [anyContextUpdates, uniqueFileReadIndices] = this.applyContextOptimizations(
apiConversationHistory,
conversationHistoryDeletedRange ? conversationHistoryDeletedRange[1] + 1 : 2,
timestamp,
)
let needToTruncate = true
if (anyContextUpdates) {
// determine whether we've saved enough chars to not truncate
const charactersSavedPercentage = this.calculateContextOptimizationMetrics(
apiConversationHistory,
conversationHistoryDeletedRange,
uniqueFileReadIndices,
)
if (charactersSavedPercentage >= 0.3) {
needToTruncate = false
}
}
if (needToTruncate) {
// go ahead with truncation
anyContextUpdates = this.applyStandardContextTruncationNoticeChange(timestamp) || anyContextUpdates
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
conversationHistoryDeletedRange = this.getNextTruncationRange(
apiConversationHistory,
conversationHistoryDeletedRange,
keep,
)
updatedConversationHistoryDeletedRange = true
}
// if we alter the context history, save the updated version to disk
if (anyContextUpdates) {
await this.saveContextHistory(taskDirectory)
}
}
}
}
const truncatedConversationHistory = this.getAndAlterTruncatedMessages(
apiConversationHistory,
conversationHistoryDeletedRange,
)
return {
conversationHistoryDeletedRange: conversationHistoryDeletedRange,
updatedConversationHistoryDeletedRange: updatedConversationHistoryDeletedRange,
truncatedConversationHistory: truncatedConversationHistory,
}
}
/**
* get truncation range
*/
public getNextTruncationRange(
apiMessages: Anthropic.Messages.MessageParam[],
currentDeletedRange: [number, number] | undefined,
keep: "half" | "quarter",
): [number, number] {
// We always keep the first user-assistant pairing, and truncate an even number of messages from there
const rangeStartIndex = 2 // index 0 and 1 are kept
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 2 // inclusive starting index
let messagesToRemove: number
if (keep === "half") {
// Remove half of remaining user-assistant pairs
// We first calculate half of the messages then divide by 2 to get the number of pairs.
// After flooring, we multiply by 2 to get the number of messages.
// Note that this will also always be an even number.
messagesToRemove = Math.floor((apiMessages.length - startOfRest) / 4) * 2 // Keep even number
} else {
// Remove 3/4 of remaining user-assistant pairs
// We calculate 3/4ths of the messages then divide by 2 to get the number of pairs.
// After flooring, we multiply by 2 to get the number of messages.
// Note that this will also always be an even number.
messagesToRemove = Math.floor(((apiMessages.length - startOfRest) * 3) / 4 / 2) * 2
}
let rangeEndIndex = startOfRest + messagesToRemove - 1 // inclusive ending index
// Make sure that the last message being removed is a assistant message, so the next message after the initial user-assistant pair is an assistant message. This preserves the user-assistant-user-assistant structure.
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
if (apiMessages[rangeEndIndex].role !== "assistant") {
rangeEndIndex -= 1
}
// this is an inclusive range that will be removed from the conversation history
return [rangeStartIndex, rangeEndIndex]
}
/**
* external interface to support old calls
*/
public getTruncatedMessages(
messages: Anthropic.Messages.MessageParam[],
deletedRange: [number, number] | undefined,
): Anthropic.Messages.MessageParam[] {
return this.getAndAlterTruncatedMessages(messages, deletedRange)
}
/**
* apply all required truncation methods to the messages in context
*/
private getAndAlterTruncatedMessages(
messages: Anthropic.Messages.MessageParam[],
deletedRange: [number, number] | undefined,
): Anthropic.Messages.MessageParam[] {
if (messages.length <= 1) {
return messages
}
const updatedMessages = this.applyContextHistoryUpdates(messages, deletedRange ? deletedRange[1] + 1 : 2)
// OLD NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
return updatedMessages
}
/**
* applies deletedRange truncation and other alterations based on changes in this.contextHistoryUpdates
*/
private applyContextHistoryUpdates(
messages: Anthropic.Messages.MessageParam[],
startFromIndex: number,
): Anthropic.Messages.MessageParam[] {
// runtime is linear in length of user messages, if expecting a limited number of alterations, could be more optimal to loop over alterations
const firstChunk = messages.slice(0, 2) // get first user-assistant pair
const secondChunk = messages.slice(startFromIndex) // get remaining messages within context
const messagesToUpdate = [...firstChunk, ...secondChunk]
// we need the mapping from the local indices in messagesToUpdate to the global array of updates in this.contextHistoryUpdates
const originalIndices = [
...Array(2).keys(),
...Array(secondChunk.length)
.fill(0)
.map((_, i) => i + startFromIndex),
]
for (let arrayIndex = 0; arrayIndex < messagesToUpdate.length; arrayIndex++) {
const messageIndex = originalIndices[arrayIndex]
const innerTuple = this.contextHistoryUpdates.get(messageIndex)
if (!innerTuple) {
continue
}
// because we are altering this, we need a deep copy
messagesToUpdate[arrayIndex] = cloneDeep(messagesToUpdate[arrayIndex])
// Extract the map from the tuple
const innerMap = innerTuple[1]
for (const [blockIndex, changes] of innerMap) {
// apply the latest change among n changes - [timestamp, updateType, update]
const latestChange = changes[changes.length - 1]
if (latestChange[1] === "text") {
// only altering text for now
const message = messagesToUpdate[arrayIndex]
if (Array.isArray(message.content)) {
const block = message.content[blockIndex]
if (block && block.type === "text") {
block.text = latestChange[2][0]
}
}
}
}
}
return messagesToUpdate
}
/**
* removes all context history updates that occurred after the specified timestamp and saves to disk
*/
async truncateContextHistory(timestamp: number, taskDirectory: string): Promise<void> {
this.truncateContextHistoryAtTimestamp(this.contextHistoryUpdates, timestamp)
// save the modified context history to disk
await this.saveContextHistory(taskDirectory)
}
/**
* alters the context history to remove all alterations after a given timestamp
* removes the index if there are no alterations there anymore, both outer and inner indices
*/
private truncateContextHistoryAtTimestamp(
contextHistory: Map<number, [number, Map<number, ContextUpdate[]>]>,
timestamp: number,
): void {
for (const [messageIndex, [_, innerMap]] of contextHistory) {
// track which blockIndices to delete
const blockIndicesToDelete: number[] = []
// loop over the innerIndices of the messages in this block
for (const [blockIndex, updates] of innerMap) {
// updates ordered by timestamp, so find cutoff point by iterating from right to left
let cutoffIndex = updates.length - 1
while (cutoffIndex >= 0 && updates[cutoffIndex][0] > timestamp) {
cutoffIndex--
}
// If we found updates to remove
if (cutoffIndex < updates.length - 1) {
// Modify the array in place to keep only updates up to cutoffIndex
updates.length = cutoffIndex + 1
// If no updates left after truncation, mark this block for deletion
if (updates.length === 0) {
blockIndicesToDelete.push(blockIndex)
}
}
}
// Remove empty blocks from inner map
for (const blockIndex of blockIndicesToDelete) {
innerMap.delete(blockIndex)
}
// If inner map is now empty, remove the message index from outer map
if (innerMap.size === 0) {
contextHistory.delete(messageIndex)
}
}
}
/**
* applies the context optimization steps and returns whether any changes were made
*/
private applyContextOptimizations(
apiMessages: Anthropic.Messages.MessageParam[],
startFromIndex: number,
timestamp: number,
): [boolean, Set<number>] {
const [fileReadUpdatesBool, uniqueFileReadIndices] = this.findAndPotentiallySaveFileReadContextHistoryUpdates(
apiMessages,
startFromIndex,
timestamp,
)
// true if any context optimization steps alter state
const contextHistoryUpdated = fileReadUpdatesBool
return [contextHistoryUpdated, uniqueFileReadIndices]
}
/**
* if there is any truncation and there is no other alteration already set, alter the assistant message to indicate this occurred
*/
private applyStandardContextTruncationNoticeChange(timestamp: number): boolean {
if (!this.contextHistoryUpdates.has(1)) {
// first assistant message always at index 1
const innerMap = new Map<number, ContextUpdate[]>()
innerMap.set(0, [[timestamp, "text", [formatResponse.contextTruncationNotice()], []]])
this.contextHistoryUpdates.set(1, [0, innerMap]) // EditType is undefined for first assistant message
return true
}
return false
}
/**
* wraps the logic for determining file reads to overwrite, and altering state
* returns whether any updates were made (bool) and indices where updates were made
*/
private findAndPotentiallySaveFileReadContextHistoryUpdates(
apiMessages: Anthropic.Messages.MessageParam[],
startFromIndex: number,
timestamp: number,
): [boolean, Set<number>] {
const [fileReadIndices, messageFilePaths] = this.getPossibleDuplicateFileReads(apiMessages, startFromIndex)
return this.applyFileReadContextHistoryUpdates(fileReadIndices, messageFilePaths, apiMessages, timestamp)
}
/**
* generate a mapping from unique file reads from multiple tool calls to their outer index position(s)
* also return additional metadata to support multiple file reads in file mention text blocks
*/
private getPossibleDuplicateFileReads(
apiMessages: Anthropic.Messages.MessageParam[],
startFromIndex: number,
): [Map<string, [number, number, string, string][]>, Map<number, string[]>] {
// fileReadIndices: { fileName => [outerIndex, EditType, searchText, replaceText] }
// messageFilePaths: { outerIndex => [fileRead1, fileRead2, ..] }
// searchText in fileReadIndices is only required for file mention file-reads since there can be more than one file in the text
// searchText will be the empty string "" in the case that it's not required, for non-file mentions
// messageFilePaths is only used for file mentions as there can be multiple files read in the same text chunk
// for all text blocks per file, has info for updating the block
const fileReadIndices = new Map<string, [number, number, string, string][]>()
// for file mention text blocks, track all the unique files read
const messageFilePaths = new Map<number, string[]>()
for (let i = startFromIndex; i < apiMessages.length; i++) {
let thisExistingFileReads: string[] = []
if (this.contextHistoryUpdates.has(i)) {
const innerTuple = this.contextHistoryUpdates.get(i)
if (innerTuple) {
// safety check
const editType = innerTuple[0]
if (editType === EditType.FILE_MENTION) {
const innerMap = innerTuple[1]
const blockIndex = 1 // file mention blocks assumed to be at index 1
const blockUpdates = innerMap.get(blockIndex)
// if we have updated this text previously, we want to check whether the lists of files in the metadata are the same
if (blockUpdates && blockUpdates.length > 0) {
// the first list indicates the files we have replaced in this text, second list indicates all unique files in this text
// if they are equal then we have replaced all the files in this text already, and can ignore further processing
if (
blockUpdates[blockUpdates.length - 1][3][0].length ===
blockUpdates[blockUpdates.length - 1][3][1].length
) {
continue
}
// otherwise there are still file reads here we can overwrite, so still need to process this text chunk
// to do so we need to keep track of which files we've already replaced so we don't replace them again
else {
thisExistingFileReads = blockUpdates[blockUpdates.length - 1][3][0]
}
}
} else {
// for all other cases we can assume that we dont need to check this again
continue
}
}
}
const message = apiMessages[i]
if (message.role === "user" && Array.isArray(message.content) && message.content.length > 0) {
const firstBlock = message.content[0]
if (firstBlock.type === "text") {
const matchTup = this.parsePotentialToolCall(firstBlock.text)
let foundNormalFileRead = false
if (matchTup) {
if (matchTup[0] === "read_file") {
this.handleReadFileToolCall(i, matchTup[1], fileReadIndices)
foundNormalFileRead = true
} else if (matchTup[0] === "replace_in_file" || matchTup[0] === "write_to_file") {
if (message.content.length > 1) {
const secondBlock = message.content[1]
if (secondBlock.type === "text") {
this.handlePotentialFileChangeToolCalls(i, matchTup[1], secondBlock.text, fileReadIndices)
foundNormalFileRead = true
}
}
}
}
// file mentions can happen in most other user message blocks
if (!foundNormalFileRead) {
if (message.content.length > 1) {
const secondBlock = message.content[1]
if (secondBlock.type === "text") {
const [hasFileRead, filePaths] = this.handlePotentialFileMentionCalls(
i,
secondBlock.text,
fileReadIndices,
thisExistingFileReads, // file reads we've already replaced in this text in the latest version of this updated text
)
if (hasFileRead) {
messageFilePaths.set(i, filePaths) // all file paths in this string
}
}
}
}
}
}
}
return [fileReadIndices, messageFilePaths]
}
/**
* handles potential file content mentions in text blocks
* there will not be more than one of the same file read in a text block
*/
private handlePotentialFileMentionCalls(
i: number,
secondBlockText: string,
fileReadIndices: Map<string, [number, number, string, string][]>,
thisExistingFileReads: string[],
): [boolean, string[]] {
const pattern = new RegExp(`<file_content path="([^"]*)">([\\s\\S]*?)</file_content>`, "g")
let foundMatch = false
const filePaths: string[] = []
let match
while ((match = pattern.exec(secondBlockText)) !== null) {
foundMatch = true
const filePath = match[1]
filePaths.push(filePath) // we will record all unique paths from file mentions in this text
// we can assume that thisExistingFileReads does not have many entries
if (!thisExistingFileReads.includes(filePath)) {
// meaning we havent already replaced this file read
const entireMatch = match[0] // The entire matched string
// Create the replacement text - keep the tags but replace the content
const replacementText = `<file_content path="${filePath}">${formatResponse.duplicateFileReadNotice()}</file_content>`
const indices = fileReadIndices.get(filePath) || []
indices.push([i, EditType.FILE_MENTION, entireMatch, replacementText])
fileReadIndices.set(filePath, indices)
}
}
return [foundMatch, filePaths]
}
/**
* parses specific tool call formats, returns null if no acceptable format is found
*/
private parsePotentialToolCall(text: string): [string, string] | null {
const match = text.match(/^\[([^\s]+) for '([^']+)'\] Result:$/)
if (!match) {
return null
}
return [match[1], match[2]]
}
/**
* file_read tool call always pastes the file, so this is always a hit
*/
private handleReadFileToolCall(
i: number,
filePath: string,
fileReadIndices: Map<string, [number, number, string, string][]>,
) {
const indices = fileReadIndices.get(filePath) || []
indices.push([i, EditType.READ_FILE_TOOL, "", formatResponse.duplicateFileReadNotice()])
fileReadIndices.set(filePath, indices)
}
/**
* write_to_file and replace_in_file tool output are handled similarly
*/
private handlePotentialFileChangeToolCalls(
i: number,
filePath: string,
secondBlockText: string,
fileReadIndices: Map<string, [number, number, string, string][]>,
) {
const pattern = new RegExp(`(<final_file_content path="[^"]*">)[\\s\\S]*?(</final_file_content>)`)
// check if this exists in the text, it wont exist if the user rejects the file change for example
if (pattern.test(secondBlockText)) {
const replacementText = secondBlockText.replace(pattern, `$1 ${formatResponse.duplicateFileReadNotice()} $2`)
const indices = fileReadIndices.get(filePath) || []
indices.push([i, EditType.ALTER_FILE_TOOL, "", replacementText])
fileReadIndices.set(filePath, indices)
}
}
/**
* alter all occurrences of file read operations and track which messages were updated
* returns the outer index of messages we alter, to count number of changes
*/
private applyFileReadContextHistoryUpdates(
fileReadIndices: Map<string, [number, number, string, string][]>,
messageFilePaths: Map<number, string[]>,
apiMessages: Anthropic.Messages.MessageParam[],
timestamp: number,
): [boolean, Set<number>] {
let didUpdate = false
const updatedMessageIndices = new Set<number>() // track which messages we update on this round
const fileMentionUpdates = new Map<number, [string, string[]]>()
for (const [filePath, indices] of fileReadIndices.entries()) {
// Only process if there are multiple reads of the same file, else we will want to keep the latest read of the file
if (indices.length > 1) {
// Process all but the last index, as we will keep that instance of the file read
for (let i = 0; i < indices.length - 1; i++) {
const messageIndex = indices[i][0]
const messageType = indices[i][1] // EditType value
const searchText = indices[i][2] // search text (for file mentions, else empty string)
const messageString = indices[i][3] // what we will replace the string with
didUpdate = true
updatedMessageIndices.add(messageIndex)
// for single-fileread text we can set the updates here
// for potential multi-fileread text we need to determine all changes & iteratively update the text prior to saving the final change
if (messageType === EditType.FILE_MENTION) {
if (!fileMentionUpdates.has(messageIndex)) {
// Get base text either from existing updates or from apiMessages
let baseText = ""
let prevFilesReplaced: string[] = []
const innerTuple = this.contextHistoryUpdates.get(messageIndex)
if (innerTuple) {
const blockUpdates = innerTuple[1].get(1) // assumed index=1 for file mention filereads
if (blockUpdates && blockUpdates.length > 0) {
baseText = blockUpdates[blockUpdates.length - 1][2][0] // index 0 of MessageContent
prevFilesReplaced = blockUpdates[blockUpdates.length - 1][3][0] // previously overwritten file reads in this text
}
}
// can assume that this content will exist, otherwise it would not have been in fileReadIndices
const messageContent = apiMessages[messageIndex]?.content
if (!baseText && Array.isArray(messageContent) && messageContent.length > 1) {
const contentBlock = messageContent[1] // assume index=1 for all text to replace for file mention filereads
if (contentBlock.type === "text") {
baseText = contentBlock.text
}
}
// prevFilesReplaced keeps track of the previous file reads we've replace in this string, empty array if none
fileMentionUpdates.set(messageIndex, [baseText, prevFilesReplaced])
}
// Replace searchText with messageString for all file reads we need to replace in this text
if (searchText) {
const currentTuple = fileMentionUpdates.get(messageIndex) || ["", []]
if (currentTuple[0]) {
// safety check
// replace this text chunk
const updatedText = currentTuple[0].replace(searchText, messageString)
// add the newly added filePath read
const updatedFileReads = currentTuple[1]
updatedFileReads.push(filePath)
fileMentionUpdates.set(messageIndex, [updatedText, updatedFileReads])
}
}
} else {
let innerTuple = this.contextHistoryUpdates.get(messageIndex)
let innerMap: Map<number, ContextUpdate[]>
if (!innerTuple) {
innerMap = new Map<number, ContextUpdate[]>()
this.contextHistoryUpdates.set(messageIndex, [messageType, innerMap])
} else {
innerMap = innerTuple[1]
}
// block index for file reads from read_file, write_to_file, replace_in_file tools is 1
const blockIndex = 1
const updates = innerMap.get(blockIndex) || []
// metadata array is empty for non-file mention occurrences
updates.push([timestamp, "text", [messageString], []])
innerMap.set(blockIndex, updates)
}
}
}
}
// apply file mention updates to contextHistoryUpdates
// in fileMentionUpdates, filePathsUpdated includes all the file paths which are updated in the latest version of this altered text
for (const [messageIndex, [updatedText, filePathsUpdated]] of fileMentionUpdates.entries()) {
let innerTuple = this.contextHistoryUpdates.get(messageIndex)
let innerMap: Map<number, ContextUpdate[]>
if (!innerTuple) {
innerMap = new Map<number, ContextUpdate[]>()
this.contextHistoryUpdates.set(messageIndex, [EditType.FILE_MENTION, innerMap])
} else {
innerMap = innerTuple[1]
}
const blockIndex = 1 // we only consider the block index of 1 for file mentions
const updates = innerMap.get(blockIndex) || []
// filePathsUpdated includes changes done previously to this timestamp, and right now
if (messageFilePaths.has(messageIndex)) {
const allFileReads = messageFilePaths.get(messageIndex)
if (allFileReads) {
// safety check
// we gather all the file reads possible in this text from messageFilePaths
// filePathsUpdated from fileMentionUpdates stores all the files reads we have replaced now & previously
updates.push([timestamp, "text", [updatedText], [filePathsUpdated, allFileReads]])
innerMap.set(blockIndex, updates)
}
}
}
return [didUpdate, updatedMessageIndices]
}
/**
* count total characters in messages and total savings within this range
*/
private countCharactersAndSavingsInRange(
apiMessages: Anthropic.Messages.MessageParam[],
startIndex: number,
endIndex: number,
uniqueFileReadIndices: Set<number>,
): { totalCharacters: number; charactersSaved: number } {
let totalCharCount = 0
let totalCharactersSaved = 0
for (let i = startIndex; i < endIndex; i++) {
// looping over the outer indicies of messages
const message = apiMessages[i]
if (!message.content) {
continue
}
// hasExistingAlterations checks whether the outer idnex has any changes
// hasExistingAlterations will also include the alterations we just made
const hasExistingAlterations = this.contextHistoryUpdates.has(i)
const hasNewAlterations = uniqueFileReadIndices.has(i)
if (Array.isArray(message.content)) {
for (let blockIndex = 0; blockIndex < message.content.length; blockIndex++) {
// looping over inner indices of messages
const block = message.content[blockIndex]
if (block.type === "text" && block.text) {
// true if we just altered it, or it was altered before
if (hasExistingAlterations) {
const innerTuple = this.contextHistoryUpdates.get(i)
const updates = innerTuple?.[1].get(blockIndex) // updated text for this inner index
if (updates && updates.length > 0) {
// exists if we have an update for the message at this index
const latestUpdate = updates[updates.length - 1]
// if block was just altered, then calculate savings
if (hasNewAlterations) {
let originalTextLength
if (updates.length > 1) {
originalTextLength = updates[updates.length - 2][2][0].length // handles case if we have multiple updates for same text block
} else {
originalTextLength = block.text.length
}
const newTextLength = latestUpdate[2][0].length // replacement text
totalCharactersSaved += originalTextLength - newTextLength
totalCharCount += originalTextLength
} else {
// meaning there was an update to this text previously, but we didnt just alter it
totalCharCount += latestUpdate[2][0].length
}
} else {
// reach here if there was one inner index with an update, but now we are at a different index, so updates is not defined
totalCharCount += block.text.length
}
} else {
// reach here if there's no alterations for this outer index, meaning each inner index wont have any changes either
totalCharCount += block.text.length
}
} else if (block.type === "image" && block.source) {
if (block.source.type === "base64" && block.source.data) {
totalCharCount += block.source.data.length
}
}
}
}
}
return { totalCharacters: totalCharCount, charactersSaved: totalCharactersSaved }
}
/**
* count total percentage character savings across in-range conversation
*/
private calculateContextOptimizationMetrics(
apiMessages: Anthropic.Messages.MessageParam[],
conversationHistoryDeletedRange: [number, number] | undefined,
uniqueFileReadIndices: Set<number>,
): number {
// count for first user-assistant message pair
const firstChunkResult = this.countCharactersAndSavingsInRange(apiMessages, 0, 2, uniqueFileReadIndices)
// count for the remaining in-range messages
const secondChunkResult = this.countCharactersAndSavingsInRange(
apiMessages,
conversationHistoryDeletedRange ? conversationHistoryDeletedRange[1] + 1 : 2,
apiMessages.length,
uniqueFileReadIndices,
)
const totalCharacters = firstChunkResult.totalCharacters + secondChunkResult.totalCharacters
const totalCharactersSaved = firstChunkResult.charactersSaved + secondChunkResult.charactersSaved
const percentCharactersSaved = totalCharacters === 0 ? 0 : totalCharactersSaved / totalCharacters
return percentCharactersSaved
}
}
+21 -752
View File
@@ -1,120 +1,16 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineApiReqInfo, ClineMessage } from "../../shared/ExtensionMessage"
import { ApiHandler } from "../../api"
import { OpenAiHandler } from "../../api/providers/openai"
import { getContextWindowInfo } from "./context-window-utils"
import { formatResponse } from "../prompts/responses"
import { GlobalFileNames } from "../storage/disk"
import { fileExistsAtPath } from "../../utils/fs"
import * as path from "path"
import fs from "fs/promises"
import cloneDeep from "clone-deep"
enum EditType {
UNDEFINED = 0,
NO_FILE_READ = 1,
READ_FILE_TOOL = 2,
ALTER_FILE_TOOL = 3,
FILE_MENTION = 4,
}
// array of string values allows us to cover all changes for message types currently supported
type MessageContent = string[]
type MessageMetadata = string[][]
// Type for a single context update
type ContextUpdate = [number, string, MessageContent, MessageMetadata] // [timestamp, updateType, update, metadata]
// Type for the serialized format of our nested maps
type SerializedContextHistory = Array<
[
number, // messageIndex
[
number, // EditType (message type)
Array<
[
number, // blockIndex
ContextUpdate[], // updates array (now with 4 elements including metadata)
]
>,
],
]
>
export class ContextManager {
// mapping from the apiMessages outer index to the inner message index to a list of actual changes, ordered by timestamp
// timestamp is required in order to support full checkpointing, where the changes we apply need to be able to be undone when
// moving to an earlier conversation history checkpoint - this ordering intuitively allows for binary search on truncation
// there is also a number stored for each (EditType) which defines which message type it is, for custom handling
// format: { outerIndex => [EditType, { innerIndex => [[timestamp, updateType, update], ...] }] }
// example: { 1 => { [0, 0 => [[<timestamp>, "text", "[NOTE] Some previous conversation history with the user has been removed ..."], ...] }] }
// the above example would be how we update the first assistant message to indicate we truncated text
private contextHistoryUpdates: Map<number, [number, Map<number, ContextUpdate[]>]>
constructor() {
this.contextHistoryUpdates = new Map()
}
/**
* public function for loading contextHistoryUpdates from disk, if it exists
*/
async initializeContextHistory(taskDirectory: string) {
this.contextHistoryUpdates = await this.getSavedContextHistory(taskDirectory)
}
/**
* get the stored context history updates from disk
*/
private async getSavedContextHistory(taskDirectory: string): Promise<Map<number, [number, Map<number, ContextUpdate[]>]>> {
try {
const filePath = path.join(taskDirectory, GlobalFileNames.contextHistory)
if (await fileExistsAtPath(filePath)) {
const data = await fs.readFile(filePath, "utf8")
const serializedUpdates = JSON.parse(data) as SerializedContextHistory
// Update to properly reconstruct the tuple structure
return new Map(
serializedUpdates.map(([messageIndex, [numberValue, innerMapArray]]) => [
messageIndex,
[numberValue, new Map(innerMapArray)],
]),
)
}
} catch (error) {
console.error("Failed to load context history:", error)
}
return new Map()
}
/**
* save the context history updates to disk
*/
private async saveContextHistory(taskDirectory: string) {
try {
const serializedUpdates: SerializedContextHistory = Array.from(this.contextHistoryUpdates.entries()).map(
([messageIndex, [numberValue, innerMap]]) => [messageIndex, [numberValue, Array.from(innerMap.entries())]],
)
await fs.writeFile(
path.join(taskDirectory, GlobalFileNames.contextHistory),
JSON.stringify(serializedUpdates),
"utf8",
)
} catch (error) {
console.error("Failed to save context history:", error)
}
}
/**
* primary entry point for getting up to date context & truncating when required
*/
async getNewContextMessagesAndMetadata(
getNewContextMessagesAndMetadata(
apiConversationHistory: Anthropic.Messages.MessageParam[],
clineMessages: ClineMessage[],
api: ApiHandler,
conversationHistoryDeletedRange: [number, number] | undefined,
previousApiReqIndex: number,
taskDirectory: string,
) {
let updatedConversationHistoryDeletedRange = false
@@ -122,7 +18,6 @@ export class ContextManager {
if (previousApiReqIndex >= 0) {
const previousRequest = clineMessages[previousApiReqIndex]
if (previousRequest && previousRequest.text) {
const timestamp = previousRequest.ts
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
const { maxAllowedSize } = getContextWindowInfo(api)
@@ -131,54 +26,23 @@ export class ContextManager {
if (totalTokens >= maxAllowedSize) {
// Since the user may switch between models with different context windows, truncating half may not be enough (ie if switching from claude 200k to deepseek 64k, half truncation will only remove 100k tokens, but we need to remove much more)
// So if totalTokens/2 is greater than maxAllowedSize, we truncate 3/4 instead of 1/2
// FIXME: truncating the conversation in a way that is optimal for prompt caching AND takes into account multi-context window complexity is something we need to improve
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
// we later check how many chars we trim to determine if we should still truncate history
let [anyContextUpdates, uniqueFileReadIndices] = this.applyContextOptimizations(
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
conversationHistoryDeletedRange = this.getNextTruncationRange(
apiConversationHistory,
conversationHistoryDeletedRange ? conversationHistoryDeletedRange[1] + 1 : 2,
timestamp,
conversationHistoryDeletedRange,
keep,
)
let needToTruncate = true
if (anyContextUpdates) {
// determine whether we've saved enough chars to not truncate
const charactersSavedPercentage = this.calculateContextOptimizationMetrics(
apiConversationHistory,
conversationHistoryDeletedRange,
uniqueFileReadIndices,
)
if (charactersSavedPercentage >= 0.3) {
needToTruncate = false
}
}
if (needToTruncate) {
// go ahead with truncation
anyContextUpdates = this.applyStandardContextTruncationNoticeChange(timestamp) || anyContextUpdates
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
conversationHistoryDeletedRange = this.getNextTruncationRange(
apiConversationHistory,
conversationHistoryDeletedRange,
keep,
)
updatedConversationHistoryDeletedRange = true
}
// if we alter the context history, save the updated version to disk
if (anyContextUpdates) {
await this.saveContextHistory(taskDirectory)
}
updatedConversationHistoryDeletedRange = true
}
}
}
const truncatedConversationHistory = this.getAndAlterTruncatedMessages(
apiConversationHistory,
conversationHistoryDeletedRange,
)
// conversationHistoryDeletedRange is updated only when we're close to hitting the context window, so we don't continuously break the prompt cache
const truncatedConversationHistory = this.getTruncatedMessages(apiConversationHistory, conversationHistoryDeletedRange)
return {
conversationHistoryDeletedRange: conversationHistoryDeletedRange,
@@ -187,17 +51,14 @@ export class ContextManager {
}
}
/**
* get truncation range
*/
public getNextTruncationRange(
apiMessages: Anthropic.Messages.MessageParam[],
currentDeletedRange: [number, number] | undefined,
keep: "half" | "quarter",
): [number, number] {
// We always keep the first user-assistant pairing, and truncate an even number of messages from there
const rangeStartIndex = 2 // index 0 and 1 are kept
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 2 // inclusive starting index
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
const rangeStartIndex = 1
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
let messagesToRemove: number
if (keep === "half") {
@@ -214,11 +75,11 @@ export class ContextManager {
messagesToRemove = Math.floor(((apiMessages.length - startOfRest) * 3) / 4 / 2) * 2
}
let rangeEndIndex = startOfRest + messagesToRemove - 1 // inclusive ending index
let rangeEndIndex = startOfRest + messagesToRemove - 1
// Make sure that the last message being removed is a assistant message, so the next message after the initial user-assistant pair is an assistant message. This preserves the user-assistant-user-assistant structure.
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
if (apiMessages[rangeEndIndex].role !== "assistant") {
if (apiMessages[rangeEndIndex].role !== "user") {
rangeEndIndex -= 1
}
@@ -226,609 +87,17 @@ export class ContextManager {
return [rangeStartIndex, rangeEndIndex]
}
/**
* external interface to support old calls
*/
public getTruncatedMessages(
messages: Anthropic.Messages.MessageParam[],
deletedRange: [number, number] | undefined,
): Anthropic.Messages.MessageParam[] {
return this.getAndAlterTruncatedMessages(messages, deletedRange)
}
/**
* apply all required truncation methods to the messages in context
*/
private getAndAlterTruncatedMessages(
messages: Anthropic.Messages.MessageParam[],
deletedRange: [number, number] | undefined,
): Anthropic.Messages.MessageParam[] {
if (messages.length <= 1) {
if (!deletedRange) {
return messages
}
const updatedMessages = this.applyContextHistoryUpdates(messages, deletedRange ? deletedRange[1] + 1 : 2)
// OLD NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
return updatedMessages
}
/**
* applies deletedRange truncation and other alterations based on changes in this.contextHistoryUpdates
*/
private applyContextHistoryUpdates(
messages: Anthropic.Messages.MessageParam[],
startFromIndex: number,
): Anthropic.Messages.MessageParam[] {
// runtime is linear in length of user messages, if expecting a limited number of alterations, could be more optimal to loop over alterations
const firstChunk = messages.slice(0, 2) // get first user-assistant pair
const secondChunk = messages.slice(startFromIndex) // get remaining messages within context
const messagesToUpdate = [...firstChunk, ...secondChunk]
// we need the mapping from the local indices in messagesToUpdate to the global array of updates in this.contextHistoryUpdates
const originalIndices = [
...Array(2).keys(),
...Array(secondChunk.length)
.fill(0)
.map((_, i) => i + startFromIndex),
]
for (let arrayIndex = 0; arrayIndex < messagesToUpdate.length; arrayIndex++) {
const messageIndex = originalIndices[arrayIndex]
const innerTuple = this.contextHistoryUpdates.get(messageIndex)
if (!innerTuple) {
continue
}
// because we are altering this, we need a deep copy
messagesToUpdate[arrayIndex] = cloneDeep(messagesToUpdate[arrayIndex])
// Extract the map from the tuple
const innerMap = innerTuple[1]
for (const [blockIndex, changes] of innerMap) {
// apply the latest change among n changes - [timestamp, updateType, update]
const latestChange = changes[changes.length - 1]
if (latestChange[1] === "text") {
// only altering text for now
const message = messagesToUpdate[arrayIndex]
if (Array.isArray(message.content)) {
const block = message.content[blockIndex]
if (block && block.type === "text") {
block.text = latestChange[2][0]
}
}
}
}
}
return messagesToUpdate
}
/**
* removes all context history updates that occurred after the specified timestamp and saves to disk
*/
async truncateContextHistory(timestamp: number, taskDirectory: string): Promise<void> {
this.truncateContextHistoryAtTimestamp(this.contextHistoryUpdates, timestamp)
// save the modified context history to disk
await this.saveContextHistory(taskDirectory)
}
/**
* alters the context history to remove all alterations after a given timestamp
* removes the index if there are no alterations there anymore, both outer and inner indices
*/
private truncateContextHistoryAtTimestamp(
contextHistory: Map<number, [number, Map<number, ContextUpdate[]>]>,
timestamp: number,
): void {
for (const [messageIndex, [_, innerMap]] of contextHistory) {
// track which blockIndices to delete
const blockIndicesToDelete: number[] = []
// loop over the innerIndices of the messages in this block
for (const [blockIndex, updates] of innerMap) {
// updates ordered by timestamp, so find cutoff point by iterating from right to left
let cutoffIndex = updates.length - 1
while (cutoffIndex >= 0 && updates[cutoffIndex][0] > timestamp) {
cutoffIndex--
}
// If we found updates to remove
if (cutoffIndex < updates.length - 1) {
// Modify the array in place to keep only updates up to cutoffIndex
updates.length = cutoffIndex + 1
// If no updates left after truncation, mark this block for deletion
if (updates.length === 0) {
blockIndicesToDelete.push(blockIndex)
}
}
}
// Remove empty blocks from inner map
for (const blockIndex of blockIndicesToDelete) {
innerMap.delete(blockIndex)
}
// If inner map is now empty, remove the message index from outer map
if (innerMap.size === 0) {
contextHistory.delete(messageIndex)
}
}
}
/**
* applies the context optimization steps and returns whether any changes were made
*/
private applyContextOptimizations(
apiMessages: Anthropic.Messages.MessageParam[],
startFromIndex: number,
timestamp: number,
): [boolean, Set<number>] {
const [fileReadUpdatesBool, uniqueFileReadIndices] = this.findAndPotentiallySaveFileReadContextHistoryUpdates(
apiMessages,
startFromIndex,
timestamp,
)
// true if any context optimization steps alter state
const contextHistoryUpdated = fileReadUpdatesBool
return [contextHistoryUpdated, uniqueFileReadIndices]
}
/**
* if there is any truncation and there is no other alteration already set, alter the assistant message to indicate this occurred
*/
private applyStandardContextTruncationNoticeChange(timestamp: number): boolean {
if (!this.contextHistoryUpdates.has(1)) {
// first assistant message always at index 1
const innerMap = new Map<number, ContextUpdate[]>()
innerMap.set(0, [[timestamp, "text", [formatResponse.contextTruncationNotice()], []]])
this.contextHistoryUpdates.set(1, [0, innerMap]) // EditType is undefined for first assistant message
return true
}
return false
}
/**
* wraps the logic for determining file reads to overwrite, and altering state
* returns whether any updates were made (bool) and indices where updates were made
*/
private findAndPotentiallySaveFileReadContextHistoryUpdates(
apiMessages: Anthropic.Messages.MessageParam[],
startFromIndex: number,
timestamp: number,
): [boolean, Set<number>] {
const [fileReadIndices, messageFilePaths] = this.getPossibleDuplicateFileReads(apiMessages, startFromIndex)
return this.applyFileReadContextHistoryUpdates(fileReadIndices, messageFilePaths, apiMessages, timestamp)
}
/**
* generate a mapping from unique file reads from multiple tool calls to their outer index position(s)
* also return additional metadata to support multiple file reads in file mention text blocks
*/
private getPossibleDuplicateFileReads(
apiMessages: Anthropic.Messages.MessageParam[],
startFromIndex: number,
): [Map<string, [number, number, string, string][]>, Map<number, string[]>] {
// fileReadIndices: { fileName => [outerIndex, EditType, searchText, replaceText] }
// messageFilePaths: { outerIndex => [fileRead1, fileRead2, ..] }
// searchText in fileReadIndices is only required for file mention file-reads since there can be more than one file in the text
// searchText will be the empty string "" in the case that it's not required, for non-file mentions
// messageFilePaths is only used for file mentions as there can be multiple files read in the same text chunk
// for all text blocks per file, has info for updating the block
const fileReadIndices = new Map<string, [number, number, string, string][]>()
// for file mention text blocks, track all the unique files read
const messageFilePaths = new Map<number, string[]>()
for (let i = startFromIndex; i < apiMessages.length; i++) {
let thisExistingFileReads: string[] = []
if (this.contextHistoryUpdates.has(i)) {
const innerTuple = this.contextHistoryUpdates.get(i)
if (innerTuple) {
// safety check
const editType = innerTuple[0]
if (editType === EditType.FILE_MENTION) {
const innerMap = innerTuple[1]
const blockIndex = 1 // file mention blocks assumed to be at index 1
const blockUpdates = innerMap.get(blockIndex)
// if we have updated this text previously, we want to check whether the lists of files in the metadata are the same
if (blockUpdates && blockUpdates.length > 0) {
// the first list indicates the files we have replaced in this text, second list indicates all unique files in this text
// if they are equal then we have replaced all the files in this text already, and can ignore further processing
if (
blockUpdates[blockUpdates.length - 1][3][0].length ===
blockUpdates[blockUpdates.length - 1][3][1].length
) {
continue
}
// otherwise there are still file reads here we can overwrite, so still need to process this text chunk
// to do so we need to keep track of which files we've already replaced so we don't replace them again
else {
thisExistingFileReads = blockUpdates[blockUpdates.length - 1][3][0]
}
}
} else {
// for all other cases we can assume that we dont need to check this again
continue
}
}
}
const message = apiMessages[i]
if (message.role === "user" && Array.isArray(message.content) && message.content.length > 0) {
const firstBlock = message.content[0]
if (firstBlock.type === "text") {
const matchTup = this.parsePotentialToolCall(firstBlock.text)
let foundNormalFileRead = false
if (matchTup) {
if (matchTup[0] === "read_file") {
this.handleReadFileToolCall(i, matchTup[1], fileReadIndices)
foundNormalFileRead = true
} else if (matchTup[0] === "replace_in_file" || matchTup[0] === "write_to_file") {
if (message.content.length > 1) {
const secondBlock = message.content[1]
if (secondBlock.type === "text") {
this.handlePotentialFileChangeToolCalls(i, matchTup[1], secondBlock.text, fileReadIndices)
foundNormalFileRead = true
}
}
}
}
// file mentions can happen in most other user message blocks
if (!foundNormalFileRead) {
if (message.content.length > 1) {
const secondBlock = message.content[1]
if (secondBlock.type === "text") {
const [hasFileRead, filePaths] = this.handlePotentialFileMentionCalls(
i,
secondBlock.text,
fileReadIndices,
thisExistingFileReads, // file reads we've already replaced in this text in the latest version of this updated text
)
if (hasFileRead) {
messageFilePaths.set(i, filePaths) // all file paths in this string
}
}
}
}
}
}
}
return [fileReadIndices, messageFilePaths]
}
/**
* handles potential file content mentions in text blocks
* there will not be more than one of the same file read in a text block
*/
private handlePotentialFileMentionCalls(
i: number,
secondBlockText: string,
fileReadIndices: Map<string, [number, number, string, string][]>,
thisExistingFileReads: string[],
): [boolean, string[]] {
const pattern = new RegExp(`<file_content path="([^"]*)">([\\s\\S]*?)</file_content>`, "g")
let foundMatch = false
const filePaths: string[] = []
let match
while ((match = pattern.exec(secondBlockText)) !== null) {
foundMatch = true
const filePath = match[1]
filePaths.push(filePath) // we will record all unique paths from file mentions in this text
// we can assume that thisExistingFileReads does not have many entries
if (!thisExistingFileReads.includes(filePath)) {
// meaning we havent already replaced this file read
const entireMatch = match[0] // The entire matched string
// Create the replacement text - keep the tags but replace the content
const replacementText = `<file_content path="${filePath}">${formatResponse.duplicateFileReadNotice()}</file_content>`
const indices = fileReadIndices.get(filePath) || []
indices.push([i, EditType.FILE_MENTION, entireMatch, replacementText])
fileReadIndices.set(filePath, indices)
}
}
return [foundMatch, filePaths]
}
/**
* parses specific tool call formats, returns null if no acceptable format is found
*/
private parsePotentialToolCall(text: string): [string, string] | null {
const match = text.match(/^\[([^\s]+) for '([^']+)'\] Result:$/)
if (!match) {
return null
}
return [match[1], match[2]]
}
/**
* file_read tool call always pastes the file, so this is always a hit
*/
private handleReadFileToolCall(
i: number,
filePath: string,
fileReadIndices: Map<string, [number, number, string, string][]>,
) {
const indices = fileReadIndices.get(filePath) || []
indices.push([i, EditType.READ_FILE_TOOL, "", formatResponse.duplicateFileReadNotice()])
fileReadIndices.set(filePath, indices)
}
/**
* write_to_file and replace_in_file tool output are handled similarly
*/
private handlePotentialFileChangeToolCalls(
i: number,
filePath: string,
secondBlockText: string,
fileReadIndices: Map<string, [number, number, string, string][]>,
) {
const pattern = new RegExp(`(<final_file_content path="[^"]*">)[\\s\\S]*?(</final_file_content>)`)
// check if this exists in the text, it wont exist if the user rejects the file change for example
if (pattern.test(secondBlockText)) {
const replacementText = secondBlockText.replace(pattern, `$1 ${formatResponse.duplicateFileReadNotice()} $2`)
const indices = fileReadIndices.get(filePath) || []
indices.push([i, EditType.ALTER_FILE_TOOL, "", replacementText])
fileReadIndices.set(filePath, indices)
}
}
/**
* alter all occurrences of file read operations and track which messages were updated
* returns the outer index of messages we alter, to count number of changes
*/
private applyFileReadContextHistoryUpdates(
fileReadIndices: Map<string, [number, number, string, string][]>,
messageFilePaths: Map<number, string[]>,
apiMessages: Anthropic.Messages.MessageParam[],
timestamp: number,
): [boolean, Set<number>] {
let didUpdate = false
const updatedMessageIndices = new Set<number>() // track which messages we update on this round
const fileMentionUpdates = new Map<number, [string, string[]]>()
for (const [filePath, indices] of fileReadIndices.entries()) {
// Only process if there are multiple reads of the same file, else we will want to keep the latest read of the file
if (indices.length > 1) {
// Process all but the last index, as we will keep that instance of the file read
for (let i = 0; i < indices.length - 1; i++) {
const messageIndex = indices[i][0]
const messageType = indices[i][1] // EditType value
const searchText = indices[i][2] // search text (for file mentions, else empty string)
const messageString = indices[i][3] // what we will replace the string with
didUpdate = true
updatedMessageIndices.add(messageIndex)
// for single-fileread text we can set the updates here
// for potential multi-fileread text we need to determine all changes & iteratively update the text prior to saving the final change
if (messageType === EditType.FILE_MENTION) {
if (!fileMentionUpdates.has(messageIndex)) {
// Get base text either from existing updates or from apiMessages
let baseText = ""
let prevFilesReplaced: string[] = []
const innerTuple = this.contextHistoryUpdates.get(messageIndex)
if (innerTuple) {
const blockUpdates = innerTuple[1].get(1) // assumed index=1 for file mention filereads
if (blockUpdates && blockUpdates.length > 0) {
baseText = blockUpdates[blockUpdates.length - 1][2][0] // index 0 of MessageContent
prevFilesReplaced = blockUpdates[blockUpdates.length - 1][3][0] // previously overwritten file reads in this text
}
}
// can assume that this content will exist, otherwise it would not have been in fileReadIndices
const messageContent = apiMessages[messageIndex]?.content
if (!baseText && Array.isArray(messageContent) && messageContent.length > 1) {
const contentBlock = messageContent[1] // assume index=1 for all text to replace for file mention filereads
if (contentBlock.type === "text") {
baseText = contentBlock.text
}
}
// prevFilesReplaced keeps track of the previous file reads we've replace in this string, empty array if none
fileMentionUpdates.set(messageIndex, [baseText, prevFilesReplaced])
}
// Replace searchText with messageString for all file reads we need to replace in this text
if (searchText) {
const currentTuple = fileMentionUpdates.get(messageIndex) || ["", []]
if (currentTuple[0]) {
// safety check
// replace this text chunk
const updatedText = currentTuple[0].replace(searchText, messageString)
// add the newly added filePath read
const updatedFileReads = currentTuple[1]
updatedFileReads.push(filePath)
fileMentionUpdates.set(messageIndex, [updatedText, updatedFileReads])
}
}
} else {
let innerTuple = this.contextHistoryUpdates.get(messageIndex)
let innerMap: Map<number, ContextUpdate[]>
if (!innerTuple) {
innerMap = new Map<number, ContextUpdate[]>()
this.contextHistoryUpdates.set(messageIndex, [messageType, innerMap])
} else {
innerMap = innerTuple[1]
}
// block index for file reads from read_file, write_to_file, replace_in_file tools is 1
const blockIndex = 1
const updates = innerMap.get(blockIndex) || []
// metadata array is empty for non-file mention occurrences
updates.push([timestamp, "text", [messageString], []])
innerMap.set(blockIndex, updates)
}
}
}
}
// apply file mention updates to contextHistoryUpdates
// in fileMentionUpdates, filePathsUpdated includes all the file paths which are updated in the latest version of this altered text
for (const [messageIndex, [updatedText, filePathsUpdated]] of fileMentionUpdates.entries()) {
let innerTuple = this.contextHistoryUpdates.get(messageIndex)
let innerMap: Map<number, ContextUpdate[]>
if (!innerTuple) {
innerMap = new Map<number, ContextUpdate[]>()
this.contextHistoryUpdates.set(messageIndex, [EditType.FILE_MENTION, innerMap])
} else {
innerMap = innerTuple[1]
}
const blockIndex = 1 // we only consider the block index of 1 for file mentions
const updates = innerMap.get(blockIndex) || []
// filePathsUpdated includes changes done previously to this timestamp, and right now
if (messageFilePaths.has(messageIndex)) {
const allFileReads = messageFilePaths.get(messageIndex)
if (allFileReads) {
// safety check
// we gather all the file reads possible in this text from messageFilePaths
// filePathsUpdated from fileMentionUpdates stores all the files reads we have replaced now & previously
updates.push([timestamp, "text", [updatedText], [filePathsUpdated, allFileReads]])
innerMap.set(blockIndex, updates)
}
}
}
return [didUpdate, updatedMessageIndices]
}
/**
* count total characters in messages and total savings within this range
*/
private countCharactersAndSavingsInRange(
apiMessages: Anthropic.Messages.MessageParam[],
startIndex: number,
endIndex: number,
uniqueFileReadIndices: Set<number>,
): { totalCharacters: number; charactersSaved: number } {
let totalCharCount = 0
let totalCharactersSaved = 0
for (let i = startIndex; i < endIndex; i++) {
// looping over the outer indicies of messages
const message = apiMessages[i]
if (!message.content) {
continue
}
// hasExistingAlterations checks whether the outer idnex has any changes
// hasExistingAlterations will also include the alterations we just made
const hasExistingAlterations = this.contextHistoryUpdates.has(i)
const hasNewAlterations = uniqueFileReadIndices.has(i)
if (Array.isArray(message.content)) {
for (let blockIndex = 0; blockIndex < message.content.length; blockIndex++) {
// looping over inner indices of messages
const block = message.content[blockIndex]
if (block.type === "text" && block.text) {
// true if we just altered it, or it was altered before
if (hasExistingAlterations) {
const innerTuple = this.contextHistoryUpdates.get(i)
const updates = innerTuple?.[1].get(blockIndex) // updated text for this inner index
if (updates && updates.length > 0) {
// exists if we have an update for the message at this index
const latestUpdate = updates[updates.length - 1]
// if block was just altered, then calculate savings
if (hasNewAlterations) {
let originalTextLength
if (updates.length > 1) {
originalTextLength = updates[updates.length - 2][2][0].length // handles case if we have multiple updates for same text block
} else {
originalTextLength = block.text.length
}
const newTextLength = latestUpdate[2][0].length // replacement text
totalCharactersSaved += originalTextLength - newTextLength
totalCharCount += originalTextLength
} else {
// meaning there was an update to this text previously, but we didnt just alter it
totalCharCount += latestUpdate[2][0].length
}
} else {
// reach here if there was one inner index with an update, but now we are at a different index, so updates is not defined
totalCharCount += block.text.length
}
} else {
// reach here if there's no alterations for this outer index, meaning each inner index wont have any changes either
totalCharCount += block.text.length
}
} else if (block.type === "image" && block.source) {
if (block.source.type === "base64" && block.source.data) {
totalCharCount += block.source.data.length
}
}
}
}
}
return { totalCharacters: totalCharCount, charactersSaved: totalCharactersSaved }
}
/**
* count total percentage character savings across in-range conversation
*/
private calculateContextOptimizationMetrics(
apiMessages: Anthropic.Messages.MessageParam[],
conversationHistoryDeletedRange: [number, number] | undefined,
uniqueFileReadIndices: Set<number>,
): number {
// count for first user-assistant message pair
const firstChunkResult = this.countCharactersAndSavingsInRange(apiMessages, 0, 2, uniqueFileReadIndices)
// count for the remaining in-range messages
const secondChunkResult = this.countCharactersAndSavingsInRange(
apiMessages,
conversationHistoryDeletedRange ? conversationHistoryDeletedRange[1] + 1 : 2,
apiMessages.length,
uniqueFileReadIndices,
)
const totalCharacters = firstChunkResult.totalCharacters + secondChunkResult.totalCharacters
const totalCharactersSaved = firstChunkResult.charactersSaved + secondChunkResult.charactersSaved
const percentCharactersSaved = totalCharacters === 0 ? 0 : totalCharactersSaved / totalCharacters
return percentCharactersSaved
const [start, end] = deletedRange
// the range is inclusive - both start and end indices and everything in between will be removed from the final result.
// NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
return [...messages.slice(0, start), ...messages.slice(end + 1)]
}
}
+25 -13
View File
@@ -1704,6 +1704,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
taskDirPath: string
apiConversationHistoryFilePath: string
uiMessagesFilePath: string
contextHistoryFilePath: string
taskMetadataFilePath: string
apiConversationHistory: Anthropic.MessageParam[]
}> {
const history = ((await getGlobalState(this.context, "taskHistory")) as HistoryItem[] | undefined) || []
@@ -1712,6 +1714,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
const taskDirPath = path.join(this.context.globalStorageUri.fsPath, "tasks", id)
const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory)
const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages)
const contextHistoryFilePath = path.join(taskDirPath, GlobalFileNames.contextHistory)
const taskMetadataFilePath = path.join(taskDirPath, GlobalFileNames.taskMetadata)
const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
if (fileExists) {
const apiConversationHistory = JSON.parse(await fs.readFile(apiConversationHistoryFilePath, "utf8"))
@@ -1720,6 +1724,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
taskDirPath,
apiConversationHistoryFilePath,
uiMessagesFilePath,
contextHistoryFilePath,
taskMetadataFilePath,
apiConversationHistory,
}
}
@@ -1791,22 +1797,28 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
console.debug("cleared task")
}
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id)
const {
taskDirPath,
apiConversationHistoryFilePath,
uiMessagesFilePath,
contextHistoryFilePath,
taskMetadataFilePath,
} = await this.getTaskWithId(id)
const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
const updatedTaskHistory = await this.deleteTaskFromState(id)
// Delete the task files
const apiConversationHistoryFileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
if (apiConversationHistoryFileExists) {
await fs.unlink(apiConversationHistoryFilePath)
}
const uiMessagesFileExists = await fileExistsAtPath(uiMessagesFilePath)
if (uiMessagesFileExists) {
await fs.unlink(uiMessagesFilePath)
}
const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
if (await fileExistsAtPath(legacyMessagesFilePath)) {
await fs.unlink(legacyMessagesFilePath)
for (const filePath of [
apiConversationHistoryFilePath,
uiMessagesFilePath,
contextHistoryFilePath,
taskMetadataFilePath,
legacyMessagesFilePath,
]) {
const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
await fs.unlink(filePath)
}
}
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
+77 -48
View File
@@ -332,10 +332,10 @@ export class Task {
await this.overwriteApiConversationHistory(newConversationHistory)
// update the context history state
await this.contextManager.truncateContextHistory(
message.ts,
await ensureTaskDirectoryExists(this.getContext(), this.taskId),
)
// await this.contextManager.truncateContextHistory(
// message.ts,
// await ensureTaskDirectoryExists(this.getContext(), this.taskId),
// )
// aggregate deleted api reqs info so we don't lose costs/tokens
const deletedMessages = this.clineMessages.slice(messageIndex + 1)
@@ -869,7 +869,7 @@ export class Task {
this.apiConversationHistory = await getSavedApiConversationHistory(this.getContext(), this.taskId)
// load the context history state
await this.contextManager.initializeContextHistory(await ensureTaskDirectoryExists(this.getContext(), this.taskId))
// await this.contextManager.initializeContextHistory(await ensureTaskDirectoryExists(this.getContext(), this.taskId))
const lastClineMessage = this.clineMessages
.slice()
@@ -890,6 +890,7 @@ export class Task {
let responseImages: string[] | undefined
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
await this.saveCheckpoint()
responseText = text
responseImages = images
}
@@ -1146,6 +1147,7 @@ export class Task {
if (userFeedback) {
await this.say("user_feedback", userFeedback.text, userFeedback.images)
await this.saveCheckpoint()
return [
true,
formatResponse.toolResult(
@@ -1284,13 +1286,15 @@ export class Task {
preferredLanguageInstructions,
)
}
const contextManagementMetadata = await this.contextManager.getNewContextMessagesAndMetadata(
// await
const contextManagementMetadata = this.contextManager.getNewContextMessagesAndMetadata(
this.apiConversationHistory,
this.clineMessages,
this.api,
this.conversationHistoryDeletedRange,
previousApiReqIndex,
await ensureTaskDirectoryExists(this.getContext(), this.taskId),
// await ensureTaskDirectoryExists(this.getContext(), this.taskId),
)
if (contextManagementMetadata.updatedConversationHistoryDeletedRange) {
@@ -1563,6 +1567,7 @@ export class Task {
if (text || images?.length) {
pushAdditionalToolFeedback(text, images)
await this.say("user_feedback", text, images)
await this.saveCheckpoint()
}
this.didRejectTool = true // Prevent further tool uses in this message
return false
@@ -1571,6 +1576,7 @@ export class Task {
if (text || images?.length) {
pushAdditionalToolFeedback(text, images)
await this.say("user_feedback", text, images)
await this.saveCheckpoint()
}
return true
}
@@ -1644,7 +1650,7 @@ export class Task {
if (!accessAllowed) {
await this.say("clineignore_error", relPath)
pushToolResult(formatResponse.toolError(formatResponse.clineIgnoreError(relPath)))
await this.saveCheckpoint()
break
}
@@ -1700,6 +1706,7 @@ export class Task {
)
await this.diffViewProvider.revertChanges()
await this.diffViewProvider.reset()
await this.saveCheckpoint()
break
}
} else if (content) {
@@ -1762,14 +1769,14 @@ export class Task {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("replace_in_file", "diff"))
await this.diffViewProvider.reset()
await this.saveCheckpoint()
break
}
if (block.name === "write_to_file" && !content) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("write_to_file", "content"))
await this.diffViewProvider.reset()
await this.saveCheckpoint()
break
}
@@ -1828,6 +1835,7 @@ export class Task {
if (text || images?.length) {
pushAdditionalToolFeedback(text, images)
await this.say("user_feedback", text, images)
await this.saveCheckpoint()
}
this.didRejectTool = true
didApprove = false
@@ -1837,12 +1845,14 @@ export class Task {
if (text || images?.length) {
pushAdditionalToolFeedback(text, images)
await this.say("user_feedback", text, images)
await this.saveCheckpoint()
}
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
}
if (!didApprove) {
await this.diffViewProvider.revertChanges()
await this.saveCheckpoint()
break
}
}
@@ -1903,7 +1913,7 @@ export class Task {
await handleError("writing file", error)
await this.diffViewProvider.revertChanges()
await this.diffViewProvider.reset()
await this.saveCheckpoint()
break
}
}
@@ -1931,7 +1941,7 @@ export class Task {
if (!relPath) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("read_file", "path"))
await this.saveCheckpoint()
break
}
@@ -1939,7 +1949,7 @@ export class Task {
if (!accessAllowed) {
await this.say("clineignore_error", relPath)
pushToolResult(formatResponse.toolError(formatResponse.clineIgnoreError(relPath)))
await this.saveCheckpoint()
break
}
@@ -1961,6 +1971,7 @@ export class Task {
this.removeLastPartialMessageIfExistsWithType("say", "tool")
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
await this.saveCheckpoint()
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
break
}
@@ -1973,12 +1984,12 @@ export class Task {
await this.fileContextTracker.trackFileContext(relPath, "read_tool")
pushToolResult(content)
await this.saveCheckpoint()
break
}
} catch (error) {
await handleError("reading file", error)
await this.saveCheckpoint()
break
}
}
@@ -2008,7 +2019,7 @@ export class Task {
if (!relDirPath) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("list_files", "path"))
await this.saveCheckpoint()
break
}
this.consecutiveMistakeCount = 0
@@ -2039,18 +2050,19 @@ export class Task {
this.removeLastPartialMessageIfExistsWithType("say", "tool")
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
await this.saveCheckpoint()
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
break
}
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
}
pushToolResult(result)
await this.saveCheckpoint()
break
}
} catch (error) {
await handleError("listing files", error)
await this.saveCheckpoint()
break
}
}
@@ -2078,7 +2090,7 @@ export class Task {
if (!relDirPath) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("list_code_definition_names", "path"))
await this.saveCheckpoint()
break
}
@@ -2106,18 +2118,19 @@ export class Task {
this.removeLastPartialMessageIfExistsWithType("say", "tool")
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
await this.saveCheckpoint()
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
break
}
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
}
pushToolResult(result)
await this.saveCheckpoint()
break
}
} catch (error) {
await handleError("parsing source code definitions", error)
await this.saveCheckpoint()
break
}
}
@@ -2149,13 +2162,13 @@ export class Task {
if (!relDirPath) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("search_files", "path"))
await this.saveCheckpoint()
break
}
if (!regex) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("search_files", "regex"))
await this.saveCheckpoint()
break
}
this.consecutiveMistakeCount = 0
@@ -2185,18 +2198,19 @@ export class Task {
this.removeLastPartialMessageIfExistsWithType("say", "tool")
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
await this.saveCheckpoint()
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
break
}
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
}
pushToolResult(results)
await this.saveCheckpoint()
break
}
} catch (error) {
await handleError("searching files", error)
await this.saveCheckpoint()
break
}
}
@@ -2212,6 +2226,7 @@ export class Task {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("browser_action", "action"))
await this.browserSession.closeBrowser()
await this.saveCheckpoint()
}
break
}
@@ -2255,7 +2270,7 @@ export class Task {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("browser_action", "url"))
await this.browserSession.closeBrowser()
await this.saveCheckpoint()
break
}
this.consecutiveMistakeCount = 0
@@ -2271,6 +2286,7 @@ export class Task {
this.removeLastPartialMessageIfExistsWithType("say", "browser_action_launch")
const didApprove = await askApproval("browser_action_launch", url)
if (!didApprove) {
await this.saveCheckpoint()
break
}
}
@@ -2297,7 +2313,7 @@ export class Task {
await this.sayAndCreateMissingParamError("browser_action", "coordinate"),
)
await this.browserSession.closeBrowser()
await this.saveCheckpoint()
break // can't be within an inner switch
}
}
@@ -2306,7 +2322,7 @@ export class Task {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("browser_action", "text"))
await this.browserSession.closeBrowser()
await this.saveCheckpoint()
break
}
}
@@ -2355,7 +2371,7 @@ export class Task {
browserActionResult.screenshot ? [browserActionResult.screenshot] : [],
),
)
await this.saveCheckpoint()
break
case "close":
pushToolResult(
@@ -2363,7 +2379,7 @@ export class Task {
`The browser has been closed. You may now proceed to using other tools.`,
),
)
await this.saveCheckpoint()
break
}
@@ -2372,7 +2388,7 @@ export class Task {
} catch (error) {
await this.browserSession.closeBrowser() // if any error occurs, the browser session is terminated
await handleError("executing browser action", error)
await this.saveCheckpoint()
break
}
}
@@ -2400,7 +2416,7 @@ export class Task {
if (!command) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("execute_command", "command"))
await this.saveCheckpoint()
break
}
if (!requiresApprovalRaw) {
@@ -2408,7 +2424,7 @@ export class Task {
pushToolResult(
await this.sayAndCreateMissingParamError("execute_command", "requires_approval"),
)
await this.saveCheckpoint()
break
}
this.consecutiveMistakeCount = 0
@@ -2456,6 +2472,7 @@ export class Task {
`${this.shouldAutoApproveTool(block.name) && requiresApprovalPerLLM ? COMMAND_REQ_APP_STRING : ""}`, // ugly hack until we refactor combineCommandSequences
)
if (!didApprove) {
await this.saveCheckpoint()
break
}
}
@@ -2491,7 +2508,7 @@ export class Task {
}
} catch (error) {
await handleError("executing command", error)
await this.saveCheckpoint()
break
}
}
@@ -2521,13 +2538,13 @@ export class Task {
if (!server_name) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("use_mcp_tool", "server_name"))
await this.saveCheckpoint()
break
}
if (!tool_name) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("use_mcp_tool", "tool_name"))
await this.saveCheckpoint()
break
}
// arguments are optional, but if they are provided they must be valid JSON
@@ -2551,7 +2568,7 @@ export class Task {
formatResponse.invalidMcpToolArgumentError(server_name, tool_name),
),
)
await this.saveCheckpoint()
break
}
}
@@ -2579,6 +2596,7 @@ export class Task {
this.removeLastPartialMessageIfExistsWithType("say", "use_mcp_server")
const didApprove = await askApproval("use_mcp_server", completeMessage)
if (!didApprove) {
await this.saveCheckpoint()
break
}
}
@@ -2614,7 +2632,7 @@ export class Task {
}
} catch (error) {
await handleError("executing MCP tool", error)
await this.saveCheckpoint()
break
}
}
@@ -2642,13 +2660,13 @@ export class Task {
if (!server_name) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("access_mcp_resource", "server_name"))
await this.saveCheckpoint()
break
}
if (!uri) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("access_mcp_resource", "uri"))
await this.saveCheckpoint()
break
}
this.consecutiveMistakeCount = 0
@@ -2669,6 +2687,7 @@ export class Task {
this.removeLastPartialMessageIfExistsWithType("say", "use_mcp_server")
const didApprove = await askApproval("use_mcp_server", completeMessage)
if (!didApprove) {
await this.saveCheckpoint()
break
}
}
@@ -2688,12 +2707,12 @@ export class Task {
.join("\n\n") || "(Empty response)"
await this.say("mcp_server_response", resourceResultPretty)
pushToolResult(formatResponse.toolResult(resourceResultPretty))
await this.saveCheckpoint()
break
}
} catch (error) {
await handleError("accessing MCP resource", error)
await this.saveCheckpoint()
break
}
}
@@ -2712,7 +2731,7 @@ export class Task {
if (!question) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("ask_followup_question", "question"))
await this.saveCheckpoint()
break
}
this.consecutiveMistakeCount = 0
@@ -2749,12 +2768,12 @@ export class Task {
}
pushToolResult(formatResponse.toolResult(`<answer>\n${text}\n</answer>`, images))
await this.saveCheckpoint()
break
}
} catch (error) {
await handleError("asking question", error)
await this.saveCheckpoint()
break
}
}
@@ -2768,6 +2787,7 @@ export class Task {
if (!context) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("new_task", "context"))
await this.saveCheckpoint()
break
}
this.consecutiveMistakeCount = 0
@@ -2796,10 +2816,12 @@ export class Task {
formatResponse.toolResult(`The user has created a new task with the provided context.`),
)
}
await this.saveCheckpoint()
break
}
} catch (error) {
await handleError("creating new task", error)
await this.saveCheckpoint()
break
}
}
@@ -2860,6 +2882,7 @@ export class Task {
if (text || images?.length) {
telemetryService.captureOptionsIgnored(this.taskId, options.length, "plan")
await this.say("user_feedback", text ?? "", images)
await this.saveCheckpoint()
}
}
@@ -2991,12 +3014,14 @@ export class Task {
// complete command message
const didApprove = await askApproval("command", command)
if (!didApprove) {
await this.saveCheckpoint()
break
}
const [userRejected, execCommandResult] = await this.executeCommandTool(command!)
if (userRejected) {
this.didRejectTool = true
pushToolResult(execCommandResult)
await this.saveCheckpoint()
break
}
// user didn't reject, but the command may have output
@@ -3015,6 +3040,7 @@ export class Task {
break
}
await this.say("user_feedback", text ?? "", images)
await this.saveCheckpoint()
const toolResults: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = []
if (commandResult) {
@@ -3043,7 +3069,7 @@ export class Task {
}
} catch (error) {
await handleError("attempting completion", error)
await this.saveCheckpoint()
break
}
}
@@ -3087,8 +3113,11 @@ export class Task {
throw new Error("Cline instance aborted")
}
// Used to know what models were used in the task if user wants to export metadata for error reporting purposes
if (this.apiProvider && this.api.getModel().id) {
await this.modelContextTracker.recordModelUsage(this.apiProvider, this.api.getModel().id, this.chatSettings.mode)
try {
await this.modelContextTracker.recordModelUsage(this.apiProvider, this.api.getModel().id, this.chatSettings.mode)
} catch {}
}
if (this.consecutiveMistakeCount >= 3) {
@@ -3697,7 +3726,7 @@ export class Task {
const lastApiReqTotalTokens = lastApiReqMessage ? getTotalTokensFromApiReqMessage(lastApiReqMessage) : 0
const usagePercentage = Math.round((lastApiReqTotalTokens / contextWindow) * 100)
details += "\n# Context Window Usage"
details += "\n\n# Context Window Usage"
details += `\n${lastApiReqTotalTokens.toLocaleString()} / ${(contextWindow / 1000).toLocaleString()}K tokens used (${usagePercentage}%)`
details += "\n\n# Current Mode"
+18 -6
View File
@@ -9,6 +9,8 @@ import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
import assert from "node:assert"
import { telemetryService } from "./services/telemetry/TelemetryService"
import { WebviewProvider } from "./core/webview"
import { createTestServer, shutdownTestServer } from "./services/test/TestServer"
import { ErrorService } from "./services/error/ErrorService"
/*
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@@ -27,6 +29,7 @@ export function activate(context: vscode.ExtensionContext) {
outputChannel = vscode.window.createOutputChannel("Cline")
context.subscriptions.push(outputChannel)
ErrorService.initialize()
Logger.initialize(outputChannel)
Logger.log("Cline extension activated")
@@ -397,12 +400,6 @@ export function activate(context: vscode.ExtensionContext) {
return createClineAPI(outputChannel, sidebarWebview.controller)
}
// This method is called when your extension is deactivated
export function deactivate() {
telemetryService.shutdown()
Logger.log("Cline extension deactivated")
}
// TODO: Find a solution for automatically removing DEV related content from production builds.
// This type of code is fine in production to keep. We just will want to remove it from production builds
// to bring down built asset sizes.
@@ -411,6 +408,16 @@ export function deactivate() {
// since vscode doesn't support hot reload for extensions
const { IS_DEV, DEV_WORKSPACE_FOLDER, IS_TEST } = process.env
// This method is called when your extension is deactivated
export function deactivate() {
// Shutdown the test server if it exists
shutdownTestServer()
telemetryService.shutdown()
Logger.log("Cline extension deactivated")
}
// Set up development mode file watcher
if (IS_DEV && IS_DEV !== "false") {
assert(DEV_WORKSPACE_FOLDER, "DEV_WORKSPACE_FOLDER must be set in development")
const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(DEV_WORKSPACE_FOLDER, "src/**/*"))
@@ -421,3 +428,8 @@ if (IS_DEV && IS_DEV !== "false") {
vscode.commands.executeCommand("workbench.action.reloadWindow")
})
}
// Set up test server if in test mode
if (IS_TEST && IS_TEST === "true") {
createTestServer()
}
+96
View File
@@ -0,0 +1,96 @@
import * as Sentry from "@sentry/browser"
import * as vscode from "vscode"
import { telemetryService } from "../telemetry/TelemetryService"
import * as pkg from "../../../package.json"
let telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
let isTelemetryEnabled = ["all", "error"].includes(telemetryLevel)
vscode.workspace.onDidChangeConfiguration(() => {
telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
isTelemetryEnabled = ["all", "error"].includes(telemetryLevel)
ErrorService.toggleEnabled(isTelemetryEnabled)
if (isTelemetryEnabled) {
ErrorService.setLevel(telemetryLevel as "error" | "all")
}
})
export class ErrorService {
private static serviceEnabled: boolean
private static serviceLevel: string
static initialize() {
// Initialize sentry
Sentry.init({
dsn: "https://7936780e3f0f0290fcf8d4a395c249b7@o4509028819664896.ingest.us.sentry.io/4509052955983872",
environment: process.env.NODE_ENV,
release: `cline@${pkg.version}`,
integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()],
beforeSend(event) {
// TelemetryService keeps track of whether the user has opted in to telemetry/error reporting
const isUserManuallyOptedIn = telemetryService.isTelemetryEnabled()
if (isUserManuallyOptedIn && ErrorService.isEnabled()) {
return event
}
return null
},
})
ErrorService.toggleEnabled(true)
ErrorService.setLevel("error")
}
static toggleEnabled(state: boolean) {
if (state === false) {
ErrorService.serviceEnabled = false
return
}
// If we are trying to enable the service, check that we are allowed to.
if (isTelemetryEnabled) {
ErrorService.serviceEnabled = true
}
}
static setLevel(level: "error" | "all") {
switch (telemetryLevel) {
case "error": {
if (level === "error") {
ErrorService.serviceLevel = level
}
break
}
default: {
ErrorService.serviceLevel = level
}
}
}
static logException(error: Error): void {
// Don't log if telemetry is off
const isUserManuallyOptedIn = telemetryService.isTelemetryEnabled()
if (!isUserManuallyOptedIn || !ErrorService.isEnabled()) {
return
}
// Log the error to Sentry
Sentry.captureException(error)
}
static logMessage(message: string, level: "error" | "warning" | "log" | "debug" | "info" = "log"): void {
// Don't log if telemetry is off
const isUserManuallyOptedIn = telemetryService.isTelemetryEnabled()
if (!isUserManuallyOptedIn || !ErrorService.isEnabled()) {
return
}
if (ErrorService.serviceLevel === "error" && level === "error") {
// Log the message if allowed
Sentry.captureMessage(message, { level })
return
}
// Log the message if allowed
Sentry.captureMessage(message, { level })
}
static isEnabled(): boolean {
return ErrorService.serviceEnabled
}
}
+20 -1
View File
@@ -1,4 +1,5 @@
import type { OutputChannel } from "vscode"
import { ErrorService } from "../error/ErrorService"
/**
* Simple logging utility for the extension's backend code.
@@ -12,7 +13,25 @@ export class Logger {
Logger.outputChannel = outputChannel
}
static error(message: string, exception?: Error) {
Logger.outputChannel.appendLine(`ERROR: ${message}`)
ErrorService.logMessage(message, "error")
exception && ErrorService.logException(exception)
}
static warn(message: string) {
Logger.outputChannel.appendLine(`WARN: ${message}`)
ErrorService.logMessage(message, "warning")
}
static log(message: string) {
Logger.outputChannel.appendLine(message)
Logger.outputChannel.appendLine(`LOG: ${message}`)
}
static debug(message: string) {
Logger.outputChannel.appendLine(`DEBUG: ${message}`)
}
static info(message: string) {
Logger.outputChannel.appendLine(`INFO: ${message}`)
}
static trace(message: string) {
Logger.outputChannel.appendLine(`TRACE: ${message}`)
}
}
+112
View File
@@ -0,0 +1,112 @@
import * as http from "http"
import { Logger } from "../../services/logging/Logger"
import { WebviewProvider } from "../../core/webview"
let testServer: http.Server | undefined
/**
* Creates and starts an HTTP server for test automation
* @returns The created HTTP server instance
*/
export function createTestServer(): http.Server {
const PORT = 9876
testServer = http.createServer((req, res) => {
// Set CORS headers
res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS")
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
// Handle preflight requests
if (req.method === "OPTIONS") {
res.writeHead(204)
res.end()
return
}
// Only handle POST requests to /task
if (req.method !== "POST" || req.url !== "/task") {
res.writeHead(404)
res.end(JSON.stringify({ error: "Not found" }))
return
}
// Parse the request body
let body = ""
req.on("data", (chunk) => {
body += chunk.toString()
})
req.on("end", async () => {
try {
// Parse the JSON body
const { task } = JSON.parse(body)
if (!task) {
res.writeHead(400)
res.end(JSON.stringify({ error: "Missing task parameter" }))
return
}
// Get a visible webview instance
const visibleWebview = WebviewProvider.getVisibleInstance()
if (!visibleWebview || !visibleWebview.controller) {
res.writeHead(500)
res.end(JSON.stringify({ error: "No active Cline instance found" }))
return
}
// Initiate a new task
Logger.log(`Test server initiating task: ${task}`)
try {
// Clear any existing task
await visibleWebview.controller.clearTask()
// Ensure we're in Act mode before initiating the task
const { chatSettings } = await visibleWebview.controller.getStateToPostToWebview()
if (chatSettings.mode === "plan") {
// Switch to Act mode if currently in Plan mode
await visibleWebview.controller.togglePlanActModeWithChatSettings({ mode: "act" })
}
// Initiate the new task
const taskId = await visibleWebview.controller.initClineWithTask(task)
// Return success response with the task ID
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true, taskId }))
} catch (error) {
Logger.log(`Error initiating task: ${error}`)
res.writeHead(500)
res.end(JSON.stringify({ error: `Failed to initiate task: ${error}` }))
}
} catch (error) {
res.writeHead(400)
res.end(JSON.stringify({ error: `Invalid JSON: ${error}` }))
}
})
})
testServer.listen(PORT, () => {
Logger.log(`Test server listening on port ${PORT}`)
})
// Handle server errors
testServer.on("error", (error) => {
Logger.log(`Test server error: ${error}`)
})
return testServer
}
/**
* Shuts down the test server if it exists
*/
export function shutdownTestServer() {
if (testServer) {
testServer.close()
Logger.log("Test server shut down")
testServer = undefined
}
}
+37 -1
View File
@@ -1319,8 +1319,44 @@ export const askSageModels = {
// X AI
// https://docs.x.ai/docs/api-reference
export type XAIModelId = keyof typeof xaiModels
export const xaiDefaultModelId: XAIModelId = "grok-2-latest"
export const xaiDefaultModelId: XAIModelId = "grok-3-beta"
export const xaiModels = {
"grok-3-beta": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
description: "X AI's Grok-3 beta model with 131K context window",
},
"grok-3-fast-beta": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 5.0,
outputPrice: 25.0,
description: "X AI's Grok-3 fast beta model with 131K context window",
},
"grok-3-mini-beta": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.3,
outputPrice: 0.5,
description: "X AI's Grok-3 mini beta model with 131K context window",
},
"grok-3-mini-fast-beta": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.6,
outputPrice: 4.0,
description: "X AI's Grok-3 mini fast beta model with 131K context window",
},
"grok-2-latest": {
maxTokens: 8192,
contextWindow: 131072,
@@ -275,19 +275,10 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
consoleLogs: currentPage?.currentState.consoleLogs,
screenshot: currentPage?.currentState.screenshot,
}
const [rowIndex, setRowIndex] = useState<number>(0)
const [hoveredRowIndex, setHoveredRowIndex] = useState<number | null>(null)
const [actionContent, { height: actionHeight }] = useSize(
<div>
{currentPage?.nextAction?.messages.map((message) => (
<BrowserSessionRowContent
key={message.ts}
{...props}
message={message}
setMaxActionHeight={setMaxActionHeight}
rowIndex={rowIndex}
hoveredRowIndex={hoveredRowIndex}
/>
<BrowserSessionRowContent key={message.ts} {...props} message={message} setMaxActionHeight={setMaxActionHeight} />
))}
{!isBrowsing && messages.some((m) => m.say === "browser_action_result") && currentPageIndex === 0 && (
<BrowserActionBox action={"launch"} text={initialUrl} />
@@ -481,8 +472,6 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
interface BrowserSessionRowContentProps extends Omit<BrowserSessionRowProps, "messages"> {
message: ClineMessage
setMaxActionHeight: (height: number) => void
rowIndex: number
hoveredRowIndex: number | null
}
const BrowserSessionRowContent = ({
@@ -492,8 +481,6 @@ const BrowserSessionRowContent = ({
lastModifiedMessage,
isLast,
setMaxActionHeight,
rowIndex,
hoveredRowIndex,
}: BrowserSessionRowContentProps) => {
if (message.ask === "browser_action_launch" || message.say === "browser_action_launch") {
return (
@@ -516,8 +503,6 @@ const BrowserSessionRowContent = ({
return (
<div style={chatRowContentContainerStyle}>
<ChatRowContent
rowIndex={rowIndex}
hoveredRowIndex={hoveredRowIndex}
message={message}
isExpanded={isExpanded(message.ts)}
onToggleExpand={() => {
+30 -30
View File
@@ -48,18 +48,12 @@ interface ChatRowProps {
isExpanded: boolean
onToggleExpand: () => void
lastModifiedMessage?: ClineMessage
isFirst: boolean
isLast: boolean
onHeightChange: (isTaller: boolean) => void
rowIndex: number
hoveredRowIndex: number | null
setHoveredRowIndex: React.Dispatch<React.SetStateAction<number | null>>
}
interface ChatRowContentProps
extends Omit<ChatRowProps, "onHeightChange" | "rowIndex" | "hoveredRowIndex" | "setHoveredRowIndex"> {
rowIndex: number
hoveredRowIndex: number | null
}
interface ChatRowContentProps extends Omit<ChatRowProps, "onHeightChange" | "isFirst"> {}
export const ProgressIndicator = () => (
<div
@@ -92,23 +86,40 @@ const Markdown = memo(({ markdown }: { markdown?: string }) => {
const ChatRow = memo(
(props: ChatRowProps) => {
const { isLast, onHeightChange, message, lastModifiedMessage, rowIndex, hoveredRowIndex, setHoveredRowIndex } = props
const { isLast, isFirst, onHeightChange, message } = props
// Store the previous height to compare with the current height
// This allows us to detect changes without causing re-renders
const prevHeightRef = useRef(0)
// Calculate dynamic styles using the custom hook
const { padding, minHeight } = useChatRowStyles(message, hoveredRowIndex, rowIndex)
const { ...chatRowStyles } = useChatRowStyles(message)
const isCheckpointMessage = message.say === "checkpoint_created"
// Special handling for first row checkpoint
const checkpointStyles = useMemo(() => {
if (isCheckpointMessage) {
// Apply additional styles for first row checkpoints
if (isFirst) {
return {
...chatRowStyles,
marginTop: "3px", // Add a small margin to ensure visibility
}
}
return chatRowStyles
}
return chatRowStyles
}, [chatRowStyles, isCheckpointMessage, isFirst])
// ChatRowContainer with updated styles
const [chatrow, { height }] = useSize(
<ChatRowContainer
style={{ padding, minHeight }}
onMouseEnter={() => setHoveredRowIndex(rowIndex)}
onMouseLeave={() => setHoveredRowIndex(null)}>
<ChatRowContent {...props} rowIndex={rowIndex} hoveredRowIndex={hoveredRowIndex} />
<ChatRowContainer style={checkpointStyles}>
<ChatRowContent {...props} />
</ChatRowContainer>,
)
useEffect(() => {
// Skip height change effects for checkpoint messages
if (isCheckpointMessage) return
// used for partials command output etc.
// NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete
const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that
@@ -119,7 +130,7 @@ const ChatRow = memo(
}
prevHeightRef.current = height
}
}, [height, isLast, onHeightChange, message])
}, [height, isLast, onHeightChange, message, isCheckpointMessage])
// we cannot return null as virtuoso does not support it so we use a separate visibleMessages array to filter out messages that should not be rendered
return chatrow
@@ -130,15 +141,7 @@ const ChatRow = memo(
export default ChatRow
export const ChatRowContent = ({
message,
isExpanded,
onToggleExpand,
lastModifiedMessage,
isLast,
rowIndex,
hoveredRowIndex,
}: ChatRowContentProps) => {
export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessage, isLast }: ChatRowContentProps) => {
const { mcpServers, mcpMarketplaceCatalog } = useExtensionState()
const [seeNewChangesDisabled, setSeeNewChangesDisabled] = useState(false)
@@ -988,15 +991,12 @@ export const ChatRowContent = ({
</>
)
case "checkpoint_created":
// Determine if the hover is near the checkpoint marker's visual position (either on the preceding row or the checkpoint row itself)
const isHoveredNearCheckpoint = hoveredRowIndex === rowIndex - 1 || hoveredRowIndex === rowIndex
return (
<>
<CheckmarkControl
messageTs={message.ts}
isCheckpointCheckedOut={message.isCheckpointCheckedOut}
isHoveredNearCheckpoint={isHoveredNearCheckpoint}
isLastRow={isLast}
/>
</>
)
+2 -13
View File
@@ -77,7 +77,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const disableAutoScrollRef = useRef(false)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [isAtBottom, setIsAtBottom] = useState(false)
const [hoveredRowIndex, setHoveredRowIndex] = useState<number | null>(null)
// UI layout depends on the last 2 messages
// (since it relies on the content of these messages, we are deep comparing. i.e. the button state after hitting button sets enableButtons to false, and this effect otherwise would have to true again even if messages didn't change
@@ -780,22 +779,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
onToggleExpand={() => toggleRowExpansion(messageOrGroup.ts)}
lastModifiedMessage={modifiedMessages.at(-1)}
isLast={index === groupedMessages.length - 1}
isFirst={index === 0}
onHeightChange={handleRowHeightChange}
rowIndex={index}
hoveredRowIndex={hoveredRowIndex}
setHoveredRowIndex={setHoveredRowIndex}
/>
)
},
[
expandedRows,
modifiedMessages,
groupedMessages.length,
toggleRowExpansion,
handleRowHeightChange,
hoveredRowIndex,
setHoveredRowIndex,
],
[expandedRows, modifiedMessages, groupedMessages.length, toggleRowExpansion, handleRowHeightChange],
)
return (
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState, useEffect } from "react"
import { useCallback, useRef, useState, useEffect, useMemo } from "react"
import { useEvent } from "react-use"
import styled from "styled-components"
import { ExtensionMessage } from "@shared/ExtensionMessage"
@@ -11,19 +11,20 @@ import { useFloating, offset, flip, shift } from "@floating-ui/react"
interface CheckmarkControlProps {
messageTs?: number
isCheckpointCheckedOut?: boolean
/** Determines if the hover is near the checkpoint marker's visual position (either on the preceding row or the checkpoint row itself) */
isHoveredNearCheckpoint: boolean
/** Whether this is the last row in the chat */
isLastRow?: boolean
}
export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut, isHoveredNearCheckpoint }: CheckmarkControlProps) => {
export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut, isLastRow = false }: CheckmarkControlProps) => {
const [compareDisabled, setCompareDisabled] = useState(false)
const [restoreTaskDisabled, setRestoreTaskDisabled] = useState(false)
const [restoreWorkspaceDisabled, setRestoreWorkspaceDisabled] = useState(false)
const [restoreBothDisabled, setRestoreBothDisabled] = useState(false)
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false)
const [hasMouseEntered, setHasMouseEntered] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const tooltipRef = useRef<HTMLDivElement>(null)
const [isComponentHovered, setIsComponentHovered] = useState(false)
const [isLineHovered, setIsLineHovered] = useState(false)
const { refs, floatingStyles, update, placement } = useFloating({
placement: "bottom-end",
@@ -37,6 +38,65 @@ export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut, isHoveredN
],
})
// Simple time formatter if date-fns is not available
const getSimpleRelativeTime = (date: Date) => {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const seconds = Math.floor(diffMs / 1000)
// Handle "just now" case in the fallback too
if (seconds < 180) return "now" // 3 minutes = 180 seconds
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
return `${days}d`
}
// Format the timestamp for relative time display
const relativeTime = useMemo(() => {
if (!messageTs) return ""
// Create a Date object from the timestamp
const date = new Date(messageTs)
const now = new Date()
// Calculate time difference in milliseconds
const diffMs = now.getTime() - date.getTime()
const diffMinutes = Math.floor(diffMs / (1000 * 60))
// Show "just now" if less than 3 minutes
if (diffMinutes < 3) {
return "now"
}
// Fallback formatting if date-fns errors
return getSimpleRelativeTime(date)
}, [messageTs])
// Format the full timestamp for the detailed display (without year)
const formattedTime = useMemo(() => {
if (!messageTs) return ""
const date = new Date(messageTs)
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
})
}, [messageTs])
// Combined time format with relative time and full date
const combinedTimeFormat = useMemo(() => {
if (!relativeTime || !formattedTime) return ""
return `${relativeTime} · ${formattedTime}`
}, [relativeTime, formattedTime])
useEffect(() => {
const handleScroll = () => {
update()
@@ -121,206 +181,334 @@ export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut, isHoveredN
useEvent("message", handleMessage)
// Hide checkpoint if it is not the currently restored one AND the user is not hovering near it.
// This keeps the UI clean but ensures the checkpoint appear on hover for interaction.
const shouldHideCheckpoint = !isCheckpointCheckedOut && !isHoveredNearCheckpoint
if (shouldHideCheckpoint) {
return null
}
// Modified: Only show the expanded UI on hover (not permanently for checked out checkpoints)
// This way checked out checkpoints only show the line indicator unless hovered
const showExpandedUI =
(isLineHovered || isComponentHovered || showRestoreConfirm) &&
!(showRestoreConfirm === false && isComponentHovered === false && isLineHovered === false)
// The line should still be highlighted when the checkpoint is checked out
const shouldShowHoveredLine = isCheckpointCheckedOut || isLineHovered || isComponentHovered || showRestoreConfirm
return (
<Container isMenuOpen={showRestoreConfirm} $isCheckedOut={isCheckpointCheckedOut} onMouseLeave={handleControlsMouseLeave}>
<i
className="codicon codicon-bookmark"
style={{
color: isCheckpointCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)",
fontSize: "12px",
flexShrink: 0,
<Container isMenuOpen={showRestoreConfirm} $isCheckedOut={isCheckpointCheckedOut}>
{/* Line indicator is still styled differently for checked out checkpoints */}
<CheckpointIndicator
$isCheckedOut={isCheckpointCheckedOut}
$isHovered={shouldShowHoveredLine}
onMouseEnter={() => setIsLineHovered(true)}
onMouseLeave={() => {
setTimeout(() => {
if (!isComponentHovered && !showRestoreConfirm) {
setIsLineHovered(false)
}
}, 50)
}}
/>
<Label $isCheckedOut={isCheckpointCheckedOut}>
{isCheckpointCheckedOut ? "Checkpoint (restored)" : "Checkpoint"}
</Label>
<DottedLine $isCheckedOut={isCheckpointCheckedOut} />
<ButtonGroup>
<CustomButton
<HoverArea
onMouseEnter={() => setIsLineHovered(true)}
onMouseLeave={() => {
if (!isComponentHovered && !showRestoreConfirm) {
setIsLineHovered(false)
}
}}
/>
{showExpandedUI && (
<ExpandedUI
$isCheckedOut={isCheckpointCheckedOut}
disabled={compareDisabled}
style={{ cursor: compareDisabled ? "wait" : "pointer" }}
onClick={() => {
setCompareDisabled(true)
vscode.postMessage({
type: "checkpointDiff",
number: messageTs,
})
$isLastRow={isLastRow}
onMouseEnter={() => {
setIsComponentHovered(true)
setIsLineHovered(true)
}}
onMouseLeave={(e) => {
if (!showRestoreConfirm) {
setIsComponentHovered(false)
setIsLineHovered(false)
} else {
handleControlsMouseLeave(e)
}
}}>
Compare
</CustomButton>
<DottedLine small $isCheckedOut={isCheckpointCheckedOut} />
<div ref={refs.setReference} style={{ position: "relative", marginTop: -2 }}>
<CustomButton
$isCheckedOut={isCheckpointCheckedOut}
isActive={showRestoreConfirm}
onClick={() => setShowRestoreConfirm(true)}>
Restore
</CustomButton>
{showRestoreConfirm &&
createPortal(
<RestoreConfirmTooltip
ref={refs.setFloating}
style={floatingStyles}
data-placement={placement}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}>
<RestoreOption>
<VSCodeButton
onClick={handleRestoreWorkspace}
disabled={restoreWorkspaceDisabled}
style={{
cursor: restoreWorkspaceDisabled ? "wait" : "pointer",
width: "100%",
marginBottom: "10px",
}}>
Restore Files
</VSCodeButton>
<p>
Restores your project's files back to a snapshot taken at this point (use "Compare" to see
what will be reverted)
</p>
</RestoreOption>
<RestoreOption>
<VSCodeButton
onClick={handleRestoreTask}
disabled={restoreTaskDisabled}
style={{
cursor: restoreTaskDisabled ? "wait" : "pointer",
width: "100%",
marginBottom: "10px",
}}>
Restore Task Only
</VSCodeButton>
<p>Deletes messages after this point (does not affect workspace files)</p>
</RestoreOption>
<RestoreOption>
<VSCodeButton
onClick={handleRestoreBoth}
disabled={restoreBothDisabled}
style={{
cursor: restoreBothDisabled ? "wait" : "pointer",
width: "100%",
marginBottom: "10px",
}}>
Restore Files & Task
</VSCodeButton>
<p>Restores your project's files and deletes all messages after this point</p>
</RestoreOption>
</RestoreConfirmTooltip>,
document.body,
)}
</div>
<DottedLine small $isCheckedOut={isCheckpointCheckedOut} />
</ButtonGroup>
<SimpleLayout>
<LabelColumn>
<div style={{ display: "flex", alignItems: "center" }}>
<i
className="codicon codicon-bookmark"
style={{
color: isCheckpointCheckedOut
? "var(--vscode-textLink-foreground)"
: "var(--vscode-descriptionForeground)",
fontSize: "12px",
marginRight: "6px",
}}
/>
<Label $isCheckedOut={isCheckpointCheckedOut}>
{isCheckpointCheckedOut ? "Checkpoint (restored)" : "Checkpoint"}
</Label>
</div>
<TimeLabel $isCheckedOut={isCheckpointCheckedOut}>{combinedTimeFormat}</TimeLabel>
</LabelColumn>
<ButtonsWrapper>
<EnhancedButton
$isCheckedOut={isCheckpointCheckedOut}
disabled={compareDisabled}
style={{ cursor: compareDisabled ? "wait" : "pointer" }}
onClick={() => {
setCompareDisabled(true)
vscode.postMessage({
type: "checkpointDiff",
number: messageTs,
})
}}>
Compare
</EnhancedButton>
<div ref={refs.setReference} style={{ position: "relative" }}>
<EnhancedButton
$isCheckedOut={isCheckpointCheckedOut}
isActive={showRestoreConfirm}
onClick={() => setShowRestoreConfirm(true)}>
Restore
</EnhancedButton>
{showRestoreConfirm &&
createPortal(
<RestoreConfirmTooltip
ref={refs.setFloating}
style={floatingStyles}
data-placement={placement}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}>
<RestoreOption>
<VSCodeButton
onClick={handleRestoreWorkspace}
disabled={restoreWorkspaceDisabled}
style={{
cursor: restoreWorkspaceDisabled ? "wait" : "pointer",
width: "100%",
marginBottom: "10px",
}}>
Restore Files
</VSCodeButton>
<p>
Restores your project's files back to a snapshot taken at this point (use
"Compare" to see what will be reverted)
</p>
</RestoreOption>
<RestoreOption>
<VSCodeButton
onClick={handleRestoreTask}
disabled={restoreTaskDisabled}
style={{
cursor: restoreTaskDisabled ? "wait" : "pointer",
width: "100%",
marginBottom: "10px",
}}>
Restore Task Only
</VSCodeButton>
<p>Deletes messages after this point (does not affect workspace files)</p>
</RestoreOption>
<RestoreOption>
<VSCodeButton
onClick={handleRestoreBoth}
disabled={restoreBothDisabled}
style={{
cursor: restoreBothDisabled ? "wait" : "pointer",
width: "100%",
marginBottom: "10px",
}}>
Restore Files & Task
</VSCodeButton>
<p>Restores your project's files and deletes all messages after this point</p>
</RestoreOption>
</RestoreConfirmTooltip>,
document.body,
)}
</div>
</ButtonsWrapper>
</SimpleLayout>
</ExpandedUI>
)}
</Container>
)
}
const Container = styled.div<{ isMenuOpen?: boolean; $isCheckedOut?: boolean }>`
display: flex;
align-items: center;
padding: 4px 0;
gap: 4px;
position: relative;
min-width: 0;
margin-top: -10px;
margin-bottom: -10px;
opacity: ${(props) => (props.$isCheckedOut ? 1 : props.isMenuOpen ? 1 : 0.5)};
// Updated Container styling - doesn't need to handle as many hover events
const Container = styled.div<{
isMenuOpen?: boolean
$isCheckedOut?: boolean
}>`
position: absolute;
left: 0;
right: 0;
top: 0;
height: 0;
z-index: 10;
pointer-events: auto;
`
&:hover {
opacity: 1;
// Invisible hover area just around the line indicator
const HoverArea = styled.div`
position: absolute;
left: 0;
top: -6px;
width: 20px; /* Wide enough to easily hover */
height: 15px; /* Tall enough to catch hover events */
cursor: pointer;
`
// Make the highlighted line more visible for checked out checkpoints
const CheckpointIndicator = styled.div<{
$isCheckedOut?: boolean
$isHovered?: boolean
}>`
position: absolute;
left: 0;
top: 0;
/* Make checked out checkpoints have a more visible line */
width: ${(props) =>
props.$isCheckedOut ? "10px" /* Wider default for checked out checkpoints */ : props.$isHovered ? "12px" : "8px"};
height: 3px;
background-color: ${(props) =>
props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)"};
opacity: ${(props) => (props.$isCheckedOut ? 1 /* Always full opacity for checked out */ : props.$isHovered ? 1 : 0.6)};
transition:
opacity 0.15s ease-in-out,
width 0.15s ease-in-out;
cursor: pointer;
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
z-index: 5;
`
// Added label column component for consistent height
const LabelColumn = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
`
// Updated SimpleLayout with center alignment
const SimpleLayout = styled.div`
display: flex;
justify-content: space-between;
align-items: center; /* Center align items vertically */
width: 100%;
`
// Updated ButtonsWrapper with center alignment
const ButtonsWrapper = styled.div`
display: flex;
flex-wrap: wrap;
gap: 6px; /* Reduced gap */
justify-content: flex-end;
align-items: center;
`
// Container for the expanded UI that appears on hover
const ExpandedUI = styled.div<{
$isCheckedOut?: boolean
$isLastRow?: boolean
}>`
position: absolute;
left: 15px;
right: 7px;
${
(props) =>
props.$isLastRow
? "bottom: -3px;" // Position above for last row
: "top: -3px;" // Position below for normal rows
}
background-color: var(--vscode-editor-background);
border-radius: 3px;
padding: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border: 1px solid var(--vscode-widget-border);
z-index: 20;
animation: ${(props) => (props.$isLastRow ? "fadeInUp" : "fadeIn")} 0.15s ease-in-out;
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`
// Updated Label with slightly smaller font size
const Label = styled.span<{ $isCheckedOut?: boolean }>`
color: ${(props) => (props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)")};
font-size: 9px;
font-size: 11px; // Reduced from 12px
font-weight: 500;
flex-shrink: 0;
`
const DottedLine = styled.div<{ small?: boolean; $isCheckedOut?: boolean }>`
flex: ${(props) => (props.small ? "0 0 5px" : "1")};
min-width: ${(props) => (props.small ? "5px" : "5px")};
height: 1px;
background-image: linear-gradient(
to right,
${(props) => (props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)")} 50%,
transparent 50%
);
background-size: 4px 1px;
background-repeat: repeat-x;
// Updated TimeLabel with word-break enabled
const TimeLabel = styled.span<{ $isCheckedOut?: boolean }>`
font-size: 10px;
color: ${(props) => (props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)")};
margin-top: 3px;
margin-left: 20px;
word-break: break-word; /* Allow breaking words */
white-space: normal; /* Changed from nowrap to allow text to wrap */
max-width: 100%;
display: block; /* Ensure it takes full width for proper breaking */
`
const ButtonGroup = styled.div`
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
`
const CustomButton = styled.button<{ disabled?: boolean; isActive?: boolean; $isCheckedOut?: boolean }>`
// Simplified button styling with minimal padding and no extra styling
const EnhancedButton = styled.button<{
disabled?: boolean
isActive?: boolean
$isCheckedOut?: boolean
}>`
background: ${(props) =>
props.isActive || props.disabled
? props.$isCheckedOut
? "var(--vscode-textLink-foreground)"
: "var(--vscode-descriptionForeground)"
: "transparent"};
border: none;
: "var(--vscode-editor-background)"};
border: 1px solid
${(props) => (props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)")};
color: ${(props) =>
props.isActive || props.disabled
? "var(--vscode-editor-background)"
: props.$isCheckedOut
? "var(--vscode-textLink-foreground)"
: "var(--vscode-descriptionForeground)"};
padding: 2px 6px;
font-size: 9px;
cursor: pointer;
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 1px;
background-image: ${(props) =>
props.isActive || props.disabled
? "none"
: `linear-gradient(to right, ${props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)"} 50%, transparent 50%),
linear-gradient(to bottom, ${props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)"} 50%, transparent 50%),
linear-gradient(to right, ${props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)"} 50%, transparent 50%),
linear-gradient(to bottom, ${props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)"} 50%, transparent 50%)`};
background-size: ${(props) => (props.isActive || props.disabled ? "auto" : `4px 1px, 1px 4px, 4px 1px, 1px 4px`)};
background-repeat: repeat-x, repeat-y, repeat-x, repeat-y;
background-position:
0 0,
100% 0,
0 100%,
0 0;
}
border-radius: 3px;
padding: 3px 4px; /* Minimal padding */
font-size: 10px;
cursor: ${(props) => (props.disabled ? "wait" : "pointer")};
line-height: 1;
height: auto;
margin: 0;
&:hover:not(:disabled) {
background: ${(props) =>
props.$isCheckedOut ? "var(--vscode-textLink-foreground)" : "var(--vscode-descriptionForeground)"};
color: var(--vscode-editor-background);
&::before {
display: none;
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
opacity: 0.6;
}
`
+26 -26
View File
@@ -1,40 +1,40 @@
import { useMemo } from "react"
import { CSSProperties, useMemo } from "react"
import { ClineMessage } from "@shared/ExtensionMessage"
/**
* Custom hook to determine the dynamic styles for a ChatRowContainer.
*
* This hook calculates the padding and minimum height for a chat row based on
* whether it represents a checkpoint message and its current hover state.
* The goal is to visually collapse checkpoint markers when they are not checked out
* and not being hovered over, while ensuring they remain interactable.
* This hook provides styles for checkpoint messages that control their
* visual appearance based on hover state and whether they're checked out.
*
* @param message - The chat message object for the current row.
* @param hoveredRowIndex - The index of the currently hovered row, or null if none.
* @param rowIndex - The index of the current row being rendered.
* @returns An object containing the calculated style properties (padding and minHeight).
* @returns An object containing the calculated style properties.
*/
export const useChatRowStyles = (
message: ClineMessage,
hoveredRowIndex: number | null,
rowIndex: number,
): { padding: number | undefined; minHeight: number | undefined } => {
export const useChatRowStyles = (message: ClineMessage): CSSProperties => {
return useMemo(() => {
// Check if the current message is a checkpoint creation message.
const isCheckpointMessage = message.say === "checkpoint_created"
// Determine if the hover state is relevant to this row or the one immediately preceding it.
// This is because the checkpoint marker is visually associated with the row *before* the checkpoint message,
// but its visibility is controlled by the hover state of *both* the preceding row and the checkpoint message row itself.
const isHoverRelevant = hoveredRowIndex === rowIndex - 1 || hoveredRowIndex === rowIndex
// For checkpoint messages, make the row take up minimal space
// The checkpoint component will be absolutely positioned
if (isCheckpointMessage) {
return {
padding: 0,
// we can't set height to 0 because virtuoso needs a height to render the row, we can't set to 1 because it results in a visual artifact bug, so we use this hack to make the row almost invisible (and the checkpoint indicator positioned absolutely at this point in the list)
height: 8,
marginTop: -4,
marginBottom: -4,
overflow: "visible", // Allow the absolutely positioned content to be visible
}
}
// Calculate styles based on checkpoint status and hover relevance.
// If it's a checkpoint message, not currently checked out, and not relevantly hovered,
// reset padding to 0 and set minHeight to 1px to visually collapse it.
// Otherwise, use default styles (undefined, letting CSS handle it).
const padding = isCheckpointMessage && !message.isCheckpointCheckedOut && !isHoverRelevant ? 0 : undefined
const minHeight = isCheckpointMessage && !message.isCheckpointCheckedOut && !isHoverRelevant ? 1 : undefined
return { padding, minHeight }
}, [message.say, message.isCheckpointCheckedOut, hoveredRowIndex, rowIndex])
// For non-checkpoint messages, return normal styling
return {
padding: undefined,
minHeight: undefined,
height: undefined,
overflow: undefined,
position: undefined,
}
}, [message.say])
}