diff --git a/src/electron/main/ipc/register-main-ipc-handlers.ts b/src/electron/main/ipc/register-main-ipc-handlers.ts index f255596..626b49a 100644 --- a/src/electron/main/ipc/register-main-ipc-handlers.ts +++ b/src/electron/main/ipc/register-main-ipc-handlers.ts @@ -205,6 +205,11 @@ export function registerMainIpcHandlers(options: RegisterMainIpcHandlersOptions) ipcChannels.storageKeys, async (_event, store: string) => options.resolveStore(store).keys(), ); + ipcMain.handle( + ipcChannels.getUsageSummary, + async (_event, params: { since?: number }) => + withRuntime((runtimeController) => runtimeController.getUsageSummary(params)), + ); // Shell, dialog, and app handlers. ipcMain.handle(ipcChannels.copyText, (_event, text: string) => { diff --git a/src/electron/main/runtime/agent-runtime/index.ts b/src/electron/main/runtime/agent-runtime/index.ts index ecf804e..627edb3 100644 --- a/src/electron/main/runtime/agent-runtime/index.ts +++ b/src/electron/main/runtime/agent-runtime/index.ts @@ -302,6 +302,40 @@ interface AgentSessionTokenTotals { type AgentTokenUsageSnapshot = AgentMessageUsage; +interface AgentLiteActionHttp { + mintContainerToken( + groupFolder: string, + isMain: boolean, + ): { url: string; token: string } | null; +} + +type AgentLiteAgentWithActions = AgentLiteAgent & { + actionsHttp?: AgentLiteActionHttp; +}; + +interface UsageByModelRow { + model: string; + input_tokens: number; + output_tokens: number; + cost_usd: number | null; +} + +interface UsageBySessionRow { + session_id: string; + agent_name: string | null; + input_tokens: number; + output_tokens: number; + cost_usd: number | null; +} + +interface UsageSummaryResult { + total_tokens: number; + total_cost_usd: number | null; + request_count: number; + by_model: UsageByModelRow[]; + by_session: UsageBySessionRow[]; +} + function asNonNegativeInteger(value: unknown): number | null { if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { return null; @@ -318,6 +352,62 @@ function asFiniteNumber(value: unknown): number | null { return value; } +function isUsageSummaryResult(value: unknown): value is UsageSummaryResult { + if (!value || typeof value !== 'object') { + return false; + } + + const summary = value as UsageSummaryResult; + return typeof summary.total_tokens === 'number' + && typeof summary.request_count === 'number' + && Array.isArray(summary.by_model) + && Array.isArray(summary.by_session); +} + +function addNullableCost(left: number | null, right: number | null): number | null { + if (left === null && right === null) { + return null; + } + + return (left ?? 0) + (right ?? 0); +} + +function mergeUsageSummaries(summaries: UsageSummaryResult[]): UsageSummaryResult { + const byModel = new Map(); + const bySession: UsageBySessionRow[] = []; + let totalTokens = 0; + let totalCostUsd: number | null = null; + let requestCount = 0; + + for (const summary of summaries) { + totalTokens += summary.total_tokens; + totalCostUsd = addNullableCost(totalCostUsd, summary.total_cost_usd); + requestCount += summary.request_count; + bySession.push(...summary.by_session); + + for (const row of summary.by_model) { + const existing = byModel.get(row.model); + + byModel.set(row.model, existing + ? { + cost_usd: addNullableCost(existing.cost_usd, row.cost_usd), + input_tokens: existing.input_tokens + row.input_tokens, + model: row.model, + output_tokens: existing.output_tokens + row.output_tokens, + } + : { ...row }); + } + } + + return { + by_model: [...byModel.values()], + by_session: bySession, + request_count: requestCount, + total_cost_usd: totalCostUsd, + total_tokens: totalTokens, + }; +} + function captureAgentTokenUsageSnapshot( message: unknown, totals: AgentSessionTokenTotals, @@ -731,6 +821,22 @@ export class AgentRuntime implements AgentRuntimeContract { await this.telegram.refreshRuntimeState({ forceReconnect: true }); } + /** Returns token usage summary from running AgentLite agents. */ + async getUsageSummary(params: { since?: number }): Promise { + const summaries = await Promise.all( + [...this.lifecycle.allRuntimes()].map(([, duneAgent]) => + this.callAgentLiteAction(duneAgent, 'usage_get_summary', params)), + ); + const usableSummaries = summaries.filter((summary): summary is UsageSummaryResult => + isUsageSummaryResult(summary)); + + if (usableSummaries.length === 0) { + return null; + } + + return mergeUsageSummaries(usableSummaries); + } + /** Resets agent. */ reset() { this.messageStream.clear(); @@ -758,6 +864,35 @@ export class AgentRuntime implements AgentRuntimeContract { void this.telegram.disconnectAll(); } + private async callAgentLiteAction( + duneAgent: DuneAgent, + name: string, + payload: Record, + ): Promise { + const actionHost = duneAgent.agentLiteAgent as AgentLiteAgentWithActions; + const binding = actionHost.actionsHttp?.mintContainerToken('main', true); + + if (!binding) { + return null; + } + + const response = await fetch(`${binding.url}/call`, { + body: JSON.stringify({ name, payload }), + headers: { + Authorization: `Bearer ${binding.token}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + }); + + if (!response.ok) { + return null; + } + + const body = await response.json() as { result?: unknown }; + return body.result ?? null; + } + // ------------------------------------------------------------------------- // Agent CRUD // ------------------------------------------------------------------------- diff --git a/src/electron/main/runtime/desktop-runtime-controller.ts b/src/electron/main/runtime/desktop-runtime-controller.ts index 11a0f8d..3218eae 100644 --- a/src/electron/main/runtime/desktop-runtime-controller.ts +++ b/src/electron/main/runtime/desktop-runtime-controller.ts @@ -21,6 +21,7 @@ import { /** Active runtime shape. */ type ActiveRuntime = AgentRuntimeContract & { + getUsageSummary?: (params: { since?: number }) => Promise; reloadExternalChannels?: () => Promise; shutdown?: () => Promise; }; @@ -194,6 +195,15 @@ export class DesktopRuntimeController { this.activeRuntime.service.selectAgent(agentId); } + /** Returns token usage summary, or null when AgentLite tracking is unavailable. */ + async getUsageSummary(params: { since?: number }): Promise { + try { + return await this.activeRuntime.getUsageSummary?.(params) ?? null; + } catch { + return null; + } + } + /** Resets desktop runtime. */ async reset() { if (typeof this.activeRuntime.reset === 'function') { diff --git a/src/electron/preload.ts b/src/electron/preload.ts index 83312ed..aa7d70a 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -57,6 +57,7 @@ const bridge: DesktopBridge = { storageKeys: (store) => ipcRenderer.invoke(ipcChannels.storageKeys, store), storageSet: (store, key, value) => ipcRenderer.invoke(ipcChannels.storageSet, store, key, value), selectProjectDirectory: () => ipcRenderer.invoke(ipcChannels.selectProjectDirectory), + getUsageSummary: (params) => ipcRenderer.invoke(ipcChannels.getUsageSummary, params), subscribe: (listener) => { /** Handles snapshot. */ const handleSnapshot = ( diff --git a/src/renderer/features/settings/components/UsageSettings.tsx b/src/renderer/features/settings/components/UsageSettings.tsx new file mode 100644 index 0000000..05db78f --- /dev/null +++ b/src/renderer/features/settings/components/UsageSettings.tsx @@ -0,0 +1,265 @@ +// Token usage dashboard settings section. + +import { useCallback, useEffect, useState } from 'react'; + +import type { UsageByModel, UsageBySession, UsageSummaryResult } from '@/shared/electron/desktop-bridge'; +import type { SettingsSectionComponentProps } from '@/renderer/features/settings/config/settings-sections'; +import { cn } from '@/renderer/shared/lib/utils'; + +import { SettingsSectionIntro } from './SettingsSectionIntro'; + +/** Time range options. */ +type TimeRange = 'all' | '7d'; + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + +/** Formats a USD cost value. */ +function formatCost(value: number | null | undefined): string { + if (value == null) return '--'; + if (value < 0.01) return '<$0.01'; + return `$${value.toFixed(2)}`; +} + +/** Formats a token count. */ +function formatTokens(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return String(value); +} + +/** Props for the stat card. */ +interface StatCardProps { + label: string; + value: string; +} + +/** Renders a single summary stat card. */ +function StatCard({ label, value }: StatCardProps) { + return ( +
+ {label} + {value} +
+ ); +} + +/** Props for a bar row. */ +interface BarRowProps { + label: string; + value: string; + pct: number; + dimmed: boolean; +} + +/** Renders a single horizontal bar row. */ +function BarRow({ label, value, pct, dimmed }: BarRowProps) { + return ( +
+ + {label} + +
+
+
+ + {value} + +
+ ); +} + +/** Skeleton bar for loading state. */ +function SkeletonBar() { + return ( +
+
+
+
+
+ ); +} + +/** Renders the usage settings UI. */ +export function UsageSettings(_props: SettingsSectionComponentProps) { + const [range, setRange] = useState('all'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [unavailable, setUnavailable] = useState(false); + + const fetchUsage = useCallback(async (timeRange: TimeRange) => { + setLoading(true); + try { + const since = timeRange === '7d' ? Date.now() - SEVEN_DAYS_MS : undefined; + const result = await window.duneDesktop?.getUsageSummary?.( + since === undefined ? {} : { since }, + ) ?? null; + if (result === null) { + setUnavailable(true); + setData(null); + } else { + setUnavailable(false); + setData(result); + } + } catch { + setUnavailable(true); + setData(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchUsage(range); + }, [fetchUsage, range]); + + const handleRangeChange = (newRange: TimeRange) => { + setRange(newRange); + }; + + const topBySession = (data?.by_session ?? []) + .slice() + .sort((a, b) => (b.cost_usd ?? 0) - (a.cost_usd ?? 0)) + .slice(0, 10); + + const topByModel = (data?.by_model ?? []) + .slice() + .sort((a, b) => (b.cost_usd ?? 0) - (a.cost_usd ?? 0)) + .slice(0, 10); + + const maxSessionCost = topBySession[0]?.cost_usd ?? 0; + const maxModelCost = topByModel[0]?.cost_usd ?? 0; + + const getSessionPct = (row: UsageBySession) => + maxSessionCost > 0 ? Math.round(((row.cost_usd ?? 0) / maxSessionCost) * 100) : 0; + + const getModelPct = (row: UsageByModel) => + maxModelCost > 0 ? Math.round(((row.cost_usd ?? 0) / maxModelCost) * 100) : 0; + + return ( + <> + + + {/* Time range toggle */} +
+ {(['all', '7d'] as TimeRange[]).map((r) => ( + + ))} +
+ + {/* Unavailable banner */} + {unavailable && !loading && ( +
+ Token usage tracking is not yet available. It requires the AgentLite token tracking + feature to be enabled. Usage data will appear here once that feature is active. +
+ )} + + {/* Summary stat cards */} + {!unavailable && ( +
+ {loading ? ( + <> + {[0, 1, 2].map((i) => ( +
+
+
+
+ ))} + + ) : ( + <> + + + + + )} +
+ )} + + {/* By Agent section */} + {!unavailable && ( +
+

By agent

+
+ {loading ? ( + <> + {[0, 1, 2, 3].map((i) => )} + + ) : topBySession.length === 0 ? ( +

No agent data for this period.

+ ) : ( + topBySession.map((row, i) => ( + = 3} + /> + )) + )} +
+
+ )} + + {/* By Model section */} + {!unavailable && ( +
+

By model

+
+ {loading ? ( + <> + {[0, 1, 2].map((i) => )} + + ) : topByModel.length === 0 ? ( +

No model data for this period.

+ ) : ( + topByModel.map((row, i) => ( + = 3} + /> + )) + )} +
+
+ )} + + ); +} diff --git a/src/renderer/features/settings/config/settings-sections.ts b/src/renderer/features/settings/config/settings-sections.ts index 6fbe04e..67adf3c 100644 --- a/src/renderer/features/settings/config/settings-sections.ts +++ b/src/renderer/features/settings/config/settings-sections.ts @@ -8,6 +8,7 @@ import { ModelsSettings } from '@/renderer/features/settings/components/ModelsSe import { NuclearSettings } from '@/renderer/features/settings/components/NuclearSettings'; import { NetworkSettings } from '@/renderer/features/settings/components/NetworkSettings'; import { ShortcutsSettings } from '@/renderer/features/settings/components/ShortcutsSettings'; +import { UsageSettings } from '@/renderer/features/settings/components/UsageSettings'; import type { SettingsRoute, @@ -66,6 +67,12 @@ export const settingsSections: SettingsSectionDefinition[] = [ description: 'Keyboard-first reference', Component: ShortcutsSettings, }, + { + id: 'usage', + title: 'Usage', + description: 'Token consumption and cost', + Component: UsageSettings, + }, { id: 'nuclear', title: 'Nuclear', diff --git a/src/renderer/features/settings/types.ts b/src/renderer/features/settings/types.ts index 5d5f72f..813fa33 100644 --- a/src/renderer/features/settings/types.ts +++ b/src/renderer/features/settings/types.ts @@ -7,6 +7,7 @@ export type SettingsRoute = | 'models' | 'network' | 'shortcuts' + | 'usage' | 'nuclear'; /** Theme preference shape. */ export type ThemePreference = 'dark' | 'light' | 'system'; diff --git a/src/shared/electron/desktop-bridge.ts b/src/shared/electron/desktop-bridge.ts index fa699ab..ce59ba0 100644 --- a/src/shared/electron/desktop-bridge.ts +++ b/src/shared/electron/desktop-bridge.ts @@ -14,6 +14,32 @@ import type { import type { WorkflowProjectActivityPage } from '@/renderer/features/workflow/types'; import type { ProjectArtifactEntry } from '@/shared/workflow/project-artifacts'; +/** Per-model token usage row. */ +export interface UsageByModel { + model: string; + input_tokens: number; + output_tokens: number; + cost_usd: number | null; +} + +/** Per-session (agent) token usage row. */ +export interface UsageBySession { + session_id: string; + agent_name: string | null; + input_tokens: number; + output_tokens: number; + cost_usd: number | null; +} + +/** Usage summary result from AgentLite usage_get_summary. */ +export interface UsageSummaryResult { + total_tokens: number; + total_cost_usd: number | null; + request_count: number; + by_model: UsageByModel[]; + by_session: UsageBySession[]; +} + /** Methods are optional to support browser-only fallback (no Electron preload). */ export interface DesktopBridge { applyNetworkSettings?: () => Promise; @@ -68,4 +94,5 @@ export interface DesktopBridge { ) => () => void; updateAgentChannel?: (input: UpdateAgentChannelInput) => Promise; updateAgentDefinition?: (agentId: string, definition: AgentDefinition) => Promise; + getUsageSummary?: (params: { since?: number }) => Promise; } diff --git a/src/shared/electron/ipc-channels.ts b/src/shared/electron/ipc-channels.ts index 04d2db3..4e43575 100644 --- a/src/shared/electron/ipc-channels.ts +++ b/src/shared/electron/ipc-channels.ts @@ -35,4 +35,5 @@ export const ipcChannels = { storageGet: 'dune:storage:get', storageKeys: 'dune:storage:keys', storageSet: 'dune:storage:set', + getUsageSummary: 'dune:usage:get-summary', } as const; diff --git a/tests/unit/src/renderer/features/settings/config/settings-sections.test.ts b/tests/unit/src/renderer/features/settings/config/settings-sections.test.ts index 0dd712b..0694a45 100644 --- a/tests/unit/src/renderer/features/settings/config/settings-sections.test.ts +++ b/tests/unit/src/renderer/features/settings/config/settings-sections.test.ts @@ -6,7 +6,7 @@ import type { SettingsRoute } from '@/renderer/features/settings/types'; import { settingsSectionRegistry, settingsSections } from '@/renderer/features/settings/config/settings-sections'; -const allRoutes: SettingsRoute[] = ['appearance', 'models', 'network', 'shortcuts', 'nuclear']; +const allRoutes: SettingsRoute[] = ['appearance', 'artifacts', 'models', 'network', 'shortcuts', 'usage', 'nuclear']; describe('settingsSectionRegistry', () => { it('has an entry for every SettingsRoute', () => { @@ -15,12 +15,14 @@ describe('settingsSectionRegistry', () => { } }); - it('renders network below models in the section order', () => { + it('renders sections in the expected order', () => { expect(settingsSections.map((section) => section.id)).toEqual([ 'appearance', 'models', 'network', + 'artifacts', 'shortcuts', + 'usage', 'nuclear', ]); });