Skip to content

Commit 45e9233

Browse files
committed
feat: add Claude Code usage stats widget to details sidebar
- Add tRPC router to fetch usage data from Anthropic OAuth API - Read OAuth token from macOS System Keychain (Claude CLI credentials) - Display 5-hour session and 7-day weekly usage with progress bars - Show model breakdown (Opus/Sonnet) in collapsible section - Color-coded status: grey (safe), orange (moderate), red (critical) - Auto-refresh every 5 minutes, manual refresh button - Widget hides automatically when not connected to Claude Code
1 parent cf6b959 commit 45e9233

6 files changed

Lines changed: 456 additions & 4 deletions

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { execSync } from "child_process"
2+
import os from "os"
3+
import { publicProcedure, router } from "../index"
4+
5+
/**
6+
* Read Claude Code credentials from macOS System Keychain
7+
* This is where the Claude CLI stores its OAuth token with proper scopes
8+
*/
9+
function readSystemKeychainCredentials(): string | null {
10+
try {
11+
const username = os.userInfo().username
12+
const result = execSync(
13+
`security find-generic-password -s "Claude Code-credentials" -a "${username}" -w 2>/dev/null`,
14+
{ encoding: "utf-8" }
15+
)
16+
return result.trim()
17+
} catch {
18+
// Item not found or error
19+
return null
20+
}
21+
}
22+
23+
/**
24+
* Extract access token from Claude CLI credentials JSON
25+
*/
26+
function extractAccessToken(jsonData: string): string | null {
27+
try {
28+
const data = JSON.parse(jsonData)
29+
return data?.claudeAiOauth?.accessToken ?? null
30+
} catch {
31+
return null
32+
}
33+
}
34+
35+
/**
36+
* Get OAuth token from system keychain (Claude CLI credentials)
37+
* This token has the proper scopes for usage API
38+
*/
39+
function getOAuthToken(): string | null {
40+
const keychainData = readSystemKeychainCredentials()
41+
42+
if (!keychainData) {
43+
console.log("[ClaudeUsage] No credentials found in system keychain")
44+
return null
45+
}
46+
47+
const token = extractAccessToken(keychainData)
48+
if (token) {
49+
console.log("[ClaudeUsage] Token from system keychain, length:", token.length)
50+
} else {
51+
console.log("[ClaudeUsage] Could not extract token from keychain data")
52+
}
53+
54+
return token
55+
}
56+
57+
/**
58+
* Parsed usage data returned to the client
59+
* Always includes all model breakdowns (defaulting to 0 if not used)
60+
*/
61+
export interface ClaudeUsageData {
62+
fiveHour: {
63+
utilization: number
64+
resetsAt: string | null
65+
}
66+
sevenDay: {
67+
utilization: number
68+
resetsAt: string | null
69+
}
70+
sevenDayOpus: {
71+
utilization: number
72+
}
73+
sevenDaySonnet: {
74+
utilization: number
75+
resetsAt: string | null
76+
}
77+
lastFetched: string
78+
}
79+
80+
/**
81+
* Parse utilization value that can be Int, Double, or String
82+
* Based on claude-usage-tracker's robust parser
83+
*/
84+
function parseUtilization(value: unknown): number {
85+
if (typeof value === "number") {
86+
return value
87+
}
88+
if (typeof value === "string") {
89+
const cleaned = value.trim().replace("%", "")
90+
const parsed = parseFloat(cleaned)
91+
return isNaN(parsed) ? 0 : parsed
92+
}
93+
return 0
94+
}
95+
96+
/**
97+
* Claude Usage Router
98+
* Fetches usage data from Anthropic's OAuth API
99+
*/
100+
export const claudeUsageRouter = router({
101+
/**
102+
* Get current usage stats
103+
*/
104+
getUsage: publicProcedure.query(async (): Promise<{
105+
data: ClaudeUsageData | null
106+
error: string | null
107+
}> => {
108+
const token = getOAuthToken()
109+
110+
if (!token) {
111+
return {
112+
data: null,
113+
error: "Not connected to Claude Code",
114+
}
115+
}
116+
117+
try {
118+
console.log("[ClaudeUsage] Fetching from API...")
119+
const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
120+
method: "GET",
121+
headers: {
122+
Authorization: `Bearer ${token}`,
123+
"Content-Type": "application/json",
124+
"User-Agent": "claude-code/2.1.5",
125+
"anthropic-beta": "oauth-2025-04-20",
126+
},
127+
})
128+
console.log("[ClaudeUsage] Response status:", response.status)
129+
130+
if (response.status === 401 || response.status === 403) {
131+
const body = await response.text()
132+
console.error("[ClaudeUsage] Auth failed:", response.status, body)
133+
return {
134+
data: null,
135+
error: "Token expired or invalid. Please reconnect Claude Code.",
136+
}
137+
}
138+
139+
if (response.status === 429) {
140+
return {
141+
data: null,
142+
error: "Rate limited. Please try again later.",
143+
}
144+
}
145+
146+
if (!response.ok) {
147+
console.error("[ClaudeUsage] API error:", response.status, response.statusText)
148+
return {
149+
data: null,
150+
error: `API error: ${response.status}`,
151+
}
152+
}
153+
154+
const rawData = await response.json() as Record<string, unknown>
155+
156+
// Debug: log raw API response
157+
console.log("[ClaudeUsage] Raw API response:", JSON.stringify(rawData, null, 2))
158+
159+
// Parse each section with robust type handling (matching claude-usage-tracker)
160+
const fiveHour = rawData.five_hour as Record<string, unknown> | undefined
161+
const sevenDay = rawData.seven_day as Record<string, unknown> | undefined
162+
const sevenDayOpus = rawData.seven_day_opus as Record<string, unknown> | undefined
163+
const sevenDaySonnet = rawData.seven_day_sonnet as Record<string, unknown> | undefined
164+
165+
const data: ClaudeUsageData = {
166+
fiveHour: {
167+
utilization: fiveHour ? parseUtilization(fiveHour.utilization) : 0,
168+
resetsAt: (fiveHour?.resets_at as string) ?? null,
169+
},
170+
sevenDay: {
171+
utilization: sevenDay ? parseUtilization(sevenDay.utilization) : 0,
172+
resetsAt: (sevenDay?.resets_at as string) ?? null,
173+
},
174+
// Always include model breakdowns (default to 0 if not present)
175+
sevenDayOpus: {
176+
utilization: sevenDayOpus ? parseUtilization(sevenDayOpus.utilization) : 0,
177+
},
178+
sevenDaySonnet: {
179+
utilization: sevenDaySonnet ? parseUtilization(sevenDaySonnet.utilization) : 0,
180+
resetsAt: (sevenDaySonnet?.resets_at as string) ?? null,
181+
},
182+
lastFetched: new Date().toISOString(),
183+
}
184+
185+
return { data, error: null }
186+
} catch (error) {
187+
console.error("[ClaudeUsage] Fetch error:", error)
188+
return {
189+
data: null,
190+
error: "Network error. Please check your connection.",
191+
}
192+
}
193+
}),
194+
})

