Skip to content

Commit 2461c56

Browse files
committed
feat: add auto-approval cost limit
Introduces a new auto-approval setting `allowedMaxCost` to limit the total cost of API requests before prompting user approval. - Adds `allowedMaxCost` to `globalSettingsSchema` and `ExtensionState`. - Implements cost tracking in `Task.ts` and prompts for approval when `allowedMaxCost` is exceeded. - Integrates `allowedMaxCost` into `ClineProvider` and `webviewMessageHandler` for state management. - Updates UI components (`AutoApproveMenu`, `AutoApprovedRequestLimitWarning`, `AutoApproveSettings`, `SettingsView`) to display and manage the new cost limit. - Adds new i18n keys for cost limit messages.
1 parent e13083e commit 2461c56

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+1019
-77
lines changed

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export const globalSettingsSchema = z.object({
6868
commandTimeoutAllowlist: z.array(z.string()).optional(),
6969
preventCompletionWithOpenTodos: z.boolean().optional(),
7070
allowedMaxRequests: z.number().nullish(),
71+
allowedMaxCost: z.number().nullish(),
7172
autoCondenseContext: z.boolean().optional(),
7273
autoCondenseContextPercent: z.number().optional(),
7374
maxConcurrentFileReads: z.number().optional(),

src/core/task/Task.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ export class Task extends EventEmitter<TaskEvents> {
200200
api: ApiHandler
201201
private static lastGlobalApiRequestTime?: number
202202
private consecutiveAutoApprovedRequestsCount: number = 0
203+
private consecutiveAutoApprovedCost: number = 0
203204

204205
/**
205206
* Reset the global API request timestamp. This should only be used for testing.
@@ -1975,13 +1976,31 @@ export class Task extends EventEmitter<TaskEvents> {
19751976
this.consecutiveAutoApprovedRequestsCount++
19761977

19771978
if (this.consecutiveAutoApprovedRequestsCount > maxRequests) {
1978-
const { response } = await this.ask("auto_approval_max_req_reached", JSON.stringify({ count: maxRequests }))
1979+
const { response } = await this.ask(
1980+
"auto_approval_max_req_reached",
1981+
JSON.stringify({ count: maxRequests, type: "requests" }),
1982+
)
19791983
// If we get past the promise, it means the user approved and did not start a new task
19801984
if (response === "yesButtonClicked") {
19811985
this.consecutiveAutoApprovedRequestsCount = 0
19821986
}
19831987
}
19841988

1989+
// Check if we've reached the maximum allowed cost
1990+
const maxCost = state?.allowedMaxCost || Infinity
1991+
this.consecutiveAutoApprovedCost = getApiMetrics(this.combineMessages(this.clineMessages.slice(1))).totalCost
1992+
1993+
if (this.consecutiveAutoApprovedCost > maxCost) {
1994+
const { response } = await this.ask(
1995+
"auto_approval_max_req_reached",
1996+
JSON.stringify({ count: maxCost.toFixed(2), type: "cost" }),
1997+
)
1998+
// If we get past the promise, it means the user approved and did not start a new task
1999+
if (response === "yesButtonClicked") {
2000+
this.consecutiveAutoApprovedCost = 0
2001+
}
2002+
}
2003+
19852004
const metadata: ApiHandlerCreateMessageMetadata = {
19862005
mode: mode,
19872006
taskId: this.taskId,

src/core/webview/ClineProvider.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1446,6 +1446,7 @@ export class ClineProvider
14461446
alwaysAllowSubtasks,
14471447
alwaysAllowUpdateTodoList,
14481448
allowedMaxRequests,
1449+
allowedMaxCost,
14491450
autoCondenseContext,
14501451
autoCondenseContextPercent,
14511452
soundEnabled,
@@ -1541,6 +1542,7 @@ export class ClineProvider
15411542
alwaysAllowSubtasks: alwaysAllowSubtasks ?? false,
15421543
alwaysAllowUpdateTodoList: alwaysAllowUpdateTodoList ?? false,
15431544
allowedMaxRequests,
1545+
allowedMaxCost,
15441546
autoCondenseContext: autoCondenseContext ?? true,
15451547
autoCondenseContextPercent: autoCondenseContextPercent ?? 100,
15461548
uriScheme: vscode.env.uriScheme,
@@ -1737,6 +1739,7 @@ export class ClineProvider
17371739
followupAutoApproveTimeoutMs: stateValues.followupAutoApproveTimeoutMs ?? 60000,
17381740
diagnosticsEnabled: stateValues.diagnosticsEnabled ?? true,
17391741
allowedMaxRequests: stateValues.allowedMaxRequests,
1742+
allowedMaxCost: stateValues.allowedMaxCost,
17401743
autoCondenseContext: stateValues.autoCondenseContext ?? true,
17411744
autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100,
17421745
taskHistory: stateValues.taskHistory,

src/core/webview/webviewMessageHandler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,10 @@ export const webviewMessageHandler = async (
332332
await updateGlobalState("allowedMaxRequests", message.value)
333333
await provider.postStateToWebview()
334334
break
335+
case "allowedMaxCost":
336+
await updateGlobalState("allowedMaxCost", message.value)
337+
await provider.postStateToWebview()
338+
break
335339
case "alwaysAllowSubtasks":
336340
await updateGlobalState("alwaysAllowSubtasks", message.bool)
337341
await provider.postStateToWebview()

src/shared/ExtensionMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ export type ExtensionState = Pick<
222222
| "allowedCommands"
223223
| "deniedCommands"
224224
| "allowedMaxRequests"
225+
| "allowedMaxCost"
225226
| "browserToolEnabled"
226227
| "browserViewportSize"
227228
| "screenshotQuality"

src/shared/WebviewMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export interface WebviewMessage {
8080
| "alwaysAllowMcp"
8181
| "alwaysAllowModeSwitch"
8282
| "allowedMaxRequests"
83+
| "allowedMaxCost"
8384
| "alwaysAllowSubtasks"
8485
| "alwaysAllowUpdateTodoList"
8586
| "autoCondenseContext"

webview-ui/src/components/chat/AutoApproveMenu.tsx

Lines changed: 10 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { memo, useCallback, useMemo, useState } from "react"
22
import { Trans } from "react-i18next"
3-
import { VSCodeCheckbox, VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
3+
import { VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
44

55
import { vscode } from "@src/utils/vscode"
66
import { useExtensionState } from "@src/context/ExtensionStateContext"
77
import { useAppTranslation } from "@src/i18n/TranslationContext"
88
import { AutoApproveToggle, AutoApproveSetting, autoApproveSettingsConfig } from "../settings/AutoApproveToggle"
9+
import { MaxLimitInputs } from "../settings/MaxLimitInputs"
910
import { StandardTooltip } from "@src/components/ui"
1011
import { useAutoApprovalState } from "@src/hooks/useAutoApprovalState"
1112
import { useAutoApprovalToggles } from "@src/hooks/useAutoApprovalToggles"
@@ -22,6 +23,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
2223
setAutoApprovalEnabled,
2324
alwaysApproveResubmit,
2425
allowedMaxRequests,
26+
allowedMaxCost,
2527
setAlwaysAllowReadOnly,
2628
setAlwaysAllowWrite,
2729
setAlwaysAllowExecute,
@@ -33,6 +35,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
3335
setAlwaysAllowFollowupQuestions,
3436
setAlwaysAllowUpdateTodoList,
3537
setAllowedMaxRequests,
38+
setAllowedMaxCost,
3639
} = useExtensionState()
3740

3841
const { t } = useAppTranslation()
@@ -243,42 +246,12 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
243246

244247
<AutoApproveToggle {...toggles} onToggle={onAutoApproveToggle} />
245248

246-
{/* Auto-approve API request count limit input row inspired by Cline */}
247-
<div
248-
style={{
249-
display: "flex",
250-
alignItems: "center",
251-
gap: "8px",
252-
marginTop: "10px",
253-
marginBottom: "8px",
254-
color: "var(--vscode-descriptionForeground)",
255-
}}>
256-
<span style={{ flexShrink: 1, minWidth: 0 }}>
257-
<Trans i18nKey="settings:autoApprove.apiRequestLimit.title" />:
258-
</span>
259-
<VSCodeTextField
260-
placeholder={t("settings:autoApprove.apiRequestLimit.unlimited")}
261-
value={(allowedMaxRequests ?? Infinity) === Infinity ? "" : allowedMaxRequests?.toString()}
262-
onInput={(e) => {
263-
const input = e.target as HTMLInputElement
264-
// Remove any non-numeric characters
265-
input.value = input.value.replace(/[^0-9]/g, "")
266-
const value = parseInt(input.value)
267-
const parsedValue = !isNaN(value) && value > 0 ? value : undefined
268-
setAllowedMaxRequests(parsedValue)
269-
vscode.postMessage({ type: "allowedMaxRequests", value: parsedValue })
270-
}}
271-
style={{ flex: 1 }}
272-
/>
273-
</div>
274-
<div
275-
style={{
276-
color: "var(--vscode-descriptionForeground)",
277-
fontSize: "12px",
278-
marginBottom: "10px",
279-
}}>
280-
<Trans i18nKey="settings:autoApprove.apiRequestLimit.description" />
281-
</div>
249+
<MaxLimitInputs
250+
allowedMaxRequests={allowedMaxRequests ?? undefined}
251+
allowedMaxCost={allowedMaxCost ?? undefined}
252+
onMaxRequestsChange={(value) => setAllowedMaxRequests(value)}
253+
onMaxCostChange={(value) => setAllowedMaxCost(value)}
254+
/>
282255
</div>
283256
)}
284257
</div>

