diff --git a/src/services/cost-tracking.service.ts b/src/services/cost-tracking.service.ts index 0468d1d2..fd257f18 100644 --- a/src/services/cost-tracking.service.ts +++ b/src/services/cost-tracking.service.ts @@ -18,6 +18,30 @@ export interface IConversationCost extends ITokenUsage { requestCount: number; } +export interface IProviderBreakdown { + provider: string; + inputTokens: number; + outputTokens: number; + totalTokens: number; + estimatedCostUSD: number; + requestCount: number; +} + +export interface ICostTotals { + inputTokens: number; + outputTokens: number; + totalTokens: number; + estimatedCostUSD: number; + requestCount: number; + conversationCount: number; +} + +export interface ICostSummary { + totals: ICostTotals; + providers: IProviderBreakdown[]; + conversations: Array<{ threadId: string } & IConversationCost>; +} + interface IPricing { inputPerMillion: number; outputPerMillion: number; @@ -142,6 +166,59 @@ export class CostTrackingService { this.conversations.clear(); } + /** + * Returns an aggregated cost summary across all tracked conversations. + */ + getCostSummary(): ICostSummary { + const conversations: ICostSummary["conversations"] = []; + const providerMap = new Map(); + + let totalInput = 0; + let totalOutput = 0; + let totalCost = 0; + let totalRequests = 0; + + for (const [threadId, cost] of this.conversations) { + conversations.push({ threadId, ...cost }); + + totalInput += cost.inputTokens; + totalOutput += cost.outputTokens; + totalCost += cost.estimatedCostUSD; + totalRequests += cost.requestCount; + + const existing = providerMap.get(cost.provider); + if (existing) { + existing.inputTokens += cost.inputTokens; + existing.outputTokens += cost.outputTokens; + existing.totalTokens += cost.totalTokens; + existing.estimatedCostUSD += cost.estimatedCostUSD; + existing.requestCount += cost.requestCount; + } else { + providerMap.set(cost.provider, { + provider: cost.provider, + inputTokens: cost.inputTokens, + outputTokens: cost.outputTokens, + totalTokens: cost.totalTokens, + estimatedCostUSD: cost.estimatedCostUSD, + requestCount: cost.requestCount, + }); + } + } + + return { + totals: { + inputTokens: totalInput, + outputTokens: totalOutput, + totalTokens: totalInput + totalOutput, + estimatedCostUSD: Math.round(totalCost * 1_000_000) / 1_000_000, + requestCount: totalRequests, + conversationCount: conversations.length, + }, + providers: [...providerMap.values()], + conversations, + }; + } + private getPricing(model: string): IPricing { // Try exact match first if (MODEL_PRICING[model]) { diff --git a/src/test/suite/cost-tracking-handler.test.ts b/src/test/suite/cost-tracking-handler.test.ts new file mode 100644 index 00000000..a2576f6a --- /dev/null +++ b/src/test/suite/cost-tracking-handler.test.ts @@ -0,0 +1,104 @@ +import * as assert from "assert"; +import * as sinon from "sinon"; +import { CostTrackingHandler } from "../../webview-providers/handlers/cost-tracking-handler"; +import { HandlerContext } from "../../webview-providers/handlers/types"; +import { CostTrackingService } from "../../services/cost-tracking.service"; + +suite("CostTrackingHandler", () => { + let handler: CostTrackingHandler; + let ctx: HandlerContext; + let postMessageStub: sinon.SinonStub; + let service: CostTrackingService; + + setup(() => { + service = CostTrackingService.getInstance(); + service.resetAll(); + + handler = new CostTrackingHandler(); + + postMessageStub = sinon.stub().resolves(true); + ctx = { + webview: { webview: { postMessage: postMessageStub } }, + logger: { warn: sinon.stub(), info: sinon.stub(), error: sinon.stub() }, + extensionUri: {} as any, + sendResponse: sinon.stub(), + } as unknown as HandlerContext; + }); + + teardown(() => { + service.resetAll(); + sinon.restore(); + }); + + // ── commands ──────────────────────────────────────────────────── + + test("registers cost-summary and cost-reset commands", () => { + assert.ok(handler.commands.includes("cost-summary")); + assert.ok(handler.commands.includes("cost-reset")); + assert.strictEqual(handler.commands.length, 2); + }); + + // ── cost-summary ─────────────────────────────────────────────── + + test("cost-summary posts empty result when no data", async () => { + await handler.handle({ command: "cost-summary" }, ctx); + + assert.ok(postMessageStub.calledOnce); + const msg = postMessageStub.firstCall.args[0]; + assert.strictEqual(msg.type, "cost-summary-result"); + assert.strictEqual(msg.totals.conversationCount, 0); + assert.strictEqual(msg.totals.totalTokens, 0); + assert.deepStrictEqual(msg.providers, []); + assert.deepStrictEqual(msg.conversations, []); + }); + + test("cost-summary returns aggregated data", async () => { + service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1000, 500); + service.recordUsage("t2", "openai", "gpt-4o", 2000, 1000); + + await handler.handle({ command: "cost-summary" }, ctx); + + assert.ok(postMessageStub.calledOnce); + const msg = postMessageStub.firstCall.args[0]; + assert.strictEqual(msg.type, "cost-summary-result"); + assert.strictEqual(msg.totals.conversationCount, 2); + assert.strictEqual(msg.totals.inputTokens, 3000); + assert.strictEqual(msg.totals.outputTokens, 1500); + assert.strictEqual(msg.totals.requestCount, 2); + assert.strictEqual(msg.providers.length, 2); + assert.strictEqual(msg.conversations.length, 2); + }); + + // ── cost-reset ───────────────────────────────────────────────── + + test("cost-reset clears data and posts null totals", async () => { + service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1000, 500); + + await handler.handle({ command: "cost-reset" }, ctx); + + assert.ok(postMessageStub.calledOnce); + const msg = postMessageStub.firstCall.args[0]; + assert.strictEqual(msg.type, "cost-summary-result"); + assert.strictEqual(msg.totals, null); + assert.deepStrictEqual(msg.providers, []); + assert.deepStrictEqual(msg.conversations, []); + + // Service should be cleared + assert.strictEqual(service.getConversationCost("t1"), null); + }); + + // ── unknown messages ─────────────────────────────────────────── + + test("ignores unrelated commands", async () => { + await handler.handle({ command: "some-other-command" }, ctx); + assert.ok(postMessageStub.notCalled); + }); + + test("ignores malformed messages", async () => { + await handler.handle({} as any, ctx); + assert.ok(postMessageStub.notCalled); + + await handler.handle({ command: 123 } as any, ctx); + assert.ok(postMessageStub.notCalled); + }); +}); diff --git a/src/test/suite/cost-tracking.service.test.ts b/src/test/suite/cost-tracking.service.test.ts new file mode 100644 index 00000000..c7db05c5 --- /dev/null +++ b/src/test/suite/cost-tracking.service.test.ts @@ -0,0 +1,174 @@ +import * as assert from "assert"; +import { CostTrackingService } from "../../services/cost-tracking.service"; + +suite("CostTrackingService", () => { + let service: CostTrackingService; + + setup(() => { + // Reset singleton state between tests + service = CostTrackingService.getInstance(); + service.resetAll(); + }); + + teardown(() => { + service.resetAll(); + }); + + // ── recordUsage ───────────────────────────────────────────────── + + test("recordUsage tracks a single turn", () => { + const result = service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1000, 500); + + assert.strictEqual(result.inputTokens, 1000); + assert.strictEqual(result.outputTokens, 500); + assert.strictEqual(result.totalTokens, 1500); + assert.strictEqual(result.requestCount, 1); + assert.strictEqual(result.provider, "anthropic"); + assert.strictEqual(result.model, "claude-sonnet-4-6"); + assert.ok(result.estimatedCostUSD > 0); + }); + + test("recordUsage accumulates across multiple turns", () => { + service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1000, 500); + const result = service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 2000, 1000); + + assert.strictEqual(result.inputTokens, 3000); + assert.strictEqual(result.outputTokens, 1500); + assert.strictEqual(result.totalTokens, 4500); + assert.strictEqual(result.requestCount, 2); + }); + + test("recordUsage tracks separate conversations independently", () => { + service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1000, 500); + service.recordUsage("t2", "openai", "gpt-4o", 2000, 1000); + + const c1 = service.getConversationCost("t1"); + const c2 = service.getConversationCost("t2"); + + assert.strictEqual(c1!.inputTokens, 1000); + assert.strictEqual(c2!.inputTokens, 2000); + assert.strictEqual(c1!.provider, "anthropic"); + assert.strictEqual(c2!.provider, "openai"); + }); + + test("recordUsage uses default pricing for unknown models", () => { + const result = service.recordUsage("t1", "custom", "some-unknown-model", 1000000, 0); + // Default pricing: 3/M input → $3.00 for 1M tokens + assert.strictEqual(result.estimatedCostUSD, 3); + }); + + // ── getConversationCost ───────────────────────────────────────── + + test("getConversationCost returns null for untracked thread", () => { + assert.strictEqual(service.getConversationCost("nonexistent"), null); + }); + + test("getConversationCost returns data for tracked thread", () => { + service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 100, 50); + const cost = service.getConversationCost("t1"); + + assert.ok(cost !== null); + assert.strictEqual(cost!.inputTokens, 100); + assert.strictEqual(cost!.outputTokens, 50); + }); + + // ── resetConversation ─────────────────────────────────────────── + + test("resetConversation clears a single conversation", () => { + service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 100, 50); + service.recordUsage("t2", "openai", "gpt-4o", 200, 100); + + service.resetConversation("t1"); + + assert.strictEqual(service.getConversationCost("t1"), null); + assert.ok(service.getConversationCost("t2") !== null); + }); + + // ── resetAll ──────────────────────────────────────────────────── + + test("resetAll clears all conversations", () => { + service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 100, 50); + service.recordUsage("t2", "openai", "gpt-4o", 200, 100); + + service.resetAll(); + + assert.strictEqual(service.getConversationCost("t1"), null); + assert.strictEqual(service.getConversationCost("t2"), null); + }); + + // ── getCostSummary ────────────────────────────────────────────── + + test("getCostSummary returns zero totals when empty", () => { + const summary = service.getCostSummary(); + + assert.strictEqual(summary.totals.inputTokens, 0); + assert.strictEqual(summary.totals.outputTokens, 0); + assert.strictEqual(summary.totals.totalTokens, 0); + assert.strictEqual(summary.totals.estimatedCostUSD, 0); + assert.strictEqual(summary.totals.requestCount, 0); + assert.strictEqual(summary.totals.conversationCount, 0); + assert.deepStrictEqual(summary.providers, []); + assert.deepStrictEqual(summary.conversations, []); + }); + + test("getCostSummary aggregates a single conversation", () => { + service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1000, 500); + + const summary = service.getCostSummary(); + + assert.strictEqual(summary.totals.inputTokens, 1000); + assert.strictEqual(summary.totals.outputTokens, 500); + assert.strictEqual(summary.totals.totalTokens, 1500); + assert.strictEqual(summary.totals.requestCount, 1); + assert.strictEqual(summary.totals.conversationCount, 1); + assert.strictEqual(summary.providers.length, 1); + assert.strictEqual(summary.providers[0].provider, "anthropic"); + assert.strictEqual(summary.conversations.length, 1); + assert.strictEqual(summary.conversations[0].threadId, "t1"); + }); + + test("getCostSummary aggregates multiple conversations per provider", () => { + service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1000, 500); + service.recordUsage("t2", "anthropic", "claude-sonnet-4-6", 2000, 1000); + + const summary = service.getCostSummary(); + + assert.strictEqual(summary.totals.conversationCount, 2); + assert.strictEqual(summary.totals.inputTokens, 3000); + assert.strictEqual(summary.totals.outputTokens, 1500); + assert.strictEqual(summary.providers.length, 1); + assert.strictEqual(summary.providers[0].inputTokens, 3000); + assert.strictEqual(summary.providers[0].requestCount, 2); + }); + + test("getCostSummary groups by provider correctly", () => { + service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1000, 500); + service.recordUsage("t2", "openai", "gpt-4o", 2000, 1000); + service.recordUsage("t3", "anthropic", "claude-3-5-haiku-20241022", 500, 250); + + const summary = service.getCostSummary(); + + assert.strictEqual(summary.totals.conversationCount, 3); + assert.strictEqual(summary.providers.length, 2); + + const anthropic = summary.providers.find((p) => p.provider === "anthropic"); + const openai = summary.providers.find((p) => p.provider === "openai"); + + assert.ok(anthropic); + assert.ok(openai); + assert.strictEqual(anthropic!.inputTokens, 1500); + assert.strictEqual(openai!.inputTokens, 2000); + assert.strictEqual(anthropic!.requestCount, 2); + assert.strictEqual(openai!.requestCount, 1); + }); + + test("getCostSummary rounds cost to 6 decimal places", () => { + // Use a known model with known pricing to verify rounding + service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1, 1); + + const summary = service.getCostSummary(); + const costStr = summary.totals.estimatedCostUSD.toString(); + const decimals = costStr.includes(".") ? costStr.split(".")[1].length : 0; + assert.ok(decimals <= 6, `Cost should have at most 6 decimals, got ${decimals}`); + }); +}); diff --git a/src/webview-providers/base.ts b/src/webview-providers/base.ts index 56fd88cb..8e9da507 100644 --- a/src/webview-providers/base.ts +++ b/src/webview-providers/base.ts @@ -55,6 +55,7 @@ import { BrowserHandler, ComposerHandler, ConnectorHandler, + CostTrackingHandler, SkillHandler, DiffReviewHandler, CheckpointHandler, @@ -270,6 +271,7 @@ export abstract class BaseWebViewProvider implements vscode.Disposable { this.handlerRegistry.register(new ComposerHandler()); this.handlerRegistry.register(new StandupHandler()); this.handlerRegistry.register(new TeamGraphHandler()); + this.handlerRegistry.register(new CostTrackingHandler()); this.handlerRegistry.register(new DoctorHandler()); this.handlerRegistry.register(new OnboardingHandler()); this.handlerRegistry.register( diff --git a/src/webview-providers/handlers/cost-tracking-handler.ts b/src/webview-providers/handlers/cost-tracking-handler.ts new file mode 100644 index 00000000..654ec2d8 --- /dev/null +++ b/src/webview-providers/handlers/cost-tracking-handler.ts @@ -0,0 +1,60 @@ +import { WebviewMessageHandler, HandlerContext } from "./types"; +import { CostTrackingService } from "../../services/cost-tracking.service"; + +// ── Message types ────────────────────────────────────────────────── +type CostSummaryMessage = { command: "cost-summary" }; +type CostResetMessage = { command: "cost-reset" }; + +type CostMessage = CostSummaryMessage | CostResetMessage; + +const COST_COMMANDS = ["cost-summary", "cost-reset"] as const; + +function isCostMessage(msg: unknown): msg is CostMessage { + return ( + typeof msg === "object" && + msg !== null && + "command" in msg && + typeof (msg as Record).command === "string" && + COST_COMMANDS.includes( + (msg as Record) + .command as (typeof COST_COMMANDS)[number], + ) + ); +} + +export class CostTrackingHandler implements WebviewMessageHandler { + readonly commands = [...COST_COMMANDS]; + + async handle( + message: Record, + ctx: HandlerContext, + ): Promise { + if (!isCostMessage(message)) return; + + const service = CostTrackingService.getInstance(); + + switch (message.command) { + case "cost-summary": { + const summary = service.getCostSummary(); + await ctx.webview.webview.postMessage({ + type: "cost-summary-result", + totals: summary.totals, + providers: summary.providers, + conversations: summary.conversations, + }); + break; + } + + case "cost-reset": { + service.resetAll(); + await ctx.webview.webview.postMessage({ + type: "cost-summary-result", + totals: null, + providers: [], + conversations: [], + }); + break; + } + } + } +} diff --git a/src/webview-providers/handlers/index.ts b/src/webview-providers/handlers/index.ts index 9c9cc982..e71f8fbb 100644 --- a/src/webview-providers/handlers/index.ts +++ b/src/webview-providers/handlers/index.ts @@ -20,5 +20,6 @@ export { CheckpointHandler } from "./checkpoint-handler"; export { ComposerHandler } from "./composer-handler"; export { StandupHandler } from "./standup-handler"; export { TeamGraphHandler } from "./team-graph-handler"; +export { CostTrackingHandler } from "./cost-tracking-handler"; export { DoctorHandler } from "./doctor-handler"; export { OnboardingHandler } from "./onboarding-handler"; diff --git a/webviewUi/src/components/cost/CostDashboardPanel.tsx b/webviewUi/src/components/cost/CostDashboardPanel.tsx new file mode 100644 index 00000000..826ff2bc --- /dev/null +++ b/webviewUi/src/components/cost/CostDashboardPanel.tsx @@ -0,0 +1,358 @@ +import { useEffect } from "react"; +import styled from "styled-components"; +import { useCostStore } from "../../stores/cost.store"; +import type { ProviderBreakdown, ConversationCostEntry } from "../../stores/cost.store"; + +interface CostDashboardProps { + isOpen: boolean; + onClose: () => void; +} + +/* ─── Helpers ─── */ + +function formatTokens(count: number): string { + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 10_000) return `${(count / 1_000).toFixed(1)}k`; + return count.toLocaleString(); +} + +function formatCost(usd: number): string { + if (usd === 0) return "$0.00"; + if (usd < 0.01) return `$${usd.toFixed(4)}`; + return `$${usd.toFixed(2)}`; +} + +/* ─── Styled Components ─── */ + +const PanelOverlay = styled.div<{ $isOpen: boolean }>` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 999; + display: ${(p) => (p.$isOpen ? "flex" : "none")}; + justify-content: flex-end; + animation: fadeIn 0.2s ease-in-out; + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } +`; + +const PanelContainer = styled.div` + width: 420px; + height: 100%; + background: var(--vscode-editor-background); + border-left: 1px solid var(--vscode-widget-border); + display: flex; + flex-direction: column; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.2); + animation: slideIn 0.2s ease-in-out; + + @keyframes slideIn { + from { transform: translateX(100%); } + to { transform: translateX(0); } + } +`; + +const Header = styled.div` + padding: 16px; + border-bottom: 1px solid var(--vscode-widget-border); + display: flex; + justify-content: space-between; + align-items: center; +`; + +const Title = styled.h2` + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--vscode-foreground); + display: flex; + align-items: center; + gap: 8px; +`; + +const CloseButton = styled.button` + background: none; + border: none; + color: var(--vscode-foreground); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + &:hover { background: var(--vscode-toolbar-hoverBackground); } +`; + +const Content = styled.div` + flex: 1; + overflow-y: auto; + padding: 16px; +`; + +const Section = styled.div` + margin-bottom: 20px; +`; + +const SectionTitle = styled.h3` + margin: 0 0 10px 0; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground); +`; + +const StatsGrid = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +`; + +const StatCard = styled.div` + background: var(--vscode-editorWidget-background, #252526); + border: 1px solid var(--vscode-widget-border); + border-radius: 6px; + padding: 12px; +`; + +const StatValue = styled.div` + font-size: 20px; + font-weight: 700; + color: var(--vscode-foreground); + margin-bottom: 2px; +`; + +const CostValue = styled(StatValue)` + color: var(--vscode-charts-green, #89d185); +`; + +const StatLabel = styled.div` + font-size: 11px; + color: var(--vscode-descriptionForeground); +`; + +const ProviderRow = styled.div` + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 6px; + background: var(--vscode-editorWidget-background, #252526); + border: 1px solid var(--vscode-widget-border); + margin-bottom: 6px; +`; + +const ProviderName = styled.span` + font-weight: 600; + font-size: 12px; + color: var(--vscode-foreground); + min-width: 80px; +`; + +const ProviderStat = styled.span` + font-size: 11px; + color: var(--vscode-descriptionForeground); +`; + +const ProviderCost = styled.span` + font-size: 12px; + font-weight: 600; + color: var(--vscode-charts-green, #89d185); + margin-left: auto; +`; + +const ConversationRow = styled.div` + padding: 8px 10px; + border-radius: 6px; + background: var(--vscode-editorWidget-background, #252526); + border: 1px solid var(--vscode-widget-border); + margin-bottom: 6px; +`; + +const ConvHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +`; + +const ConvModel = styled.span` + font-size: 12px; + font-weight: 500; + color: var(--vscode-foreground); +`; + +const ConvCost = styled.span` + font-size: 12px; + font-weight: 600; + color: var(--vscode-charts-green, #89d185); +`; + +const ConvMeta = styled.div` + font-size: 11px; + color: var(--vscode-descriptionForeground); + display: flex; + gap: 8px; +`; + +const EmptyState = styled.div` + text-align: center; + padding: 40px 16px; + color: var(--vscode-descriptionForeground); + font-size: 13px; +`; + +const ActionBar = styled.div` + display: flex; + gap: 8px; + margin-bottom: 16px; +`; + +const ActionButton = styled.button` + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + border: none; + border-radius: 4px; + padding: 6px 12px; + font-size: 11px; + cursor: pointer; + &:hover { background: var(--vscode-button-secondaryHoverBackground); } +`; + +const ResetButton = styled(ActionButton)` + color: var(--vscode-errorForeground, #f48771); +`; + +/* ─── Component ─── */ + +export function CostDashboardPanel({ isOpen, onClose }: CostDashboardProps) { + const { totals, providers, conversations, isLoading, requestSummary, requestReset } = useCostStore(); + + useEffect(() => { + if (isOpen) requestSummary(); + }, [isOpen, requestSummary]); + + const handleReset = () => { + if (confirm("Reset all cost tracking data?")) { + requestReset(); + } + }; + + return ( + + e.stopPropagation()}> +
+ + <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> + <path d="M8 1a7 7 0 110 14A7 7 0 018 1zm0 1.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM8 4a.75.75 0 01.75.75v2.5h2.5a.75.75 0 010 1.5h-2.5v2.5a.75.75 0 01-1.5 0v-2.5h-2.5a.75.75 0 010-1.5h2.5v-2.5A.75.75 0 018 4z" /> + </svg> + Cost Dashboard + + + + + + +
+ + + {isLoading && Loading…} + + {!isLoading && !totals && ( + + No cost data yet. Start a conversation to begin tracking. + + )} + + {!isLoading && totals && ( + <> + + Refresh + Reset All + + + {/* Totals */} +
+ Session Totals + + + {formatCost(totals.estimatedCostUSD)} + Estimated Cost + + + {formatTokens(totals.totalTokens)} + Total Tokens + + + {totals.requestCount} + Requests + + + {totals.conversationCount} + Conversations + + +
+ + {/* Token Breakdown */} +
+ Token Breakdown + + + {formatTokens(totals.inputTokens)} + Input Tokens + + + {formatTokens(totals.outputTokens)} + Output Tokens + + +
+ + {/* Provider Breakdown */} + {providers.length > 0 && ( +
+ By Provider + {providers.map((p: ProviderBreakdown) => ( + + {p.provider} + {formatTokens(p.totalTokens)} tokens + {p.requestCount} req + {formatCost(p.estimatedCostUSD)} + + ))} +
+ )} + + {/* Per-conversation history */} + {conversations.length > 0 && ( +
+ Conversations + {conversations.map((c: ConversationCostEntry) => ( + + + {c.model} + {formatCost(c.estimatedCostUSD)} + + + {formatTokens(c.totalTokens)} tokens + {c.requestCount} requests + {c.provider} + + + ))} +
+ )} + + )} +
+
+
+ ); +} diff --git a/webviewUi/src/components/webview.styles.tsx b/webviewUi/src/components/webview.styles.tsx index 9fe6e751..2409dada 100644 --- a/webviewUi/src/components/webview.styles.tsx +++ b/webviewUi/src/components/webview.styles.tsx @@ -47,6 +47,7 @@ export const ObservabilityToggleButton = SidebarButton; export const BrowserToggleButton = SidebarButton; export const CoWorkerToggleButton = SidebarButton; export const TeamToggleButton = SidebarButton; +export const CostToggleButton = SidebarButton; // ── Font Size Controls ── @@ -190,3 +191,19 @@ export const TeamIcon = ({ size = 14 }: { size?: number }) => ( ); + +export const CostIcon = ({ size = 14 }: { size?: number }) => ( + + + + +); diff --git a/webviewUi/src/components/webview.tsx b/webviewUi/src/components/webview.tsx index f600e1bc..f7534360 100644 --- a/webviewUi/src/components/webview.tsx +++ b/webviewUi/src/components/webview.tsx @@ -42,6 +42,7 @@ import { UpdatesPanel } from "./updates/UpdatesPanel"; import { ObservabilityPanel } from "./observability/ObservabilityPanel"; import { CoWorkerPanel } from "./coworker/CoWorkerPanel"; import { TeamPanel } from "./team/TeamPanel"; +import { CostDashboardPanel } from "./cost/CostDashboardPanel"; import { BrowserPanel } from "./browser/BrowserPanel"; import { PanelErrorBoundary } from "./PanelErrorBoundary"; import { OnboardingWizard } from "./onboarding/OnboardingWizard"; @@ -56,6 +57,7 @@ import { BrowserToggleButton, CoWorkerToggleButton, TeamToggleButton, + CostToggleButton, FontSizeGroup, FontSizeButton, SessionsIcon, @@ -65,6 +67,7 @@ import { CoWorkerIcon, ObservabilityIcon, TeamIcon, + CostIcon, } from "./webview.styles"; const hljsApi = window["hljs" as any] as unknown as typeof hljs; @@ -97,6 +100,7 @@ export const WebviewUI = () => { const isCoWorkerOpen = usePanelStore((s) => s.isCoWorkerOpen); const isBrowserPanelOpen = usePanelStore((s) => s.isBrowserPanelOpen); const isTeamPanelOpen = usePanelStore((s) => s.isTeamPanelOpen); + const isCostDashboardOpen = usePanelStore((s) => s.isCostDashboardOpen); // Onboarding const onboardingVisible = useOnboardingStore((s) => s.isVisible); @@ -354,6 +358,14 @@ export const WebviewUI = () => { + usePanelStore.getState().openCostDashboard()} + aria-label="Open cost dashboard" + title="Cost Dashboard" + > + + + useSettingsStore.getState().handleIncreaseFontSize()} title="Increase Font Size"> A+ @@ -466,6 +478,14 @@ export const WebviewUI = () => { /> + {/* Cost Dashboard Panel */} + + usePanelStore.getState().closeCostDashboard()} + /> + + CHAT FAQ diff --git a/webviewUi/src/hooks/useMessageDispatcher.ts b/webviewUi/src/hooks/useMessageDispatcher.ts index 9bcc46fc..b89bcee9 100644 --- a/webviewUi/src/hooks/useMessageDispatcher.ts +++ b/webviewUi/src/hooks/useMessageDispatcher.ts @@ -12,6 +12,7 @@ import { useStandupStore } from "../stores/standup.store"; import { useDoctorStore } from "../stores/doctor.store"; import { useOnboardingStore } from "../stores/onboarding.store"; import { useTeamStore } from "../stores/team.store"; +import { useCostStore } from "../stores/cost.store"; import type { IWebviewMessage } from "./useStreamingChat"; interface ConfigData { @@ -475,6 +476,17 @@ export function useMessageDispatcher(streamingChat: StreamingChatAPI) { useTeamStore.getState().setError(message.error ?? "Unknown error"); break; + // ── Cost Dashboard ── + case "cost-summary-result": + useCostStore + .getState() + .setSummary( + message.totals ?? null, + message.providers ?? [], + message.conversations ?? [], + ); + break; + // ── Onboarding ── case "onboarding-state": { const ob = useOnboardingStore.getState(); diff --git a/webviewUi/src/stores/cost.store.ts b/webviewUi/src/stores/cost.store.ts new file mode 100644 index 00000000..14e1ff77 --- /dev/null +++ b/webviewUi/src/stores/cost.store.ts @@ -0,0 +1,73 @@ +import { create } from "zustand"; +import { vscode } from "../utils/vscode"; + +// ── DTOs (mirroring handler output) ──────────────────────────────── +export interface CostTotals { + inputTokens: number; + outputTokens: number; + totalTokens: number; + estimatedCostUSD: number; + requestCount: number; + conversationCount: number; +} + +export interface ProviderBreakdown { + provider: string; + inputTokens: number; + outputTokens: number; + totalTokens: number; + estimatedCostUSD: number; + requestCount: number; +} + +export interface ConversationCostEntry { + threadId: string; + inputTokens: number; + outputTokens: number; + totalTokens: number; + estimatedCostUSD: number; + provider: string; + model: string; + requestCount: number; +} + +// ── Store shape ──────────────────────────────────────────────────── +interface CostState { + totals: CostTotals | null; + providers: ProviderBreakdown[]; + conversations: ConversationCostEntry[]; + isLoading: boolean; + + // Actions dispatched to extension + requestSummary: () => void; + requestReset: () => void; + + // Setters called from dispatcher + setSummary: ( + totals: CostTotals | null, + providers: ProviderBreakdown[], + conversations: ConversationCostEntry[], + ) => void; + setLoading: (v: boolean) => void; +} + +export const useCostStore = create()((set) => ({ + totals: null, + providers: [], + conversations: [], + isLoading: false, + + requestSummary: () => { + set({ isLoading: true }); + vscode.postMessage({ command: "cost-summary" }); + }, + + requestReset: () => { + vscode.postMessage({ command: "cost-reset" }); + }, + + setSummary: (totals, providers, conversations) => + set({ totals, providers, conversations, isLoading: false }), + + setLoading: (v) => set({ isLoading: v }), +})); diff --git a/webviewUi/src/stores/index.ts b/webviewUi/src/stores/index.ts index b3c0391f..15ebd6e4 100644 --- a/webviewUi/src/stores/index.ts +++ b/webviewUi/src/stores/index.ts @@ -11,3 +11,4 @@ export { useNotificationsStore } from "./notifications.store"; export { useContentStore } from "./content.store"; export { useChatStore } from "./chat.store"; export { useTeamStore } from "./team.store"; +export { useCostStore } from "./cost.store"; diff --git a/webviewUi/src/stores/panels.store.ts b/webviewUi/src/stores/panels.store.ts index e875a58e..1b71c477 100644 --- a/webviewUi/src/stores/panels.store.ts +++ b/webviewUi/src/stores/panels.store.ts @@ -9,6 +9,7 @@ interface PanelState { isCoWorkerOpen: boolean; isBrowserPanelOpen: boolean; isTeamPanelOpen: boolean; + isCostDashboardOpen: boolean; openSettings: () => void; closeSettings: () => void; @@ -26,6 +27,8 @@ interface PanelState { closeBrowserPanel: () => void; openTeamPanel: () => void; closeTeamPanel: () => void; + openCostDashboard: () => void; + closeCostDashboard: () => void; } export const usePanelStore = create()((set) => ({ @@ -37,6 +40,7 @@ export const usePanelStore = create()((set) => ({ isCoWorkerOpen: false, isBrowserPanelOpen: false, isTeamPanelOpen: false, + isCostDashboardOpen: false, openSettings: () => set({ isSettingsOpen: true }), closeSettings: () => set({ isSettingsOpen: false }), @@ -55,4 +59,6 @@ export const usePanelStore = create()((set) => ({ closeBrowserPanel: () => set({ isBrowserPanelOpen: false }), openTeamPanel: () => set({ isTeamPanelOpen: true }), closeTeamPanel: () => set({ isTeamPanelOpen: false }), + openCostDashboard: () => set({ isCostDashboardOpen: true }), + closeCostDashboard: () => set({ isCostDashboardOpen: false }), }));