Compare commits

...
Author SHA1 Message Date
Trevor Hudson acd272ea07 filter by workspace 2025-04-28 12:15:15 -07:00
Evan 0a73590b3c It depends... (#3126)
* add protos to more dependsOn, also make it so that the scripts are always displayed and the window does not automatically close

* changeset

* add back build script
2025-04-28 12:15:15 -07:00
nomaven e8ae36de69 ENG-318 feat: Show openrouter balance next to provider (#3003)
* feat: add OpenRouter base URL and balance display component

* feat: add OpenRouter base URL and balance display component
2025-04-28 12:15:15 -07:00
Wesley SmithandWesley Smith 65afea4fda Markdown copy (#3060)
* add markdown copy

* add changeset

* fmt

---------

Co-authored-by: Wesley Smith <wes@neofactory.ai>
2025-04-28 12:15:15 -07:00
WingsDrafterworkandnomaven 8331b963a8 Support for custom timeout (#3029)
* Support for custom timeout

* Make custom timeout visible only for ollama

* Remove parameters from other providers, only kept for ollama

* Update webview-ui/src/components/settings/ApiOptions.tsx

Co-authored-by: nomaven <arafat.da.khan@gmail.com>

---------

Co-authored-by: nomaven <arafat.da.khan@gmail.com>
2025-04-28 12:15:15 -07:00
Sarah Fortune 851a971ae7 Add java options to protobufs (#3141)
Set the java class path.
2025-04-28 12:15:15 -07:00
Trevor Hudson 32f47b0d8d workspace tasks 2025-04-27 12:56:56 -07:00
30 changed files with 1403 additions and 105 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add markdown copy to chat
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add dependsOn to more blocks in the tasks.json
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Adding UI to show openrouter balance next to provider
+13 -15
View File
@@ -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"
+2 -2
View File
@@ -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
View File
@@ -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);
}
}
+2
View File
@@ -1,6 +1,8 @@
syntax = "proto3";
package cline;
option java_package = "bot.cline.proto";
option java_multiple_files = true;
import "common.proto";
+2
View File
@@ -1,6 +1,8 @@
syntax = "proto3";
package cline;
option java_package = "bot.cline.proto";
option java_multiple_files = true;
import "common.proto";
+2
View File
@@ -1,6 +1,8 @@
syntax = "proto3";
package cline;
option java_package = "bot.cline.proto";
option java_multiple_files = true;
message Metadata {
}
+2
View File
@@ -1,6 +1,8 @@
syntax = "proto3";
package cline;
option java_package = "bot.cline.proto";
option java_multiple_files = true;
import "common.proto";
+2
View File
@@ -1,6 +1,8 @@
syntax = "proto3";
package cline;
option java_package = "bot.cline.proto";
option java_multiple_files = true;
import "common.proto";
+2
View File
@@ -1,6 +1,8 @@
syntax = "proto3";
package cline;
option java_package = "bot.cline.proto";
option java_multiple_files = true;
import "common.proto";
+4 -2
View File
@@ -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
+27 -2
View File
@@ -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
+2
View File
@@ -75,5 +75,7 @@ export type GlobalStateKey =
| "reasoningEffort"
| "planActSeparateModelsSetting"
| "favoritedModelIds"
| "requestTimeoutMs"
| "showWorkspaceTasksOnly"
export type LocalStateKey = "localClineRulesToggles"
+5
View File
@@ -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) {
+2
View File
@@ -148,6 +148,8 @@ export interface ExtensionState {
vscMachineId: string
globalClineRulesToggles: ClineRulesToggles
localClineRulesToggles: ClineRulesToggles
workspaceRoot?: string
showWorkspaceTasksOnly?: boolean // Add this line
}
export interface ClineMessage {
+1
View File
@@ -11,4 +11,5 @@ export type HistoryItem = {
size?: number
shadowGitConfigWorkTree?: string
conversationHistoryDeletedRange?: [number, number]
workspaceRoot?: string // Add this field to identify the workspace
}
+2
View File
@@ -72,6 +72,8 @@ export interface WebviewMessage {
| "grpc_request"
| "toggleClineRule"
| "deleteClineRule"
| "copyToClipboard"
| "toggleWorkspaceTasksOnly" // Add this line
// | "relaunchChromeDebugMode"
text?: string
+1
View File
@@ -80,6 +80,7 @@ export interface ApiHandlerOptions {
thinkingBudgetTokens?: number
reasoningEffort?: string
sambanovaApiKey?: string
requestTimeoutMs?: number
}
export type ApiConfiguration = ApiHandlerOptions & {
+3 -2
View File
@@ -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",
+951 -56
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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>