diff --git a/.changeset/soft-maxcost-nudge.md b/.changeset/soft-maxcost-nudge.md new file mode 100644 index 00000000000..27477d80049 --- /dev/null +++ b/.changeset/soft-maxcost-nudge.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Warn before continuing a session whose spend is above the configured max cost. Opt-in and disabled by default — set a whole-dollar Session Cost Alert under Auto-Approve settings to enable it. diff --git a/bun.lock b/bun.lock index a3e2388f2a5..23853e970e6 100644 --- a/bun.lock +++ b/bun.lock @@ -341,6 +341,7 @@ "@kilocode/kilo-ui": "workspace:*", "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", + "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@pierre/diffs": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index ac9f2079158..1fdb8f44e1c 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -945,6 +945,12 @@ "default": false, "description": "Start Kilo Code with the main auto-approve toggle enabled. When enabled, permission prompts are approved automatically." }, + "kilo-code.new.maxCost": { + "type": "number", + "default": 0, + "minimum": 0, + "description": "Show a non-blocking alert when a session exceeds this USD amount. Use whole dollars; set to 0 to disable." + }, "kilo-code.new.fontSize": { "type": "number", "default": 13, @@ -1127,6 +1133,7 @@ "@kilocode/kilo-ui": "workspace:*", "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", + "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@pierre/diffs": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 652f107e567..c7dbd6ad787 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -10,6 +10,7 @@ import type { FilePartInput, Config, } from "@kilocode/sdk/v2/client" +import { MaxCostNudge, type MaxCostChoice } from "@opencode-ai/core/kilocode/cost/max-cost-nudge" import { type KiloConnectionService, ServerStartupError } from "./services/cli-backend" import { previewSound } from "./services/attention" import type { EditorContext, IndexingStatus } from "./services/cli-backend/types" @@ -167,6 +168,8 @@ import { watchIndexingConfig, } from "./kilo-provider/indexing-settings" +let maxCost = 0 + type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile" type ContextMessage = { contextDirectory?: unknown } type SandboxSupportClient = { @@ -377,6 +380,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private readonly streams = new SessionStreamScheduler((msg) => this.postMessage(msg)) private readonly visibleTaskStreams = new VisibleTaskStreams((id, visible) => this.streams.setVisible(id, visible)) private readonly confirmations = new MessageConfirmation() + private readonly costs = new MaxCostNudge() + private readonly activeAlerts = new Map() // sid -> limit currently shown in UI private unsubscribeEvent: (() => void) | null = null private unsubscribeState: (() => void) | null = null /** Cached migration data so migration doesn't re-read from disk/SecretStorage. */ // legacy-migration @@ -1170,6 +1175,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.pendingFollowup = null await handleQuestionReject(this.questionCtx, message.requestID, message.sessionID) break + case "sessionCostAlertResponse": + await this.handleCostAlertResponse(message.sessionID, message.limit, message.response) + break case "requestSandboxStatus": await this.fetchAndSendSandboxStatus(message.sessionID) break @@ -1786,6 +1794,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper for (const message of messages) { this.connectionService.recordMessageSessionId(message.id, message.sessionID) } + if (mode === "replace" || mode === "reconcile") this.resetMessageCosts(sessionID, messages) // Authoritative snapshots normally supersede buffered deltas. A newly // opened sub-agent viewer has no earlier renderer state, so its buffered // updates arrived during this fetch and must follow the snapshot. @@ -1847,6 +1856,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper for (const message of messages) { this.connectionService.recordMessageSessionId(message.id, message.sessionID) } + this.resetMessageCosts(sessionID, messages) // Snapshot supersedes any queued deltas (see handleLoadMessages for the // snapshot-freshness assumption that governs drop() here). @@ -1959,6 +1969,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.streams.drop(sessionID) this.visibleTaskStreams.delete(sessionID) this.syncedChildSessions.delete(sessionID) + this.costs.onSessionDeleted(sessionID) + const deletedAlertLimit = this.activeAlerts.get(sessionID) + if (deletedAlertLimit !== undefined) { + this.activeAlerts.delete(sessionID) + this.postMessage({ type: "sessionCostAlertResolved", sessionID: sessionID, limit: deletedAlertLimit }) + } this.sessionDirectories.delete(sessionID) this.aborts.delete(sessionID) this.lastReconciledAt.delete(sessionID) @@ -2347,6 +2363,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper config, globalConfig: global, projectConfig: overlay?.project, + settings: { maxCost: this.maxCostSetting() }, features: configFeatures(config), } this.cachedConfigMessage = message @@ -2442,6 +2459,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper config, globalConfig: global, projectConfig: overlay?.project, + settings: { maxCost: this.maxCostSetting() }, features: configFeatures(config), } this.postMessage({ @@ -2449,6 +2467,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper config, globalConfig: global, projectConfig: overlay?.project, + settings: { maxCost: this.maxCostSetting() }, features: configFeatures(config), }) } catch (error) { @@ -2830,6 +2849,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper config: merged, globalConfig: global, projectConfig: overlay?.project, + settings: { maxCost: this.maxCostSetting() }, features: configFeatures(merged), } this.postMessage({ @@ -2837,6 +2857,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper config: merged, globalConfig: global, projectConfig: overlay?.project, + settings: { maxCost: this.maxCostSetting() }, features: configFeatures(merged), }) this.requirements.clear() @@ -2858,6 +2879,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper type: "configUpdated", config: optimistic, globalConfig: this.cachedGlobalConfig ?? undefined, + settings: { maxCost: this.maxCostSetting() }, features: features ?? configFeatures(optimistic as Config), }) this.requirements.clear() @@ -2994,6 +3016,67 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + private maxCostSetting(): number { + return this.setMaxCost(vscode.workspace.getConfiguration("kilo-code.new").get("maxCost", 0)) + } + + private setMaxCost(value: unknown): number { + maxCost = MaxCostNudge.normalizeLimit(typeof value === "number" ? value : Number(value)) ?? 0 + this.costs.setLimit(maxCost) + return maxCost + } + + private costLimit(): number | undefined { + const limit = maxCost + this.costs.setLimit(limit) + return this.costs.limit + } + + private requestCostAlert(sid: string, cost: number): void { + const limit = this.costLimit() + if (limit === undefined || !Number.isFinite(cost) || cost < limit) return + + this.costs.setSessionCost(sid, cost) + const alert = this.costs.check(sid) + if (!alert) return + this.activeAlerts.set(sid, alert.limit) + this.postMessage({ + type: "sessionCostAlert", + sessionID: sid, + limit: alert.limit, + cost: MaxCostNudge.formatCost(alert.cost), + }) + } + + private async handleCostAlertResponse(sid: string, limit: number, response: MaxCostChoice): Promise { + this.activeAlerts.delete(sid) + this.costs.resolve(sid, response, limit) + if (response !== "continue") await this.handleAbort(sid) + this.postMessage({ type: "sessionCostAlertResolved", sessionID: sid, limit }) + } + + private resetMessageCosts( + sid: string, + messages: Array<{ id: string; sessionID: string; role?: string; cost?: number }>, + ) { + const total = this.costs.resetMessageCosts(sid, messages) + this.requestCostAlert(sid, total) + } + + private updateMessageCost( + sid: string, + id: string, + role: string | undefined, + cost: number | undefined, + ): number | undefined { + if (role !== "assistant" || !Number.isFinite(cost)) return undefined + return this.costs.updateMessageCost(sid, id, role, cost) + } + + private removeMessageCost(id: string): void { + this.costs.removeMessageCost(id) + } + private async handleSendMessage( text: string, messageID?: string, @@ -3028,7 +3111,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.sandboxKey({ sessionID, draftID, agentManagerContext: context, contextDirectory }), ) resolved = await this.resolveSession(sessionID, draftID, context, contextDirectory) + if (!resolved) throw new Error("Failed to resolve session") if (sandbox) await sandbox + const sid = resolved.sid + const dir = resolved.dir const parts: Array = [] if (files) { @@ -3038,8 +3124,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } parts.push({ type: "text", text, metadata: review ? reviewMetadata(review) : undefined }) - const sid = resolved!.sid - const dir = resolved!.dir await this.requirements.assertAgentRequirements(agent, dir) const editorContext = await this.gatherEditorContext(dir) @@ -3114,10 +3198,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.sandboxKey({ sessionID, draftID, agentManagerContext: context, contextDirectory }), ) resolved = await this.resolveSession(sessionID, draftID, context, contextDirectory) + if (!resolved) throw new Error("Failed to resolve session") if (sandbox) await sandbox + const sid = resolved.sid + const dir = resolved.dir if (messageID) { - this.connectionService.recordMessageSessionId(messageID, resolved!.sid) + this.connectionService.recordMessageSessionId(messageID, sid) } const parts = files?.map((f) => ({ @@ -3128,8 +3215,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper source: f.source, })) - const sid = resolved!.sid - const dir = resolved!.dir await this.requirements.assertAgentRequirements(agent, dir) await this.checkpoints.get(sid) await runWithMessageConfirmation(this.confirmations, messageID, "KiloProvider: Command request", () => @@ -3170,6 +3255,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (!this.client || !sid || !(await this.aborts.stop(this.client, sid, this.getWorkspaceDirectory(sid)))) return this.sessionStatusMap.set(sid, "idle") this.streams.flush(sid) + this.postMessage({ type: "sessionTurnClosed", sessionID: sid, reason: "interrupted" }) this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" }) } @@ -3344,6 +3430,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * The key uses dot notation relative to `kilo-code.new` (e.g. "browserAutomation.enabled"). */ private async handleUpdateSetting(key: string, value: unknown): Promise { + if (key === "maxCost") { + const normalized = this.setMaxCost(value) + await vscode.workspace + .getConfiguration("kilo-code.new") + .update("maxCost", normalized, vscode.ConfigurationTarget.Global) + for (const sid of this.trackedSessionIds) { + const oldLimit = this.activeAlerts.get(sid) + if (oldLimit !== undefined) { + this.activeAlerts.delete(sid) + this.postMessage({ type: "sessionCostAlertResolved", sessionID: sid, limit: oldLimit }) + } + this.costs.rearm(sid) + this.requestCostAlert(sid, this.costs.sessionCost(sid)) + } + return + } const { section, leaf } = buildSettingPath(key) if (section === "autocomplete" && !validAutocompleteSetting(leaf, value)) return if (section === "indexing" && !validIndexingSetting(leaf, value)) return @@ -3602,6 +3704,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // busy-session warning on Save. if (event.type === "session.status") { const sid = event.properties.sessionID + const prev = this.sessionStatusMap.get(sid) + if ((prev === undefined || prev === "idle") && event.properties.status.type !== "idle") { + this.costs.rearm(sid) + } this.sessionStatusMap.set(sid, event.properties.status.type) this.aborts.observe(sid, event.properties.status.type, directory) const msg = mapSSEEventToWebviewMessage(event, sid) @@ -3628,6 +3734,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } + if (event.type === "session.updated" && typeof event.properties.info.cost === "number") { + const cost = this.costs.setSessionCost(event.properties.sessionID, event.properties.info.cost) + this.requestCostAlert(event.properties.sessionID, cost) + } + if (event.type === "session.updated") { // Full bus snapshots duplicate sync patches with the same event ID but no sequence metadata. if (isFullSessionUpdatedEvent(event)) return @@ -3664,6 +3775,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Forward relevant events to webview // Side effects that must happen before the webview message is sent + if (event.type === "message.updated") { + const info = event.properties.info + const value = info.role === "assistant" ? info.cost : undefined + const cost = this.updateMessageCost(event.properties.sessionID, info.id, info.role, value) + if (cost !== undefined) this.requestCostAlert(event.properties.sessionID, cost) + } + if (event.type === "message.removed") { + this.removeMessageCost(event.properties.messageID) + } if (event.type === "session.created" && !this.currentSession) { this.setCurrentSession(event.properties.info) this.contextSessionID = event.properties.info.id @@ -3679,6 +3799,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.modelUsageSessionIds.delete(sid) this.sessionDirectories.delete(sid) this.connectionService.pruneSession(sid) + this.costs.onSessionDeleted(sid) } // Auto-adopt child sessions as soon as the task tool part reveals their ID. diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts index 43d76663e00..cf2fdf2bb4c 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -204,7 +204,7 @@ function createConnection(client: ReturnType) { type ProviderInternals = { connectionState: State webview: { postMessage: (message: unknown) => Promise } | null - currentSession: { id: string; directory?: string; revert?: { messageID: string } } | null + currentSession: { id: string; directory?: string; cost?: number; revert?: { messageID: string } } | null contextSessionID: string | undefined sessionDirectories: Map trackedSessionIds: Set @@ -217,6 +217,8 @@ type ProviderInternals = { stopCurrentSessionProcesses: (next?: string) => void handleEvent: (event: unknown, directory?: string) => void handleAbort: (sid?: string) => Promise + handleCostAlertResponse: (sid: string, limit: number, response: "continue" | "stop") => Promise + setMaxCost: (value: unknown) => void handleRevertSession: (sid: string, messageID: string) => Promise handleSendMessage: (text: string, messageID?: string, sessionID?: string, draftID?: string) => Promise fetchAndSendSandboxDefault: (directory?: string, requestID?: string) => Promise @@ -240,6 +242,10 @@ function makeProvider(client: ReturnType) { return { provider, internal, sent } } +function mockMaxCost(internal: ProviderInternals, value: number) { + internal.setMaxCost(value) +} + describe("KiloProvider.handleAbort", () => { it("aborts the original owner after a running session moves to a worktree", async () => { const client = createClient() @@ -834,6 +840,234 @@ describe("KiloProvider.handleDeleteSession / background processes", () => { }) describe("KiloProvider.handleLoadMessages / slim payload", () => { + it("shows a cost alert even when cost arrives after the session is idle", () => { + const client = createClient() + const { internal, sent } = makeProvider(client) + mockMaxCost(internal, 1) + internal.trackedSessionIds.add("s1") + internal.handleEvent({ + type: "session.status", + properties: { sessionID: "s1", status: { type: "idle" } }, + }) + + internal.handleEvent({ + type: "session.updated", + properties: { + sessionID: "s1", + info: { + id: "s1", + cost: 1.46, + }, + }, + }) + + expect(sent).toContainEqual({ + type: "sessionCostAlert", + sessionID: "s1", + limit: 1, + cost: "$1.46", + }) + }) + + it("shares the in-memory limit across provider instances", () => { + const settings = makeProvider(createClient()) + const chat = makeProvider(createClient()) + mockMaxCost(settings.internal, 1) + chat.internal.trackedSessionIds.add("s1") + + chat.internal.handleEvent({ + type: "message.updated", + properties: { + sessionID: "s1", + info: { id: "m1", sessionID: "s1", role: "assistant", time: { created: 1 }, cost: 1.5 }, + }, + }) + + expect(chat.sent).toContainEqual({ + type: "sessionCostAlert", + sessionID: "s1", + limit: 1, + cost: "$1.50", + }) + }) + + it("remembers continue for that session and limit", async () => { + const client = createClient() + const { internal, sent } = makeProvider(client) + mockMaxCost(internal, 1) + internal.trackedSessionIds.add("s1") + + await internal.handleCostAlertResponse("s1", 1, "continue") + sent.length = 0 + internal.handleEvent({ + type: "session.updated", + properties: { + sessionID: "s1", + info: { + id: "s1", + cost: 1.46, + }, + }, + }) + + expect( + sent.some((msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "sessionCostAlert"), + ).toBe(false) + }) + + it("re-alerts after stop when the session reruns above the limit", async () => { + const client = createClient() + const { internal, sent } = makeProvider(client) + mockMaxCost(internal, 1) + internal.trackedSessionIds.add("s1") + internal.handleEvent( + { + type: "session.status", + properties: { sessionID: "s1", status: { type: "busy" } }, + }, + "/repo", + ) + + internal.handleEvent({ + type: "session.updated", + properties: { + sessionID: "s1", + info: { + id: "s1", + cost: 1.46, + }, + }, + }) + await internal.handleCostAlertResponse("s1", 1, "stop") + + // Same run: alert already shown, no duplicate within same run + sent.length = 0 + internal.handleEvent({ + type: "session.updated", + properties: { + sessionID: "s1", + info: { + id: "s1", + cost: 1.46, + }, + }, + }) + expect( + sent.some((msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "sessionCostAlert"), + ).toBe(false) + + // New run (busy rearms): alert fires again since stop does not ack the limit + sent.length = 0 + internal.handleEvent( + { + type: "session.status", + properties: { sessionID: "s1", status: { type: "busy" } }, + }, + "/repo", + ) + internal.handleEvent({ + type: "session.updated", + properties: { + sessionID: "s1", + info: { + id: "s1", + cost: 1.46, + }, + }, + }) + + expect(sent).toContainEqual({ + type: "sessionCostAlert", + sessionID: "s1", + limit: 1, + cost: "$1.46", + }) + }) + + it("alerts from message.updated assistant cost — the reliable cost signal", () => { + const client = createClient() + const { internal, sent } = makeProvider(client) + mockMaxCost(internal, 1) + internal.trackedSessionIds.add("s1") + + internal.handleEvent({ + type: "message.updated", + properties: { + sessionID: "s1", + info: { id: "m1", sessionID: "s1", role: "assistant", time: { created: 1 }, cost: 1.5 }, + }, + }) + + expect(sent).toContainEqual({ + type: "sessionCostAlert", + sessionID: "s1", + limit: 1, + cost: "$1.50", + }) + }) + + it("does not re-alert on repeated busy status while already active", () => { + const client = createClient() + const { internal, sent } = makeProvider(client) + mockMaxCost(internal, 1) + internal.trackedSessionIds.add("s1") + internal.handleEvent({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } }, "/repo") + internal.handleEvent({ + type: "message.updated", + properties: { + sessionID: "s1", + info: { id: "m1", sessionID: "s1", role: "assistant", time: { created: 1 }, cost: 1.5 }, + }, + }) + sent.length = 0 + + internal.handleEvent({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } }, "/repo") + internal.handleEvent({ + type: "message.updated", + properties: { + sessionID: "s1", + info: { id: "m1", sessionID: "s1", role: "assistant", time: { created: 1 }, cost: 1.5 }, + }, + }) + + expect( + sent.some((msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "sessionCostAlert"), + ).toBe(false) + }) + + it("does not block sends above the limit", async () => { + const client = createClient() + const { internal } = makeProvider(client) + mockMaxCost(internal, 1) + internal.currentSession = { ...mkSession(), cost: 2 } + internal.gatherEditorContext = async () => ({}) + + await internal.handleSendMessage("hello", "m1", "s1") + + expect(client.prompted).toHaveLength(1) + }) + + it("aborts when the cost alert is stopped", async () => { + const client = createClient() + const { internal, sent } = makeProvider(client) + mockMaxCost(internal, 1) + internal.trackedSessionIds.add("s1") + internal.handleEvent({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } }, "/repo") + internal.handleEvent({ + type: "message.updated", + properties: { + sessionID: "s1", + info: { id: "m1", sessionID: "s1", role: "assistant", time: { created: 1 }, cost: 2 }, + }, + }) + + await internal.handleCostAlertResponse("s1", 1, "stop") + + expect(client.aborted).toContainEqual({ sessionID: "s1", directory: "/repo" }) + expect(sent).toContainEqual({ type: "sessionCostAlertResolved", sessionID: "s1", limit: 1 }) + expect(sent).toContainEqual({ type: "sessionTurnClosed", sessionID: "s1", reason: "interrupted" }) + }) + it("strips transcript-only metadata before posting messages to the webview", async () => { const user = mkMessage("m1", "user", 1) const assistant = mkMessage("m2", "assistant", 2) diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index e9f8126a56e..5c2c53dcdb5 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -54,7 +54,7 @@ describe("sendMessage dismisses pending tool requests", () => { }) it("rejects questions before sending", () => { - expect(body).toContain("rejectQuestion") + expect(body).toContain("dismissQuestion") }) }) @@ -71,7 +71,7 @@ describe("sendCommand dismisses pending tool requests", () => { }) it("rejects questions before sending", () => { - expect(body).toContain("rejectQuestion") + expect(body).toContain("dismissQuestion") }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx index 06380094d82..15695ecd04c 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx @@ -153,6 +153,13 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => syncAgent(answers, kinds) + // Cost alerts use a single affirmative option and should respond on click. + // Normal questions keep the explicit Submit flow. + if (single() && props.request.autoSubmit) { + reply(answers) + return + } + const outcome = pickOutcome({ single: single(), multi: multi(), custom }) if (outcome.kind === "advance") { setStore("tab", store.tab + 1) @@ -286,6 +293,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => close() return } + if (props.request.dismissResponse === "continue") { + session.closeQuestion(props.request.id) + return + } reject() return } @@ -319,6 +330,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => ref={root} data-component="question-dock" data-collapsed={store.collapsed ? "true" : "false"} + data-tone={props.request.tone} onClick={(e: MouseEvent) => e.stopPropagation()} onKeyDown={onRoot} > @@ -491,7 +503,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {/* Footer row — inside the same box */}
diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/AutoApproveTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/AutoApproveTab.tsx index c63cb533504..21e87583e51 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AutoApproveTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AutoApproveTab.tsx @@ -1,21 +1,63 @@ import { Component, createMemo } from "solid-js" +import { Card } from "@kilocode/kilo-ui/card" +import { TextField } from "@kilocode/kilo-ui/text-field" import { useConfig } from "../../context/config" import { useLanguage } from "../../context/language" import PermissionEditor from "./PermissionEditor" +import SettingsRow from "./SettingsRow" const AutoApproveTab: Component = () => { - const { config, updateConfig } = useConfig() + const { config, settings, updateConfig, updateSetting } = useConfig() const language = useLanguage() const permissions = createMemo(() => config().permission ?? {}) + const cost = createMemo(() => { + const value = settings().maxCost + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.ceil(value) : 0 + }) + + const updateCost = (value: string) => { + const trimmed = value.trim() + if (!trimmed) { + updateSetting("maxCost", 0) + return + } + if (!/^\d+$/.test(trimmed)) return + const next = Number(trimmed) + if (Number.isFinite(next) && next >= 0) updateSetting("maxCost", next) + } return ( - updateConfig({ permission: patch })} - /> +
+ + + + + + +
+ + updateConfig({ permission: patch })} + /> +
) } diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index d996c6eb9a0..e63b84ff6fa 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -111,6 +111,7 @@ export const ConfigProvider: ParentComponent = (props) => { setConfig(resolveConfig(message.config, draft(), has(draft() as Record))) setFeatures(message.features) setSaved(message.config) + if (message.settings) mergeSettings(message.settings) if (message.globalConfig !== undefined) { setGlobalConfig(mergeScopedConfig(message.globalConfig, globalDraft())) setSavedGlobal(message.globalConfig) @@ -161,6 +162,7 @@ export const ConfigProvider: ParentComponent = (props) => { } setFeatures(message.features) } + if (message.settings) mergeSettings(message.settings) setSaved(message.config) return } diff --git a/packages/kilo-vscode/webview-ui/src/context/cost-alert.ts b/packages/kilo-vscode/webview-ui/src/context/cost-alert.ts new file mode 100644 index 00000000000..dd9a3dcfcc9 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/cost-alert.ts @@ -0,0 +1,65 @@ +import type { ExtensionMessage, QuestionRequest, WebviewMessage } from "../types/messages" + +type Alert = { sessionID: string; limit: number } + +export function createCostAlertHandler( + postMessage: (msg: WebviewMessage) => void, + handleQuestionRequest: (request: QuestionRequest) => void, + handleQuestionResolved: (id: string) => void, + t: (key: string, params?: Record) => string, +) { + const costAlerts = new Map() + + function handleMessage(message: ExtensionMessage): boolean { + if (message.type === "sessionCostAlert") { + const id = crypto.randomUUID() + costAlerts.set(id, { sessionID: message.sessionID, limit: message.limit }) + const params = { limit: `$${message.limit.toFixed(2)}`, cost: message.cost } + handleQuestionRequest({ + id, + sessionID: message.sessionID, + autoSubmit: true, + dismissResponse: "continue", + rejectLabel: t("session.costAlert.stop"), + tone: "warning", + questions: [ + { + header: t("session.costAlert.header"), + question: t("session.costAlert.question", params), + custom: false, + options: [{ label: t("session.costAlert.continue"), description: "" }], + }, + ], + }) + return true + } + if (message.type === "sessionCostAlertResolved") { + costAlerts.forEach((a, id) => { + if (a.sessionID === message.sessionID && a.limit === message.limit) { + costAlerts.delete(id) + handleQuestionResolved(id) + } + }) + return true + } + return false + } + + function reply(id: string, response: "continue" | "stop"): boolean { + const alert = costAlerts.get(id) + if (!alert) return false + postMessage({ type: "sessionCostAlertResponse", sessionID: alert.sessionID, limit: alert.limit, response }) + return true + } + + function close(id: string, dismiss: (id: string) => void) { + if (costAlerts.has(id)) { + costAlerts.delete(id) + handleQuestionResolved(id) + return + } + dismiss(id) + } + + return { handleMessage, reply, close } +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 66fee2ee5f2..9dc87f21803 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -23,6 +23,7 @@ import { useServer } from "./server" import { useProvider } from "./provider" import { useConfig } from "./config" import { useLanguage } from "./language" +import { createCostAlertHandler } from "./cost-alert" import { showToast } from "@kilocode/kilo-ui/toast" import type { SessionInfo, @@ -275,6 +276,7 @@ interface SessionContextValue { ) => void replyToQuestion: (requestID: string, answers: string[][]) => void rejectQuestion: (requestID: string) => void + closeQuestion: (requestID: string) => void acceptSuggestion: (requestID: string, index: number) => void dismissSuggestion: (requestID: string) => void createSession: () => void @@ -360,6 +362,7 @@ export const SessionProvider: ParentComponent = (props) => { // Pending questions const [questions, setQuestions] = createSignal([]) + const cah = createCostAlertHandler(vscode.postMessage, handleQuestionRequest, handleQuestionResolved, language.t) // Tracks question IDs that failed so the UI can reset sending state const [questionErrors, setQuestionErrors] = createSignal>(new Set()) @@ -1050,6 +1053,7 @@ export const SessionProvider: ParentComponent = (props) => { if (handleModelUsageMessage(message)) return refreshModelUsageForMessage(message) if (handleStreamMessage(message)) return + cah.handleMessage(message) switch (message.type) { case "sessionCreated": handleSessionCreated(message.session, message.draftID) @@ -2256,7 +2260,7 @@ export const SessionProvider: ParentComponent = (props) => { const suggestion = scopedSuggestions(sid)[0] if (suggestion) dismissSuggestion(suggestion.id) for (const q of scopedQuestions(sid)) { - rejectQuestion(q.id) + dismissQuestion(q.id) } const scope = draftID ?? sid @@ -2324,7 +2328,7 @@ export const SessionProvider: ParentComponent = (props) => { const suggestion = scopedSuggestions(sid)[0] if (suggestion) dismissSuggestion(suggestion.id) for (const q of scopedQuestions(sid)) { - rejectQuestion(q.id) + dismissQuestion(q.id) } const scope = draftID ?? sid @@ -2435,6 +2439,7 @@ export const SessionProvider: ParentComponent = (props) => { clearQuestionError(requestID) const question = questions().find((item) => item.id === requestID) const sessionID = question?.sessionID ?? currentSessionID() ?? "" + if (cah.reply(requestID, "continue")) return vscode.postMessage({ type: "questionReply", requestID, @@ -2443,10 +2448,20 @@ export const SessionProvider: ParentComponent = (props) => { }) } + function dismissQuestion(requestID: string) { + questions().find((item) => item.id === requestID)?.dismissResponse === "continue" + ? replyToQuestion(requestID, []) + : rejectQuestion(requestID) + } + + function closeQuestion(id: string) { + cah.close(id, dismissQuestion) + } function rejectQuestion(requestID: string) { clearQuestionError(requestID) const question = questions().find((item) => item.id === requestID) const sessionID = question?.sessionID ?? currentSessionID() ?? "" + if (cah.reply(requestID, "stop")) return vscode.postMessage({ type: "questionReject", requestID, @@ -2929,6 +2944,7 @@ export const SessionProvider: ParentComponent = (props) => { respondToPermission, replyToQuestion, rejectQuestion, + closeQuestion, acceptSuggestion, dismissSuggestion, createSession, diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 448d55dbb4b..a87c8ac9b6c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1266,6 +1266,11 @@ export const dict = { "session.outcome.interrupted": "تمت مقاطعة الدور", "session.outcome.error": "فشل الدور", "session.outcome.finish": "سبب الإنهاء: {{reason}}", + "session.costAlert.header": "تنبيه تكلفة الجلسة", + "session.costAlert.continue": "متابعة", + "session.costAlert.question": + "تجاوزت هذه الجلسة للتو حد التنبيه لكل جلسة البالغ {{limit}} وبلغت تكلفتها {{cost}}. هل تريد المتابعة؟", + "session.costAlert.stop": "إيقاف", "ui.sessionTurn.cancel": "إلغاء", "ui.sessionTurn.status.thinking": "...جارٍ التفكير", @@ -1542,6 +1547,9 @@ export const dict = { "settings.autoApprove.description": "تحديد كيفية السماح بتشغيل الأدوات. معظم الأدوات معينة افتراضياً على السماح. doom_loop و external_directory معينة افتراضياً على السؤال.", + "settings.autoApprove.maxCost.title": "تنبيه تكلفة الجلسة", + "settings.autoApprove.maxCost.description": + "نبّه قبل متابعة الجلسة بعد أن يتجاوز إنفاقها هذا المبلغ بالدولار الأمريكي. استخدم أرقامًا صحيحة بالدولار؛ اتركه فارغًا للتعطيل.", "settings.autoApprove.level.allow": "سماح", "settings.autoApprove.level.ask": "سؤال", "settings.autoApprove.level.deny": "رفض", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index ada4a29366f..5daf082020e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1290,6 +1290,11 @@ export const dict = { "session.outcome.interrupted": "Turno interrompido", "session.outcome.error": "Turno falhou", "session.outcome.finish": "Motivo da conclusão: {{reason}}", + "session.costAlert.header": "Alerta de custo da sessão", + "session.costAlert.continue": "Continuar", + "session.costAlert.question": + "Esta sessão acabou de ultrapassar o limite de alerta por sessão de {{limit}} e custou {{cost}}. Continuar?", + "session.costAlert.stop": "Parar", "ui.sessionTurn.cancel": "Cancelar", "ui.sessionTurn.status.thinking": "Pensando...", @@ -1584,6 +1589,9 @@ export const dict = { "settings.autoApprove.description": "Defina como as ferramentas têm permissão para serem executadas. A maioria das ferramentas tem o padrão Permitir. doom_loop e external_directory têm o padrão Perguntar.", + "settings.autoApprove.maxCost.title": "Alerta de custo da sessão", + "settings.autoApprove.maxCost.description": + "Alerte antes de continuar uma sessão depois que o gasto exceder este valor em USD. Use valores inteiros em dólares; deixe em branco para desativar.", "settings.autoApprove.level.allow": "Permitir", "settings.autoApprove.level.ask": "Perguntar", "settings.autoApprove.level.deny": "Negar", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 8dfd4977777..a5a894c7f0f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1293,6 +1293,11 @@ export const dict = { "session.outcome.interrupted": "Potez prekinut", "session.outcome.error": "Potez nije uspio", "session.outcome.finish": "Razlog završetka: {{reason}}", + "session.costAlert.header": "Upozorenje o trošku sesije", + "session.costAlert.continue": "Nastavi", + "session.costAlert.question": + "Ova sesija je upravo prešla prag upozorenja po sesiji od {{limit}} i košta {{cost}}. Nastaviti?", + "session.costAlert.stop": "Zaustavi", "ui.sessionTurn.cancel": "Otkaži", "ui.sessionTurn.status.thinking": "Razmišljam...", @@ -1576,6 +1581,9 @@ export const dict = { "settings.autoApprove.description": "Definišite kako je dozvoljeno pokretanje alata. Većina alata je podrazumijevano na Dozvoli. doom_loop i external_directory su podrazumijevano na Pitaj.", + "settings.autoApprove.maxCost.title": "Upozorenje o trošku sesije", + "settings.autoApprove.maxCost.description": + "Upozori prije nastavka sesije nakon što potrošnja premaši ovaj iznos u USD. Koristite cijele dolare; ostavite prazno za isključivanje.", "settings.autoApprove.level.allow": "Dozvoli", "settings.autoApprove.level.ask": "Pitaj", "settings.autoApprove.level.deny": "Odbij", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index a7a5c0a1680..14b21e2ab80 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1286,6 +1286,11 @@ export const dict = { "session.outcome.interrupted": "Tur afbrudt", "session.outcome.error": "Tur mislykkedes", "session.outcome.finish": "Afslutningsårsag: {{reason}}", + "session.costAlert.header": "Advarsel om sessionsomkostning", + "session.costAlert.continue": "Fortsæt", + "session.costAlert.question": + "Denne session er lige kommet over din advarselsgrænse pr. session på {{limit}} og koster {{cost}}. Fortsæt?", + "session.costAlert.stop": "Stop", "ui.sessionTurn.cancel": "Annuller", "ui.sessionTurn.status.thinking": "Tænker...", @@ -1569,6 +1574,9 @@ export const dict = { "settings.autoApprove.description": "Definer, hvordan værktøjer må køre. De fleste værktøjer er som standard indstillet til Tillad. doom_loop og external_directory er som standard indstillet til Spørg.", + "settings.autoApprove.maxCost.title": "Advarsel om sessionsomkostning", + "settings.autoApprove.maxCost.description": + "Advar før en session fortsættes, når forbruget overstiger dette USD-beløb. Brug hele dollars; lad feltet være tomt for at deaktivere.", "settings.autoApprove.level.allow": "Tillad", "settings.autoApprove.level.ask": "Spørg", "settings.autoApprove.level.deny": "Afvis", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 52d54775a89..7c3ebe2962b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1307,6 +1307,11 @@ export const dict = { "session.outcome.interrupted": "Zug unterbrochen", "session.outcome.error": "Zug fehlgeschlagen", "session.outcome.finish": "Abschlussgrund: {{reason}}", + "session.costAlert.header": "Sitzungskostenwarnung", + "session.costAlert.continue": "Fortfahren", + "session.costAlert.question": + "Diese Sitzung hat gerade deinen Warnschwellenwert pro Sitzung von {{limit}} überschritten und kostet {{cost}}. Weitermachen?", + "session.costAlert.stop": "Stopp", "ui.sessionTurn.cancel": "Abbrechen", "ui.sessionTurn.status.thinking": "Denke nach...", @@ -1603,6 +1608,9 @@ export const dict = { "settings.autoApprove.description": "Legen Sie fest, wie Tools ausgeführt werden dürfen. Die meisten Tools sind standardmäßig auf Zulassen eingestellt. doom_loop und external_directory sind standardmäßig auf Fragen eingestellt.", + "settings.autoApprove.maxCost.title": "Sitzungskostenwarnung", + "settings.autoApprove.maxCost.description": + "Vor dem Fortsetzen einer Sitzung warnen, wenn ihre Kosten diesen USD-Betrag überschreiten. Ganze Dollarbeträge verwenden; zum Deaktivieren leer lassen.", "settings.autoApprove.level.allow": "Erlauben", "settings.autoApprove.level.ask": "Fragen", "settings.autoApprove.level.deny": "Ablehnen", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 4ae43a37ef8..d4435bcf03f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1236,6 +1236,11 @@ export const dict = { "session.outcome.interrupted": "Turn interrupted.", "session.outcome.error": "Turn failed.", "session.outcome.finish": "Technical finish reason: {{reason}}", + "session.costAlert.header": "Session Cost Alert", + "session.costAlert.continue": "Continue", + "session.costAlert.question": + "This session just went above your {{limit}} per-session alert threshold and cost {{cost}}. Keep going?", + "session.costAlert.stop": "Stop", "sidebar.session.newSession": "New Session", "sidebar.session.newSession.tooltip": "Start a fresh conversation while keeping the current session intact.", "sidebar.session.newSession.disabled": "This session is already new. Start chatting or create a worktree instead.", @@ -1553,6 +1558,9 @@ export const dict = { "settings.autoApprove.description": "Define how tools are allowed to run. Most tools default to Allow. doom_loop and external_directory default to Ask.", + "settings.autoApprove.maxCost.title": "Session Cost Alert", + "settings.autoApprove.maxCost.description": + "Show a non-blocking alert when a session exceeds this USD amount. Use whole dollars; leave empty to disable.", "settings.autoApprove.level.allow": "Allow", "settings.autoApprove.level.ask": "Ask", "settings.autoApprove.level.deny": "Deny", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 0d52bbcdd01..a5bf4b39bd0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1300,6 +1300,11 @@ export const dict = { "session.outcome.interrupted": "Turno interrumpido", "session.outcome.error": "Turno fallido", "session.outcome.finish": "Motivo de finalización: {{reason}}", + "session.costAlert.header": "Alerta de coste de sesión", + "session.costAlert.continue": "Continuar", + "session.costAlert.question": + "Esta sesión acaba de superar tu umbral de alerta por sesión de {{limit}} y cuesta {{cost}}. ¿Seguir?", + "session.costAlert.stop": "Detener", "ui.sessionTurn.cancel": "Cancelar", "ui.sessionTurn.status.thinking": "Pensando...", @@ -1592,6 +1597,9 @@ export const dict = { "settings.autoApprove.description": "Defina cómo se permite la ejecución de las herramientas. La mayoría de las herramientas tienen como valor predeterminado Permitir. doom_loop y external_directory tienen como valor predeterminado Preguntar.", + "settings.autoApprove.maxCost.title": "Alerta de coste de sesión", + "settings.autoApprove.maxCost.description": + "Avisa antes de continuar una sesión cuando su gasto supere este importe en USD. Usa dólares enteros; déjalo vacío para desactivarlo.", "settings.autoApprove.level.allow": "Permitir", "settings.autoApprove.level.ask": "Preguntar", "settings.autoApprove.level.deny": "Denegar", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 1982b3efa69..e793315324c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1310,6 +1310,11 @@ export const dict = { "session.outcome.interrupted": "Tour interrompu", "session.outcome.error": "Échec du tour", "session.outcome.finish": "Motif de fin : {{reason}}", + "session.costAlert.header": "Alerte de coût de session", + "session.costAlert.continue": "Continuer", + "session.costAlert.question": + "Cette session vient de dépasser votre seuil d’alerte par session de {{limit}} et coûte {{cost}}. Continuer ?", + "session.costAlert.stop": "Arrêter", "ui.sessionTurn.cancel": "Annuler", "ui.sessionTurn.status.thinking": "Réflexion...", @@ -1608,6 +1613,9 @@ export const dict = { "settings.autoApprove.description": "Définissez comment les outils sont autorisés à s'exécuter. La plupart des outils sont définis sur Autoriser par défaut. doom_loop et external_directory sont définis sur Demander par défaut.", + "settings.autoApprove.maxCost.title": "Alerte de coût de session", + "settings.autoApprove.maxCost.description": + "Alerter avant de continuer une session lorsque sa dépense dépasse ce montant en USD. Utilisez des dollars entiers ; laissez vide pour désactiver.", "settings.autoApprove.level.allow": "Autoriser", "settings.autoApprove.level.ask": "Demander", "settings.autoApprove.level.deny": "Refuser", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 53be1aaf782..5e1079426a4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -1383,6 +1383,9 @@ export const dict = { "Le regole vengono valutate in ordine: l'ultima regola corrispondente vince. Questo è il set di regole risolto dal backend CLI.", "settings.autoApprove.description": "Definisci come i tool possono essere eseguiti. La maggior parte dei tool usa Consenti di default. doom_loop e external_directory usano Chiedi di default.", + "settings.autoApprove.maxCost.title": "Avviso costo sessione", + "settings.autoApprove.maxCost.description": + "Avvisa prima di continuare una sessione quando la spesa supera questo importo in USD. Usa dollari interi; lascia vuoto per disattivare.", "settings.autoApprove.level.allow": "Consenti", "settings.autoApprove.level.ask": "Chiedi", "settings.autoApprove.level.deny": "Nega", @@ -1614,6 +1617,11 @@ export const dict = { "session.outcome.interrupted": "Turno interrotto.", "session.outcome.error": "Turno fallito.", "session.outcome.finish": "Motivo tecnico di fine: {{reason}}", + "session.costAlert.header": "Avviso costo sessione", + "session.costAlert.continue": "Continua", + "session.costAlert.question": + "Questa sessione ha appena superato la soglia di avviso per sessione di {{limit}} e costa {{cost}}. Continuare?", + "session.costAlert.stop": "Interrompi", // Speech to Text "settings.experimental.speechToText.title": "Da voce a testo", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index d9b34c4dc90..5483784d610 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1281,6 +1281,11 @@ export const dict = { "session.outcome.interrupted": "ターンが中断されました", "session.outcome.error": "ターンが失敗しました", "session.outcome.finish": "終了理由: {{reason}}", + "session.costAlert.header": "セッションコストアラート", + "session.costAlert.continue": "続行", + "session.costAlert.question": + "このセッションは、セッションごとのアラートしきい値 {{limit}} を超え、現在のコストは {{cost}} です。続行しますか?", + "session.costAlert.stop": "停止", "ui.sessionTurn.cancel": "キャンセル", "ui.sessionTurn.status.thinking": "考え中...", @@ -1566,6 +1571,9 @@ export const dict = { "settings.autoApprove.description": "ツールの実行許可を定義します。ほとんどのツールはデフォルトで「許可」されます。doom_loop と external_directory はデフォルトで「確認」になります。", + "settings.autoApprove.maxCost.title": "セッションコストアラート", + "settings.autoApprove.maxCost.description": + "セッションの支出がこの米ドル金額を超えた後、続行する前に警告します。整数のドル額を使用してください。無効にするには空欄にします。", "settings.autoApprove.level.allow": "許可", "settings.autoApprove.level.ask": "確認", "settings.autoApprove.level.deny": "拒否", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index abf8b7008b7..1c68d5d743a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1277,6 +1277,10 @@ export const dict = { "session.outcome.interrupted": "턴이 중단되었습니다", "session.outcome.error": "턴이 실패했습니다", "session.outcome.finish": "종료 이유: {{reason}}", + "session.costAlert.header": "세션 비용 알림", + "session.costAlert.continue": "계속", + "session.costAlert.question": "이 세션이 세션별 알림 기준 {{limit}}을 방금 넘었고 비용은 {{cost}}입니다. 계속할까요?", + "session.costAlert.stop": "중지", "ui.sessionTurn.cancel": "취소", "ui.sessionTurn.status.thinking": "생각 중...", @@ -1555,6 +1559,9 @@ export const dict = { "settings.autoApprove.description": "도구 실행 허용 방식을 정의합니다. 대부분의 도구 기본값은 '허용'입니다. doom_loop 및 external_directory의 기본값은 '확인'입니다.", + "settings.autoApprove.maxCost.title": "세션 비용 알림", + "settings.autoApprove.maxCost.description": + "세션 지출이 이 USD 금액을 초과한 뒤 계속하기 전에 알립니다. 정수 달러 금액을 사용하세요. 비활성화하려면 비워 두세요.", "settings.autoApprove.level.allow": "허용", "settings.autoApprove.level.ask": "확인", "settings.autoApprove.level.deny": "거부", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 2dbfc885559..845b5d5a162 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1281,6 +1281,11 @@ export const dict = { "session.outcome.interrupted": "Beurt onderbroken", "session.outcome.error": "Beurt mislukt", "session.outcome.finish": "Voltooiingsreden: {{reason}}", + "session.costAlert.header": "Sessiekostenwaarschuwing", + "session.costAlert.continue": "Doorgaan", + "session.costAlert.question": + "Deze sessie is net boven je waarschuwingsdrempel per sessie van {{limit}} gekomen en kost {{cost}}. Doorgaan?", + "session.costAlert.stop": "Stoppen", "ui.sessionTurn.cancel": "Annuleren", "ui.sessionTurn.status.thinking": "Denken...", @@ -1542,6 +1547,9 @@ export const dict = { "settings.autoApprove.description": "Definieer hoe tools mogen worden uitgevoerd. De meeste tools staan standaard op Toestaan. doom_loop en external_directory staan standaard op Vragen.", + "settings.autoApprove.maxCost.title": "Sessiekostenwaarschuwing", + "settings.autoApprove.maxCost.description": + "Waarschuw voordat een sessie doorgaat nadat de kosten dit USD-bedrag overschrijden. Gebruik hele dollars; laat leeg om uit te schakelen.", "settings.autoApprove.level.allow": "Toestaan", "settings.autoApprove.level.ask": "Vragen", "settings.autoApprove.level.deny": "Weigeren", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 4baa33ef1ab..775d9dc7e6e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1250,6 +1250,11 @@ export const dict = { "session.outcome.interrupted": "Runde avbrutt", "session.outcome.error": "Runden feilet", "session.outcome.finish": "Avslutningsårsak: {{reason}}", + "session.costAlert.header": "Varsel om øktkostnad", + "session.costAlert.continue": "Fortsett", + "session.costAlert.question": + "Denne økten gikk nettopp over varselgrensen per økt på {{limit}} og koster {{cost}}. Fortsette?", + "session.costAlert.stop": "Stopp", "ui.sessionTurn.cancel": "Avbryt", "ui.sessionTurn.status.thinking": "Tenker...", @@ -1569,6 +1574,9 @@ export const dict = { "settings.autoApprove.description": "Definer hvordan verktøy kan kjøre. De fleste verktøy har Tillat som standard. doom_loop og external_directory har Spør som standard.", + "settings.autoApprove.maxCost.title": "Varsel om øktkostnad", + "settings.autoApprove.maxCost.description": + "Varsle før en økt fortsetter etter at kostnaden overstiger dette USD-beløpet. Bruk hele dollar; la feltet stå tomt for å deaktivere.", "settings.autoApprove.level.allow": "Tillat", "settings.autoApprove.level.ask": "Spør", "settings.autoApprove.level.deny": "Avvis", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index b4c943ef39c..d3fa876fb7b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1249,6 +1249,11 @@ export const dict = { "session.outcome.interrupted": "Tura przerwana", "session.outcome.error": "Tura nie powiodła się", "session.outcome.finish": "Powód zakończenia: {{reason}}", + "session.costAlert.header": "Alert kosztu sesji", + "session.costAlert.continue": "Kontynuuj", + "session.costAlert.question": + "Ta sesja właśnie przekroczyła próg alertu na sesję wynoszący {{limit}} i kosztuje {{cost}}. Kontynuować?", + "session.costAlert.stop": "Zatrzymaj", "ui.sessionTurn.cancel": "Anuluj", "ui.sessionTurn.status.thinking": "Myślę...", @@ -1573,6 +1578,9 @@ export const dict = { "settings.autoApprove.description": "Zdefiniuj, jak narzędzia mogą być uruchamiane. Większość narzędzi domyślnie ma ustawienie Zezwalaj. doom_loop i external_directory domyślnie mają ustawienie Pytaj.", + "settings.autoApprove.maxCost.title": "Alert kosztu sesji", + "settings.autoApprove.maxCost.description": + "Ostrzegaj przed kontynuowaniem sesji, gdy jej koszt przekroczy tę kwotę w USD. Używaj pełnych dolarów; pozostaw puste, aby wyłączyć.", "settings.autoApprove.level.allow": "Zezwól", "settings.autoApprove.level.ask": "Pytaj", "settings.autoApprove.level.deny": "Odmów", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 5c13a0baafd..6f616a81d8c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1292,6 +1292,11 @@ export const dict = { "session.outcome.interrupted": "Раунд прерван", "session.outcome.error": "Раунд завершился с ошибкой", "session.outcome.finish": "Причина завершения: {{reason}}", + "session.costAlert.header": "Оповещение о стоимости сессии", + "session.costAlert.continue": "Продолжить", + "session.costAlert.question": + "Эта сессия только что превысила порог оповещения на сессию {{limit}} и стоит {{cost}}. Продолжить?", + "session.costAlert.stop": "Остановить", "ui.sessionTurn.cancel": "Отмена", "ui.sessionTurn.status.thinking": "Думаю...", @@ -1574,6 +1579,9 @@ export const dict = { "settings.autoApprove.description": "Определите правила запуска инструментов. Большинство инструментов по умолчанию Разрешены. Для doom_loop и external_directory по умолчанию установлено Спрашивать.", + "settings.autoApprove.maxCost.title": "Оповещение о стоимости сессии", + "settings.autoApprove.maxCost.description": + "Предупреждать перед продолжением сессии, если ее стоимость превышает эту сумму в USD. Используйте целые доллары; оставьте пустым, чтобы отключить.", "settings.autoApprove.level.allow": "Разрешить", "settings.autoApprove.level.ask": "Спросить", "settings.autoApprove.level.deny": "Отклонить", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 66872d7b83a..e5ec7f48ed4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1273,6 +1273,11 @@ export const dict = { "session.outcome.interrupted": "เทิร์นถูกขัดจังหวะ", "session.outcome.error": "เทิร์นล้มเหลว", "session.outcome.finish": "เหตุผลการเสร็จสิ้น: {{reason}}", + "session.costAlert.header": "การแจ้งเตือนค่าใช้จ่ายของเซสชัน", + "session.costAlert.continue": "ดำเนินการต่อ", + "session.costAlert.question": + "เซสชันนี้เพิ่งเกินเกณฑ์แจ้งเตือนต่อเซสชันของคุณที่ {{limit}} และมีค่าใช้จ่าย {{cost}} ต้องการดำเนินการต่อหรือไม่?", + "session.costAlert.stop": "หยุด", "ui.sessionTurn.cancel": "ยกเลิก", "ui.sessionTurn.status.thinking": "กำลังคิด...", @@ -1551,6 +1556,9 @@ export const dict = { "settings.autoApprove.description": "กำหนดวิธีอนุญาตการทำงานของเครื่องมือ โดยค่าเริ่มต้นเครื่องมือส่วนใหญ่คืออนุญาต ส่วน doom_loop และ external_directory ค่าเริ่มต้นคือถาม", + "settings.autoApprove.maxCost.title": "การแจ้งเตือนค่าใช้จ่ายของเซสชัน", + "settings.autoApprove.maxCost.description": + "แจ้งเตือนก่อนดำเนินเซสชันต่อหลังจากค่าใช้จ่ายเกินจำนวน USD นี้ ใช้จำนวนดอลลาร์เต็ม; เว้นว่างไว้เพื่อปิดใช้งาน", "settings.autoApprove.level.allow": "อนุญาต", "settings.autoApprove.level.ask": "ถาม", "settings.autoApprove.level.deny": "ปฏิเสธ", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 629334f680f..e57d018906b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1277,6 +1277,11 @@ export const dict = { "session.outcome.interrupted": "Tur kesintiye uğradı", "session.outcome.error": "Tur başarısız oldu", "session.outcome.finish": "Bitiş nedeni: {{reason}}", + "session.costAlert.header": "Oturum Maliyeti Uyarısı", + "session.costAlert.continue": "Devam et", + "session.costAlert.question": + "Bu oturum, oturum başına {{limit}} uyarı eşiğini yeni geçti ve maliyeti {{cost}} oldu. Devam edilsin mi?", + "session.costAlert.stop": "Durdur", "ui.sessionTurn.cancel": "İptal", "ui.sessionTurn.status.thinking": "Düşünüyor...", @@ -1531,6 +1536,9 @@ export const dict = { "settings.autoApprove.description": "Araçların nasıl çalıştırılacağını tanımlayın. Çoğu araç varsayılan olarak İzin Ver'dir. doom_loop ve external_directory varsayılan olarak Sor'dur.", + "settings.autoApprove.maxCost.title": "Oturum Maliyeti Uyarısı", + "settings.autoApprove.maxCost.description": + "Bir oturumun harcaması bu USD tutarını aştıktan sonra devam etmeden önce uyar. Tam dolar kullanın; devre dışı bırakmak için boş bırakın.", "settings.autoApprove.level.allow": "İzin Ver", "settings.autoApprove.level.ask": "Sor", "settings.autoApprove.level.deny": "Reddet", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index e8847f4d7ae..06c0f00dfa4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1274,6 +1274,11 @@ export const dict = { "session.outcome.interrupted": "Хід перервано", "session.outcome.error": "Хід не вдався", "session.outcome.finish": "Причина завершення: {{reason}}", + "session.costAlert.header": "Сповіщення про вартість сесії", + "session.costAlert.continue": "Продовжити", + "session.costAlert.question": + "Ця сесія щойно перевищила поріг сповіщення для сесії {{limit}} і коштує {{cost}}. Продовжити?", + "session.costAlert.stop": "Зупинити", "ui.sessionTurn.cancel": "Скасувати", "ui.sessionTurn.status.thinking": "Думаю...", @@ -1528,6 +1533,9 @@ export const dict = { "settings.autoApprove.description": "Визначте, як виконуються інструменти. Більшість інструментів за замовчуванням — Дозволити. doom_loop та external_directory за замовчуванням — Запитувати.", + "settings.autoApprove.maxCost.title": "Сповіщення про вартість сесії", + "settings.autoApprove.maxCost.description": + "Попереджати перед продовженням сесії, якщо її витрати перевищують цю суму в USD. Використовуйте цілі долари; залиште порожнім, щоб вимкнути.", "settings.autoApprove.level.allow": "Дозволити", "settings.autoApprove.level.ask": "Запитувати", "settings.autoApprove.level.deny": "Відхилити", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 2be47698b5f..3f0eafa95e2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1253,6 +1253,10 @@ export const dict = { "session.outcome.interrupted": "回合已中断", "session.outcome.error": "回合失败", "session.outcome.finish": "结束原因:{{reason}}", + "session.costAlert.header": "会话费用提醒", + "session.costAlert.continue": "继续", + "session.costAlert.question": "此会话刚刚超过每会话提醒阈值 {{limit}},当前费用为 {{cost}}。是否继续?", + "session.costAlert.stop": "停止", "ui.sessionTurn.cancel": "取消", "ui.sessionTurn.status.thinking": "思考中...", @@ -1516,6 +1520,8 @@ export const dict = { "settings.autoApprove.description": "定义工具的运行权限。大多数工具默认为「允许」。doom_loop 和 external_directory 默认为「询问」。", + "settings.autoApprove.maxCost.title": "会话费用提醒", + "settings.autoApprove.maxCost.description": "当会话花费超过此美元金额后,在继续前提醒。请使用整数美元;留空则禁用。", "settings.autoApprove.level.allow": "允许", "settings.autoApprove.level.ask": "询问", "settings.autoApprove.level.deny": "拒绝", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 06155e8d5ee..d50689f1f76 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1217,6 +1217,10 @@ export const dict = { "session.outcome.interrupted": "回合已中斷", "session.outcome.error": "回合失敗", "session.outcome.finish": "結束原因:{{reason}}", + "session.costAlert.header": "工作階段費用提醒", + "session.costAlert.continue": "繼續", + "session.costAlert.question": "此工作階段剛剛超過每工作階段提醒門檻 {{limit}},目前費用為 {{cost}}。是否繼續?", + "session.costAlert.stop": "停止", "ui.sessionTurn.cancel": "取消", "ui.sessionTurn.status.thinking": "思考中...", @@ -1482,6 +1486,9 @@ export const dict = { "settings.autoApprove.description": "定義工具的執行權限。大多數工具預設為允許。doom_loop 與 external_directory 預設為詢問。", + "settings.autoApprove.maxCost.title": "工作階段費用提醒", + "settings.autoApprove.maxCost.description": + "當工作階段花費超過此美元金額後,在繼續前提醒。請使用整數美元;留空則停用。", "settings.autoApprove.level.allow": "允許", "settings.autoApprove.level.ask": "詢問", "settings.autoApprove.level.deny": "拒絕", diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index fd7694f75df..38fe24e3d1c 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -255,6 +255,7 @@ export function mockSessionValue(overrides?: { respondToPermission: noop, replyToQuestion: noop, rejectQuestion: noop, + closeQuestion: noop, acceptSuggestion: noop, dismissSuggestion: noop, createSession: noop, diff --git a/packages/kilo-vscode/webview-ui/src/styles/question-dock.css b/packages/kilo-vscode/webview-ui/src/styles/question-dock.css index 1a72e2eb1d3..6a93da5d6a4 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/question-dock.css +++ b/packages/kilo-vscode/webview-ui/src/styles/question-dock.css @@ -8,6 +8,21 @@ border-radius: 8px; overflow: hidden; + &[data-tone="warning"] { + background-color: rgba(245, 158, 11, 0.1); + box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.45); + color: color-mix(in oklab, var(--text-warning, #d97706) 85%, var(--text-base, #ccc)); + } + + &[data-tone="warning"] [data-slot="question-header-title"], + &[data-tone="warning"] [data-slot="question-text"] { + color: color-mix(in oklab, var(--text-warning, #d97706) 85%, var(--text-base, #ccc)); + } + + &[data-tone="warning"] [data-slot="question-option"]:hover:not([data-picked="true"]) { + background-color: rgba(245, 158, 11, 0.08); + } + /* ── Header (always visible) ── */ [data-slot="question-dock-header"] { display: flex; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 4eda43ac404..55dad3b4604 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -472,6 +472,19 @@ export interface QuestionErrorMessage { requestID: string } +export interface SessionCostAlertMessage { + type: "sessionCostAlert" + sessionID: string + limit: number + cost: string +} + +export interface SessionCostAlertResolvedMessage { + type: "sessionCostAlertResolved" + sessionID: string + limit: number +} + export interface SuggestionRequestMessage { type: "suggestionRequest" suggestion: SuggestionRequest @@ -497,11 +510,17 @@ export interface ClaudeCompatSettingLoadedMessage { enabled: boolean } +export interface ExtensionSettings { + maxCost?: number + [key: string]: unknown +} + export interface ConfigLoadedMessage { type: "configLoaded" config: Config globalConfig?: Config projectConfig?: Config + settings?: ExtensionSettings features: FeatureFlags } @@ -510,6 +529,7 @@ export interface ConfigUpdatedMessage { config: Config globalConfig?: Config projectConfig?: Config + settings?: ExtensionSettings features: FeatureFlags } @@ -1091,6 +1111,8 @@ export type ExtensionMessage = | QuestionRequestMessage | QuestionResolvedMessage | QuestionErrorMessage + | SessionCostAlertMessage + | SessionCostAlertResolvedMessage | SuggestionRequestMessage | SuggestionResolvedMessage | SuggestionErrorMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/questions.ts b/packages/kilo-vscode/webview-ui/src/types/messages/questions.ts index 6169d92afa1..a829646bd18 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/questions.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/questions.ts @@ -32,6 +32,10 @@ export interface QuestionRequest { sessionID: string questions: QuestionInfo[] blocking?: boolean + autoSubmit?: boolean + dismissResponse?: "continue" + rejectLabel?: string + tone?: "warning" tool?: { messageID: string callID: string diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 5cbefbe425e..e51414364c4 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -326,6 +326,13 @@ export interface QuestionRejectRequest { sessionID?: string } +export interface SessionCostAlertResponseRequest { + type: "sessionCostAlertResponse" + sessionID: string + limit: number + response: "continue" | "stop" +} + export interface SuggestionAcceptRequest { type: "suggestionAccept" requestID: string @@ -1208,6 +1215,7 @@ export type WebviewMessage = | SetLanguageRequest | QuestionReplyRequest | QuestionRejectRequest + | SessionCostAlertResponseRequest | SuggestionAcceptRequest | SuggestionDismissRequest | DeleteSessionRequest