diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index 4a374c8..5e4f351 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -105,13 +105,18 @@ struct CurrentBlock: Codable, Sendable { let topSessions: [TopSessionEntry] let retryTax: RetryTax let routingWaste: RoutingWaste + let tools: [ToolEntry] + let skills: [SkillEntry] + let subagents: [SubagentEntry] + let mcpServers: [McpServerEntry] } extension CurrentBlock { enum CodingKeys: String, CodingKey { case label, cost, calls, sessions, oneShotRate, inputTokens, outputTokens, cacheHitPercent, topActivities, topModels, providers, topProjects, - modelEfficiency, topSessions, retryTax, routingWaste + modelEfficiency, topSessions, retryTax, routingWaste, + tools, skills, subagents, mcpServers } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) @@ -131,6 +136,10 @@ extension CurrentBlock { topSessions = try c.decodeIfPresent([TopSessionEntry].self, forKey: .topSessions) ?? [] retryTax = try c.decodeIfPresent(RetryTax.self, forKey: .retryTax) ?? RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []) routingWaste = try c.decodeIfPresent(RoutingWaste.self, forKey: .routingWaste) ?? RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []) + tools = try c.decodeIfPresent([ToolEntry].self, forKey: .tools) ?? [] + skills = try c.decodeIfPresent([SkillEntry].self, forKey: .skills) ?? [] + subagents = try c.decodeIfPresent([SubagentEntry].self, forKey: .subagents) ?? [] + mcpServers = try c.decodeIfPresent([McpServerEntry].self, forKey: .mcpServers) ?? [] } } @@ -195,6 +204,28 @@ struct TopSessionEntry: Codable, Sendable { let date: String } +struct ToolEntry: Codable, Sendable { + let name: String + let calls: Int +} + +struct SkillEntry: Codable, Sendable { + let name: String + let turns: Int + let cost: Double +} + +struct SubagentEntry: Codable, Sendable { + let name: String + let calls: Int + let cost: Double +} + +struct McpServerEntry: Codable, Sendable { + let name: String + let calls: Int +} + struct OptimizeBlock: Codable, Sendable { let findingCount: Int let savingsUSD: Double @@ -230,7 +261,11 @@ extension MenubarPayload { modelEfficiency: [], topSessions: [], retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []), - routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []) + routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []), + tools: [], + skills: [], + subagents: [], + mcpServers: [] ), optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []), history: HistoryBlock(daily: []) diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index 9194d00..25fe62f 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -36,6 +36,8 @@ struct MenuBarContent: View { Divider().opacity(0.5) ModelsSection() Divider().opacity(0.5) + ToolingSection() + Divider().opacity(0.5) FindingsSection() } } diff --git a/mac/Sources/CodeBurnMenubar/Views/ToolingSection.swift b/mac/Sources/CodeBurnMenubar/Views/ToolingSection.swift new file mode 100644 index 0000000..1bcf9de --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/ToolingSection.swift @@ -0,0 +1,137 @@ +import SwiftUI + +struct ToolingSection: View { + @Environment(AppStore.self) private var store + @State private var isExpanded: Bool = false + + private var skillsAndAgents: [CostRowData] { + let current = store.payload.current + var merged: [String: (uses: Int, cost: Double)] = [:] + for s in current.skills { + let e = merged[s.name, default: (0, 0)] + merged[s.name] = (e.uses + s.turns, e.cost + s.cost) + } + for a in current.subagents { + let e = merged[a.name, default: (0, 0)] + merged[a.name] = (e.uses + a.calls, e.cost + a.cost) + } + return merged + .map { CostRowData(name: $0.key, uses: $0.value.uses, cost: $0.value.cost) } + .sorted { $0.cost > $1.cost } + } + + var body: some View { + let current = store.payload.current + let combined = skillsAndAgents + let hasAny = !current.tools.isEmpty || !combined.isEmpty || !current.mcpServers.isEmpty + if hasAny { + CollapsibleSection(caption: "Tooling", isExpanded: $isExpanded) { + VStack(alignment: .leading, spacing: 12) { + if !current.tools.isEmpty { + ToolingSubsection(title: "Tools") { + let maxCalls = current.tools.map(\.calls).max() ?? 1 + ForEach(current.tools, id: \.name) { t in + CallsRow(name: t.name, calls: t.calls, maxCalls: maxCalls) + } + } + } + if !combined.isEmpty { + ToolingSubsection(title: "Skills & Agents") { + let maxCost = max(combined.map(\.cost).max() ?? 0.01, 0.01) + ForEach(combined, id: \.name) { d in + CostRow(name: d.name, cost: d.cost, count: d.uses, countLabel: "uses", maxCost: maxCost) + } + } + } + if !current.mcpServers.isEmpty { + ToolingSubsection(title: "MCP Servers") { + let maxCalls = current.mcpServers.map(\.calls).max() ?? 1 + ForEach(current.mcpServers, id: \.name) { m in + CallsRow(name: m.name, calls: m.calls, maxCalls: maxCalls) + } + } + } + } + } + } + } +} + +private struct CostRowData { + let name: String + let uses: Int + let cost: Double +} + +private struct ToolingSubsection: View { + let title: String + let content: Content + + init(title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(.tertiary) + .textCase(.uppercase) + .tracking(0.5) + content + } + } +} + +private struct CallsRow: View { + let name: String + let calls: Int + let maxCalls: Int + + var body: some View { + HStack(spacing: 8) { + FixedBar(fraction: Double(calls) / Double(max(maxCalls, 1))) + .frame(width: 40, height: 5) + Text(name) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + Text("\(calls)") + .font(.system(size: 11)) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(minWidth: 36, alignment: .trailing) + } + .padding(.vertical, 1) + } +} + +private struct CostRow: View { + let name: String + let cost: Double + let count: Int + let countLabel: String + let maxCost: Double + + var body: some View { + HStack(spacing: 8) { + FixedBar(fraction: cost / max(maxCost, 0.01)) + .frame(width: 40, height: 5) + Text(name) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + Text("\(count)") + .font(.system(size: 11)) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(minWidth: 30, alignment: .trailing) + Text(cost.asCompactCurrency()) + .font(.codeMono(size: 11, weight: .medium)) + .tracking(-0.2) + .frame(minWidth: 46, alignment: .trailing) + } + .padding(.vertical, 1) + } +} diff --git a/src/dashboard.tsx b/src/dashboard.tsx index f1b49ed..a70eea2 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -40,12 +40,12 @@ const PANEL_COLORS = { overview: '#FF8C42', daily: '#5B9EF5', project: '#5BF5A0', - sessions: '#FF6B6B', model: '#E05BF5', activity: '#F5C85B', tools: '#5BF5E0', mcp: '#F55BE0', bash: '#F5A05B', + skills: '#7B68EE', } const PROVIDER_COLORS: Record = { @@ -465,43 +465,6 @@ function ToolBreakdown({ projects, pw, bw, title, filterPrefix }: { projects: Pr ) } -const TOP_SESSIONS_DATE_LEN = 10 -const TOP_SESSIONS_COST_COL = 8 -const TOP_SESSIONS_CALLS_COL = 6 - -function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { - const allSessions = projects.flatMap(p => - p.sessions.map(s => ({ ...s, projectPath: p.projectPath })) - ) - const top = [...allSessions].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, 5) - - if (top.length === 0) { - return No sessions - } - - const maxCost = top[0].totalCostUSD - const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_CALLS_COL - 1 - PANEL_CHROME) - - return ( - - {''.padEnd(bw + 1 + nw)}{'cost'.padStart(TOP_SESSIONS_COST_COL)}{'calls'.padStart(TOP_SESSIONS_CALLS_COL)} - {top.map((session, i) => { - const date = session.firstTimestamp - ? session.firstTimestamp.slice(0, TOP_SESSIONS_DATE_LEN) - : '----------' - const label = `${date} ${shortProject(session.projectPath)}` - return ( - - - {fit(label, nw - 1)} - {formatCost(session.totalCostUSD).padStart(TOP_SESSIONS_COST_COL)} - {String(session.apiCalls).padStart(TOP_SESSIONS_CALLS_COL)} - - ) - })} - - ) -} function McpBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { const mcpTotals: Record = {} @@ -537,6 +500,26 @@ function BashBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: n ) } +function SkillsAndAgents({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { + const merged: Record = {} + for (const project of projects) { for (const session of project.sessions) { + for (const [skill, d] of Object.entries(session.skillBreakdown)) { const e = merged[skill] ?? { uses: 0, cost: 0 }; e.uses += d.turns; e.cost += d.costUSD; merged[skill] = e } + for (const [agent, d] of Object.entries(session.subagentBreakdown)) { const e = merged[agent] ?? { uses: 0, cost: 0 }; e.uses += d.calls; e.cost += d.costUSD; merged[agent] = e } + } } + const sorted = Object.entries(merged).sort(([, a], [, b]) => b.cost - a.cost) + if (sorted.length === 0) return No skill/agent usage + const maxCost = sorted[0]?.[1]?.cost ?? 0 + const nw = Math.max(6, pw - bw - 22) + return ( + + {''.padEnd(bw + 1 + nw)}{'uses'.padStart(6)}{'cost'.padStart(8)} + {sorted.slice(0, 10).map(([name, d]) => ( + {fit(name, nw)}{String(d.uses).padStart(6)}{formatCost(d.cost).padStart(8)} + ))} + + ) +} + const PROVIDER_DISPLAY_NAMES: Record = { all: 'All', claude: 'Claude', @@ -712,12 +695,11 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets, - {isCursor ? ( ) : ( - <> + <> )} ) diff --git a/src/main.ts b/src/main.ts index ec0d900..48071c8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,7 @@ import { loadPricing, setModelAliases } from './models.js' import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js' import { convertCost } from './currency.js' import { renderStatusBar } from './format.js' -import { type PeriodData, type ProviderCost } from './menubar-json.js' +import { type PeriodData, type ProviderCost, type BreakdownArrays } from './menubar-json.js' import { buildMenubarPayload } from './menubar-json.js' import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache } from './daily-cache.js' import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js' @@ -290,6 +290,8 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: const toolMap: Record = {} const mcpMap: Record = {} const bashMap: Record = {} + const skillMap: Record = {} + const subagentMap: Record = {} for (const sess of sessions) { for (const [tool, d] of Object.entries(sess.toolBreakdown)) { toolMap[tool] = (toolMap[tool] ?? 0) + d.calls @@ -300,6 +302,16 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: for (const [cmd, d] of Object.entries(sess.bashBreakdown)) { bashMap[cmd] = (bashMap[cmd] ?? 0) + d.calls } + for (const [skill, d] of Object.entries(sess.skillBreakdown)) { + if (!skillMap[skill]) skillMap[skill] = { turns: 0, cost: 0 } + skillMap[skill].turns += d.turns + skillMap[skill].cost += d.costUSD + } + for (const [sat, d] of Object.entries(sess.subagentBreakdown)) { + if (!subagentMap[sat]) subagentMap[sat] = { calls: 0, cost: 0 } + subagentMap[sat].calls += d.calls + subagentMap[sat].cost += d.costUSD + } } const sortedMap = (m: Record) => @@ -334,6 +346,8 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: tools: sortedMap(toolMap), mcpServers: sortedMap(mcpMap), shellCommands: sortedMap(bashMap), + skills: Object.entries(skillMap).sort(([, a], [, b]) => b.cost - a.cost).map(([name, d]) => ({ name, turns: d.turns, cost: convertCost(d.cost) })), + subagents: Object.entries(subagentMap).sort(([, a], [, b]) => b.cost - a.cost).map(([name, d]) => ({ name, calls: d.calls, cost: convertCost(d.cost) })), topSessions, } } @@ -680,8 +694,27 @@ program byModel: routingWasteByModel.slice(0, 5), } + const breakdowns: BreakdownArrays = (() => { + const toolMap: Record = {} + const skillMap: Record = {} + const subagentMap: Record = {} + const mcpMap: Record = {} + for (const p of scanProjects) for (const s of p.sessions) { + for (const [t, d] of Object.entries(s.toolBreakdown)) { if (!t.startsWith('lang:')) toolMap[t] = (toolMap[t] ?? 0) + d.calls } + for (const [sk, d] of Object.entries(s.skillBreakdown)) { const e = skillMap[sk] ?? { turns: 0, cost: 0 }; e.turns += d.turns; e.cost += d.costUSD; skillMap[sk] = e } + for (const [sa, d] of Object.entries(s.subagentBreakdown)) { const e = subagentMap[sa] ?? { calls: 0, cost: 0 }; e.calls += d.calls; e.cost += d.costUSD; subagentMap[sa] = e } + for (const [m, d] of Object.entries(s.mcpBreakdown)) { mcpMap[m] = (mcpMap[m] ?? 0) + d.calls } + } + return { + tools: Object.entries(toolMap).sort(([, a], [, b]) => b - a).slice(0, 10).map(([name, calls]) => ({ name, calls })), + skills: Object.entries(skillMap).sort(([, a], [, b]) => b.cost - a.cost).slice(0, 10).map(([name, d]) => ({ name, ...d })), + subagents: Object.entries(subagentMap).sort(([, a], [, b]) => b.cost - a.cost).slice(0, 10).map(([name, d]) => ({ name, ...d })), + mcpServers: Object.entries(mcpMap).sort(([, a], [, b]) => b - a).slice(0, 10).map(([name, calls]) => ({ name, calls })), + } + })() + const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange) - console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste))) + console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns))) return } diff --git a/src/menubar-json.ts b/src/menubar-json.ts index 660f82c..e3704e6 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -123,6 +123,10 @@ export type MenubarPayload = { savingsUSD: number }> } + tools: Array<{ name: string; calls: number }> + skills: Array<{ name: string; turns: number; cost: number }> + subagents: Array<{ name: string; calls: number; cost: number }> + mcpServers: Array<{ name: string; calls: number }> } optimize: { findingCount: number @@ -246,6 +250,13 @@ function buildTopSessions(sessions: PeriodData['topSessions']): MenubarPayload[' .map(s => ({ project: s.project, cost: s.cost, calls: s.calls, date: s.date })) } +export type BreakdownArrays = { + tools?: MenubarPayload['current']['tools'] + skills?: MenubarPayload['current']['skills'] + subagents?: MenubarPayload['current']['subagents'] + mcpServers?: MenubarPayload['current']['mcpServers'] +} + export function buildMenubarPayload( current: PeriodData, providers: ProviderCost[], @@ -253,6 +264,7 @@ export function buildMenubarPayload( dailyHistory?: DailyHistoryEntry[], retryTax?: MenubarPayload['current']['retryTax'], routingWaste?: MenubarPayload['current']['routingWaste'], + breakdowns?: BreakdownArrays, ): MenubarPayload { return { generated: new Date().toISOString(), @@ -273,6 +285,10 @@ export function buildMenubarPayload( topSessions: buildTopSessions(current.topSessions ?? []), retryTax: retryTax ?? { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] }, routingWaste: routingWaste ?? { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] }, + tools: breakdowns?.tools ?? [], + skills: breakdowns?.skills ?? [], + subagents: breakdowns?.subagents ?? [], + mcpServers: breakdowns?.mcpServers ?? [], }, optimize: buildOptimize(optimize), history: buildHistory(dailyHistory), diff --git a/src/parser.ts b/src/parser.ts index 045b994..bf0fd7b 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -908,6 +908,17 @@ function extractSkillNames(content: ContentBlock[]): string[] { .filter(name => name.length > 0) } +function extractSubagentTypes(content: ContentBlock[]): string[] { + return content + .filter((b): b is ToolUseBlock => b.type === 'tool_use' && (b.name === 'Agent' || b.name === 'Task')) + .map(b => { + const input = (b.input ?? {}) as Record + const raw = input['subagent_type'] + return typeof raw === 'string' ? raw.trim() : '' + }) + .filter(name => name.length > 0) +} + function extractCoreTools(tools: string[]): string[] { return tools.filter(t => !t.startsWith('mcp__')) } @@ -981,6 +992,7 @@ function parseApiCall(entry: JournalEntry): ParsedApiCall | null { const tools = extractToolNames(msg.content ?? []) const skills = extractSkillNames(msg.content ?? []) + const subagentTypes = extractSubagentTypes(msg.content ?? []) const costUSD = calculateCost( msg.model, tokens.inputTokens, @@ -1002,6 +1014,7 @@ function parseApiCall(entry: JournalEntry): ParsedApiCall | null { tools, mcpTools: extractMcpTools(tools), skills, + subagentTypes, hasAgentSpawn: tools.includes('Agent'), hasPlanMode: tools.includes('EnterPlanMode'), speed: usage.speed ?? 'standard', @@ -1145,6 +1158,7 @@ function buildSessionSummary( const bashBreakdown: SessionSummary['bashBreakdown'] = Object.create(null) const categoryBreakdown: SessionSummary['categoryBreakdown'] = Object.create(null) const skillBreakdown: SessionSummary['skillBreakdown'] = Object.create(null) + const subagentBreakdown: SessionSummary['subagentBreakdown'] = Object.create(null) let totalCost = 0 let totalInput = 0 @@ -1218,6 +1232,11 @@ function buildSessionSummary( bashBreakdown[cmd] = bashBreakdown[cmd] ?? { calls: 0 } bashBreakdown[cmd].calls++ } + for (const sat of call.subagentTypes) { + subagentBreakdown[sat] = subagentBreakdown[sat] ?? { calls: 0, costUSD: 0 } + subagentBreakdown[sat].calls++ + subagentBreakdown[sat].costUSD += call.costUSD + } if (!firstTs || call.timestamp < firstTs) firstTs = call.timestamp if (!lastTs || call.timestamp > lastTs) lastTs = call.timestamp @@ -1242,6 +1261,7 @@ function buildSessionSummary( bashBreakdown, categoryBreakdown, skillBreakdown, + subagentBreakdown, ...(mcpInventory && mcpInventory.length > 0 ? { mcpInventory } : {}), } } @@ -1499,6 +1519,7 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn { tools, mcpTools: extractMcpTools(tools), skills: [], + subagentTypes: [], hasAgentSpawn: tools.includes('Agent'), hasPlanMode: tools.includes('EnterPlanMode'), speed: call.speed, @@ -1537,6 +1558,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall { tools: call.tools, bashCommands: call.bashCommands, skills: [], + subagentTypes: [], deduplicationKey: call.deduplicationKey, project: call.project, projectPath: call.projectPath, @@ -1554,6 +1576,7 @@ function apiCallToCachedCall(call: ParsedApiCall): CachedCall { tools: call.tools, bashCommands: call.bashCommands, skills: call.skills, + subagentTypes: call.subagentTypes, deduplicationKey: call.deduplicationKey, } } @@ -1630,6 +1653,7 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall { tools: call.tools, mcpTools: extractMcpTools(call.tools), skills: call.skills, + subagentTypes: call.subagentTypes ?? [], hasAgentSpawn: call.tools.includes('Agent'), hasPlanMode: call.tools.includes('EnterPlanMode'), speed: call.speed, diff --git a/src/session-cache.ts b/src/session-cache.ts index 107b4e6..7a860e3 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -27,6 +27,7 @@ export type CachedCall = { tools: string[] bashCommands: string[] skills: string[] + subagentTypes: string[] deduplicationKey: string project?: string projectPath?: string diff --git a/src/types.ts b/src/types.ts index 7e362e7..bb0a204 100644 --- a/src/types.ts +++ b/src/types.ts @@ -77,6 +77,7 @@ export type ParsedApiCall = { tools: string[] mcpTools: string[] skills: string[] + subagentTypes: string[] hasAgentSpawn: boolean hasPlanMode: boolean speed: 'standard' | 'fast' @@ -127,6 +128,7 @@ export type SessionSummary = { bashBreakdown: Record categoryBreakdown: Record skillBreakdown: Record + subagentBreakdown: Record // Observed MCP tools available in this session, captured from // `attachment.deferred_tools_delta.addedNames` entries. Union across all // turns. Each name is a fully-qualified `mcp____` identifier.