src/main/lib/trpc/routers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { chatsRouter } from "./chats"
44
import { claudeRouter } from "./claude"
55
import { claudeCodeRouter } from "./claude-code"
66
import { claudeSettingsRouter } from "./claude-settings"
7+
import { claudeUsageRouter } from "./claude-usage"
78
import { anthropicAccountsRouter } from "./anthropic-accounts"
89
import { ollamaRouter } from "./ollama"
910
import { terminalRouter } from "./terminal"
@@ -30,6 +31,7 @@ export function createAppRouter(getWindow: () => BrowserWindow | null) {
3031
claude: claudeRouter,
3132
claudeCode: claudeCodeRouter,
3233
claudeSettings: claudeSettingsRouter,
34+
claudeUsage: claudeUsageRouter,
3335
anthropicAccounts: anthropicAccountsRouter,
3436
ollama: ollamaRouter,
3537
terminal: terminalRouter,

src/renderer/features/details-sidebar/atoms/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import { atom } from "jotai"
22
import { atomFamily, atomWithStorage } from "jotai/utils"
33
import { atomWithWindowStorage } from "../../../lib/window-storage"
44
import type { LucideIcon } from "lucide-react"
5-
import { Box, FileText, Terminal, FileDiff, ListTodo } from "lucide-react"
5+
import { Box, FileText, Terminal, FileDiff, ListTodo, Gauge } from "lucide-react"
66

77
// ============================================================================
88
// Widget System Types & Registry
99
// ============================================================================
1010

11-
export type WidgetId = "info" | "todo" | "plan" | "terminal" | "diff"
11+
export type WidgetId = "info" | "todo" | "plan" | "terminal" | "diff" | "usage"
1212

1313
export interface WidgetConfig {
1414
id: WidgetId
@@ -20,6 +20,7 @@ export interface WidgetConfig {
2020

2121
export const WIDGET_REGISTRY: WidgetConfig[] = [
2222
{ id: "info", label: "Workspace", icon: Box, canExpand: false, defaultVisible: true },
23+
{ id: "usage", label: "Usage", icon: Gauge, canExpand: false, defaultVisible: true },
2324
{ id: "todo", label: "To-dos", icon: ListTodo, canExpand: false, defaultVisible: true },
2425
{ id: "plan", label: "Plan", icon: FileText, canExpand: true, defaultVisible: true },
2526
{ id: "terminal", label: "Terminal", icon: Terminal, canExpand: true, defaultVisible: false },

src/renderer/features/details-sidebar/details-sidebar.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useCallback, useEffect, useMemo } from "react"
44
import { useAtom, useAtomValue } from "jotai"
5-
import { ArrowUpRight, TerminalSquare, Box, ListTodo } from "lucide-react"
5+
import { ArrowUpRight, TerminalSquare, Box, ListTodo, Gauge } from "lucide-react"
66
import { ResizableSidebar } from "@/components/ui/resizable-sidebar"
77
import { Button } from "@/components/ui/button"
88
import {
@@ -32,6 +32,7 @@ import { TodoWidget } from "./sections/todo-widget"
3232
import { PlanWidget } from "./sections/plan-widget"
3333
import { TerminalWidget } from "./sections/terminal-widget"
3434
import { ChangesWidget } from "./sections/changes-widget"
35+
import { UsageWidget } from "./sections/usage-widget"
3536
import type { ParsedDiffFile } from "./types"
3637
import type { AgentMode } from "../agents/atoms"
3738

@@ -187,6 +188,8 @@ export function DetailsSidebar({
187188
switch (widgetId) {
188189
case "info":
189190
return Box
191+
case "usage":
192+
return Gauge
190193
case "todo":
191194
return ListTodo
192195
case "plan":
@@ -343,6 +346,9 @@ export function DetailsSidebar({
343346
</WidgetCard>
344347
)
345348

349+
case "usage":
350+
return <UsageWidget key="usage" />
351+
346352
case "todo":
347353
return (
348354
<TodoWidget key="todo" subChatId={activeSubChatId || null} />

0 commit comments

Comments
 (0)