Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export const globalSettingsSchema = z.object({
commandTimeoutAllowlist: z.array(z.string()).optional(),
preventCompletionWithOpenTodos: z.boolean().optional(),
allowedMaxRequests: z.number().nullish(),
allowedMaxCost: z.number().nullish(),
autoCondenseContext: z.boolean().optional(),
autoCondenseContextPercent: z.number().optional(),
maxConcurrentFileReads: z.number().optional(),
Expand Down
21 changes: 20 additions & 1 deletion src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ export class Task extends EventEmitter<TaskEvents> {
api: ApiHandler
private static lastGlobalApiRequestTime?: number
private consecutiveAutoApprovedRequestsCount: number = 0
private consecutiveAutoApprovedCost: number = 0

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

if (this.consecutiveAutoApprovedRequestsCount > maxRequests) {
const { response } = await this.ask("auto_approval_max_req_reached", JSON.stringify({ count: maxRequests }))
const { response } = await this.ask(
"auto_approval_max_req_reached",
JSON.stringify({ count: maxRequests, type: "requests" }),
)
// If we get past the promise, it means the user approved and did not start a new task
if (response === "yesButtonClicked") {
this.consecutiveAutoApprovedRequestsCount = 0
}
}

// Check if we've reached the maximum allowed cost
const maxCost = state?.allowedMaxCost || Infinity
this.consecutiveAutoApprovedCost = getApiMetrics(this.combineMessages(this.clineMessages.slice(1))).totalCost

if (this.consecutiveAutoApprovedCost > maxCost) {
const { response } = await this.ask(
"auto_approval_max_req_reached",
JSON.stringify({ count: maxCost.toFixed(2), type: "cost" }),
)
// If we get past the promise, it means the user approved and did not start a new task
if (response === "yesButtonClicked") {
this.consecutiveAutoApprovedCost = 0
}
}

const metadata: ApiHandlerCreateMessageMetadata = {
mode: mode,
taskId: this.taskId,
Expand Down
3 changes: 3 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1446,6 +1446,7 @@ export class ClineProvider
alwaysAllowSubtasks,
alwaysAllowUpdateTodoList,
allowedMaxRequests,
allowedMaxCost,
autoCondenseContext,
autoCondenseContextPercent,
soundEnabled,
Expand Down Expand Up @@ -1541,6 +1542,7 @@ export class ClineProvider
alwaysAllowSubtasks: alwaysAllowSubtasks ?? false,
alwaysAllowUpdateTodoList: alwaysAllowUpdateTodoList ?? false,
allowedMaxRequests,
allowedMaxCost,
autoCondenseContext: autoCondenseContext ?? true,
autoCondenseContextPercent: autoCondenseContextPercent ?? 100,
uriScheme: vscode.env.uriScheme,
Expand Down Expand Up @@ -1737,6 +1739,7 @@ export class ClineProvider
followupAutoApproveTimeoutMs: stateValues.followupAutoApproveTimeoutMs ?? 60000,
diagnosticsEnabled: stateValues.diagnosticsEnabled ?? true,
allowedMaxRequests: stateValues.allowedMaxRequests,
allowedMaxCost: stateValues.allowedMaxCost,
autoCondenseContext: stateValues.autoCondenseContext ?? true,
autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100,
taskHistory: stateValues.taskHistory,
Expand Down
4 changes: 4 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,10 @@ export const webviewMessageHandler = async (
await updateGlobalState("allowedMaxRequests", message.value)
await provider.postStateToWebview()
break
case "allowedMaxCost":
await updateGlobalState("allowedMaxCost", message.value)
await provider.postStateToWebview()
break
case "alwaysAllowSubtasks":
await updateGlobalState("alwaysAllowSubtasks", message.bool)
await provider.postStateToWebview()
Expand Down
1 change: 1 addition & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ export type ExtensionState = Pick<
| "allowedCommands"
| "deniedCommands"
| "allowedMaxRequests"
| "allowedMaxCost"
| "browserToolEnabled"
| "browserViewportSize"
| "screenshotQuality"
Expand Down
1 change: 1 addition & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface WebviewMessage {
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
| "allowedMaxRequests"
| "allowedMaxCost"
| "alwaysAllowSubtasks"
| "alwaysAllowUpdateTodoList"
| "autoCondenseContext"
Expand Down
47 changes: 10 additions & 37 deletions webview-ui/src/components/chat/AutoApproveMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { memo, useCallback, useMemo, useState } from "react"
import { Trans } from "react-i18next"
import { VSCodeCheckbox, VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react"

import { vscode } from "@src/utils/vscode"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { AutoApproveToggle, AutoApproveSetting, autoApproveSettingsConfig } from "../settings/AutoApproveToggle"
import { MaxLimitInputs } from "../settings/MaxLimitInputs"
import { StandardTooltip } from "@src/components/ui"
import { useAutoApprovalState } from "@src/hooks/useAutoApprovalState"
import { useAutoApprovalToggles } from "@src/hooks/useAutoApprovalToggles"
Expand All @@ -22,6 +23,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
setAutoApprovalEnabled,
alwaysApproveResubmit,
allowedMaxRequests,
allowedMaxCost,
setAlwaysAllowReadOnly,
setAlwaysAllowWrite,
setAlwaysAllowExecute,
Expand All @@ -33,6 +35,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
setAlwaysAllowFollowupQuestions,
setAlwaysAllowUpdateTodoList,
setAllowedMaxRequests,
setAllowedMaxCost,
} = useExtensionState()

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

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

{/* Auto-approve API request count limit input row inspired by Cline */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginTop: "10px",
marginBottom: "8px",
color: "var(--vscode-descriptionForeground)",
}}>
<span style={{ flexShrink: 1, minWidth: 0 }}>
<Trans i18nKey="settings:autoApprove.apiRequestLimit.title" />:
</span>
<VSCodeTextField
placeholder={t("settings:autoApprove.apiRequestLimit.unlimited")}
value={(allowedMaxRequests ?? Infinity) === Infinity ? "" : allowedMaxRequests?.toString()}
onInput={(e) => {
const input = e.target as HTMLInputElement
// Remove any non-numeric characters
input.value = input.value.replace(/[^0-9]/g, "")
const value = parseInt(input.value)
const parsedValue = !isNaN(value) && value > 0 ? value : undefined
setAllowedMaxRequests(parsedValue)
vscode.postMessage({ type: "allowedMaxRequests", value: parsedValue })
}}
style={{ flex: 1 }}
/>
</div>
<div
style={{
color: "var(--vscode-descriptionForeground)",
fontSize: "12px",
marginBottom: "10px",
}}>
<Trans i18nKey="settings:autoApprove.apiRequestLimit.description" />
</div>
<MaxLimitInputs
allowedMaxRequests={allowedMaxRequests ?? undefined}
allowedMaxCost={allowedMaxCost ?? undefined}
onMaxRequestsChange={(value) => setAllowedMaxRequests(value)}
onMaxCostChange={(value) => setAllowedMaxCost(value)}
/>
</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,29 @@ type AutoApprovedRequestLimitWarningProps = {

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

if (buttonClicked) {
return null
}

const isCostLimit = type === "cost"
const titleKey = isCostLimit
? "ask.autoApprovedCostLimitReached.title"
: "ask.autoApprovedRequestLimitReached.title"
const descriptionKey = isCostLimit
? "ask.autoApprovedCostLimitReached.description"
: "ask.autoApprovedRequestLimitReached.description"
const buttonKey = isCostLimit
? "ask.autoApprovedCostLimitReached.button"
: "ask.autoApprovedRequestLimitReached.button"

return (
<>
<div style={{ display: "flex", alignItems: "center", gap: "8px", color: "var(--vscode-foreground)" }}>
<span className="codicon codicon-warning" />
<span style={{ fontWeight: "bold" }}>
<Trans i18nKey="ask.autoApprovedRequestLimitReached.title" ns="chat" />
<Trans i18nKey={titleKey} ns="chat" />
</span>
</div>

Expand All @@ -37,7 +48,7 @@ export const AutoApprovedRequestLimitWarning = memo(({ message }: AutoApprovedRe
justifyContent: "center",
}}>
<div className="flex justify-between items-center">
<Trans i18nKey="ask.autoApprovedRequestLimitReached.description" ns="chat" values={{ count }} />
<Trans i18nKey={descriptionKey} ns="chat" values={{ count }} />
</div>
<VSCodeButton
style={{ width: "100%", padding: "6px", borderRadius: "4px" }}
Expand All @@ -46,7 +57,7 @@ export const AutoApprovedRequestLimitWarning = memo(({ message }: AutoApprovedRe
setButtonClicked(true)
vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" })
}}>
<Trans i18nKey="ask.autoApprovedRequestLimitReached.button" ns="chat" />
<Trans i18nKey={buttonKey} ns="chat" />
</VSCodeButton>
</div>
</>
Expand Down
92 changes: 92 additions & 0 deletions webview-ui/src/components/common/DecoratedVSCodeTextField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { cn } from "@/lib/utils"
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { forwardRef, useCallback, useRef, ReactNode, ComponentRef, ComponentProps } from "react"

// Type for web components that have shadow DOM
interface WebComponentWithShadowRoot extends HTMLElement {
shadowRoot: ShadowRoot | null
}

export interface VSCodeTextFieldWithNodesProps extends ComponentProps<typeof VSCodeTextField> {
leftNodes?: ReactNode[]
rightNodes?: ReactNode[]
}

function VSCodeTextFieldWithNodesInner(
props: VSCodeTextFieldWithNodesProps,
forwardedRef: React.Ref<HTMLInputElement>,
) {
const { className, style, "data-testid": dataTestId, leftNodes, rightNodes, ...restProps } = props

const inputRef = useRef<HTMLInputElement | null>(null)

// Callback ref to get access to the underlying input element.
// VSCodeTextField doesn't expose this directly so we have to query for it!
const handleVSCodeFieldRef = useCallback(
(element: ComponentRef<typeof VSCodeTextField>) => {
if (!element) return

const webComponent = element as unknown as WebComponentWithShadowRoot
const inputElement =
webComponent.shadowRoot?.querySelector?.("input") || webComponent.querySelector?.("input")
if (inputElement && inputElement instanceof HTMLInputElement) {
inputRef.current = inputElement
if (typeof forwardedRef === "function") {
forwardedRef?.(inputElement)
} else if (forwardedRef) {
;(forwardedRef as React.MutableRefObject<HTMLInputElement | null>).current = inputElement
}
}
},
[forwardedRef],
)

const focusInput = useCallback(async () => {
if (inputRef.current && document.activeElement !== inputRef.current) {
setTimeout(() => {
inputRef.current?.focus()
})
}
}, [])

const hasLeftNodes = leftNodes && leftNodes.filter(Boolean).length > 0
const hasRightNodes = rightNodes && rightNodes.filter(Boolean).length > 0

return (
<div
className={cn(
`group`,
`relative flex items-center cursor-text`,
`bg-[var(--input-background)] text-[var(--input-foreground)]`,
`rounded-[calc(var(--corner-radius-round)*1px)]`,
className,
)}
style={style}
onMouseDown={focusInput}>
{hasLeftNodes && (
<div className="absolute left-2 z-10 flex items-center gap-1 pointer-events-none">{leftNodes}</div>
)}

<VSCodeTextField
data-testid={dataTestId}
ref={handleVSCodeFieldRef}
style={{
flex: 1,
paddingLeft: hasLeftNodes ? "24px" : undefined,
paddingRight: hasRightNodes ? "24px" : undefined,
}}
className="[--border-width:0]"
{...restProps}
/>

{hasRightNodes && (
<div className="absolute right-2 z-10 flex items-center gap-1 pointer-events-none">{rightNodes}</div>
)}

{/* Absolutely positioned focus border overlay */}
<div className="absolute top-0 left-0 size-full border border-vscode-input-border group-focus-within:border-[var(--focus-border)] rounded"></div>
</div>
)
}

export const DecoratedVSCodeTextField = forwardRef(VSCodeTextFieldWithNodesInner)
Loading