webview-ui/src/components/chat/AutoApprovedRequestLimitWarning.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,29 @@ type AutoApprovedRequestLimitWarningProps = {
1212

1313
export const AutoApprovedRequestLimitWarning = memo(({ message }: AutoApprovedRequestLimitWarningProps) => {
1414
const [buttonClicked, setButtonClicked] = useState(false)
15-
const { count } = JSON.parse(message.text ?? "{}")
15+
const { count, type = "requests" } = JSON.parse(message.text ?? "{}")
1616

1717
if (buttonClicked) {
1818
return null
1919
}
2020

21+
const isCostLimit = type === "cost"
22+
const titleKey = isCostLimit
23+
? "ask.autoApprovedCostLimitReached.title"
24+
: "ask.autoApprovedRequestLimitReached.title"
25+
const descriptionKey = isCostLimit
26+
? "ask.autoApprovedCostLimitReached.description"
27+
: "ask.autoApprovedRequestLimitReached.description"
28+
const buttonKey = isCostLimit
29+
? "ask.autoApprovedCostLimitReached.button"
30+
: "ask.autoApprovedRequestLimitReached.button"
31+
2132
return (
2233
<>
2334
<div style={{ display: "flex", alignItems: "center", gap: "8px", color: "var(--vscode-foreground)" }}>
2435
<span className="codicon codicon-warning" />
2536
<span style={{ fontWeight: "bold" }}>
26-
<Trans i18nKey="ask.autoApprovedRequestLimitReached.title" ns="chat" />
37+
<Trans i18nKey={titleKey} ns="chat" />
2738
</span>
2839
</div>
2940

@@ -37,7 +48,7 @@ export const AutoApprovedRequestLimitWarning = memo(({ message }: AutoApprovedRe
3748
justifyContent: "center",
3849
}}>
3950
<div className="flex justify-between items-center">
40-
<Trans i18nKey="ask.autoApprovedRequestLimitReached.description" ns="chat" values={{ count }} />
51+
<Trans i18nKey={descriptionKey} ns="chat" values={{ count }} />
4152
</div>
4253
<VSCodeButton
4354
style={{ width: "100%", padding: "6px", borderRadius: "4px" }}
@@ -46,7 +57,7 @@ export const AutoApprovedRequestLimitWarning = memo(({ message }: AutoApprovedRe
4657
setButtonClicked(true)
4758
vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" })
4859
}}>
49-
<Trans i18nKey="ask.autoApprovedRequestLimitReached.button" ns="chat" />
60+
<Trans i18nKey={buttonKey} ns="chat" />
5061
</VSCodeButton>
5162
</div>
5263
</>
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { cn } from "@/lib/utils"
2+
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
3+
import { forwardRef, useCallback, useRef, ReactNode, ComponentRef, ComponentProps } from "react"
4+
5+
// Type for web components that have shadow DOM
6+
interface WebComponentWithShadowRoot extends HTMLElement {
7+
shadowRoot: ShadowRoot | null
8+
}
9+
10+
export interface VSCodeTextFieldWithNodesProps extends ComponentProps<typeof VSCodeTextField> {
11+
leftNodes?: ReactNode[]
12+
rightNodes?: ReactNode[]
13+
}
14+
15+
function VSCodeTextFieldWithNodesInner(
16+
props: VSCodeTextFieldWithNodesProps,
17+
forwardedRef: React.Ref<HTMLInputElement>,
18+
) {
19+
const { className, style, "data-testid": dataTestId, leftNodes, rightNodes, ...restProps } = props
20+
21+
const inputRef = useRef<HTMLInputElement | null>(null)
22+
23+
// Callback ref to get access to the underlying input element.
24+
// VSCodeTextField doesn't expose this directly so we have to query for it!
25+
const handleVSCodeFieldRef = useCallback(
26+
(element: ComponentRef<typeof VSCodeTextField>) => {
27+
if (!element) return
28+
29+
const webComponent = element as unknown as WebComponentWithShadowRoot
30+
const inputElement =
31+
webComponent.shadowRoot?.querySelector?.("input") || webComponent.querySelector?.("input")
32+
if (inputElement && inputElement instanceof HTMLInputElement) {
33+
inputRef.current = inputElement
34+
if (typeof forwardedRef === "function") {
35+
forwardedRef?.(inputElement)
36+
} else if (forwardedRef) {
37+
;(forwardedRef as React.MutableRefObject<HTMLInputElement | null>).current = inputElement
38+
}
39+
}
40+
},
41+
[forwardedRef],
42+
)
43+
44+
const focusInput = useCallback(async () => {
45+
if (inputRef.current && document.activeElement !== inputRef.current) {
46+
setTimeout(() => {
47+
inputRef.current?.focus()
48+
})
49+
}
50+
}, [])
51+
52+
const hasLeftNodes = leftNodes && leftNodes.filter(Boolean).length > 0
53+
const hasRightNodes = rightNodes && rightNodes.filter(Boolean).length > 0
54+
55+
return (
56+
<div
57+
className={cn(
58+
`group`,
59+
`relative flex items-center cursor-text`,
60+
`bg-[var(--input-background)] text-[var(--input-foreground)]`,
61+
`rounded-[calc(var(--corner-radius-round)*1px)]`,
62+
className,
63+
)}
64+
style={style}
65+
onMouseDown={focusInput}>
66+
{hasLeftNodes && (
67+
<div className="absolute left-2 z-10 flex items-center gap-1 pointer-events-none">{leftNodes}</div>
68+
)}
69+
70+
<VSCodeTextField
71+
data-testid={dataTestId}
72+
ref={handleVSCodeFieldRef}
73+
style={{
74+
flex: 1,
75+
paddingLeft: hasLeftNodes ? "24px" : undefined,
76+
paddingRight: hasRightNodes ? "24px" : undefined,
77+
}}
78+
className="[--border-width:0]"
79+
{...restProps}
80+
/>
81+
82+
{hasRightNodes && (
83+
<div className="absolute right-2 z-10 flex items-center gap-1 pointer-events-none">{rightNodes}</div>
84+
)}
85+
86+
{/* Absolutely positioned focus border overlay */}
87+
<div className="absolute top-0 left-0 size-full border border-vscode-input-border group-focus-within:border-[var(--focus-border)] rounded"></div>
88+
</div>
89+
)
90+
}
91+
92+
export const DecoratedVSCodeTextField = forwardRef(VSCodeTextFieldWithNodesInner)

0 commit comments

Comments
 (0)