mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Merge branch 'RooVetGit:main' into human-relay
This commit is contained in:
commit
8a5f16cbf2
46 changed files with 1895 additions and 503 deletions
5
.changeset/chilly-bugs-pay.md
Normal file
5
.changeset/chilly-bugs-pay.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"roo-cline": patch
|
||||
---
|
||||
|
||||
Delete task confirmation enhancements
|
||||
5
.changeset/young-hornets-taste.md
Normal file
5
.changeset/young-hornets-taste.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"roo-cline": patch
|
||||
---
|
||||
|
||||
Prettier thinking blocks
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -1,5 +1,17 @@
|
|||
# Roo Code Changelog
|
||||
|
||||
## [3.7.8]
|
||||
|
||||
- Add Vertex AI prompt caching support for Claude models (thanks @aitoroses and @lupuletic!)
|
||||
- Add gpt-4.5-preview
|
||||
- Add an advanced feature to customize the system prompt
|
||||
|
||||
## [3.7.7]
|
||||
|
||||
- Graduate checkpoints out of beta
|
||||
- Fix enhance prompt button when using Thinking Sonnet
|
||||
- Add tooltips to make what buttons do more obvious
|
||||
|
||||
## [3.7.6]
|
||||
|
||||
- Handle really long text better in the in the ChatRow similar to TaskHeader (thanks @joemanley201!)
|
||||
|
|
|
|||
14
package-lock.json
generated
14
package-lock.json
generated
|
|
@ -1,16 +1,16 @@
|
|||
{
|
||||
"name": "roo-cline",
|
||||
"version": "3.7.6",
|
||||
"version": "3.7.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "roo-cline",
|
||||
"version": "3.7.6",
|
||||
"version": "3.7.8",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/bedrock-sdk": "^0.10.2",
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.4.1",
|
||||
"@anthropic-ai/vertex-sdk": "^0.7.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.706.0",
|
||||
"@google/generative-ai": "^0.18.0",
|
||||
"@mistralai/mistralai": "^1.3.6",
|
||||
|
|
@ -150,11 +150,11 @@
|
|||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
|
||||
},
|
||||
"node_modules/@anthropic-ai/vertex-sdk": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.4.3.tgz",
|
||||
"integrity": "sha512-2Uef0C5P2Hx+T88RnUSRA3u4aZqmqnrRSOb2N64ozgKPiSUPTM5JlggAq2b32yWMj5d3MLYa6spJXKMmHXOcoA==",
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.7.0.tgz",
|
||||
"integrity": "sha512-zNm3hUXgYmYDTyveIxOyxbcnh5VXFkrLo4bSnG6LAfGzW7k3k2iCNDSVKtR9qZrK2BCid7JtVu7jsEKaZ/9dSw==",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.14 <1",
|
||||
"@anthropic-ai/sdk": ">=0.35 <1",
|
||||
"google-auth-library": "^9.4.2"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"displayName": "Roo Code (prev. Roo Cline)",
|
||||
"description": "A whole dev team of AI agents in your editor.",
|
||||
"publisher": "RooVeterinaryInc",
|
||||
"version": "3.7.6",
|
||||
"version": "3.7.8",
|
||||
"icon": "assets/icons/rocket.png",
|
||||
"galleryBanner": {
|
||||
"color": "#617A91",
|
||||
|
|
@ -305,7 +305,7 @@
|
|||
"dependencies": {
|
||||
"@anthropic-ai/bedrock-sdk": "^0.10.2",
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.4.1",
|
||||
"@anthropic-ai/vertex-sdk": "^0.7.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.706.0",
|
||||
"@google/generative-ai": "^0.18.0",
|
||||
"@mistralai/mistralai": "^1.3.6",
|
||||
|
|
|
|||
|
|
@ -140,7 +140,6 @@ const mockFs = {
|
|||
currentPath += "/" + parts[parts.length - 1]
|
||||
mockDirectories.add(currentPath)
|
||||
return Promise.resolve()
|
||||
return Promise.resolve()
|
||||
}),
|
||||
|
||||
access: jest.fn().mockImplementation(async (path: string) => {
|
||||
|
|
|
|||
|
|
@ -15,3 +15,33 @@ jest.mock("../utils/logging", () => ({
|
|||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
// Add toPosix method to String prototype for all tests, mimicking src/utils/path.ts
|
||||
// This is needed because the production code expects strings to have this method
|
||||
// Note: In production, this is added via import in the entry point (extension.ts)
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
interface String {
|
||||
toPosix(): string
|
||||
}
|
||||
}
|
||||
|
||||
// Implementation that matches src/utils/path.ts
|
||||
function toPosixPath(p: string) {
|
||||
// Extended-Length Paths in Windows start with "\\?\" to allow longer paths
|
||||
// and bypass usual parsing. If detected, we return the path unmodified.
|
||||
const isExtendedLengthPath = p.startsWith("\\\\?\\")
|
||||
|
||||
if (isExtendedLengthPath) {
|
||||
return p
|
||||
}
|
||||
|
||||
return p.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
if (!String.prototype.toPosix) {
|
||||
String.prototype.toPosix = function (this: string): string {
|
||||
return toPosixPath(this)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ describe("AnthropicHandler", () => {
|
|||
})
|
||||
|
||||
it("should handle API errors", async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error("API Error"))
|
||||
mockCreate.mockRejectedValueOnce(new Error("Anthropic completion error: API Error"))
|
||||
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Anthropic completion error: API Error")
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ describe("OpenAiNativeHandler", () => {
|
|||
const modelInfo = handler.getModel()
|
||||
expect(modelInfo.id).toBe(mockOptions.apiModelId)
|
||||
expect(modelInfo.info).toBeDefined()
|
||||
expect(modelInfo.info.maxTokens).toBe(4096)
|
||||
expect(modelInfo.info.maxTokens).toBe(16384)
|
||||
expect(modelInfo.info.contextWindow).toBe(128_000)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"
|
||||
import { BetaThinkingConfigParam } from "@anthropic-ai/sdk/resources/beta"
|
||||
|
||||
import { VertexHandler } from "../vertex"
|
||||
import { ApiStreamChunk } from "../../transform/stream"
|
||||
|
||||
// Mock Vertex SDK
|
||||
jest.mock("@anthropic-ai/vertex-sdk", () => ({
|
||||
|
|
@ -128,7 +130,7 @@ describe("VertexHandler", () => {
|
|||
;(handler["client"].messages as any).create = mockCreate
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, mockMessages)
|
||||
const chunks = []
|
||||
const chunks: ApiStreamChunk[] = []
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
|
|
@ -158,8 +160,29 @@ describe("VertexHandler", () => {
|
|||
model: "claude-3-5-sonnet-v2@20241022",
|
||||
max_tokens: 8192,
|
||||
temperature: 0,
|
||||
system: systemPrompt,
|
||||
messages: mockMessages,
|
||||
system: [
|
||||
{
|
||||
type: "text",
|
||||
text: "You are a helpful assistant",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Hello",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Hi there!",
|
||||
},
|
||||
],
|
||||
stream: true,
|
||||
})
|
||||
})
|
||||
|
|
@ -196,7 +219,7 @@ describe("VertexHandler", () => {
|
|||
;(handler["client"].messages as any).create = mockCreate
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, mockMessages)
|
||||
const chunks = []
|
||||
const chunks: ApiStreamChunk[] = []
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
|
|
@ -230,6 +253,315 @@ describe("VertexHandler", () => {
|
|||
}
|
||||
}).rejects.toThrow("Vertex API error")
|
||||
})
|
||||
|
||||
it("should handle prompt caching for supported models", async () => {
|
||||
const mockStream = [
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 3,
|
||||
cache_read_input_tokens: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "text",
|
||||
text: "Hello",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
delta: {
|
||||
type: "text_delta",
|
||||
text: " world!",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message_delta",
|
||||
usage: {
|
||||
output_tokens: 5,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const asyncIterator = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const chunk of mockStream) {
|
||||
yield chunk
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const mockCreate = jest.fn().mockResolvedValue(asyncIterator)
|
||||
;(handler["client"].messages as any).create = mockCreate
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, [
|
||||
{
|
||||
role: "user",
|
||||
content: "First message",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Response",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "Second message",
|
||||
},
|
||||
])
|
||||
|
||||
const chunks: ApiStreamChunk[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify usage information
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks).toHaveLength(2)
|
||||
expect(usageChunks[0]).toEqual({
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 0,
|
||||
cacheWriteTokens: 3,
|
||||
cacheReadTokens: 2,
|
||||
})
|
||||
expect(usageChunks[1]).toEqual({
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
// Verify text content
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(2)
|
||||
expect(textChunks[0].text).toBe("Hello")
|
||||
expect(textChunks[1].text).toBe(" world!")
|
||||
|
||||
// Verify cache control was added correctly
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
system: [
|
||||
{
|
||||
type: "text",
|
||||
text: "You are a helpful assistant",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "First message",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: "Response",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Second message",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle cache-related usage metrics", async () => {
|
||||
const mockStream = [
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 5,
|
||||
cache_read_input_tokens: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "text",
|
||||
text: "Hello",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const asyncIterator = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const chunk of mockStream) {
|
||||
yield chunk
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const mockCreate = jest.fn().mockResolvedValue(asyncIterator)
|
||||
;(handler["client"].messages as any).create = mockCreate
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, mockMessages)
|
||||
const chunks: ApiStreamChunk[] = []
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Check for cache-related metrics in usage chunk
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0]).toHaveProperty("cacheWriteTokens", 5)
|
||||
expect(usageChunks[0]).toHaveProperty("cacheReadTokens", 3)
|
||||
})
|
||||
})
|
||||
|
||||
describe("thinking functionality", () => {
|
||||
const mockMessages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
},
|
||||
]
|
||||
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
|
||||
it("should handle thinking content blocks and deltas", async () => {
|
||||
const mockStream = [
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "thinking",
|
||||
thinking: "Let me think about this...",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
delta: {
|
||||
type: "thinking_delta",
|
||||
thinking: " I need to consider all options.",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 1,
|
||||
content_block: {
|
||||
type: "text",
|
||||
text: "Here's my answer:",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Setup async iterator for mock stream
|
||||
const asyncIterator = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const chunk of mockStream) {
|
||||
yield chunk
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const mockCreate = jest.fn().mockResolvedValue(asyncIterator)
|
||||
;(handler["client"].messages as any).create = mockCreate
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, mockMessages)
|
||||
const chunks: ApiStreamChunk[] = []
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify thinking content is processed correctly
|
||||
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
|
||||
expect(reasoningChunks).toHaveLength(2)
|
||||
expect(reasoningChunks[0].text).toBe("Let me think about this...")
|
||||
expect(reasoningChunks[1].text).toBe(" I need to consider all options.")
|
||||
|
||||
// Verify text content is processed correctly
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(2) // One for the text block, one for the newline
|
||||
expect(textChunks[0].text).toBe("\n")
|
||||
expect(textChunks[1].text).toBe("Here's my answer:")
|
||||
})
|
||||
|
||||
it("should handle multiple thinking blocks with line breaks", async () => {
|
||||
const mockStream = [
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "thinking",
|
||||
thinking: "First thinking block",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 1,
|
||||
content_block: {
|
||||
type: "thinking",
|
||||
thinking: "Second thinking block",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const asyncIterator = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const chunk of mockStream) {
|
||||
yield chunk
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const mockCreate = jest.fn().mockResolvedValue(asyncIterator)
|
||||
;(handler["client"].messages as any).create = mockCreate
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, mockMessages)
|
||||
const chunks: ApiStreamChunk[] = []
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks.length).toBe(3)
|
||||
expect(chunks[0]).toEqual({
|
||||
type: "reasoning",
|
||||
text: "First thinking block",
|
||||
})
|
||||
expect(chunks[1]).toEqual({
|
||||
type: "reasoning",
|
||||
text: "\n",
|
||||
})
|
||||
expect(chunks[2]).toEqual({
|
||||
type: "reasoning",
|
||||
text: "Second thinking block",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt", () => {
|
||||
|
|
@ -240,7 +572,13 @@ describe("VertexHandler", () => {
|
|||
model: "claude-3-5-sonnet-v2@20241022",
|
||||
max_tokens: 8192,
|
||||
temperature: 0,
|
||||
messages: [{ role: "user", content: "Test prompt" }],
|
||||
system: "",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Test prompt", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
})
|
||||
})
|
||||
|
|
@ -295,4 +633,109 @@ describe("VertexHandler", () => {
|
|||
expect(modelInfo.id).toBe("claude-3-7-sonnet@20250219") // Default model
|
||||
})
|
||||
})
|
||||
|
||||
describe("thinking model configuration", () => {
|
||||
it("should configure thinking for models with :thinking suffix", () => {
|
||||
const thinkingHandler = new VertexHandler({
|
||||
apiModelId: "claude-3-7-sonnet@20250219:thinking",
|
||||
vertexProjectId: "test-project",
|
||||
vertexRegion: "us-central1",
|
||||
modelMaxTokens: 16384,
|
||||
modelMaxThinkingTokens: 4096,
|
||||
})
|
||||
|
||||
const modelInfo = thinkingHandler.getModel()
|
||||
|
||||
// Verify thinking configuration
|
||||
expect(modelInfo.id).toBe("claude-3-7-sonnet@20250219")
|
||||
expect(modelInfo.thinking).toBeDefined()
|
||||
const thinkingConfig = modelInfo.thinking as { type: "enabled"; budget_tokens: number }
|
||||
expect(thinkingConfig.type).toBe("enabled")
|
||||
expect(thinkingConfig.budget_tokens).toBe(4096)
|
||||
expect(modelInfo.temperature).toBe(1.0) // Thinking requires temperature 1.0
|
||||
})
|
||||
|
||||
it("should calculate thinking budget correctly", () => {
|
||||
// Test with explicit thinking budget
|
||||
const handlerWithBudget = new VertexHandler({
|
||||
apiModelId: "claude-3-7-sonnet@20250219:thinking",
|
||||
vertexProjectId: "test-project",
|
||||
vertexRegion: "us-central1",
|
||||
modelMaxTokens: 16384,
|
||||
modelMaxThinkingTokens: 5000,
|
||||
})
|
||||
|
||||
expect((handlerWithBudget.getModel().thinking as any).budget_tokens).toBe(5000)
|
||||
|
||||
// Test with default thinking budget (80% of max tokens)
|
||||
const handlerWithDefaultBudget = new VertexHandler({
|
||||
apiModelId: "claude-3-7-sonnet@20250219:thinking",
|
||||
vertexProjectId: "test-project",
|
||||
vertexRegion: "us-central1",
|
||||
modelMaxTokens: 10000,
|
||||
})
|
||||
|
||||
expect((handlerWithDefaultBudget.getModel().thinking as any).budget_tokens).toBe(8000) // 80% of 10000
|
||||
|
||||
// Test with minimum thinking budget (should be at least 1024)
|
||||
const handlerWithSmallMaxTokens = new VertexHandler({
|
||||
apiModelId: "claude-3-7-sonnet@20250219:thinking",
|
||||
vertexProjectId: "test-project",
|
||||
vertexRegion: "us-central1",
|
||||
modelMaxTokens: 1000, // This would result in 800 tokens for thinking, but minimum is 1024
|
||||
})
|
||||
|
||||
expect((handlerWithSmallMaxTokens.getModel().thinking as any).budget_tokens).toBe(1024)
|
||||
})
|
||||
|
||||
it("should pass thinking configuration to API", async () => {
|
||||
const thinkingHandler = new VertexHandler({
|
||||
apiModelId: "claude-3-7-sonnet@20250219:thinking",
|
||||
vertexProjectId: "test-project",
|
||||
vertexRegion: "us-central1",
|
||||
modelMaxTokens: 16384,
|
||||
modelMaxThinkingTokens: 4096,
|
||||
})
|
||||
|
||||
const mockCreate = jest.fn().mockImplementation(async (options) => {
|
||||
if (!options.stream) {
|
||||
return {
|
||||
id: "test-completion",
|
||||
content: [{ type: "text", text: "Test response" }],
|
||||
role: "assistant",
|
||||
model: options.model,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
;(thinkingHandler["client"].messages as any).create = mockCreate
|
||||
|
||||
await thinkingHandler
|
||||
.createMessage("You are a helpful assistant", [{ role: "user", content: "Hello" }])
|
||||
.next()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thinking: { type: "enabled", budget_tokens: 4096 },
|
||||
temperature: 1.0, // Thinking requires temperature 1.0
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -30,29 +30,7 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler {
|
|||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
let stream: AnthropicStream<Anthropic.Messages.RawMessageStreamEvent>
|
||||
const cacheControl: CacheControlEphemeral = { type: "ephemeral" }
|
||||
let { id: modelId, info: modelInfo } = this.getModel()
|
||||
const maxTokens = this.options.modelMaxTokens || modelInfo.maxTokens || 8192
|
||||
let temperature = this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE
|
||||
let thinking: BetaThinkingConfigParam | undefined = undefined
|
||||
|
||||
// Anthropic "Thinking" models require a temperature of 1.0.
|
||||
if (modelId === "claude-3-7-sonnet-20250219:thinking") {
|
||||
// The `:thinking` variant is a virtual identifier for the
|
||||
// `claude-3-7-sonnet-20250219` model with a thinking budget.
|
||||
// We can handle this more elegantly in the future.
|
||||
modelId = "claude-3-7-sonnet-20250219"
|
||||
|
||||
// Clamp the thinking budget to be at most 80% of max tokens and at
|
||||
// least 1024 tokens.
|
||||
const maxBudgetTokens = Math.floor(maxTokens * 0.8)
|
||||
const budgetTokens = Math.max(
|
||||
Math.min(this.options.anthropicThinking ?? maxBudgetTokens, maxBudgetTokens),
|
||||
1024,
|
||||
)
|
||||
|
||||
thinking = { type: "enabled", budget_tokens: budgetTokens }
|
||||
temperature = 1.0
|
||||
}
|
||||
let { id: modelId, temperature, maxTokens, thinking } = this.getModel()
|
||||
|
||||
switch (modelId) {
|
||||
case "claude-3-7-sonnet-20250219":
|
||||
|
|
@ -202,40 +180,62 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler {
|
|||
}
|
||||
}
|
||||
|
||||
getModel(): { id: AnthropicModelId; info: ModelInfo } {
|
||||
getModel() {
|
||||
const modelId = this.options.apiModelId
|
||||
let temperature = this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE
|
||||
let thinking: BetaThinkingConfigParam | undefined = undefined
|
||||
|
||||
if (modelId && modelId in anthropicModels) {
|
||||
const id = modelId as AnthropicModelId
|
||||
return { id, info: anthropicModels[id] }
|
||||
let id = modelId as AnthropicModelId
|
||||
const info: ModelInfo = anthropicModels[id]
|
||||
|
||||
// The `:thinking` variant is a virtual identifier for the
|
||||
// `claude-3-7-sonnet-20250219` model with a thinking budget.
|
||||
// We can handle this more elegantly in the future.
|
||||
if (id === "claude-3-7-sonnet-20250219:thinking") {
|
||||
id = "claude-3-7-sonnet-20250219"
|
||||
}
|
||||
|
||||
const maxTokens = this.options.modelMaxTokens || info.maxTokens || 8192
|
||||
|
||||
if (info.thinking) {
|
||||
// Anthropic "Thinking" models require a temperature of 1.0.
|
||||
temperature = 1.0
|
||||
|
||||
// Clamp the thinking budget to be at most 80% of max tokens and at
|
||||
// least 1024 tokens.
|
||||
const maxBudgetTokens = Math.floor(maxTokens * 0.8)
|
||||
const budgetTokens = Math.max(
|
||||
Math.min(this.options.modelMaxThinkingTokens ?? maxBudgetTokens, maxBudgetTokens),
|
||||
1024,
|
||||
)
|
||||
|
||||
thinking = { type: "enabled", budget_tokens: budgetTokens }
|
||||
}
|
||||
|
||||
return { id, info, temperature, maxTokens, thinking }
|
||||
}
|
||||
|
||||
return { id: anthropicDefaultModelId, info: anthropicModels[anthropicDefaultModelId] }
|
||||
const id = anthropicDefaultModelId
|
||||
const info: ModelInfo = anthropicModels[id]
|
||||
const maxTokens = this.options.modelMaxTokens || info.maxTokens || 8192
|
||||
|
||||
return { id, info, temperature, maxTokens, thinking }
|
||||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
try {
|
||||
const response = await this.client.messages.create({
|
||||
model: this.getModel().id,
|
||||
max_tokens: this.getModel().info.maxTokens || 8192,
|
||||
temperature: this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
stream: false,
|
||||
})
|
||||
async completePrompt(prompt: string) {
|
||||
let { id: modelId, temperature, maxTokens, thinking } = this.getModel()
|
||||
|
||||
const content = response.content[0]
|
||||
const message = await this.client.messages.create({
|
||||
model: modelId,
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
thinking,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
stream: false,
|
||||
})
|
||||
|
||||
if (content.type === "text") {
|
||||
return content.text
|
||||
}
|
||||
|
||||
return ""
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Anthropic completion error: ${error.message}`)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
const content = message.content.find(({ type }) => type === "text")
|
||||
return content?.type === "text" ? content.text : ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
|
|||
// least 1024 tokens.
|
||||
const maxBudgetTokens = Math.floor((maxTokens || 8192) * 0.8)
|
||||
const budgetTokens = Math.max(
|
||||
Math.min(this.options.anthropicThinking ?? maxBudgetTokens, maxBudgetTokens),
|
||||
Math.min(this.options.modelMaxThinkingTokens ?? maxBudgetTokens, maxBudgetTokens),
|
||||
1024,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,97 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"
|
||||
import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming"
|
||||
import { ApiHandler, SingleCompletionHandler } from "../"
|
||||
import { BetaThinkingConfigParam } from "@anthropic-ai/sdk/resources/beta"
|
||||
import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../../shared/api"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
// Types for Vertex SDK
|
||||
|
||||
/**
|
||||
* Vertex API has specific limitations for prompt caching:
|
||||
* 1. Maximum of 4 blocks can have cache_control
|
||||
* 2. Only text blocks can be cached (images and other content types cannot)
|
||||
* 3. Cache control can only be applied to user messages, not assistant messages
|
||||
*
|
||||
* Our caching strategy:
|
||||
* - Cache the system prompt (1 block)
|
||||
* - Cache the last text block of the second-to-last user message (1 block)
|
||||
* - Cache the last text block of the last user message (1 block)
|
||||
* This ensures we stay under the 4-block limit while maintaining effective caching
|
||||
* for the most relevant context.
|
||||
*/
|
||||
|
||||
interface VertexTextBlock {
|
||||
type: "text"
|
||||
text: string
|
||||
cache_control?: { type: "ephemeral" }
|
||||
}
|
||||
|
||||
interface VertexImageBlock {
|
||||
type: "image"
|
||||
source: {
|
||||
type: "base64"
|
||||
media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp"
|
||||
data: string
|
||||
}
|
||||
}
|
||||
|
||||
type VertexContentBlock = VertexTextBlock | VertexImageBlock
|
||||
|
||||
interface VertexUsage {
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
}
|
||||
|
||||
interface VertexMessage extends Omit<Anthropic.Messages.MessageParam, "content"> {
|
||||
content: string | VertexContentBlock[]
|
||||
}
|
||||
|
||||
interface VertexMessageCreateParams {
|
||||
model: string
|
||||
max_tokens: number
|
||||
temperature: number
|
||||
system: string | VertexTextBlock[]
|
||||
messages: VertexMessage[]
|
||||
stream: boolean
|
||||
}
|
||||
|
||||
interface VertexMessageResponse {
|
||||
content: Array<{ type: "text"; text: string }>
|
||||
}
|
||||
|
||||
interface VertexMessageStreamEvent {
|
||||
type: "message_start" | "message_delta" | "content_block_start" | "content_block_delta"
|
||||
message?: {
|
||||
usage: VertexUsage
|
||||
}
|
||||
usage?: {
|
||||
output_tokens: number
|
||||
}
|
||||
content_block?:
|
||||
| {
|
||||
type: "text"
|
||||
text: string
|
||||
}
|
||||
| {
|
||||
type: "thinking"
|
||||
thinking: string
|
||||
}
|
||||
index?: number
|
||||
delta?:
|
||||
| {
|
||||
type: "text_delta"
|
||||
text: string
|
||||
}
|
||||
| {
|
||||
type: "thinking_delta"
|
||||
thinking: string
|
||||
}
|
||||
}
|
||||
|
||||
// https://docs.anthropic.com/en/api/claude-on-vertex-ai
|
||||
export class VertexHandler implements ApiHandler, SingleCompletionHandler {
|
||||
private options: ApiHandlerOptions
|
||||
|
|
@ -18,37 +106,122 @@ export class VertexHandler implements ApiHandler, SingleCompletionHandler {
|
|||
})
|
||||
}
|
||||
|
||||
private formatMessageForCache(message: Anthropic.Messages.MessageParam, shouldCache: boolean): VertexMessage {
|
||||
// Assistant messages are kept as-is since they can't be cached
|
||||
if (message.role === "assistant") {
|
||||
return message as VertexMessage
|
||||
}
|
||||
|
||||
// For string content, we convert to array format with optional cache control
|
||||
if (typeof message.content === "string") {
|
||||
return {
|
||||
...message,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: message.content,
|
||||
// For string content, we only have one block so it's always the last
|
||||
...(shouldCache && { cache_control: { type: "ephemeral" } }),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// For array content, find the last text block index once before mapping
|
||||
const lastTextBlockIndex = message.content.reduce(
|
||||
(lastIndex, content, index) => (content.type === "text" ? index : lastIndex),
|
||||
-1,
|
||||
)
|
||||
|
||||
// Then use this pre-calculated index in the map function
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((content, contentIndex) => {
|
||||
// Images and other non-text content are passed through unchanged
|
||||
if (content.type === "image") {
|
||||
return content as VertexImageBlock
|
||||
}
|
||||
|
||||
// Check if this is the last text block using our pre-calculated index
|
||||
const isLastTextBlock = contentIndex === lastTextBlockIndex
|
||||
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: (content as { text: string }).text,
|
||||
...(shouldCache && isLastTextBlock && { cache_control: { type: "ephemeral" } }),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const stream = await this.client.messages.create({
|
||||
model: this.getModel().id,
|
||||
max_tokens: this.getModel().info.maxTokens || 8192,
|
||||
temperature: this.options.modelTemperature ?? 0,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
const model = this.getModel()
|
||||
let { id, info, temperature, maxTokens, thinking } = model
|
||||
const useCache = model.info.supportsPromptCache
|
||||
|
||||
// Find indices of user messages that we want to cache
|
||||
// We only cache the last two user messages to stay within the 4-block limit
|
||||
// (1 block for system + 1 block each for last two user messages = 3 total)
|
||||
const userMsgIndices = useCache
|
||||
? messages.reduce((acc, msg, i) => (msg.role === "user" ? [...acc, i] : acc), [] as number[])
|
||||
: []
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
|
||||
// Create the stream with appropriate caching configuration
|
||||
const params = {
|
||||
model: id,
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
thinking,
|
||||
// Cache the system prompt if caching is enabled
|
||||
system: useCache
|
||||
? [
|
||||
{
|
||||
text: systemPrompt,
|
||||
type: "text" as const,
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
]
|
||||
: systemPrompt,
|
||||
messages: messages.map((message, index) => {
|
||||
// Only cache the last two user messages
|
||||
const shouldCache = useCache && (index === lastUserMsgIndex || index === secondLastMsgUserIndex)
|
||||
return this.formatMessageForCache(message, shouldCache)
|
||||
}),
|
||||
stream: true,
|
||||
})
|
||||
}
|
||||
|
||||
const stream = (await this.client.messages.create(
|
||||
params as Anthropic.Messages.MessageCreateParamsStreaming,
|
||||
)) as unknown as AnthropicStream<VertexMessageStreamEvent>
|
||||
|
||||
// Process the stream chunks
|
||||
for await (const chunk of stream) {
|
||||
switch (chunk.type) {
|
||||
case "message_start":
|
||||
const usage = chunk.message.usage
|
||||
case "message_start": {
|
||||
const usage = chunk.message!.usage
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
cacheWriteTokens: usage.cache_creation_input_tokens,
|
||||
cacheReadTokens: usage.cache_read_input_tokens,
|
||||
}
|
||||
break
|
||||
case "message_delta":
|
||||
}
|
||||
case "message_delta": {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: chunk.usage.output_tokens || 0,
|
||||
outputTokens: chunk.usage!.output_tokens || 0,
|
||||
}
|
||||
break
|
||||
|
||||
case "content_block_start":
|
||||
switch (chunk.content_block.type) {
|
||||
case "text":
|
||||
if (chunk.index > 0) {
|
||||
}
|
||||
case "content_block_start": {
|
||||
switch (chunk.content_block!.type) {
|
||||
case "text": {
|
||||
if (chunk.index! > 0) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: "\n",
|
||||
|
|
@ -56,43 +229,124 @@ export class VertexHandler implements ApiHandler, SingleCompletionHandler {
|
|||
}
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.content_block.text,
|
||||
text: chunk.content_block!.text,
|
||||
}
|
||||
break
|
||||
}
|
||||
case "thinking": {
|
||||
if (chunk.index! > 0) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: "\n",
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: (chunk.content_block as any).thinking,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
case "content_block_delta":
|
||||
switch (chunk.delta.type) {
|
||||
case "text_delta":
|
||||
}
|
||||
case "content_block_delta": {
|
||||
switch (chunk.delta!.type) {
|
||||
case "text_delta": {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.delta.text,
|
||||
text: chunk.delta!.text,
|
||||
}
|
||||
break
|
||||
}
|
||||
case "thinking_delta": {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: (chunk.delta as any).thinking,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: VertexModelId; info: ModelInfo } {
|
||||
getModel(): {
|
||||
id: VertexModelId
|
||||
info: ModelInfo
|
||||
temperature: number
|
||||
maxTokens: number
|
||||
thinking?: BetaThinkingConfigParam
|
||||
} {
|
||||
const modelId = this.options.apiModelId
|
||||
let temperature = this.options.modelTemperature ?? 0
|
||||
let thinking: BetaThinkingConfigParam | undefined = undefined
|
||||
|
||||
if (modelId && modelId in vertexModels) {
|
||||
const id = modelId as VertexModelId
|
||||
return { id, info: vertexModels[id] }
|
||||
const info: ModelInfo = vertexModels[id]
|
||||
|
||||
// The `:thinking` variant is a virtual identifier for thinking-enabled models
|
||||
// Similar to how it's handled in the Anthropic provider
|
||||
let actualId = id
|
||||
if (id.endsWith(":thinking")) {
|
||||
actualId = id.replace(":thinking", "") as VertexModelId
|
||||
}
|
||||
|
||||
const maxTokens = this.options.modelMaxTokens || info.maxTokens || 8192
|
||||
|
||||
if (info.thinking) {
|
||||
temperature = 1.0 // Thinking requires temperature 1.0
|
||||
const maxBudgetTokens = Math.floor(maxTokens * 0.8)
|
||||
const budgetTokens = Math.max(
|
||||
Math.min(this.options.modelMaxThinkingTokens ?? maxBudgetTokens, maxBudgetTokens),
|
||||
1024,
|
||||
)
|
||||
thinking = { type: "enabled", budget_tokens: budgetTokens }
|
||||
}
|
||||
|
||||
return { id: actualId, info, temperature, maxTokens, thinking }
|
||||
}
|
||||
return { id: vertexDefaultModelId, info: vertexModels[vertexDefaultModelId] }
|
||||
|
||||
const id = vertexDefaultModelId
|
||||
const info = vertexModels[id]
|
||||
const maxTokens = this.options.modelMaxTokens || info.maxTokens || 8192
|
||||
|
||||
return { id, info, temperature, maxTokens, thinking }
|
||||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
try {
|
||||
const response = await this.client.messages.create({
|
||||
model: this.getModel().id,
|
||||
max_tokens: this.getModel().info.maxTokens || 8192,
|
||||
temperature: this.options.modelTemperature ?? 0,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
let { id, info, temperature, maxTokens, thinking } = this.getModel()
|
||||
const useCache = info.supportsPromptCache
|
||||
|
||||
const params = {
|
||||
model: id,
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
thinking,
|
||||
system: "", // No system prompt needed for single completions
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: useCache
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: prompt,
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
]
|
||||
: prompt,
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
})
|
||||
}
|
||||
|
||||
const response = (await this.client.messages.create(
|
||||
params as Anthropic.Messages.MessageCreateParamsNonStreaming,
|
||||
)) as unknown as VertexMessageResponse
|
||||
|
||||
const content = response.content[0]
|
||||
if (content.type === "text") {
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export class Cline {
|
|||
isInitialized = false
|
||||
|
||||
// checkpoints
|
||||
checkpointsEnabled: boolean = false
|
||||
enableCheckpoints: boolean = false
|
||||
private checkpointService?: CheckpointService
|
||||
|
||||
// streaming
|
||||
|
|
@ -159,7 +159,7 @@ export class Cline {
|
|||
this.fuzzyMatchThreshold = fuzzyMatchThreshold ?? 1.0
|
||||
this.providerRef = new WeakRef(provider)
|
||||
this.diffViewProvider = new DiffViewProvider(cwd)
|
||||
this.checkpointsEnabled = enableCheckpoints ?? false
|
||||
this.enableCheckpoints = enableCheckpoints ?? false
|
||||
|
||||
if (historyItem) {
|
||||
this.taskId = historyItem.id
|
||||
|
|
@ -3337,7 +3337,7 @@ export class Cline {
|
|||
// Checkpoints
|
||||
|
||||
private async getCheckpointService() {
|
||||
if (!this.checkpointsEnabled) {
|
||||
if (!this.enableCheckpoints) {
|
||||
throw new Error("Checkpoints are disabled")
|
||||
}
|
||||
|
||||
|
|
@ -3378,7 +3378,7 @@ export class Cline {
|
|||
commitHash: string
|
||||
mode: "full" | "checkpoint"
|
||||
}) {
|
||||
if (!this.checkpointsEnabled) {
|
||||
if (!this.enableCheckpoints) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -3417,12 +3417,12 @@ export class Cline {
|
|||
)
|
||||
} catch (err) {
|
||||
this.providerRef.deref()?.log("[checkpointDiff] disabling checkpoints for this task")
|
||||
this.checkpointsEnabled = false
|
||||
this.enableCheckpoints = false
|
||||
}
|
||||
}
|
||||
|
||||
public async checkpointSave({ isFirst }: { isFirst: boolean }) {
|
||||
if (!this.checkpointsEnabled) {
|
||||
if (!this.enableCheckpoints) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -3443,7 +3443,7 @@ export class Cline {
|
|||
}
|
||||
} catch (err) {
|
||||
this.providerRef.deref()?.log("[checkpointSave] disabling checkpoints for this task")
|
||||
this.checkpointsEnabled = false
|
||||
this.enableCheckpoints = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3456,7 +3456,7 @@ export class Cline {
|
|||
commitHash: string
|
||||
mode: "preview" | "restore"
|
||||
}) {
|
||||
if (!this.checkpointsEnabled) {
|
||||
if (!this.enableCheckpoints) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -3511,7 +3511,7 @@ export class Cline {
|
|||
this.providerRef.deref()?.cancelTask()
|
||||
} catch (err) {
|
||||
this.providerRef.deref()?.log("[checkpointRestore] disabling checkpoints for this task")
|
||||
this.checkpointsEnabled = false
|
||||
this.enableCheckpoints = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
172
src/core/prompts/__tests__/custom-system-prompt.test.ts
Normal file
172
src/core/prompts/__tests__/custom-system-prompt.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { SYSTEM_PROMPT } from "../system"
|
||||
import { defaultModeSlug, modes } from "../../../shared/modes"
|
||||
import * as vscode from "vscode"
|
||||
import * as fs from "fs/promises"
|
||||
|
||||
// Mock the fs/promises module
|
||||
jest.mock("fs/promises", () => ({
|
||||
readFile: jest.fn(),
|
||||
mkdir: jest.fn().mockResolvedValue(undefined),
|
||||
access: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
// Get the mocked fs module
|
||||
const mockedFs = fs as jest.Mocked<typeof fs>
|
||||
|
||||
// Mock the fileExistsAtPath function
|
||||
jest.mock("../../../utils/fs", () => ({
|
||||
fileExistsAtPath: jest.fn().mockResolvedValue(true),
|
||||
createDirectoriesForFile: jest.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
// Create a mock ExtensionContext with relative paths instead of absolute paths
|
||||
const mockContext = {
|
||||
extensionPath: "mock/extension/path",
|
||||
globalStoragePath: "mock/storage/path",
|
||||
storagePath: "mock/storage/path",
|
||||
logPath: "mock/log/path",
|
||||
subscriptions: [],
|
||||
workspaceState: {
|
||||
get: () => undefined,
|
||||
update: () => Promise.resolve(),
|
||||
},
|
||||
globalState: {
|
||||
get: () => undefined,
|
||||
update: () => Promise.resolve(),
|
||||
setKeysForSync: () => {},
|
||||
},
|
||||
extensionUri: { fsPath: "mock/extension/path" },
|
||||
globalStorageUri: { fsPath: "mock/settings/path" },
|
||||
asAbsolutePath: (relativePath: string) => `mock/extension/path/${relativePath}`,
|
||||
extension: {
|
||||
packageJSON: {
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
} as unknown as vscode.ExtensionContext
|
||||
|
||||
describe("File-Based Custom System Prompt", () => {
|
||||
const experiments = {}
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks before each test
|
||||
jest.clearAllMocks()
|
||||
|
||||
// Default behavior: file doesn't exist
|
||||
mockedFs.readFile.mockRejectedValue({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
it("should use default generation when no file-based system prompt is found", async () => {
|
||||
const customModePrompts = {
|
||||
[defaultModeSlug]: {
|
||||
roleDefinition: "Test role definition",
|
||||
},
|
||||
}
|
||||
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"test/path", // Using a relative path without leading slash
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
defaultModeSlug,
|
||||
customModePrompts,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
experiments,
|
||||
true,
|
||||
)
|
||||
|
||||
// Should contain default sections
|
||||
expect(prompt).toContain("TOOL USE")
|
||||
expect(prompt).toContain("CAPABILITIES")
|
||||
expect(prompt).toContain("MODES")
|
||||
expect(prompt).toContain("Test role definition")
|
||||
})
|
||||
|
||||
it("should use file-based custom system prompt when available", async () => {
|
||||
// Mock the readFile to return content from a file
|
||||
const fileCustomSystemPrompt = "Custom system prompt from file"
|
||||
// When called with utf-8 encoding, return a string
|
||||
mockedFs.readFile.mockImplementation((filePath, options) => {
|
||||
if (filePath.toString().includes(`.roo/system-prompt-${defaultModeSlug}`) && options === "utf-8") {
|
||||
return Promise.resolve(fileCustomSystemPrompt)
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"test/path", // Using a relative path without leading slash
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
defaultModeSlug,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
experiments,
|
||||
true,
|
||||
)
|
||||
|
||||
// Should contain role definition and file-based system prompt
|
||||
expect(prompt).toContain(modes[0].roleDefinition)
|
||||
expect(prompt).toContain(fileCustomSystemPrompt)
|
||||
|
||||
// Should not contain any of the default sections
|
||||
expect(prompt).not.toContain("TOOL USE")
|
||||
expect(prompt).not.toContain("CAPABILITIES")
|
||||
expect(prompt).not.toContain("MODES")
|
||||
})
|
||||
|
||||
it("should combine file-based system prompt with role definition and custom instructions", async () => {
|
||||
// Mock the readFile to return content from a file
|
||||
const fileCustomSystemPrompt = "Custom system prompt from file"
|
||||
mockedFs.readFile.mockImplementation((filePath, options) => {
|
||||
if (filePath.toString().includes(`.roo/system-prompt-${defaultModeSlug}`) && options === "utf-8") {
|
||||
return Promise.resolve(fileCustomSystemPrompt)
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
// Define custom role definition
|
||||
const customRoleDefinition = "Custom role definition"
|
||||
const customModePrompts = {
|
||||
[defaultModeSlug]: {
|
||||
roleDefinition: customRoleDefinition,
|
||||
},
|
||||
}
|
||||
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"test/path", // Using a relative path without leading slash
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
defaultModeSlug,
|
||||
customModePrompts,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
experiments,
|
||||
true,
|
||||
)
|
||||
|
||||
// Should contain custom role definition and file-based system prompt
|
||||
expect(prompt).toContain(customRoleDefinition)
|
||||
expect(prompt).toContain(fileCustomSystemPrompt)
|
||||
|
||||
// Should not contain any of the default sections
|
||||
expect(prompt).not.toContain("TOOL USE")
|
||||
expect(prompt).not.toContain("CAPABILITIES")
|
||||
expect(prompt).not.toContain("MODES")
|
||||
})
|
||||
})
|
||||
60
src/core/prompts/sections/custom-system-prompt.ts
Normal file
60
src/core/prompts/sections/custom-system-prompt.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Mode } from "../../../shared/modes"
|
||||
import { fileExistsAtPath } from "../../../utils/fs"
|
||||
|
||||
/**
|
||||
* Safely reads a file, returning an empty string if the file doesn't exist
|
||||
*/
|
||||
async function safeReadFile(filePath: string): Promise<string> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8")
|
||||
// When reading with "utf-8" encoding, content should be a string
|
||||
return content.trim()
|
||||
} catch (err) {
|
||||
const errorCode = (err as NodeJS.ErrnoException).code
|
||||
if (!errorCode || !["ENOENT", "EISDIR"].includes(errorCode)) {
|
||||
throw err
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to a system prompt file for a specific mode
|
||||
*/
|
||||
export function getSystemPromptFilePath(cwd: string, mode: Mode): string {
|
||||
return path.join(cwd, ".roo", `system-prompt-${mode}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads custom system prompt from a file at .roo/system-prompt-[mode slug]
|
||||
* If the file doesn't exist, returns an empty string
|
||||
*/
|
||||
export async function loadSystemPromptFile(cwd: string, mode: Mode): Promise<string> {
|
||||
const filePath = getSystemPromptFilePath(cwd, mode)
|
||||
return safeReadFile(filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the .roo directory exists, creating it if necessary
|
||||
*/
|
||||
export async function ensureRooDirectory(cwd: string): Promise<void> {
|
||||
const rooDir = path.join(cwd, ".roo")
|
||||
|
||||
// Check if directory already exists
|
||||
if (await fileExistsAtPath(rooDir)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create the directory
|
||||
try {
|
||||
await fs.mkdir(rooDir, { recursive: true })
|
||||
} catch (err) {
|
||||
// If directory already exists (race condition), ignore the error
|
||||
const errorCode = (err as NodeJS.ErrnoException).code
|
||||
if (errorCode !== "EEXIST") {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
getModesSection,
|
||||
addCustomInstructions,
|
||||
} from "./sections"
|
||||
import { loadSystemPromptFile } from "./sections/custom-system-prompt"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
|
|
@ -119,11 +120,25 @@ export const SYSTEM_PROMPT = async (
|
|||
return undefined
|
||||
}
|
||||
|
||||
// Try to load custom system prompt from file
|
||||
const fileCustomSystemPrompt = await loadSystemPromptFile(cwd, mode)
|
||||
|
||||
// Check if it's a custom mode
|
||||
const promptComponent = getPromptComponent(customModePrompts?.[mode])
|
||||
|
||||
// Get full mode config from custom modes or fall back to built-in modes
|
||||
const currentMode = getModeBySlug(mode, customModes) || modes.find((m) => m.slug === mode) || modes[0]
|
||||
|
||||
// If a file-based custom system prompt exists, use it
|
||||
if (fileCustomSystemPrompt) {
|
||||
const roleDefinition = promptComponent?.roleDefinition || currentMode.roleDefinition
|
||||
return `${roleDefinition}
|
||||
|
||||
${fileCustomSystemPrompt}
|
||||
|
||||
${await addCustomInstructions(promptComponent?.customInstructions || currentMode.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage })}`
|
||||
}
|
||||
|
||||
// If diff is disabled, don't pass the diffStrategy
|
||||
const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
private cline?: Cline
|
||||
private workspaceTracker?: WorkspaceTracker
|
||||
protected mcpHub?: McpHub // Change from private to protected
|
||||
private latestAnnouncementId = "jan-21-2025-custom-modes" // update to some unique identifier when we add a new announcement
|
||||
private latestAnnouncementId = "feb-27-2025-automatic-checkpoints" // update to some unique identifier when we add a new announcement
|
||||
configManager: ConfigManager
|
||||
customModesManager: CustomModesManager
|
||||
|
||||
|
|
@ -327,7 +327,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
apiConfiguration,
|
||||
customModePrompts,
|
||||
diffEnabled,
|
||||
checkpointsEnabled,
|
||||
enableCheckpoints,
|
||||
fuzzyMatchThreshold,
|
||||
mode,
|
||||
customInstructions: globalInstructions,
|
||||
|
|
@ -342,7 +342,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
apiConfiguration,
|
||||
customInstructions: effectiveInstructions,
|
||||
enableDiff: diffEnabled,
|
||||
enableCheckpoints: checkpointsEnabled,
|
||||
enableCheckpoints,
|
||||
fuzzyMatchThreshold,
|
||||
task,
|
||||
images,
|
||||
|
|
@ -357,7 +357,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
apiConfiguration,
|
||||
customModePrompts,
|
||||
diffEnabled,
|
||||
checkpointsEnabled,
|
||||
enableCheckpoints,
|
||||
fuzzyMatchThreshold,
|
||||
mode,
|
||||
customInstructions: globalInstructions,
|
||||
|
|
@ -372,7 +372,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
apiConfiguration,
|
||||
customInstructions: effectiveInstructions,
|
||||
enableDiff: diffEnabled,
|
||||
enableCheckpoints: checkpointsEnabled,
|
||||
enableCheckpoints,
|
||||
fuzzyMatchThreshold,
|
||||
historyItem,
|
||||
experiments,
|
||||
|
|
@ -1027,9 +1027,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
await this.updateGlobalState("diffEnabled", diffEnabled)
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "checkpointsEnabled":
|
||||
const checkpointsEnabled = message.bool ?? false
|
||||
await this.updateGlobalState("checkpointsEnabled", checkpointsEnabled)
|
||||
case "enableCheckpoints":
|
||||
const enableCheckpoints = message.bool ?? true
|
||||
await this.updateGlobalState("enableCheckpoints", enableCheckpoints)
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "browserViewportSize":
|
||||
|
|
@ -1680,7 +1680,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
lmStudioModelId,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
anthropicThinking,
|
||||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
|
|
@ -1701,6 +1700,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
requestyModelInfo,
|
||||
modelTemperature,
|
||||
modelMaxTokens,
|
||||
modelMaxThinkingTokens,
|
||||
} = apiConfiguration
|
||||
await Promise.all([
|
||||
this.updateGlobalState("apiProvider", apiProvider),
|
||||
|
|
@ -1729,7 +1729,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
this.updateGlobalState("lmStudioModelId", lmStudioModelId),
|
||||
this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl),
|
||||
this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl),
|
||||
this.updateGlobalState("anthropicThinking", anthropicThinking),
|
||||
this.storeSecret("geminiApiKey", geminiApiKey),
|
||||
this.storeSecret("openAiNativeApiKey", openAiNativeApiKey),
|
||||
this.storeSecret("deepSeekApiKey", deepSeekApiKey),
|
||||
|
|
@ -1750,6 +1749,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
this.updateGlobalState("requestyModelInfo", requestyModelInfo),
|
||||
this.updateGlobalState("modelTemperature", modelTemperature),
|
||||
this.updateGlobalState("modelMaxTokens", modelMaxTokens),
|
||||
this.updateGlobalState("anthropicThinking", modelMaxThinkingTokens),
|
||||
])
|
||||
if (this.cline) {
|
||||
this.cline.api = buildApiHandler(apiConfiguration)
|
||||
|
|
@ -1968,11 +1968,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
await fs.unlink(legacyMessagesFilePath)
|
||||
}
|
||||
|
||||
const { checkpointsEnabled } = await this.getState()
|
||||
const { enableCheckpoints } = await this.getState()
|
||||
const baseDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
|
||||
// Delete checkpoints branch.
|
||||
if (checkpointsEnabled && baseDir) {
|
||||
if (enableCheckpoints && baseDir) {
|
||||
const branchSummary = await simpleGit(baseDir)
|
||||
.branch(["-D", `roo-code-checkpoints-${id}`])
|
||||
.catch(() => undefined)
|
||||
|
|
@ -2028,7 +2028,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
alwaysAllowModeSwitch,
|
||||
soundEnabled,
|
||||
diffEnabled,
|
||||
checkpointsEnabled,
|
||||
enableCheckpoints,
|
||||
taskHistory,
|
||||
soundVolume,
|
||||
browserViewportSize,
|
||||
|
|
@ -2077,7 +2077,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
.sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts),
|
||||
soundEnabled: soundEnabled ?? false,
|
||||
diffEnabled: diffEnabled ?? true,
|
||||
checkpointsEnabled: checkpointsEnabled ?? false,
|
||||
enableCheckpoints: enableCheckpoints ?? true,
|
||||
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
|
||||
allowedCommands,
|
||||
soundVolume: soundVolume ?? 0.5,
|
||||
|
|
@ -2186,7 +2186,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
lmStudioModelId,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
anthropicThinking,
|
||||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
|
|
@ -2210,7 +2209,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
allowedCommands,
|
||||
soundEnabled,
|
||||
diffEnabled,
|
||||
checkpointsEnabled,
|
||||
enableCheckpoints,
|
||||
soundVolume,
|
||||
browserViewportSize,
|
||||
fuzzyMatchThreshold,
|
||||
|
|
@ -2242,6 +2241,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
requestyModelInfo,
|
||||
modelTemperature,
|
||||
modelMaxTokens,
|
||||
modelMaxThinkingTokens,
|
||||
maxOpenTabsContext,
|
||||
] = await Promise.all([
|
||||
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
|
||||
|
|
@ -2270,7 +2270,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
this.getGlobalState("lmStudioModelId") as Promise<string | undefined>,
|
||||
this.getGlobalState("lmStudioBaseUrl") as Promise<string | undefined>,
|
||||
this.getGlobalState("anthropicBaseUrl") as Promise<string | undefined>,
|
||||
this.getGlobalState("anthropicThinking") as Promise<number | undefined>,
|
||||
this.getSecret("geminiApiKey") as Promise<string | undefined>,
|
||||
this.getSecret("openAiNativeApiKey") as Promise<string | undefined>,
|
||||
this.getSecret("deepSeekApiKey") as Promise<string | undefined>,
|
||||
|
|
@ -2294,7 +2293,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
this.getGlobalState("allowedCommands") as Promise<string[] | undefined>,
|
||||
this.getGlobalState("soundEnabled") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("diffEnabled") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("checkpointsEnabled") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("enableCheckpoints") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("soundVolume") as Promise<number | undefined>,
|
||||
this.getGlobalState("browserViewportSize") as Promise<string | undefined>,
|
||||
this.getGlobalState("fuzzyMatchThreshold") as Promise<number | undefined>,
|
||||
|
|
@ -2326,6 +2325,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
this.getGlobalState("requestyModelInfo") as Promise<ModelInfo | undefined>,
|
||||
this.getGlobalState("modelTemperature") as Promise<number | undefined>,
|
||||
this.getGlobalState("modelMaxTokens") as Promise<number | undefined>,
|
||||
this.getGlobalState("anthropicThinking") as Promise<number | undefined>,
|
||||
this.getGlobalState("maxOpenTabsContext") as Promise<number | undefined>,
|
||||
])
|
||||
|
||||
|
|
@ -2371,7 +2371,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
lmStudioModelId,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
anthropicThinking,
|
||||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
|
|
@ -2392,6 +2391,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
requestyModelInfo,
|
||||
modelTemperature,
|
||||
modelMaxTokens,
|
||||
modelMaxThinkingTokens,
|
||||
},
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
|
|
@ -2405,7 +2405,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
allowedCommands,
|
||||
soundEnabled: soundEnabled ?? false,
|
||||
diffEnabled: diffEnabled ?? true,
|
||||
checkpointsEnabled: checkpointsEnabled ?? false,
|
||||
enableCheckpoints: enableCheckpoints ?? true,
|
||||
soundVolume,
|
||||
browserViewportSize: browserViewportSize ?? "900x600",
|
||||
screenshotQuality: screenshotQuality ?? 75,
|
||||
|
|
|
|||
|
|
@ -369,7 +369,7 @@ describe("ClineProvider", () => {
|
|||
uriScheme: "vscode",
|
||||
soundEnabled: false,
|
||||
diffEnabled: false,
|
||||
checkpointsEnabled: false,
|
||||
enableCheckpoints: false,
|
||||
writeDelayMs: 1000,
|
||||
browserViewportSize: "900x600",
|
||||
fuzzyMatchThreshold: 1.0,
|
||||
|
|
@ -677,7 +677,7 @@ describe("ClineProvider", () => {
|
|||
},
|
||||
mode: "code",
|
||||
diffEnabled: true,
|
||||
checkpointsEnabled: false,
|
||||
enableCheckpoints: false,
|
||||
fuzzyMatchThreshold: 1.0,
|
||||
experiments: experimentDefault,
|
||||
} as any)
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export interface ExtensionState {
|
|||
soundEnabled?: boolean
|
||||
soundVolume?: number
|
||||
diffEnabled?: boolean
|
||||
checkpointsEnabled: boolean
|
||||
enableCheckpoints: boolean
|
||||
browserViewportSize?: string
|
||||
screenshotQuality?: number
|
||||
fuzzyMatchThreshold?: number
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export interface WebviewMessage {
|
|||
| "soundEnabled"
|
||||
| "soundVolume"
|
||||
| "diffEnabled"
|
||||
| "checkpointsEnabled"
|
||||
| "enableCheckpoints"
|
||||
| "browserViewportSize"
|
||||
| "screenshotQuality"
|
||||
| "openMcpSettings"
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ describe("checkExistKey", () => {
|
|||
apiKey: "test-key",
|
||||
apiProvider: undefined,
|
||||
anthropicBaseUrl: undefined,
|
||||
anthropicThinking: undefined,
|
||||
modelMaxThinkingTokens: undefined,
|
||||
}
|
||||
expect(checkExistKey(config)).toBe(true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ export interface ApiHandlerOptions {
|
|||
apiModelId?: string
|
||||
apiKey?: string // anthropic
|
||||
anthropicBaseUrl?: string
|
||||
anthropicThinking?: number
|
||||
vsCodeLmModelSelector?: vscode.LanguageModelChatSelector
|
||||
glamaModelId?: string
|
||||
glamaModelInfo?: ModelInfo
|
||||
|
|
@ -70,6 +69,7 @@ export interface ApiHandlerOptions {
|
|||
requestyModelInfo?: ModelInfo
|
||||
modelTemperature?: number
|
||||
modelMaxTokens?: number
|
||||
modelMaxThinkingTokens?: number
|
||||
}
|
||||
|
||||
export type ApiConfiguration = ApiHandlerOptions & {
|
||||
|
|
@ -437,55 +437,80 @@ export const openRouterDefaultModelInfo: ModelInfo = {
|
|||
export type VertexModelId = keyof typeof vertexModels
|
||||
export const vertexDefaultModelId: VertexModelId = "claude-3-7-sonnet@20250219"
|
||||
export const vertexModels = {
|
||||
"claude-3-7-sonnet@20250219:thinking": {
|
||||
maxTokens: 64000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
thinking: true,
|
||||
},
|
||||
"claude-3-7-sonnet@20250219": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: true,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
thinking: false,
|
||||
},
|
||||
"claude-3-5-sonnet-v2@20241022": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: true,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
},
|
||||
"claude-3-5-sonnet@20240620": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
},
|
||||
"claude-3-5-haiku@20241022": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 1.0,
|
||||
outputPrice: 5.0,
|
||||
cacheWritesPrice: 1.25,
|
||||
cacheReadsPrice: 0.1,
|
||||
},
|
||||
"claude-3-opus@20240229": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 15.0,
|
||||
outputPrice: 75.0,
|
||||
cacheWritesPrice: 18.75,
|
||||
cacheReadsPrice: 1.5,
|
||||
},
|
||||
"claude-3-haiku@20240307": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.25,
|
||||
outputPrice: 1.25,
|
||||
cacheWritesPrice: 0.3,
|
||||
cacheReadsPrice: 0.03,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
|
|
@ -667,8 +692,16 @@ export const openAiNativeModels = {
|
|||
inputPrice: 1.1,
|
||||
outputPrice: 4.4,
|
||||
},
|
||||
"gpt-4.5-preview": {
|
||||
maxTokens: 16_384,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 75,
|
||||
outputPrice: 150,
|
||||
},
|
||||
"gpt-4o": {
|
||||
maxTokens: 4_096,
|
||||
maxTokens: 16_384,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ export type GlobalStateKey =
|
|||
| "lmStudioModelId"
|
||||
| "lmStudioBaseUrl"
|
||||
| "anthropicBaseUrl"
|
||||
| "anthropicThinking"
|
||||
| "azureApiVersion"
|
||||
| "openAiStreamingEnabled"
|
||||
| "openRouterModelId"
|
||||
|
|
@ -53,7 +52,7 @@ export type GlobalStateKey =
|
|||
| "soundEnabled"
|
||||
| "soundVolume"
|
||||
| "diffEnabled"
|
||||
| "checkpointsEnabled"
|
||||
| "enableCheckpoints"
|
||||
| "browserViewportSize"
|
||||
| "screenshotQuality"
|
||||
| "fuzzyMatchThreshold"
|
||||
|
|
@ -82,5 +81,6 @@ export type GlobalStateKey =
|
|||
| "unboundModelInfo"
|
||||
| "modelTemperature"
|
||||
| "modelMaxTokens"
|
||||
| "anthropicThinking" // TODO: Rename to `modelMaxThinkingTokens`.
|
||||
| "mistralCodestralUrl"
|
||||
| "maxOpenTabsContext"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { memo } from "react"
|
||||
// import VSCodeButtonLink from "./VSCodeButtonLink"
|
||||
// import { getOpenRouterAuthUrl } from "./ApiOptions"
|
||||
// import { vscode } from "../utils/vscode"
|
||||
|
||||
interface AnnouncementProps {
|
||||
version: string
|
||||
|
|
@ -25,39 +22,42 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
|
|||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={hideAnnouncement}
|
||||
title="Hide announcement"
|
||||
style={{ position: "absolute", top: "8px", right: "8px" }}>
|
||||
<span className="codicon codicon-close"></span>
|
||||
</VSCodeButton>
|
||||
<h2 style={{ margin: "0 0 8px" }}>🎉{" "}Introducing Roo Code 3.2</h2>
|
||||
<h2 style={{ margin: "0 0 8px" }}>🎉{" "}Automatic Checkpoints Now Enabled</h2>
|
||||
|
||||
<p style={{ margin: "5px 0px" }}>
|
||||
Our biggest update yet is here - we're officially changing our name from Roo Cline to Roo Code! After
|
||||
growing beyond 50,000 installations, we're ready to chart our own course. Our heartfelt thanks to
|
||||
everyone in the Cline community who helped us reach this milestone.
|
||||
We're thrilled to announce that our experimental Checkpoints feature is now enabled by default for all
|
||||
users. This powerful feature automatically tracks your project changes during a task, allowing you to
|
||||
quickly review or revert to earlier states if needed.
|
||||
</p>
|
||||
|
||||
<h3 style={{ margin: "12px 0 8px" }}>Custom Modes: Celebrating Our New Identity</h3>
|
||||
<h3 style={{ margin: "12px 0 8px" }}>What's New</h3>
|
||||
<p style={{ margin: "5px 0px" }}>
|
||||
To mark this new chapter, we're introducing the power to shape Roo Code into any role you need! Create
|
||||
specialized personas and create an entire team of agents with deeply customized prompts:
|
||||
Automatic Checkpoints provide you with:
|
||||
<ul style={{ margin: "4px 0 6px 20px", padding: 0 }}>
|
||||
<li>QA Engineers who write thorough test cases and catch edge cases</li>
|
||||
<li>Product Managers who excel at user stories and feature prioritization</li>
|
||||
<li>UI/UX Designers who craft beautiful, accessible interfaces</li>
|
||||
<li>Code Reviewers who ensure quality and maintainability</li>
|
||||
<li>Peace of mind when making significant changes</li>
|
||||
<li>Ability to visually inspect changes between steps</li>
|
||||
<li>Easy rollback if you're not satisfied with certain code modifications</li>
|
||||
<li>Improved navigation through complex task execution</li>
|
||||
</ul>
|
||||
Just click the <span className="codicon codicon-notebook" style={{ fontSize: "10px" }}></span> icon to
|
||||
get started with Custom Modes!
|
||||
</p>
|
||||
|
||||
<h3 style={{ margin: "12px 0 8px" }}>Join Us for the Next Chapter</h3>
|
||||
<h3 style={{ margin: "12px 0 8px" }}>Customize Your Experience</h3>
|
||||
<p style={{ margin: "5px 0px" }}>
|
||||
We can't wait to see how you'll push Roo Code's potential even further! Share your custom modes and join
|
||||
the discussion at{" "}
|
||||
<VSCodeLink href="https://www.reddit.com/r/RooCode" style={{ display: "inline" }}>
|
||||
reddit.com/r/RooCode
|
||||
</VSCodeLink>
|
||||
.
|
||||
While we recommend keeping this feature enabled, you can disable it if needed.{" "}
|
||||
<VSCodeLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.postMessage({ type: "action", action: "settingsButtonClicked" }, "*")
|
||||
}}
|
||||
style={{ display: "inline", padding: "0 2px" }}>
|
||||
Open Settings
|
||||
</VSCodeLink>{" "}
|
||||
and look for the "Enable automatic checkpoints" option in the Advanced Settings section.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { vscode } from "../../utils/vscode"
|
|||
import CodeAccordian, { removeLeadingNonAlphanumeric } from "../common/CodeAccordian"
|
||||
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
import MarkdownBlock from "../common/MarkdownBlock"
|
||||
import ReasoningBlock from "./ReasoningBlock"
|
||||
import { ReasoningBlock } from "./ReasoningBlock"
|
||||
import Thumbnails from "../common/Thumbnails"
|
||||
import McpResourceRow from "../mcp/McpResourceRow"
|
||||
import McpToolRow from "../mcp/McpToolRow"
|
||||
|
|
@ -25,12 +25,12 @@ import { CheckpointSaved } from "./checkpoints/CheckpointSaved"
|
|||
|
||||
interface ChatRowProps {
|
||||
message: ClineMessage
|
||||
isExpanded: boolean
|
||||
onToggleExpand: () => void
|
||||
lastModifiedMessage?: ClineMessage
|
||||
isExpanded: boolean
|
||||
isLast: boolean
|
||||
onHeightChange: (isTaller: boolean) => void
|
||||
isStreaming: boolean
|
||||
onToggleExpand: () => void
|
||||
onHeightChange: (isTaller: boolean) => void
|
||||
}
|
||||
|
||||
interface ChatRowContentProps extends Omit<ChatRowProps, "onHeightChange"> {}
|
||||
|
|
@ -43,10 +43,7 @@ const ChatRow = memo(
|
|||
const prevHeightRef = useRef(0)
|
||||
|
||||
const [chatrow, { height }] = useSize(
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 6px 10px 15px",
|
||||
}}>
|
||||
<div className="px-[15px] py-[10px] pr-[6px]">
|
||||
<ChatRowContent {...props} />
|
||||
</div>,
|
||||
)
|
||||
|
|
@ -75,33 +72,32 @@ export default ChatRow
|
|||
|
||||
export const ChatRowContent = ({
|
||||
message,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
lastModifiedMessage,
|
||||
isExpanded,
|
||||
isLast,
|
||||
isStreaming,
|
||||
onToggleExpand,
|
||||
}: ChatRowContentProps) => {
|
||||
const { mcpServers, alwaysAllowMcp, currentCheckpoint } = useExtensionState()
|
||||
const [reasoningCollapsed, setReasoningCollapsed] = useState(false)
|
||||
const [reasoningCollapsed, setReasoningCollapsed] = useState(true)
|
||||
|
||||
// Auto-collapse reasoning when new messages arrive
|
||||
useEffect(() => {
|
||||
if (!isLast && message.say === "reasoning") {
|
||||
setReasoningCollapsed(true)
|
||||
}
|
||||
}, [isLast, message.say])
|
||||
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => {
|
||||
if (message.text !== null && message.text !== undefined && message.say === "api_req_started") {
|
||||
const info: ClineApiReqInfo = JSON.parse(message.text)
|
||||
return [info.cost, info.cancelReason, info.streamingFailedMessage]
|
||||
}
|
||||
|
||||
return [undefined, undefined, undefined]
|
||||
}, [message.text, message.say])
|
||||
// when resuming task, last wont be api_req_failed but a resume_task message, so api_req_started will show loading spinner. that's why we just remove the last api_req_started that failed without streaming anything
|
||||
|
||||
// When resuming task, last wont be api_req_failed but a resume_task
|
||||
// message, so api_req_started will show loading spinner. That's why we just
|
||||
// remove the last api_req_started that failed without streaming anything.
|
||||
const apiRequestFailedMessage =
|
||||
isLast && lastModifiedMessage?.ask === "api_req_failed" // if request is retried then the latest message is a api_req_retried
|
||||
? lastModifiedMessage?.text
|
||||
: undefined
|
||||
|
||||
const isCommandExecuting =
|
||||
isLast && lastModifiedMessage?.ask === "command" && lastModifiedMessage?.text?.includes(COMMAND_OUTPUT_STRING)
|
||||
|
||||
|
|
@ -428,32 +424,6 @@ export const ChatRowContent = ({
|
|||
/>
|
||||
</>
|
||||
)
|
||||
// case "inspectSite":
|
||||
// const isInspecting =
|
||||
// isLast && lastModifiedMessage?.say === "inspect_site_result" && !lastModifiedMessage?.images
|
||||
// return (
|
||||
// <>
|
||||
// <div style={headerStyle}>
|
||||
// {isInspecting ? <ProgressIndicator /> : toolIcon("inspect")}
|
||||
// <span style={{ fontWeight: "bold" }}>
|
||||
// {message.type === "ask" ? (
|
||||
// <>Roo wants to inspect this website:</>
|
||||
// ) : (
|
||||
// <>Roo is inspecting this website:</>
|
||||
// )}
|
||||
// </span>
|
||||
// </div>
|
||||
// <div
|
||||
// style={{
|
||||
// borderRadius: 3,
|
||||
// border: "1px solid var(--vscode-editorGroup-border)",
|
||||
// overflow: "hidden",
|
||||
// backgroundColor: CODE_BLOCK_BG_COLOR,
|
||||
// }}>
|
||||
// <CodeBlock source={`${"```"}shell\n${tool.path}\n${"```"}`} forceWrap={true} />
|
||||
// </div>
|
||||
// </>
|
||||
// )
|
||||
case "switchMode":
|
||||
return (
|
||||
<>
|
||||
|
|
@ -501,6 +471,7 @@ export const ChatRowContent = ({
|
|||
return (
|
||||
<ReasoningBlock
|
||||
content={message.text || ""}
|
||||
elapsed={isLast && isStreaming ? Date.now() - message.ts : undefined}
|
||||
isCollapsed={reasoningCollapsed}
|
||||
onToggleCollapse={() => setReasoningCollapsed(!reasoningCollapsed)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -798,6 +798,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
<select
|
||||
value={mode}
|
||||
disabled={textAreaDisabled}
|
||||
title="Select mode for interaction"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === "prompts-action") {
|
||||
|
|
@ -849,6 +850,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
<select
|
||||
value={currentApiConfigName || ""}
|
||||
disabled={textAreaDisabled}
|
||||
title="Select API configuration"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === "settings-action") {
|
||||
|
|
@ -915,6 +917,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
role="button"
|
||||
aria-label="enhance prompt"
|
||||
data-testid="enhance-prompt-button"
|
||||
title="Enhance prompt with additional context"
|
||||
className={`input-icon-button ${
|
||||
textAreaDisabled ? "disabled" : ""
|
||||
} codicon codicon-sparkle`}
|
||||
|
|
@ -927,11 +930,13 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
className={`input-icon-button ${
|
||||
shouldDisableImages ? "disabled" : ""
|
||||
} codicon codicon-device-camera`}
|
||||
title="Add images to message"
|
||||
onClick={() => !shouldDisableImages && onSelectImages()}
|
||||
style={{ fontSize: 16.5 }}
|
||||
/>
|
||||
<span
|
||||
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`}
|
||||
title="Send message"
|
||||
onClick={() => !textAreaDisabled && onSend()}
|
||||
style={{ fontSize: 15 }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1077,7 +1077,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
|||
onClick={() => {
|
||||
scrollToBottomSmooth()
|
||||
disableAutoScrollRef.current = false
|
||||
}}>
|
||||
}}
|
||||
title="Scroll to bottom of chat">
|
||||
<span className="codicon codicon-chevron-down" style={{ fontSize: "18px" }}></span>
|
||||
</ScrollToBottomButton>
|
||||
</div>
|
||||
|
|
@ -1101,6 +1102,25 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
|||
flex: secondaryButtonText ? 1 : 2,
|
||||
marginRight: secondaryButtonText ? "6px" : "0",
|
||||
}}
|
||||
title={
|
||||
primaryButtonText === "Retry"
|
||||
? "Try the operation again"
|
||||
: primaryButtonText === "Save"
|
||||
? "Save the file changes"
|
||||
: primaryButtonText === "Approve"
|
||||
? "Approve this action"
|
||||
: primaryButtonText === "Run Command"
|
||||
? "Execute this command"
|
||||
: primaryButtonText === "Start New Task"
|
||||
? "Begin a new task"
|
||||
: primaryButtonText === "Resume Task"
|
||||
? "Continue the current task"
|
||||
: primaryButtonText === "Proceed Anyways"
|
||||
? "Continue despite warnings"
|
||||
: primaryButtonText === "Proceed While Running"
|
||||
? "Continue while command executes"
|
||||
: undefined
|
||||
}
|
||||
onClick={(e) => handlePrimaryButtonClick(inputValue, selectedImages)}>
|
||||
{primaryButtonText}
|
||||
</VSCodeButton>
|
||||
|
|
@ -1113,6 +1133,17 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
|||
flex: isStreaming ? 2 : 1,
|
||||
marginLeft: isStreaming ? 0 : "6px",
|
||||
}}
|
||||
title={
|
||||
isStreaming
|
||||
? "Cancel the current operation"
|
||||
: secondaryButtonText === "Start New Task"
|
||||
? "Begin a new task"
|
||||
: secondaryButtonText === "Reject"
|
||||
? "Reject this action"
|
||||
: secondaryButtonText === "Terminate"
|
||||
? "End the current task"
|
||||
: undefined
|
||||
}
|
||||
onClick={(e) => handleSecondaryButtonClick(inputValue, selectedImages)}>
|
||||
{isStreaming ? "Cancel" : secondaryButtonText}
|
||||
</VSCodeButton>
|
||||
|
|
|
|||
|
|
@ -1,70 +1,97 @@
|
|||
import React, { useEffect, useRef } from "react"
|
||||
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { CaretDownIcon, CaretUpIcon, CounterClockwiseClockIcon } from "@radix-ui/react-icons"
|
||||
|
||||
import MarkdownBlock from "../common/MarkdownBlock"
|
||||
import { useMount } from "react-use"
|
||||
|
||||
interface ReasoningBlockProps {
|
||||
content: string
|
||||
elapsed?: number
|
||||
isCollapsed?: boolean
|
||||
onToggleCollapse?: () => void
|
||||
autoHeight?: boolean
|
||||
}
|
||||
|
||||
const ReasoningBlock: React.FC<ReasoningBlockProps> = ({
|
||||
content,
|
||||
isCollapsed = false,
|
||||
onToggleCollapse,
|
||||
autoHeight = false,
|
||||
}) => {
|
||||
export const ReasoningBlock = ({ content, elapsed, isCollapsed = false, onToggleCollapse }: ReasoningBlockProps) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const elapsedRef = useRef<number>(0)
|
||||
const [thought, setThought] = useState<string>()
|
||||
const [prevThought, setPrevThought] = useState<string>("Thinking")
|
||||
const [isTransitioning, setIsTransitioning] = useState<boolean>(false)
|
||||
const cursorRef = useRef<number>(0)
|
||||
const queueRef = useRef<string[]>([])
|
||||
|
||||
// Scroll to bottom when content updates
|
||||
useEffect(() => {
|
||||
if (contentRef.current && !isCollapsed) {
|
||||
contentRef.current.scrollTop = contentRef.current.scrollHeight
|
||||
}
|
||||
}, [content, isCollapsed])
|
||||
|
||||
useEffect(() => {
|
||||
if (elapsed) {
|
||||
elapsedRef.current = elapsed
|
||||
}
|
||||
}, [elapsed])
|
||||
|
||||
// Process the transition queue.
|
||||
const processNextTransition = useCallback(() => {
|
||||
const nextThought = queueRef.current.pop()
|
||||
queueRef.current = []
|
||||
|
||||
if (nextThought) {
|
||||
setIsTransitioning(true)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (nextThought) {
|
||||
setPrevThought(nextThought)
|
||||
setIsTransitioning(false)
|
||||
}
|
||||
|
||||
setTimeout(() => processNextTransition(), 500)
|
||||
}, 200)
|
||||
}, [])
|
||||
|
||||
useMount(() => {
|
||||
processNextTransition()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (content.length - cursorRef.current > 160) {
|
||||
setThought("... " + content.slice(cursorRef.current))
|
||||
cursorRef.current = content.length
|
||||
}
|
||||
}, [content])
|
||||
|
||||
useEffect(() => {
|
||||
if (thought && thought !== prevThought) {
|
||||
queueRef.current.push(thought)
|
||||
}
|
||||
}, [thought, prevThought])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: CODE_BLOCK_BG_COLOR,
|
||||
border: "1px solid var(--vscode-editorGroup-border)",
|
||||
borderRadius: "3px",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div className="bg-vscode-editor-background border border-vscode-border rounded-xs overflow-hidden">
|
||||
<div
|
||||
onClick={onToggleCollapse}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: isCollapsed ? "none" : "1px solid var(--vscode-editorGroup-border)",
|
||||
}}>
|
||||
<span style={{ fontWeight: "bold" }}>Reasoning</span>
|
||||
<span className={`codicon codicon-chevron-${isCollapsed ? "right" : "down"}`}></span>
|
||||
className="flex items-center justify-between gap-1 px-3 py-2 cursor-pointer text-muted-foreground"
|
||||
onClick={onToggleCollapse}>
|
||||
<div
|
||||
className={`truncate flex-1 transition-opacity duration-200 ${isTransitioning ? "opacity-0" : "opacity-100"}`}>
|
||||
{prevThought}
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
{elapsedRef.current > 1000 && (
|
||||
<>
|
||||
<CounterClockwiseClockIcon className="scale-80" />
|
||||
<div>{Math.round(elapsedRef.current / 1000)}s</div>
|
||||
</>
|
||||
)}
|
||||
{isCollapsed ? <CaretDownIcon /> : <CaretUpIcon />}
|
||||
</div>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
ref={contentRef}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
maxHeight: autoHeight ? "none" : "160px",
|
||||
overflowY: "auto",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
opacity: 0.9,
|
||||
}}>
|
||||
<MarkdownBlock markdown={content} />
|
||||
</div>
|
||||
<div ref={contentRef} className="px-3 max-h-[160px] overflow-y-auto">
|
||||
<MarkdownBlock markdown={content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReasoningBlock
|
||||
|
|
|
|||
|
|
@ -3,16 +3,19 @@ import { useWindowSize } from "react-use"
|
|||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import prettyBytes from "pretty-bytes"
|
||||
|
||||
import { vscode } from "@/utils/vscode"
|
||||
import { formatLargeNumber } from "@/utils/format"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
import { ClineMessage } from "../../../../src/shared/ExtensionMessage"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import Thumbnails from "../common/Thumbnails"
|
||||
import { mentionRegexGlobal } from "../../../../src/shared/context-mentions"
|
||||
import { formatLargeNumber } from "../../utils/format"
|
||||
import { normalizeApiConfiguration } from "../settings/ApiOptions"
|
||||
import { Button } from "../ui"
|
||||
import { HistoryItem } from "../../../../src/shared/HistoryItem"
|
||||
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import Thumbnails from "../common/Thumbnails"
|
||||
import { normalizeApiConfiguration } from "../settings/ApiOptions"
|
||||
import { DeleteTaskDialog } from "../history/DeleteTaskDialog"
|
||||
|
||||
interface TaskHeaderProps {
|
||||
task: ClineMessage
|
||||
tokensIn: number
|
||||
|
|
@ -46,7 +49,21 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
const contextWindow = selectedModelInfo?.contextWindow || 1
|
||||
|
||||
/*
|
||||
When dealing with event listeners in React components that depend on state variables, we face a challenge. We want our listener to always use the most up-to-date version of a callback function that relies on current state, but we don't want to constantly add and remove event listeners as that function updates. This scenario often arises with resize listeners or other window events. Simply adding the listener in a useEffect with an empty dependency array risks using stale state, while including the callback in the dependencies can lead to unnecessary re-registrations of the listener. There are react hook libraries that provide a elegant solution to this problem by utilizing the useRef hook to maintain a reference to the latest callback function without triggering re-renders or effect re-runs. This approach ensures that our event listener always has access to the most current state while minimizing performance overhead and potential memory leaks from multiple listener registrations.
|
||||
When dealing with event listeners in React components that depend on state
|
||||
variables, we face a challenge. We want our listener to always use the most
|
||||
up-to-date version of a callback function that relies on current state, but
|
||||
we don't want to constantly add and remove event listeners as that function
|
||||
updates. This scenario often arises with resize listeners or other window
|
||||
events. Simply adding the listener in a useEffect with an empty dependency
|
||||
array risks using stale state, while including the callback in the
|
||||
dependencies can lead to unnecessary re-registrations of the listener. There
|
||||
are react hook libraries that provide a elegant solution to this problem by
|
||||
utilizing the useRef hook to maintain a reference to the latest callback
|
||||
function without triggering re-renders or effect re-runs. This approach
|
||||
ensures that our event listener always has access to the most current state
|
||||
while minimizing performance overhead and potential memory leaks from
|
||||
multiple listener registrations.
|
||||
|
||||
Sources
|
||||
- https://usehooks-ts.com/react-hook/use-event-listener
|
||||
- https://streamich.github.io/react-use/?path=/story/sensors-useevent--docs
|
||||
|
|
@ -180,7 +197,11 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
${totalCost?.toFixed(4)}
|
||||
</div>
|
||||
)}
|
||||
<VSCodeButton appearance="icon" onClick={onClose} style={{ marginLeft: 6, flexShrink: 0 }}>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={onClose}
|
||||
style={{ marginLeft: 6, flexShrink: 0 }}
|
||||
title="Close task and start a new one">
|
||||
<span className="codicon codicon-close"></span>
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
|
|
@ -346,22 +367,48 @@ export const highlightMentions = (text?: string, withShadow = true) => {
|
|||
})
|
||||
}
|
||||
|
||||
const TaskActions = ({ item }: { item: HistoryItem | undefined }) => (
|
||||
<div className="flex flex-row gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}>
|
||||
<span className="codicon codicon-cloud-download" />
|
||||
</Button>
|
||||
{!!item?.size && item.size > 0 && (
|
||||
const TaskActions = ({ item }: { item: HistoryItem | undefined }) => {
|
||||
const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => vscode.postMessage({ type: "deleteTaskWithId", text: item.id })}>
|
||||
<span className="codicon codicon-trash" />
|
||||
{prettyBytes(item.size)}
|
||||
title="Export task history"
|
||||
onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}>
|
||||
<span className="codicon codicon-cloud-download" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
{!!item?.size && item.size > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Delete Task (Shift + Click to skip confirmation)"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.shiftKey) {
|
||||
vscode.postMessage({ type: "deleteTaskWithId", text: item.id })
|
||||
} else {
|
||||
setDeleteTaskId(item.id)
|
||||
}
|
||||
}}>
|
||||
<span className="codicon codicon-trash" />
|
||||
{prettyBytes(item.size)}
|
||||
</Button>
|
||||
{deleteTaskId && (
|
||||
<DeleteTaskDialog
|
||||
taskId={deleteTaskId}
|
||||
onOpenChange={(open) => !open && setDeleteTaskId(null)}
|
||||
open
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ContextWindowProgress = ({ contextWindow, contextTokens }: { contextWindow: number; contextTokens: number }) => (
|
||||
<>
|
||||
|
|
|
|||
32
webview-ui/src/components/history/CopyButton.tsx
Normal file
32
webview-ui/src/components/history/CopyButton.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useCallback } from "react"
|
||||
|
||||
import { useClipboard } from "@/components/ui/hooks"
|
||||
import { Button } from "@/components/ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type CopyButtonProps = {
|
||||
itemTask: string
|
||||
}
|
||||
|
||||
export const CopyButton = ({ itemTask }: CopyButtonProps) => {
|
||||
const { isCopied, copy } = useClipboard()
|
||||
|
||||
const onCopy = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
!isCopied && copy(itemTask)
|
||||
},
|
||||
[isCopied, copy, itemTask],
|
||||
)
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Copy Prompt"
|
||||
onClick={onCopy}
|
||||
className="opacity-50 hover:opacity-100">
|
||||
<span className={cn("codicon scale-80", { "codicon-check": isCopied, "codicon-copy": !isCopied })} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
import React from "react"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { useKeyPress } from "react-use"
|
||||
import { AlertDialogProps } from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -8,25 +11,36 @@ import {
|
|||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui"
|
||||
Button,
|
||||
} from "@/components/ui"
|
||||
|
||||
import { vscode } from "@/utils/vscode"
|
||||
|
||||
interface DeleteTaskDialogProps {
|
||||
interface DeleteTaskDialogProps extends AlertDialogProps {
|
||||
taskId: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const DeleteTaskDialog = ({ taskId, open, onOpenChange }: DeleteTaskDialogProps) => {
|
||||
const handleDelete = () => {
|
||||
vscode.postMessage({ type: "deleteTaskWithId", text: taskId })
|
||||
onOpenChange(false)
|
||||
}
|
||||
export const DeleteTaskDialog = ({ taskId, ...props }: DeleteTaskDialogProps) => {
|
||||
const [isEnterPressed] = useKeyPress("Enter")
|
||||
|
||||
const { onOpenChange } = props
|
||||
|
||||
const onDelete = useCallback(() => {
|
||||
if (taskId) {
|
||||
vscode.postMessage({ type: "deleteTaskWithId", text: taskId })
|
||||
onOpenChange?.(false)
|
||||
}
|
||||
}, [taskId, onOpenChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (taskId && isEnterPressed) {
|
||||
onDelete()
|
||||
}
|
||||
}, [taskId, isEnterPressed, onDelete])
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialog {...props}>
|
||||
<AlertDialogContent onEscapeKeyDown={() => onOpenChange?.(false)}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Task</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
|
|
@ -38,7 +52,7 @@ export const DeleteTaskDialog = ({ taskId, open, onOpenChange }: DeleteTaskDialo
|
|||
<Button variant="secondary">Cancel</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
<Button variant="destructive" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
|
|
|
|||
16
webview-ui/src/components/history/ExportButton.tsx
Normal file
16
webview-ui/src/components/history/ExportButton.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { vscode } from "@/utils/vscode"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
export const ExportButton = ({ itemId }: { itemId: string }) => (
|
||||
<Button
|
||||
data-testid="export"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Export Task"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
vscode.postMessage({ type: "exportTaskWithId", text: itemId })
|
||||
}}>
|
||||
<span className="codicon codicon-cloud-download" />
|
||||
</Button>
|
||||
)
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { memo } from "react"
|
||||
import { formatLargeNumber } from "../../utils/format"
|
||||
import { useCopyToClipboard } from "../../utils/clipboard"
|
||||
|
||||
import { vscode } from "@/utils/vscode"
|
||||
import { formatLargeNumber, formatDate } from "@/utils/format"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { CopyButton } from "./CopyButton"
|
||||
|
||||
type HistoryPreviewProps = {
|
||||
showHistoryView: () => void
|
||||
|
|
@ -11,52 +13,15 @@ type HistoryPreviewProps = {
|
|||
|
||||
const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
|
||||
const { taskHistory } = useExtensionState()
|
||||
const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard()
|
||||
|
||||
const handleHistorySelect = (id: string) => {
|
||||
vscode.postMessage({ type: "showTaskWithId", text: id })
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date
|
||||
?.toLocaleString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
.replace(", ", " ")
|
||||
.replace(" at", ",")
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
{showCopyFeedback && <div className="copy-modal">Prompt Copied to Clipboard</div>}
|
||||
<style>
|
||||
{`
|
||||
.copy-modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: var(--vscode-notifications-background);
|
||||
color: var(--vscode-notifications-foreground);
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
.copy-button {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.history-preview-item:hover .copy-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.history-preview-item {
|
||||
background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 65%, transparent);
|
||||
border-radius: 4px;
|
||||
|
|
@ -73,7 +38,6 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
|
|||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div
|
||||
style={{
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
|
|
@ -81,20 +45,10 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
|
|||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<span
|
||||
className="codicon codicon-comment-discussion"
|
||||
style={{ marginRight: "4px", transform: "scale(0.9)" }}></span>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
fontSize: "0.85em",
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
Recent Tasks
|
||||
</span>
|
||||
<span className="codicon codicon-comment-discussion scale-90 mr-1" />
|
||||
<span className="font-medium text-xs uppercase">Recent Tasks</span>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "0px 20px 0 20px" }}>
|
||||
<div className="px-5">
|
||||
{taskHistory
|
||||
.filter((item) => item.ts && item.task)
|
||||
.slice(0, 3)
|
||||
|
|
@ -103,48 +57,25 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
|
|||
key={item.id}
|
||||
className="history-preview-item"
|
||||
onClick={() => handleHistorySelect(item.id)}>
|
||||
<div style={{ padding: "12px", position: "relative" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "8px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
fontWeight: 500,
|
||||
fontSize: "0.85em",
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
<div className="flex flex-col gap-2 p-3 pt-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs font-medium text-vscode-descriptionForeground uppercase">
|
||||
{formatDate(item.ts)}
|
||||
</span>
|
||||
<button
|
||||
title="Copy Prompt"
|
||||
aria-label="Copy Prompt"
|
||||
className="copy-button"
|
||||
data-appearance="icon"
|
||||
onClick={(e) => copyWithFeedback(item.task, e)}>
|
||||
<span className="codicon codicon-copy"></span>
|
||||
</button>
|
||||
<CopyButton itemTask={item.task} />
|
||||
</div>
|
||||
<div
|
||||
className="text-vscode-descriptionForeground overflow-hidden whitespace-pre-wrap"
|
||||
style={{
|
||||
fontSize: "var(--vscode-font-size)",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
marginBottom: "8px",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}}>
|
||||
{item.task}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.85em", color: "var(--vscode-descriptionForeground)" }}>
|
||||
<div className="text-xs text-vscode-descriptionForeground">
|
||||
<span>
|
||||
Tokens: ↑{formatLargeNumber(item.tokensIn || 0)} ↓
|
||||
{formatLargeNumber(item.tokensOut || 0)}
|
||||
|
|
@ -168,21 +99,14 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
|
|||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => showHistoryView()}
|
||||
style={{
|
||||
opacity: 0.9,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "var(--vscode-font-size)",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
View all history
|
||||
</div>
|
||||
</VSCodeButton>
|
||||
className="font-normal text-vscode-descriptionForeground">
|
||||
View all history
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import prettyBytes from "pretty-bytes"
|
|||
import { Virtuoso } from "react-virtuoso"
|
||||
import { VSCodeButton, VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@vscode/webview-ui-toolkit/react"
|
||||
|
||||
import { vscode } from "@/utils/vscode"
|
||||
import { formatLargeNumber, formatDate } from "@/utils/format"
|
||||
import { highlightFzfMatch } from "@/utils/highlight"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { formatLargeNumber } from "../../utils/format"
|
||||
import { highlightFzfMatch } from "../../utils/highlight"
|
||||
import { useCopyToClipboard } from "../../utils/clipboard"
|
||||
import { Button } from "../ui"
|
||||
import { ExportButton } from "./ExportButton"
|
||||
import { CopyButton } from "./CopyButton"
|
||||
|
||||
type HistoryViewProps = {
|
||||
onDone: () => void
|
||||
|
|
@ -38,28 +40,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||
vscode.postMessage({ type: "showTaskWithId", text: id })
|
||||
}
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [taskToDelete, setTaskToDelete] = useState<string | null>(null)
|
||||
|
||||
const handleDeleteHistoryItem = (id: string) => {
|
||||
setTaskToDelete(id)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date
|
||||
?.toLocaleString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
.replace(", ", " ")
|
||||
.replace(" at", ",")
|
||||
.toUpperCase()
|
||||
}
|
||||
const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null)
|
||||
|
||||
const presentableTasks = useMemo(() => {
|
||||
return taskHistory.filter((item) => item.ts && item.task)
|
||||
|
|
@ -230,10 +211,15 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Delete Task"
|
||||
title="Delete Task (Shift + Click to skip confirmation)"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteHistoryItem(item.id)
|
||||
|
||||
if (e.shiftKey) {
|
||||
vscode.postMessage({ type: "deleteTaskWithId", text: item.id })
|
||||
} else {
|
||||
setDeleteTaskId(item.id)
|
||||
}
|
||||
}}>
|
||||
<span className="codicon codicon-trash" />
|
||||
{item.size && prettyBytes(item.size)}
|
||||
|
|
@ -403,44 +389,11 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||
)}
|
||||
/>
|
||||
</div>
|
||||
{taskToDelete && (
|
||||
<DeleteTaskDialog
|
||||
taskId={taskToDelete}
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDeleteDialogOpen(open)
|
||||
if (!open) {
|
||||
setTaskToDelete(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{deleteTaskId && (
|
||||
<DeleteTaskDialog taskId={deleteTaskId} onOpenChange={(open) => !open && setDeleteTaskId(null)} open />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CopyButton = ({ itemTask }: { itemTask: string }) => {
|
||||
const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard()
|
||||
|
||||
return (
|
||||
<Button variant="ghost" size="icon" title="Copy Prompt" onClick={(e) => copyWithFeedback(itemTask, e)}>
|
||||
{showCopyFeedback ? <span className="codicon codicon-check" /> : <span className="codicon codicon-copy" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const ExportButton = ({ itemId }: { itemId: string }) => (
|
||||
<Button
|
||||
data-testid="export"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Export Task"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
vscode.postMessage({ type: "exportTaskWithId", text: itemId })
|
||||
}}>
|
||||
<span className="codicon codicon-cloud-download" />
|
||||
</Button>
|
||||
)
|
||||
|
||||
export default memo(HistoryView)
|
||||
|
|
|
|||
|
|
@ -135,26 +135,54 @@ describe("HistoryView", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("handles task deletion", async () => {
|
||||
const onDone = jest.fn()
|
||||
render(<HistoryView onDone={onDone} />)
|
||||
describe("task deletion", () => {
|
||||
it("shows confirmation dialog on regular click", () => {
|
||||
const onDone = jest.fn()
|
||||
render(<HistoryView onDone={onDone} />)
|
||||
|
||||
// Find and hover over first task
|
||||
const taskContainer = screen.getByTestId("virtuoso-item-1")
|
||||
fireEvent.mouseEnter(taskContainer)
|
||||
// Find and hover over first task
|
||||
const taskContainer = screen.getByTestId("virtuoso-item-1")
|
||||
fireEvent.mouseEnter(taskContainer)
|
||||
|
||||
// Click delete button to open confirmation dialog
|
||||
const deleteButton = within(taskContainer).getByTitle("Delete Task")
|
||||
fireEvent.click(deleteButton)
|
||||
// Click delete button to open confirmation dialog
|
||||
const deleteButton = within(taskContainer).getByTitle("Delete Task (Shift + Click to skip confirmation)")
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
// Find and click the confirm delete button in the dialog
|
||||
const confirmDeleteButton = screen.getByRole("button", { name: /delete/i })
|
||||
fireEvent.click(confirmDeleteButton)
|
||||
// Verify dialog is shown
|
||||
const dialog = screen.getByRole("alertdialog")
|
||||
expect(dialog).toBeInTheDocument()
|
||||
|
||||
// Verify vscode message was sent
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "deleteTaskWithId",
|
||||
text: "1",
|
||||
// Find and click the confirm delete button in the dialog
|
||||
const confirmDeleteButton = within(dialog).getByRole("button", { name: /delete/i })
|
||||
fireEvent.click(confirmDeleteButton)
|
||||
|
||||
// Verify vscode message was sent
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "deleteTaskWithId",
|
||||
text: "1",
|
||||
})
|
||||
})
|
||||
|
||||
it("deletes immediately on shift-click without confirmation", () => {
|
||||
const onDone = jest.fn()
|
||||
render(<HistoryView onDone={onDone} />)
|
||||
|
||||
// Find and hover over first task
|
||||
const taskContainer = screen.getByTestId("virtuoso-item-1")
|
||||
fireEvent.mouseEnter(taskContainer)
|
||||
|
||||
// Shift-click delete button
|
||||
const deleteButton = within(taskContainer).getByTitle("Delete Task (Shift + Click to skip confirmation)")
|
||||
fireEvent.click(deleteButton, { shiftKey: true })
|
||||
|
||||
// Verify no dialog is shown
|
||||
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument()
|
||||
|
||||
// Verify vscode message was sent
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "deleteTaskWithId",
|
||||
text: "1",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
const [showConfigMenu, setShowConfigMenu] = useState(false)
|
||||
const [isCreateModeDialogOpen, setIsCreateModeDialogOpen] = useState(false)
|
||||
const [activeSupportTab, setActiveSupportTab] = useState<SupportPromptType>("ENHANCE")
|
||||
const [isSystemPromptDisclosureOpen, setIsSystemPromptDisclosureOpen] = useState(false)
|
||||
|
||||
// Direct update functions
|
||||
const updateAgentPrompt = useCallback(
|
||||
|
|
@ -971,6 +972,45 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
<span className="codicon codicon-copy"></span>
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
|
||||
{/* Custom System Prompt Disclosure */}
|
||||
<div className="mb-3 mt-12">
|
||||
<button
|
||||
onClick={() => setIsSystemPromptDisclosureOpen(!isSystemPromptDisclosureOpen)}
|
||||
className="flex items-center text-xs text-vscode-foreground hover:text-vscode-textLink-foreground focus:outline-none"
|
||||
aria-expanded={isSystemPromptDisclosureOpen}>
|
||||
<span
|
||||
className={`codicon codicon-${isSystemPromptDisclosureOpen ? "chevron-down" : "chevron-right"} mr-1`}></span>
|
||||
<span>Advanced: Override System Prompt</span>
|
||||
</button>
|
||||
|
||||
{isSystemPromptDisclosureOpen && (
|
||||
<div className="text-xs text-vscode-descriptionForeground mt-2 ml-5">
|
||||
You can completely replace the system prompt for this mode (aside from the role
|
||||
definition and custom instructions) by creating a file at{" "}
|
||||
<span
|
||||
className="text-vscode-textLink-foreground cursor-pointer underline"
|
||||
onClick={() => {
|
||||
const currentMode = getCurrentMode()
|
||||
if (!currentMode) return
|
||||
|
||||
// Open or create an empty file
|
||||
vscode.postMessage({
|
||||
type: "openFile",
|
||||
text: `./.roo/system-prompt-${currentMode.slug}`,
|
||||
values: {
|
||||
create: true,
|
||||
content: "",
|
||||
},
|
||||
})
|
||||
}}>
|
||||
.roo/system-prompt-{getCurrentMode()?.slug || "code"}
|
||||
</span>{" "}
|
||||
in your workspace. This is a very advanced feature that bypasses built-in safeguards and
|
||||
consistency checks (especially around tool usage), so be careful!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import * as vscodemodels from "vscode"
|
|||
import {
|
||||
ApiConfiguration,
|
||||
ModelInfo,
|
||||
ApiProvider,
|
||||
anthropicDefaultModelId,
|
||||
anthropicModels,
|
||||
azureOpenAiDefaultApiVersion,
|
||||
|
|
@ -1407,9 +1408,11 @@ const ApiOptions = ({
|
|||
/>
|
||||
</div>
|
||||
<ThinkingBudget
|
||||
key={`${selectedProvider}-${selectedModelId}`}
|
||||
apiConfiguration={apiConfiguration}
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
modelInfo={selectedModelInfo}
|
||||
provider={selectedProvider as ApiProvider}
|
||||
/>
|
||||
<ModelInfoView
|
||||
selectedModelId={selectedModelId}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
alwaysAllowWrite,
|
||||
alwaysApproveResubmit,
|
||||
browserViewportSize,
|
||||
checkpointsEnabled,
|
||||
enableCheckpoints,
|
||||
diffEnabled,
|
||||
experiments,
|
||||
fuzzyMatchThreshold,
|
||||
|
|
@ -143,7 +143,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
vscode.postMessage({ type: "soundEnabled", bool: soundEnabled })
|
||||
vscode.postMessage({ type: "soundVolume", value: soundVolume })
|
||||
vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
|
||||
vscode.postMessage({ type: "checkpointsEnabled", bool: checkpointsEnabled })
|
||||
vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints })
|
||||
vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize })
|
||||
vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
|
||||
vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs })
|
||||
|
|
@ -706,6 +706,25 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 15 }}>
|
||||
<VSCodeCheckbox
|
||||
checked={enableCheckpoints}
|
||||
onChange={(e: any) => {
|
||||
setCachedStateField("enableCheckpoints", e.target.checked)
|
||||
}}>
|
||||
<span style={{ fontWeight: "500" }}>Enable automatic checkpoints</span>
|
||||
</VSCodeCheckbox>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
When enabled, Roo will automatically create checkpoints during task execution, making it
|
||||
easy to review changes or revert to earlier states.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 15 }}>
|
||||
<VSCodeCheckbox
|
||||
checked={diffEnabled}
|
||||
|
|
@ -779,28 +798,6 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 15 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
|
||||
<span style={{ color: "var(--vscode-errorForeground)" }}>⚠️</span>
|
||||
<VSCodeCheckbox
|
||||
checked={checkpointsEnabled}
|
||||
onChange={(e: any) => {
|
||||
setCachedStateField("checkpointsEnabled", e.target.checked)
|
||||
}}>
|
||||
<span style={{ fontWeight: "500" }}>Enable experimental checkpoints</span>
|
||||
</VSCodeCheckbox>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
When enabled, Roo will save a checkpoint whenever a file in the workspace is modified,
|
||||
added or deleted, letting you easily revert to a previous state.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{Object.entries(experimentConfigsMap)
|
||||
.filter((config) => config[0] !== "DIFF_STRATEGY")
|
||||
.map((config) => (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect } from "react"
|
||||
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { ApiProvider } from "../../../../src/shared/api"
|
||||
import { Slider } from "@/components/ui"
|
||||
|
||||
import { ApiConfiguration, ModelInfo } from "../../../../src/shared/api"
|
||||
|
|
@ -8,24 +8,35 @@ interface ThinkingBudgetProps {
|
|||
apiConfiguration: ApiConfiguration
|
||||
setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
|
||||
modelInfo?: ModelInfo
|
||||
provider?: ApiProvider
|
||||
}
|
||||
|
||||
export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, modelInfo }: ThinkingBudgetProps) => {
|
||||
export const ThinkingBudget = ({
|
||||
apiConfiguration,
|
||||
setApiConfigurationField,
|
||||
modelInfo,
|
||||
provider,
|
||||
}: ThinkingBudgetProps) => {
|
||||
const tokens = apiConfiguration?.modelMaxTokens || modelInfo?.maxTokens || 64_000
|
||||
const tokensMin = 8192
|
||||
const tokensMax = modelInfo?.maxTokens || 64_000
|
||||
|
||||
const thinkingTokens = apiConfiguration?.anthropicThinking || 8192
|
||||
// Get the appropriate thinking tokens based on provider
|
||||
const thinkingTokens = useMemo(() => {
|
||||
const value = apiConfiguration?.modelMaxThinkingTokens
|
||||
return value || Math.min(Math.floor(0.8 * tokens), 8192)
|
||||
}, [apiConfiguration, tokens])
|
||||
|
||||
const thinkingTokensMin = 1024
|
||||
const thinkingTokensMax = Math.floor(0.8 * tokens)
|
||||
|
||||
useEffect(() => {
|
||||
if (thinkingTokens > thinkingTokensMax) {
|
||||
setApiConfigurationField("anthropicThinking", thinkingTokensMax)
|
||||
setApiConfigurationField("modelMaxThinkingTokens", thinkingTokensMax)
|
||||
}
|
||||
}, [thinkingTokens, thinkingTokensMax, setApiConfigurationField])
|
||||
|
||||
if (!modelInfo || !modelInfo.thinking) {
|
||||
if (!modelInfo?.thinking) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +63,7 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
|
|||
max={thinkingTokensMax}
|
||||
step={1024}
|
||||
value={[thinkingTokens]}
|
||||
onValueChange={([value]) => setApiConfigurationField("anthropicThinking", value)}
|
||||
onValueChange={([value]) => setApiConfigurationField("modelMaxThinkingTokens", value)}
|
||||
/>
|
||||
<div className="w-12 text-sm text-center">{thinkingTokens}</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,16 @@ jest.mock("../TemperatureControl", () => ({
|
|||
),
|
||||
}))
|
||||
|
||||
// Mock ThinkingBudget component
|
||||
jest.mock("../ThinkingBudget", () => ({
|
||||
ThinkingBudget: ({ apiConfiguration, setApiConfigurationField, modelInfo, provider }: any) =>
|
||||
modelInfo?.thinking ? (
|
||||
<div data-testid="thinking-budget" data-provider={provider}>
|
||||
<input data-testid="thinking-tokens" value={apiConfiguration?.modelMaxThinkingTokens} />
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
describe("ApiOptions", () => {
|
||||
const renderApiOptions = (props = {}) => {
|
||||
render(
|
||||
|
|
@ -72,5 +82,45 @@ describe("ApiOptions", () => {
|
|||
expect(screen.queryByTestId("temperature-control")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
//TODO: More test cases needed
|
||||
describe("thinking functionality", () => {
|
||||
it("should show ThinkingBudget for Anthropic models that support thinking", () => {
|
||||
renderApiOptions({
|
||||
apiConfiguration: {
|
||||
apiProvider: "anthropic",
|
||||
apiModelId: "claude-3-7-sonnet-20250219:thinking",
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByTestId("thinking-budget")).toBeInTheDocument()
|
||||
expect(screen.getByTestId("thinking-budget")).toHaveAttribute("data-provider", "anthropic")
|
||||
})
|
||||
|
||||
it("should show ThinkingBudget for Vertex models that support thinking", () => {
|
||||
renderApiOptions({
|
||||
apiConfiguration: {
|
||||
apiProvider: "vertex",
|
||||
apiModelId: "claude-3-7-sonnet@20250219:thinking",
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByTestId("thinking-budget")).toBeInTheDocument()
|
||||
expect(screen.getByTestId("thinking-budget")).toHaveAttribute("data-provider", "vertex")
|
||||
})
|
||||
|
||||
it("should not show ThinkingBudget for models that don't support thinking", () => {
|
||||
renderApiOptions({
|
||||
apiConfiguration: {
|
||||
apiProvider: "anthropic",
|
||||
apiModelId: "claude-3-opus-20240229",
|
||||
modelInfo: { thinking: false }, // Non-thinking model
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId("thinking-budget")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Note: We don't need to test the actual ThinkingBudget component functionality here
|
||||
// since we have separate tests for that component. We just need to verify that
|
||||
// it's included in the ApiOptions component when appropriate.
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
import React from "react"
|
||||
import { render, screen, fireEvent } from "@testing-library/react"
|
||||
import { ThinkingBudget } from "../ThinkingBudget"
|
||||
import { ApiProvider, ModelInfo } from "../../../../../src/shared/api"
|
||||
|
||||
// Mock Slider component
|
||||
jest.mock("@/components/ui", () => ({
|
||||
Slider: ({ value, onValueChange, min, max }: any) => (
|
||||
<input
|
||||
type="range"
|
||||
data-testid="slider"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value[0]}
|
||||
onChange={(e) => onValueChange([parseInt(e.target.value)])}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
describe("ThinkingBudget", () => {
|
||||
const mockModelInfo: ModelInfo = {
|
||||
thinking: true,
|
||||
maxTokens: 16384,
|
||||
contextWindow: 200000,
|
||||
supportsPromptCache: true,
|
||||
supportsImages: true,
|
||||
}
|
||||
const defaultProps = {
|
||||
apiConfiguration: {},
|
||||
setApiConfigurationField: jest.fn(),
|
||||
modelInfo: mockModelInfo,
|
||||
provider: "anthropic" as ApiProvider,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should render nothing when model doesn't support thinking", () => {
|
||||
const { container } = render(
|
||||
<ThinkingBudget
|
||||
{...defaultProps}
|
||||
modelInfo={{
|
||||
...mockModelInfo,
|
||||
thinking: false,
|
||||
maxTokens: 16384,
|
||||
contextWindow: 200000,
|
||||
supportsPromptCache: true,
|
||||
supportsImages: true,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it("should render sliders when model supports thinking", () => {
|
||||
render(<ThinkingBudget {...defaultProps} />)
|
||||
|
||||
expect(screen.getAllByTestId("slider")).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should use modelMaxThinkingTokens field for Anthropic provider", () => {
|
||||
const setApiConfigurationField = jest.fn()
|
||||
|
||||
render(
|
||||
<ThinkingBudget
|
||||
{...defaultProps}
|
||||
apiConfiguration={{ modelMaxThinkingTokens: 4096 }}
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
provider="anthropic"
|
||||
/>,
|
||||
)
|
||||
|
||||
const sliders = screen.getAllByTestId("slider")
|
||||
fireEvent.change(sliders[1], { target: { value: "5000" } })
|
||||
|
||||
expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxThinkingTokens", 5000)
|
||||
})
|
||||
|
||||
it("should use modelMaxThinkingTokens field for Vertex provider", () => {
|
||||
const setApiConfigurationField = jest.fn()
|
||||
|
||||
render(
|
||||
<ThinkingBudget
|
||||
{...defaultProps}
|
||||
apiConfiguration={{ modelMaxThinkingTokens: 4096 }}
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
provider="vertex"
|
||||
/>,
|
||||
)
|
||||
|
||||
const sliders = screen.getAllByTestId("slider")
|
||||
fireEvent.change(sliders[1], { target: { value: "5000" } })
|
||||
|
||||
expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxThinkingTokens", 5000)
|
||||
})
|
||||
|
||||
it("should cap thinking tokens at 80% of max tokens", () => {
|
||||
const setApiConfigurationField = jest.fn()
|
||||
|
||||
render(
|
||||
<ThinkingBudget
|
||||
{...defaultProps}
|
||||
apiConfiguration={{ modelMaxTokens: 10000, modelMaxThinkingTokens: 9000 }}
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Effect should trigger and cap the value
|
||||
expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxThinkingTokens", 8000) // 80% of 10000
|
||||
})
|
||||
|
||||
it("should use default thinking tokens if not provided", () => {
|
||||
render(<ThinkingBudget {...defaultProps} apiConfiguration={{ modelMaxTokens: 10000 }} />)
|
||||
|
||||
// Default is 80% of max tokens, capped at 8192
|
||||
const sliders = screen.getAllByTestId("slider")
|
||||
expect(sliders[1]).toHaveValue("8000") // 80% of 10000
|
||||
})
|
||||
|
||||
it("should use min thinking tokens of 1024", () => {
|
||||
render(<ThinkingBudget {...defaultProps} apiConfiguration={{ modelMaxTokens: 1000 }} />)
|
||||
|
||||
const sliders = screen.getAllByTestId("slider")
|
||||
expect(sliders[1].getAttribute("min")).toBe("1024")
|
||||
})
|
||||
|
||||
it("should update max tokens when slider changes", () => {
|
||||
const setApiConfigurationField = jest.fn()
|
||||
|
||||
render(
|
||||
<ThinkingBudget
|
||||
{...defaultProps}
|
||||
apiConfiguration={{ modelMaxTokens: 10000 }}
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
/>,
|
||||
)
|
||||
|
||||
const sliders = screen.getAllByTestId("slider")
|
||||
fireEvent.change(sliders[0], { target: { value: "12000" } })
|
||||
|
||||
expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxTokens", 12000)
|
||||
})
|
||||
})
|
||||
|
|
@ -32,7 +32,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
|||
setSoundEnabled: (value: boolean) => void
|
||||
setSoundVolume: (value: number) => void
|
||||
setDiffEnabled: (value: boolean) => void
|
||||
setCheckpointsEnabled: (value: boolean) => void
|
||||
setEnableCheckpoints: (value: boolean) => void
|
||||
setBrowserViewportSize: (value: string) => void
|
||||
setFuzzyMatchThreshold: (value: number) => void
|
||||
preferredLanguage: string
|
||||
|
|
@ -79,7 +79,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
soundEnabled: false,
|
||||
soundVolume: 0.5,
|
||||
diffEnabled: false,
|
||||
checkpointsEnabled: false,
|
||||
enableCheckpoints: true,
|
||||
fuzzyMatchThreshold: 1.0,
|
||||
preferredLanguage: "English",
|
||||
writeDelayMs: 1000,
|
||||
|
|
@ -219,7 +219,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })),
|
||||
setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })),
|
||||
setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })),
|
||||
setCheckpointsEnabled: (value) => setState((prevState) => ({ ...prevState, checkpointsEnabled: value })),
|
||||
setEnableCheckpoints: (value) => setState((prevState) => ({ ...prevState, enableCheckpoints: value })),
|
||||
setBrowserViewportSize: (value: string) =>
|
||||
setState((prevState) => ({ ...prevState, browserViewportSize: value })),
|
||||
setFuzzyMatchThreshold: (value) => setState((prevState) => ({ ...prevState, fuzzyMatchThreshold: value })),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@
|
|||
|
||||
@theme {
|
||||
--font-display: var(--vscode-font-family);
|
||||
|
||||
--text-xs: calc(var(--vscode-font-size) * 0.85);
|
||||
--text-sm: calc(var(--vscode-font-size) * 0.9);
|
||||
--text-base: var(--vscode-font-size);
|
||||
--text-lg: calc(var(--vscode-font-size) * 1.1);
|
||||
|
|
@ -64,6 +66,8 @@
|
|||
--color-vscode-editor-foreground: var(--vscode-editor-foreground);
|
||||
--color-vscode-editor-background: var(--vscode-editor-background);
|
||||
|
||||
--color-vscode-editorGroup-border: var(--vscode-editorGroup-border);
|
||||
|
||||
--color-vscode-button-foreground: var(--vscode-button-foreground);
|
||||
--color-vscode-button-background: var(--vscode-button-background);
|
||||
--color-vscode-button-secondaryForeground: var(--vscode-button-secondaryForeground);
|
||||
|
|
|
|||
51
webview-ui/src/utils/__tests__/format.test.ts
Normal file
51
webview-ui/src/utils/__tests__/format.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// npx jest src/utils/__tests__/format.test.ts
|
||||
|
||||
import { formatDate } from "../format"
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("formats a timestamp correctly", () => {
|
||||
// January 15, 2023, 10:30 AM
|
||||
const timestamp = new Date(2023, 0, 15, 10, 30).getTime()
|
||||
const result = formatDate(timestamp)
|
||||
|
||||
expect(result).toBe("JANUARY 15, 10:30 AM")
|
||||
})
|
||||
|
||||
it("handles different months correctly", () => {
|
||||
// February 28, 2023, 3:45 PM
|
||||
const timestamp1 = new Date(2023, 1, 28, 15, 45).getTime()
|
||||
expect(formatDate(timestamp1)).toBe("FEBRUARY 28, 3:45 PM")
|
||||
|
||||
// December 31, 2023, 11:59 PM
|
||||
const timestamp2 = new Date(2023, 11, 31, 23, 59).getTime()
|
||||
expect(formatDate(timestamp2)).toBe("DECEMBER 31, 11:59 PM")
|
||||
})
|
||||
|
||||
it("handles AM/PM correctly", () => {
|
||||
// Morning time - 7:05 AM
|
||||
const morningTimestamp = new Date(2023, 5, 15, 7, 5).getTime()
|
||||
expect(formatDate(morningTimestamp)).toBe("JUNE 15, 7:05 AM")
|
||||
|
||||
// Noon - 12:00 PM
|
||||
const noonTimestamp = new Date(2023, 5, 15, 12, 0).getTime()
|
||||
expect(formatDate(noonTimestamp)).toBe("JUNE 15, 12:00 PM")
|
||||
|
||||
// Evening time - 8:15 PM
|
||||
const eveningTimestamp = new Date(2023, 5, 15, 20, 15).getTime()
|
||||
expect(formatDate(eveningTimestamp)).toBe("JUNE 15, 8:15 PM")
|
||||
})
|
||||
|
||||
it("handles single-digit minutes with leading zeros", () => {
|
||||
// 9:05 AM
|
||||
const timestamp = new Date(2023, 3, 10, 9, 5).getTime()
|
||||
expect(formatDate(timestamp)).toBe("APRIL 10, 9:05 AM")
|
||||
})
|
||||
|
||||
it("converts the result to uppercase", () => {
|
||||
const timestamp = new Date(2023, 8, 21, 16, 45).getTime()
|
||||
const result = formatDate(timestamp)
|
||||
|
||||
expect(result).toBe(result.toUpperCase())
|
||||
expect(result).toBe("SEPTEMBER 21, 4:45 PM")
|
||||
})
|
||||
})
|
||||
|
|
@ -10,3 +10,18 @@ export function formatLargeNumber(num: number): string {
|
|||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
export const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date
|
||||
.toLocaleString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
.replace(", ", " ")
|
||||
.replace(" at", ",")
|
||||
.toUpperCase()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue