mirror of
https://github.com/cline/cline.git
synced 2026-09-14 11:29:25 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acd272ea07 | ||
|
|
0a73590b3c | ||
|
|
e8ae36de69 | ||
|
|
65afea4fda | ||
|
|
8331b963a8 | ||
|
|
851a971ae7 | ||
|
|
32f47b0d8d |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add markdown copy to chat
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": minor
|
||||
---
|
||||
|
||||
Add support for custom API request timeout. Previously, timeouts were hardcoded to 30 seconds for providers like Ollama or 15 seconds for OpenRouter and Cline. Now users can set a custom timeout value in milliseconds through the settings interface.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add dependsOn to more blocks in the tasks.json
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding UI to show openrouter balance next to provider
|
||||
Vendored
+13
-15
@@ -22,7 +22,7 @@
|
||||
"label": "watch",
|
||||
"dependsOn": ["npm: protos", "npm: build:webview", "npm: dev:webview", "npm: watch:tsc", "npm: watch:esbuild"],
|
||||
"presentation": {
|
||||
"reveal": "never"
|
||||
"reveal": "always"
|
||||
},
|
||||
"group": {
|
||||
"kind": "build",
|
||||
@@ -39,7 +39,7 @@
|
||||
"npm: watch:esbuild:test"
|
||||
],
|
||||
"presentation": {
|
||||
"reveal": "never"
|
||||
"reveal": "always"
|
||||
},
|
||||
"group": "build"
|
||||
},
|
||||
@@ -53,8 +53,7 @@
|
||||
"dependsOn": ["npm: protos"],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
@@ -69,10 +68,10 @@
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
"label": "npm: build:webview:test",
|
||||
"dependsOn": ["npm: protos"],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
@@ -104,10 +103,10 @@
|
||||
],
|
||||
"isBackground": true,
|
||||
"label": "npm: dev:webview",
|
||||
"dependsOn": ["npm: protos"],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
@@ -125,8 +124,7 @@
|
||||
"dependsOn": ["npm: protos"],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
@@ -144,8 +142,7 @@
|
||||
"dependsOn": ["npm: protos"],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
@@ -161,10 +158,10 @@
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:tsc",
|
||||
"dependsOn": ["npm: protos"],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
"reveal": "always"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -172,8 +169,9 @@
|
||||
"script": "watch-tests",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
"dependsOn": ["npm: protos"],
|
||||
"presentation": {
|
||||
"reveal": "never",
|
||||
"reveal": "always",
|
||||
"group": "watchers"
|
||||
},
|
||||
"group": "build"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.13.1",
|
||||
"version": "3.13.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.13.1",
|
||||
"version": "3.13.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/bedrock-sdk": "^0.12.4",
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
@@ -10,4 +12,4 @@ service AccountService {
|
||||
// Generates a secure nonce for state validation, stores it in secrets,
|
||||
// and opens the authentication URL in the external browser.
|
||||
rpc accountLoginClicked(EmptyRequest) returns (String);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
message Metadata {
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
|
||||
@@ -21,8 +21,9 @@ export class OllamaHandler implements ApiHandler {
|
||||
|
||||
try {
|
||||
// Create a promise that rejects after timeout
|
||||
const timeoutMs = this.options.requestTimeoutMs || 30000
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Ollama request timed out after 120 seconds")), 120000)
|
||||
setTimeout(() => reject(new Error(`Ollama request timed out after ${timeoutMs / 1000} seconds`)), timeoutMs)
|
||||
})
|
||||
|
||||
// Create the actual API request promise
|
||||
@@ -63,7 +64,8 @@ export class OllamaHandler implements ApiHandler {
|
||||
} catch (error: any) {
|
||||
// Check if it's a timeout error
|
||||
if (error.message && error.message.includes("timed out")) {
|
||||
throw new Error("Ollama request timed out after 120 seconds")
|
||||
const timeoutMs = this.options.requestTimeoutMs || 30000
|
||||
throw new Error(`Ollama request timed out after ${timeoutMs / 1000} seconds`)
|
||||
}
|
||||
|
||||
// Enhance error reporting
|
||||
|
||||
@@ -801,6 +801,21 @@ export class Controller {
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "copyToClipboard": {
|
||||
try {
|
||||
await vscode.env.clipboard.writeText(message.text || "")
|
||||
} catch (error) {
|
||||
console.error("Error copying to clipboard:", error)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "toggleWorkspaceTasksOnly": {
|
||||
// Handle the toggle workspace tasks only message
|
||||
await updateGlobalState(this.context, "showWorkspaceTasksOnly", message.bool)
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
}
|
||||
// Add more switch case statements here as more webview message commands
|
||||
// are created within the webview context (i.e. inside media/main.js)
|
||||
}
|
||||
@@ -1815,6 +1830,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
vscMachineId: vscode.env.machineId,
|
||||
globalClineRulesToggles: globalClineRulesToggles || {},
|
||||
localClineRulesToggles: localClineRulesToggles || {},
|
||||
workspaceRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath,
|
||||
showWorkspaceTasksOnly: Boolean(await getGlobalState(this.context, "showWorkspaceTasksOnly")) || false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1865,11 +1882,19 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
|
||||
async updateTaskHistory(item: HistoryItem): Promise<HistoryItem[]> {
|
||||
const history = ((await getGlobalState(this.context, "taskHistory")) as HistoryItem[]) || []
|
||||
|
||||
// Add workspace root to the history item
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
const itemWithWorkspace = {
|
||||
...item,
|
||||
workspaceRoot,
|
||||
}
|
||||
|
||||
const existingItemIndex = history.findIndex((h) => h.id === item.id)
|
||||
if (existingItemIndex !== -1) {
|
||||
history[existingItemIndex] = item
|
||||
history[existingItemIndex] = itemWithWorkspace
|
||||
} else {
|
||||
history.push(item)
|
||||
history.push(itemWithWorkspace)
|
||||
}
|
||||
await updateGlobalState(this.context, "taskHistory", history)
|
||||
return history
|
||||
|
||||
@@ -75,5 +75,7 @@ export type GlobalStateKey =
|
||||
| "reasoningEffort"
|
||||
| "planActSeparateModelsSetting"
|
||||
| "favoritedModelIds"
|
||||
| "requestTimeoutMs"
|
||||
| "showWorkspaceTasksOnly"
|
||||
|
||||
export type LocalStateKey = "localClineRulesToggles"
|
||||
|
||||
@@ -125,6 +125,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
planActSeparateModelsSettingRaw,
|
||||
favoritedModelIds,
|
||||
globalClineRulesToggles,
|
||||
requestTimeoutMs,
|
||||
] = await Promise.all([
|
||||
getGlobalState(context, "apiProvider") as Promise<ApiProvider | undefined>,
|
||||
getGlobalState(context, "apiModelId") as Promise<string | undefined>,
|
||||
@@ -198,6 +199,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
getGlobalState(context, "planActSeparateModelsSetting") as Promise<boolean | undefined>,
|
||||
getGlobalState(context, "favoritedModelIds") as Promise<string[] | undefined>,
|
||||
getGlobalState(context, "globalClineRulesToggles") as Promise<ClineRulesToggles | undefined>,
|
||||
getGlobalState(context, "requestTimeoutMs") as Promise<number | undefined>,
|
||||
])
|
||||
|
||||
let apiProvider: ApiProvider
|
||||
@@ -297,6 +299,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
xaiApiKey,
|
||||
sambanovaApiKey,
|
||||
favoritedModelIds,
|
||||
requestTimeoutMs,
|
||||
},
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
@@ -316,6 +319,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
mcpMarketplaceEnabled,
|
||||
telemetrySetting: telemetrySetting || "unset",
|
||||
planActSeparateModelsSetting,
|
||||
showWorkspaceTasksOnly: Boolean(await getGlobalState(context, "showWorkspaceTasksOnly")) || false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,6 +438,7 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
|
||||
await storeSecret(context, "clineApiKey", clineApiKey)
|
||||
await storeSecret(context, "sambanovaApiKey", sambanovaApiKey)
|
||||
await updateGlobalState(context, "favoritedModelIds", favoritedModelIds)
|
||||
await updateGlobalState(context, "requestTimeoutMs", apiConfiguration.requestTimeoutMs)
|
||||
}
|
||||
|
||||
export async function resetExtensionState(context: vscode.ExtensionContext) {
|
||||
|
||||
@@ -148,6 +148,8 @@ export interface ExtensionState {
|
||||
vscMachineId: string
|
||||
globalClineRulesToggles: ClineRulesToggles
|
||||
localClineRulesToggles: ClineRulesToggles
|
||||
workspaceRoot?: string
|
||||
showWorkspaceTasksOnly?: boolean // Add this line
|
||||
}
|
||||
|
||||
export interface ClineMessage {
|
||||
|
||||
@@ -11,4 +11,5 @@ export type HistoryItem = {
|
||||
size?: number
|
||||
shadowGitConfigWorkTree?: string
|
||||
conversationHistoryDeletedRange?: [number, number]
|
||||
workspaceRoot?: string // Add this field to identify the workspace
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface WebviewMessage {
|
||||
| "grpc_request"
|
||||
| "toggleClineRule"
|
||||
| "deleteClineRule"
|
||||
| "copyToClipboard"
|
||||
| "toggleWorkspaceTasksOnly" // Add this line
|
||||
|
||||
// | "relaunchChromeDebugMode"
|
||||
text?: string
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface ApiHandlerOptions {
|
||||
thinkingBudgetTokens?: number
|
||||
reasoningEffort?: string
|
||||
sambanovaApiKey?: string
|
||||
requestTimeoutMs?: number
|
||||
}
|
||||
|
||||
export type ApiConfiguration = ApiHandlerOptions & {
|
||||
|
||||
@@ -16,8 +16,9 @@ export const AccountServiceDefinition = {
|
||||
fullName: "cline.AccountService",
|
||||
methods: {
|
||||
/**
|
||||
* The user clicked on the login link in the UI.
|
||||
* Returns the login URL.
|
||||
* Handles the user clicking the login link in the UI.
|
||||
* Generates a secure nonce for state validation, stores it in secrets,
|
||||
* and opens the authentication URL in the external browser.
|
||||
*/
|
||||
accountLoginClicked: {
|
||||
name: "accountLoginClicked",
|
||||
|
||||
Generated
+951
-56
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,11 @@
|
||||
"react-use": "^17.6.0",
|
||||
"react-virtuoso": "^4.12.3",
|
||||
"rehype-highlight": "^7.0.1",
|
||||
"rehype-parse": "^9.0.1",
|
||||
"rehype-remark": "^10.0.1",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"styled-components": "^6.1.15",
|
||||
"unified": "^11.0.5",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -28,6 +28,10 @@ import ChatRow from "@/components/chat/ChatRow"
|
||||
import ChatTextArea from "@/components/chat/ChatTextArea"
|
||||
import TaskHeader from "@/components/chat/TaskHeader"
|
||||
import TelemetryBanner from "@/components/common/TelemetryBanner"
|
||||
import { unified } from "unified"
|
||||
import remarkStringify from "remark-stringify"
|
||||
import rehypeRemark from "rehype-remark"
|
||||
import rehypeParse from "rehype-parse"
|
||||
|
||||
interface ChatViewProps {
|
||||
isHidden: boolean
|
||||
@@ -36,6 +40,26 @@ interface ChatViewProps {
|
||||
showHistoryView: () => void
|
||||
}
|
||||
|
||||
async function convertHtmlToMarkdown(html: string) {
|
||||
// Process the HTML to Markdown
|
||||
const result = await unified()
|
||||
.use(rehypeParse as any, { fragment: true }) // Parse HTML fragments
|
||||
.use(rehypeRemark as any) // Convert HTML to Markdown AST
|
||||
.use(remarkStringify as any, {
|
||||
// Convert Markdown AST to text
|
||||
bullet: "-", // Use - for unordered lists
|
||||
emphasis: "*", // Use * for emphasis
|
||||
strong: "_", // Use _ for strong
|
||||
listItemIndent: "one", // Use one space for list indentation
|
||||
rule: "-", // Use - for horizontal rules
|
||||
ruleSpaces: false, // No spaces in horizontal rules
|
||||
fences: true,
|
||||
})
|
||||
.process(html)
|
||||
|
||||
return String(result)
|
||||
}
|
||||
|
||||
export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
|
||||
|
||||
const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
|
||||
@@ -79,6 +103,33 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||
const [isAtBottom, setIsAtBottom] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleCopy = async (e: ClipboardEvent) => {
|
||||
if (window.getSelection) {
|
||||
const selection = window.getSelection()
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
// Get the selected HTML content
|
||||
const range = selection.getRangeAt(0)
|
||||
const clonedSelection = range.cloneContents()
|
||||
const div = document.createElement("div")
|
||||
div.appendChild(clonedSelection)
|
||||
const selectedHtml = div.innerHTML
|
||||
|
||||
// Convert HTML to Markdown
|
||||
const markdown = await convertHtmlToMarkdown(selectedHtml)
|
||||
|
||||
vscode.postMessage({ type: "copyToClipboard", text: markdown })
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener("copy", handleCopy)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("copy", handleCopy)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// UI layout depends on the last 2 messages
|
||||
// (since it relies on the content of these messages, we are deep comparing. i.e. the button state after hitting button sets enableButtons to false, and this effect otherwise would have to true again even if messages didn't change
|
||||
const lastMessage = useMemo(() => messages.at(-1), [messages])
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { VSCodeButton, VSCodeCheckbox } 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"
|
||||
// Remove the incorrect import: import { getWorkspacePath } from "@utils/path"
|
||||
|
||||
type HistoryPreviewProps = {
|
||||
showHistoryView: () => void
|
||||
}
|
||||
|
||||
const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
|
||||
const { taskHistory } = useExtensionState()
|
||||
const { taskHistory, showWorkspaceTasksOnly, setShowWorkspaceTasksOnly, workspaceRoot } = useExtensionState() // Add workspaceRoot
|
||||
const handleHistorySelect = (id: string) => {
|
||||
vscode.postMessage({ type: "showTaskWithId", text: id })
|
||||
}
|
||||
|
||||
// Use workspaceRoot from state
|
||||
const currentWorkspaceRoot = workspaceRoot
|
||||
|
||||
// Filter tasks by workspace if the toggle is on
|
||||
const filteredTaskHistory =
|
||||
showWorkspaceTasksOnly && currentWorkspaceRoot
|
||||
? taskHistory.filter((item) => item.workspaceRoot === currentWorkspaceRoot)
|
||||
: taskHistory
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date
|
||||
@@ -56,25 +66,45 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
|
||||
margin: "10px 20px 10px 20px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}>
|
||||
<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>
|
||||
<div style={{ 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>
|
||||
</div>
|
||||
|
||||
{/* Add workspace filter toggle */}
|
||||
<div style={{ display: "flex", alignItems: "center", fontSize: "0.85em" }}>
|
||||
<label style={{ marginRight: "8px", color: "var(--vscode-descriptionForeground)" }}>
|
||||
Current workspace only
|
||||
</label>
|
||||
<VSCodeCheckbox
|
||||
checked={showWorkspaceTasksOnly}
|
||||
onChange={() => {
|
||||
setShowWorkspaceTasksOnly(!showWorkspaceTasksOnly)
|
||||
vscode.postMessage({
|
||||
type: "toggleWorkspaceTasksOnly",
|
||||
bool: !showWorkspaceTasksOnly,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "0px 20px 0 20px" }}>
|
||||
{taskHistory
|
||||
{filteredTaskHistory
|
||||
.filter((item) => item.ts && item.task)
|
||||
.slice(0, 3)
|
||||
.map((item) => (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { VSCodeButton, VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@vscode/webview-ui-toolkit/react"
|
||||
import { VSCodeButton, VSCodeTextField, VSCodeRadioGroup, VSCodeRadio, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" // Add VSCodeCheckbox
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { vscode } from "@/utils/vscode"
|
||||
import { Virtuoso } from "react-virtuoso"
|
||||
@@ -17,7 +17,7 @@ type HistoryViewProps = {
|
||||
type SortOption = "newest" | "oldest" | "mostExpensive" | "mostTokens" | "mostRelevant"
|
||||
|
||||
const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
const { taskHistory, totalTasksSize } = useExtensionState()
|
||||
const { taskHistory, totalTasksSize, showWorkspaceTasksOnly, setShowWorkspaceTasksOnly, workspaceRoot } = useExtensionState() // Add showWorkspaceTasksOnly, setShowWorkspaceTasksOnly, workspaceRoot
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [sortOption, setSortOption] = useState<SortOption>("newest")
|
||||
const [lastNonRelevantSort, setLastNonRelevantSort] = useState<SortOption | null>("newest")
|
||||
@@ -72,8 +72,15 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
return taskHistory.filter((item) => item.ts && item.task)
|
||||
}, [taskHistory])
|
||||
|
||||
// Filter tasks by workspace if the toggle is on
|
||||
const workspaceFilteredTasks = useMemo(() => {
|
||||
return showWorkspaceTasksOnly && workspaceRoot
|
||||
? presentableTasks.filter((item) => item.workspaceRoot === workspaceRoot)
|
||||
: presentableTasks
|
||||
}, [presentableTasks, showWorkspaceTasksOnly, workspaceRoot])
|
||||
|
||||
const fuse = useMemo(() => {
|
||||
return new Fuse(presentableTasks, {
|
||||
return new Fuse(workspaceFilteredTasks, {
|
||||
keys: ["task"],
|
||||
threshold: 0.6,
|
||||
shouldSort: true,
|
||||
@@ -82,10 +89,10 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
includeMatches: true,
|
||||
minMatchCharLength: 1,
|
||||
})
|
||||
}, [presentableTasks])
|
||||
}, [workspaceFilteredTasks])
|
||||
|
||||
const taskHistorySearchResults = useMemo(() => {
|
||||
const results = searchQuery ? highlight(fuse.search(searchQuery)) : presentableTasks
|
||||
const results = searchQuery ? highlight(fuse.search(searchQuery)) : workspaceFilteredTasks // Use workspaceFilteredTasks
|
||||
|
||||
results.sort((a, b) => {
|
||||
switch (sortOption) {
|
||||
@@ -111,7 +118,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
})
|
||||
|
||||
return results
|
||||
}, [presentableTasks, searchQuery, fuse, sortOption])
|
||||
}, [workspaceFilteredTasks, searchQuery, fuse, sortOption]) // Update dependencies
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -216,10 +223,26 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
Most Relevant
|
||||
</VSCodeRadio>
|
||||
</VSCodeRadioGroup>
|
||||
{/* Add workspace filter toggle */}
|
||||
<div style={{ display: "flex", alignItems: "center", fontSize: "0.85em", marginLeft: "2px" }}>
|
||||
<label style={{ marginRight: "8px", color: "var(--vscode-descriptionForeground)" }}>
|
||||
Current workspace only
|
||||
</label>
|
||||
<VSCodeCheckbox
|
||||
checked={showWorkspaceTasksOnly}
|
||||
onChange={() => {
|
||||
setShowWorkspaceTasksOnly(!showWorkspaceTasksOnly)
|
||||
vscode.postMessage({
|
||||
type: "toggleWorkspaceTasksOnly",
|
||||
bool: !showWorkspaceTasksOnly,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flexGrow: 1, overflowY: "auto", margin: 0 }}>
|
||||
{/* {presentableTasks.length === 0 && (
|
||||
{/* {workspaceFilteredTasks.length === 0 && ( // Use workspaceFilteredTasks
|
||||
<div
|
||||
style={{
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ import VSCodeButtonLink from "@/components/common/VSCodeButtonLink"
|
||||
import OpenRouterModelPicker, { ModelDescriptionMarkdown, OPENROUTER_MODEL_PICKER_Z_INDEX } from "./OpenRouterModelPicker"
|
||||
import { ClineAccountInfoCard } from "./ClineAccountInfoCard"
|
||||
import RequestyModelPicker from "./RequestyModelPicker"
|
||||
import { useOpenRouterKeyInfo } from "../ui/hooks/useOpenRouterKeyInfo"
|
||||
|
||||
interface ApiOptionsProps {
|
||||
showModelOptions: boolean
|
||||
@@ -69,6 +70,44 @@ interface ApiOptionsProps {
|
||||
saveImmediately?: boolean // Add prop to control immediate saving
|
||||
}
|
||||
|
||||
const OpenRouterBalanceDisplay = ({ apiKey }: { apiKey: string }) => {
|
||||
const { data: keyInfo, isLoading, error } = useOpenRouterKeyInfo(apiKey)
|
||||
|
||||
if (isLoading) {
|
||||
return <span style={{ fontSize: "12px", color: "var(--vscode-descriptionForeground)" }}>Loading...</span>
|
||||
}
|
||||
|
||||
if (error || !keyInfo || keyInfo.limit === null) {
|
||||
// Don't show anything if there's an error, no info, or no limit set
|
||||
return null
|
||||
}
|
||||
|
||||
// Calculate remaining balance
|
||||
const remainingBalance = keyInfo.limit - keyInfo.usage
|
||||
const formattedBalance = remainingBalance.toLocaleString("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4,
|
||||
})
|
||||
|
||||
return (
|
||||
<VSCodeLink
|
||||
href="https://openrouter.ai/settings/keys"
|
||||
title={`Remaining balance: ${formattedBalance}\nLimit: ${keyInfo.limit.toLocaleString("en-US", { style: "currency", currency: "USD" })}\nUsage: ${keyInfo.usage.toLocaleString("en-US", { style: "currency", currency: "USD" })}`}
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-foreground)",
|
||||
textDecoration: "none",
|
||||
fontWeight: 500,
|
||||
paddingLeft: 4,
|
||||
cursor: "pointer",
|
||||
}}>
|
||||
Balance: {formattedBalance}
|
||||
</VSCodeLink>
|
||||
)
|
||||
}
|
||||
|
||||
// This is necessary to ensure dropdown opens downward, important for when this is used in popup
|
||||
const DROPDOWN_Z_INDEX = OPENROUTER_MODEL_PICKER_Z_INDEX + 2 // Higher than the OpenRouterModelPicker's and ModelSelectorTooltip's z-index
|
||||
|
||||
@@ -530,7 +569,12 @@ const ApiOptions = ({
|
||||
type="password"
|
||||
onInput={handleInputChange("openRouterApiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<span style={{ fontWeight: 500 }}>OpenRouter API Key</span>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%" }}>
|
||||
<span style={{ fontWeight: 500 }}>OpenRouter API Key</span>
|
||||
{apiConfiguration?.openRouterApiKey && (
|
||||
<OpenRouterBalanceDisplay apiKey={apiConfiguration.openRouterApiKey} />
|
||||
)}
|
||||
</div>
|
||||
</VSCodeTextField>
|
||||
{!apiConfiguration?.openRouterApiKey && (
|
||||
<VSCodeButtonLink
|
||||
@@ -1574,6 +1618,31 @@ const ApiOptions = ({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedProvider === "ollama" && showModelOptions && (
|
||||
<>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.requestTimeoutMs ? apiConfiguration.requestTimeoutMs.toString() : "30000"}
|
||||
style={{ width: "100%" }}
|
||||
onInput={(e: any) => {
|
||||
const value = e.target.value
|
||||
// Convert to number, with validation
|
||||
const numValue = parseInt(value, 10)
|
||||
if (!isNaN(numValue) && numValue > 0) {
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
requestTimeoutMs: numValue,
|
||||
})
|
||||
}
|
||||
}}
|
||||
placeholder="Default: 30000 (30 seconds)">
|
||||
<span style={{ fontWeight: 500 }}>Request Timeout (ms)</span>
|
||||
</VSCodeTextField>
|
||||
<p style={{ fontSize: "12px", marginTop: 3, color: "var(--vscode-descriptionForeground)" }}>
|
||||
Maximum time in milliseconds to wait for API responses before timing out.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(selectedProvider === "openrouter" || selectedProvider === "cline") && showModelOptions && (
|
||||
<>
|
||||
<VSCodeCheckbox
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { z } from "zod"
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
const CACHE_DURATION_MS = 30 * 1000 // 30 seconds
|
||||
const OpenRouterBaseURL = "https://openrouter.ai/api/v1"
|
||||
|
||||
// Define schema for OpenRouter key response
|
||||
const openRouterKeyInfoSchema = z.object({
|
||||
data: z.object({
|
||||
label: z.string().nullable(),
|
||||
usage: z.number(),
|
||||
is_free_tier: z.boolean(),
|
||||
is_provisioning_key: z.boolean(),
|
||||
rate_limit: z.object({
|
||||
requests: z.number(),
|
||||
interval: z.string(),
|
||||
}),
|
||||
limit: z.number().nullable(),
|
||||
}),
|
||||
})
|
||||
export type OpenRouterKeyInfo = z.infer<typeof openRouterKeyInfoSchema>["data"]
|
||||
|
||||
// Module-level cache variables
|
||||
let moduleCachedData: OpenRouterKeyInfo | null = null
|
||||
let moduleLastFetchTime: number | null = null
|
||||
|
||||
async function getOpenRouterKeyInfo(apiKey: string, signal: AbortSignal): Promise<OpenRouterKeyInfo | null> {
|
||||
try {
|
||||
const keyEndpoint = `${OpenRouterBaseURL}/key`
|
||||
const response = await fetch(keyEndpoint, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
console.warn("OpenRouter API key is invalid or unauthorized.")
|
||||
} else {
|
||||
console.error(`Error fetching OpenRouter key info: HTTP ${response.status}`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const responseData = await response.json()
|
||||
const result = openRouterKeyInfoSchema.safeParse(responseData)
|
||||
if (!result.success) {
|
||||
console.error("OpenRouter API key info validation failed:", result.error.flatten().fieldErrors)
|
||||
return null
|
||||
}
|
||||
return result.data.data
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
console.error("Error fetching OpenRouter key info:", error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to fetch OpenRouter API key information.
|
||||
* Implements stale-while-revalidate caching using module-level variables.
|
||||
* @param apiKey The OpenRouter API key.
|
||||
* @returns An object containing the key info data, loading state, and error state.
|
||||
*/
|
||||
export const useOpenRouterKeyInfo = (apiKey?: string) => {
|
||||
// State reflects the currently displayed data, initialized from cache
|
||||
const [data, setData] = useState<OpenRouterKeyInfo | null>(moduleCachedData)
|
||||
// Loading is true only if there's no initial cache and a key is provided
|
||||
const [isLoading, setIsLoading] = useState<boolean>(!moduleCachedData && !!apiKey)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
|
||||
if (!apiKey) {
|
||||
// Clear state and cache if API key is removed
|
||||
setData(null)
|
||||
moduleCachedData = null
|
||||
moduleLastFetchTime = null
|
||||
setIsLoading(false)
|
||||
setError(null)
|
||||
return () => controller.abort()
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const cacheAge = moduleLastFetchTime ? now - moduleLastFetchTime : Infinity
|
||||
const isCacheStale = cacheAge >= CACHE_DURATION_MS
|
||||
const hasCache = !!moduleCachedData
|
||||
|
||||
// Use cached data immediately if available
|
||||
if (hasCache) {
|
||||
// Ensure local state matches module cache if it hasn't updated yet
|
||||
// This handles cases where the hook re-renders before the effect runs
|
||||
if (data !== moduleCachedData) {
|
||||
setData(moduleCachedData)
|
||||
}
|
||||
setIsLoading(false)
|
||||
} else {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
// Fetch if cache is stale or doesn't exist
|
||||
if (isCacheStale || !hasCache) {
|
||||
const isBackgroundFetch = hasCache && isCacheStale // Fetching while showing stale data
|
||||
|
||||
// Don't set loading true for background fetches
|
||||
if (!isBackgroundFetch) {
|
||||
setIsLoading(true)
|
||||
}
|
||||
|
||||
getOpenRouterKeyInfo(apiKey, signal)
|
||||
.then((result) => {
|
||||
if (!signal.aborted) {
|
||||
moduleCachedData = result // Update module cache
|
||||
moduleLastFetchTime = Date.now() // Update module fetch time
|
||||
setData(result) // Update state
|
||||
setError(null) // Clear error on success
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!signal.aborted) {
|
||||
console.error("[useOpenRouterKeyInfo] Fetch error:", err)
|
||||
setError(err instanceof Error ? err : new Error("An unknown error occurred"))
|
||||
if (!isBackgroundFetch) {
|
||||
setData(null)
|
||||
moduleCachedData = null
|
||||
moduleLastFetchTime = null
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!signal.aborted) {
|
||||
setIsLoading(false) // Stop loading indicator
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
}, [apiKey]) // Re-run effect only when apiKey changes
|
||||
|
||||
return { data, isLoading, error }
|
||||
}
|
||||
@@ -29,12 +29,14 @@ interface ExtensionStateContextType extends ExtensionState {
|
||||
mcpMarketplaceCatalog: McpMarketplaceCatalog
|
||||
filePaths: string[]
|
||||
totalTasksSize: number | null
|
||||
showWorkspaceTasksOnly: boolean // Add this line
|
||||
setApiConfiguration: (config: ApiConfiguration) => void
|
||||
setCustomInstructions: (value?: string) => void
|
||||
setTelemetrySetting: (value: TelemetrySetting) => void
|
||||
setShowAnnouncement: (value: boolean) => void
|
||||
setPlanActSeparateModelsSetting: (value: boolean) => void
|
||||
setMcpServers: (value: McpServer[]) => void
|
||||
setShowWorkspaceTasksOnly: (value: boolean) => void // Add this line
|
||||
}
|
||||
|
||||
const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
|
||||
@@ -72,6 +74,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
})
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
|
||||
const [mcpMarketplaceCatalog, setMcpMarketplaceCatalog] = useState<McpMarketplaceCatalog>({ items: [] })
|
||||
const [showWorkspaceTasksOnly, setShowWorkspaceTasksOnly] = useState<boolean>(false) // Add this line
|
||||
const handleMessage = useCallback((event: MessageEvent) => {
|
||||
const message: ExtensionMessage = event.data
|
||||
switch (message.type) {
|
||||
@@ -200,6 +203,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
mcpMarketplaceCatalog,
|
||||
filePaths,
|
||||
totalTasksSize,
|
||||
showWorkspaceTasksOnly, // Add this line
|
||||
globalClineRulesToggles: state.globalClineRulesToggles || {},
|
||||
localClineRulesToggles: state.localClineRulesToggles || {},
|
||||
setApiConfiguration: (value) =>
|
||||
@@ -228,6 +232,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
shouldShowAnnouncement: value,
|
||||
})),
|
||||
setMcpServers: (mcpServers: McpServer[]) => setMcpServers(mcpServers),
|
||||
setShowWorkspaceTasksOnly: (value: boolean) => setShowWorkspaceTasksOnly(value), // Add this line
|
||||
}
|
||||
|
||||
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
|
||||
|
||||
Reference in New Issue
Block a user