From 87f8bedfda153444d992d03df2f04055d7db90d3 Mon Sep 17 00:00:00 2001 From: Dune Developer 6 Date: Fri, 24 Apr 2026 15:38:25 +0800 Subject: [PATCH] feat: add token usage dashboard to Dune settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new 'Usage' section to Dune Settings showing per-agent and per-model token consumption and cost. The UI is built speculatively against the planned AgentLite usage_get_summary action — the IPC handler returns null gracefully until that action is available, and the component shows a clear "not yet available" banner in that state. Changes: - ipc-channels.ts: add getUsageSummary channel - desktop-bridge.ts: add UsageSummaryResult types + getUsageSummary method - preload.ts: wire IPC call through context bridge - main.ts: add IPC handler (returns null until AgentLite tracking lands) - desktop-runtime-controller.ts: stub getUsageSummary returning null - types.ts: add 'usage' to SettingsRoute union - UsageSettings.tsx: full component with time range toggle, stat cards, CSS bar charts by agent and by model, skeleton loading, and unavailable banner - settings-sections.ts: register Usage section between Shortcuts and Nuclear - settings-sections.test.ts: update expected section order Co-Authored-By: Claude Sonnet 4.6 --- .../main/ipc/register-main-ipc-handlers.ts | 10 + .../main/runtime/agent-runtime/index.ts | 56 ++++ .../runtime/desktop-runtime-controller.ts | 9 + src/electron/preload.ts | 1 + .../settings/components/UsageSettings.tsx | 264 ++++++++++++++++++ .../settings/config/settings-sections.ts | 7 + src/renderer/features/settings/types.ts | 1 + src/shared/agents/agent-runtime.ts | 1 + src/shared/electron/desktop-bridge.ts | 27 ++ src/shared/electron/ipc-channels.ts | 1 + .../settings/config/settings-sections.test.ts | 6 +- 11 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 src/renderer/features/settings/components/UsageSettings.tsx diff --git a/src/electron/main/ipc/register-main-ipc-handlers.ts b/src/electron/main/ipc/register-main-ipc-handlers.ts index f255596..78ddfe7 100644 --- a/src/electron/main/ipc/register-main-ipc-handlers.ts +++ b/src/electron/main/ipc/register-main-ipc-handlers.ts @@ -171,6 +171,16 @@ export function registerMainIpcHandlers(options: RegisterMainIpcHandlersOptions) async (_event, agentId: string, input) => withRuntime((runtimeController) => runtimeController.runIsolatedResearch(agentId, input)), ); + ipcMain.handle( + ipcChannels.getUsageSummary, + async (_event, params: { since?: number }) => { + try { + return await withRuntime((runtimeController) => runtimeController.getUsageSummary(params)); + } catch { + return null; + } + }, + ); // Workflow and storage handlers. ipcMain.handle( diff --git a/src/electron/main/runtime/agent-runtime/index.ts b/src/electron/main/runtime/agent-runtime/index.ts index ecf804e..db0c256 100644 --- a/src/electron/main/runtime/agent-runtime/index.ts +++ b/src/electron/main/runtime/agent-runtime/index.ts @@ -130,6 +130,17 @@ const SHUTDOWN_TIMEOUT_MS = 5_000; const TRANSCRIPT_SUMMARY_CARD_ID = 'transcript-rolling-summary'; const ISOLATED_RESEARCH_GROUP_FOLDER_PREFIX = 'isolated-research-slot-'; +interface AgentActionHttp { + mintContainerToken: ( + groupFolder: string, + isMain: boolean, + ) => { url: string; token: string } | null; +} + +type AgentLiteAgentWithActions = AgentLiteAgent & { + actionsHttp?: AgentActionHttp; +}; + function cloneAgentMessage(message: AgentMessage): AgentMessage { return { ...message, @@ -607,6 +618,7 @@ export class AgentRuntime implements AgentRuntimeContract { cancelTelegramSetupSession: async (sessionId) => { await this.telegram.cancelSetupSession(sessionId); }, + callAction: async (name, payload) => this.callAction(name, payload), createAgent: async (input) => this.createAgent(input), deleteAgent: async (agentId) => this.deleteAgent(agentId), ensureProjectMainAgent: async (projectId, projectName, projectRootPath) => @@ -692,6 +704,37 @@ export class AgentRuntime implements AgentRuntimeContract { await this.reloadExternalChannels(); } + /** Calls a host-registered AgentLite action from the main process. */ + async callAction(name: string, payload: Record = {}): Promise { + const duneAgent = await this.resolveActionHostRuntime(); + const agentLiteAgent = duneAgent.agentLiteAgent as AgentLiteAgentWithActions; + const actionAuth = agentLiteAgent.actionsHttp?.mintContainerToken( + duneAgent.groupFolder, + true, + ); + + if (!actionAuth) { + throw new Error('AgentLite action transport is unavailable.'); + } + + const response = await fetch(`${actionAuth.url}/call`, { + body: JSON.stringify({ name, payload }), + headers: { + authorization: `Bearer ${actionAuth.token}`, + 'content-type': 'application/json', + }, + method: 'POST', + }); + + const body = await response.json() as { result?: unknown; error?: string }; + + if (!response.ok) { + throw new Error(body.error ?? `AgentLite action "${name}" failed.`); + } + + return body.result ?? null; + } + /** Shuts down agent. */ shutdown(): Promise { if (this.shutdownPromise) { @@ -2336,6 +2379,19 @@ export class AgentRuntime implements AgentRuntimeContract { } } + private async resolveActionHostRuntime(): Promise { + const selectedAgentId = this.snapshot.selectedAgentId; + const selectedRecord = selectedAgentId ? this.records.get(selectedAgentId) : undefined; + const fallbackRecord = this.records.values().next().value; + const record = selectedRecord ?? fallbackRecord; + + if (!record) { + throw new Error('No AgentLite agents are available for action calls.'); + } + + return this.ensureAgentRuntime(record); + } + private async resolveExternalChannelFactory( channelId: string, agentId: string, diff --git a/src/electron/main/runtime/desktop-runtime-controller.ts b/src/electron/main/runtime/desktop-runtime-controller.ts index 11a0f8d..0cc6426 100644 --- a/src/electron/main/runtime/desktop-runtime-controller.ts +++ b/src/electron/main/runtime/desktop-runtime-controller.ts @@ -194,6 +194,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.service.callAction?.('usage_get_summary', 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..967ae55 --- /dev/null +++ b/src/renderer/features/settings/components/UsageSettings.tsx @@ -0,0 +1,264 @@ +// 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 params = typeof since === 'number' ? { since } : {}; + const result = await window.duneDesktop?.getUsageSummary?.(params) ?? 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/agents/agent-runtime.ts b/src/shared/agents/agent-runtime.ts index dae6b57..4b4a582 100644 --- a/src/shared/agents/agent-runtime.ts +++ b/src/shared/agents/agent-runtime.ts @@ -39,6 +39,7 @@ export type AgentServiceListener = (snapshot: AgentServiceSnapshot) => void; */ export interface AgentService { cancelTelegramSetupSession: (sessionId: string) => Promise; + callAction?: (name: string, payload?: Record) => Promise; createAgent: (input: CreateAgentInput) => Promise; deleteAgent: (agentId: string) => Promise; ensureProjectMainAgent: ( 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', ]); });