From 0793e71361ce477304dd58ac02b6d7326233a6c1 Mon Sep 17 00:00:00 2001 From: Varma D Aadi Narayana Date: Mon, 26 Jan 2026 18:50:17 +0530 Subject: [PATCH 1/2] 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 --- src/main/lib/trpc/routers/claude-usage.ts | 194 ++++++++++++++ src/main/lib/trpc/routers/index.ts | 2 + .../features/details-sidebar/atoms/index.ts | 5 +- .../details-sidebar/details-sidebar.tsx | 8 +- .../details-sidebar/sections/usage-widget.tsx | 247 ++++++++++++++++++ .../details-sidebar/widget-settings-popup.tsx | 4 +- 6 files changed, 456 insertions(+), 4 deletions(-) create mode 100644 src/main/lib/trpc/routers/claude-usage.ts create mode 100644 src/renderer/features/details-sidebar/sections/usage-widget.tsx diff --git a/src/main/lib/trpc/routers/claude-usage.ts b/src/main/lib/trpc/routers/claude-usage.ts new file mode 100644 index 00000000..828c5d8e --- /dev/null +++ b/src/main/lib/trpc/routers/claude-usage.ts @@ -0,0 +1,194 @@ +import { execSync } from "child_process" +import os from "os" +import { publicProcedure, router } from "../index" + +/** + * Read Claude Code credentials from macOS System Keychain + * This is where the Claude CLI stores its OAuth token with proper scopes + */ +function readSystemKeychainCredentials(): string | null { + try { + const username = os.userInfo().username + const result = execSync( + `security find-generic-password -s "Claude Code-credentials" -a "${username}" -w 2>/dev/null`, + { encoding: "utf-8" } + ) + return result.trim() + } catch { + // Item not found or error + return null + } +} + +/** + * Extract access token from Claude CLI credentials JSON + */ +function extractAccessToken(jsonData: string): string | null { + try { + const data = JSON.parse(jsonData) + return data?.claudeAiOauth?.accessToken ?? null + } catch { + return null + } +} + +/** + * Get OAuth token from system keychain (Claude CLI credentials) + * This token has the proper scopes for usage API + */ +function getOAuthToken(): string | null { + const keychainData = readSystemKeychainCredentials() + + if (!keychainData) { + console.log("[ClaudeUsage] No credentials found in system keychain") + return null + } + + const token = extractAccessToken(keychainData) + if (token) { + console.log("[ClaudeUsage] Token from system keychain, length:", token.length) + } else { + console.log("[ClaudeUsage] Could not extract token from keychain data") + } + + return token +} + +/** + * Parsed usage data returned to the client + * Always includes all model breakdowns (defaulting to 0 if not used) + */ +export interface ClaudeUsageData { + fiveHour: { + utilization: number + resetsAt: string | null + } + sevenDay: { + utilization: number + resetsAt: string | null + } + sevenDayOpus: { + utilization: number + } + sevenDaySonnet: { + utilization: number + resetsAt: string | null + } + lastFetched: string +} + +/** + * Parse utilization value that can be Int, Double, or String + * Based on claude-usage-tracker's robust parser + */ +function parseUtilization(value: unknown): number { + if (typeof value === "number") { + return value + } + if (typeof value === "string") { + const cleaned = value.trim().replace("%", "") + const parsed = parseFloat(cleaned) + return isNaN(parsed) ? 0 : parsed + } + return 0 +} + +/** + * Claude Usage Router + * Fetches usage data from Anthropic's OAuth API + */ +export const claudeUsageRouter = router({ + /** + * Get current usage stats + */ + getUsage: publicProcedure.query(async (): Promise<{ + data: ClaudeUsageData | null + error: string | null + }> => { + const token = getOAuthToken() + + if (!token) { + return { + data: null, + error: "Not connected to Claude Code", + } + } + + try { + console.log("[ClaudeUsage] Fetching from API...") + const response = await fetch("https://api.anthropic.com/api/oauth/usage", { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "claude-code/2.1.5", + "anthropic-beta": "oauth-2025-04-20", + }, + }) + console.log("[ClaudeUsage] Response status:", response.status) + + if (response.status === 401 || response.status === 403) { + const body = await response.text() + console.error("[ClaudeUsage] Auth failed:", response.status, body) + return { + data: null, + error: "Token expired or invalid. Please reconnect Claude Code.", + } + } + + if (response.status === 429) { + return { + data: null, + error: "Rate limited. Please try again later.", + } + } + + if (!response.ok) { + console.error("[ClaudeUsage] API error:", response.status, response.statusText) + return { + data: null, + error: `API error: ${response.status}`, + } + } + + const rawData = await response.json() as Record + + // Debug: log raw API response + console.log("[ClaudeUsage] Raw API response:", JSON.stringify(rawData, null, 2)) + + // Parse each section with robust type handling (matching claude-usage-tracker) + const fiveHour = rawData.five_hour as Record | undefined + const sevenDay = rawData.seven_day as Record | undefined + const sevenDayOpus = rawData.seven_day_opus as Record | undefined + const sevenDaySonnet = rawData.seven_day_sonnet as Record | undefined + + const data: ClaudeUsageData = { + fiveHour: { + utilization: fiveHour ? parseUtilization(fiveHour.utilization) : 0, + resetsAt: (fiveHour?.resets_at as string) ?? null, + }, + sevenDay: { + utilization: sevenDay ? parseUtilization(sevenDay.utilization) : 0, + resetsAt: (sevenDay?.resets_at as string) ?? null, + }, + // Always include model breakdowns (default to 0 if not present) + sevenDayOpus: { + utilization: sevenDayOpus ? parseUtilization(sevenDayOpus.utilization) : 0, + }, + sevenDaySonnet: { + utilization: sevenDaySonnet ? parseUtilization(sevenDaySonnet.utilization) : 0, + resetsAt: (sevenDaySonnet?.resets_at as string) ?? null, + }, + lastFetched: new Date().toISOString(), + } + + return { data, error: null } + } catch (error) { + console.error("[ClaudeUsage] Fetch error:", error) + return { + data: null, + error: "Network error. Please check your connection.", + } + } + }), +}) diff --git a/src/main/lib/trpc/routers/index.ts b/src/main/lib/trpc/routers/index.ts index 7f35a7a0..4cbfac07 100644 --- a/src/main/lib/trpc/routers/index.ts +++ b/src/main/lib/trpc/routers/index.ts @@ -4,6 +4,7 @@ import { chatsRouter } from "./chats" import { claudeRouter } from "./claude" import { claudeCodeRouter } from "./claude-code" import { claudeSettingsRouter } from "./claude-settings" +import { claudeUsageRouter } from "./claude-usage" import { anthropicAccountsRouter } from "./anthropic-accounts" import { ollamaRouter } from "./ollama" import { terminalRouter } from "./terminal" @@ -30,6 +31,7 @@ export function createAppRouter(getWindow: () => BrowserWindow | null) { claude: claudeRouter, claudeCode: claudeCodeRouter, claudeSettings: claudeSettingsRouter, + claudeUsage: claudeUsageRouter, anthropicAccounts: anthropicAccountsRouter, ollama: ollamaRouter, terminal: terminalRouter, diff --git a/src/renderer/features/details-sidebar/atoms/index.ts b/src/renderer/features/details-sidebar/atoms/index.ts index 16546361..0d25951b 100644 --- a/src/renderer/features/details-sidebar/atoms/index.ts +++ b/src/renderer/features/details-sidebar/atoms/index.ts @@ -2,13 +2,13 @@ import { atom } from "jotai" import { atomFamily, atomWithStorage } from "jotai/utils" import { atomWithWindowStorage } from "../../../lib/window-storage" import type { LucideIcon } from "lucide-react" -import { Box, FileText, Terminal, FileDiff, ListTodo } from "lucide-react" +import { Box, FileText, Terminal, FileDiff, ListTodo, Gauge } from "lucide-react" // ============================================================================ // Widget System Types & Registry // ============================================================================ -export type WidgetId = "info" | "todo" | "plan" | "terminal" | "diff" +export type WidgetId = "info" | "usage" | "todo" | "plan" | "terminal" | "diff" export interface WidgetConfig { id: WidgetId @@ -20,6 +20,7 @@ export interface WidgetConfig { export const WIDGET_REGISTRY: WidgetConfig[] = [ { id: "info", label: "Workspace", icon: Box, canExpand: false, defaultVisible: true }, + { id: "usage", label: "Usage", icon: Gauge, canExpand: false, defaultVisible: true }, { id: "todo", label: "To-dos", icon: ListTodo, canExpand: false, defaultVisible: true }, { id: "plan", label: "Plan", icon: FileText, canExpand: true, defaultVisible: true }, { id: "terminal", label: "Terminal", icon: Terminal, canExpand: true, defaultVisible: false }, diff --git a/src/renderer/features/details-sidebar/details-sidebar.tsx b/src/renderer/features/details-sidebar/details-sidebar.tsx index 6b0d40fb..02765d06 100644 --- a/src/renderer/features/details-sidebar/details-sidebar.tsx +++ b/src/renderer/features/details-sidebar/details-sidebar.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo } from "react" import { useAtom, useAtomValue } from "jotai" -import { ArrowUpRight, TerminalSquare, Box, ListTodo } from "lucide-react" +import { ArrowUpRight, TerminalSquare, Box, ListTodo, Gauge } from "lucide-react" import { ResizableSidebar } from "@/components/ui/resizable-sidebar" import { Button } from "@/components/ui/button" import { @@ -32,6 +32,7 @@ import { TodoWidget } from "./sections/todo-widget" import { PlanWidget } from "./sections/plan-widget" import { TerminalWidget } from "./sections/terminal-widget" import { ChangesWidget } from "./sections/changes-widget" +import { UsageWidget } from "./sections/usage-widget" import type { ParsedDiffFile } from "./types" import type { AgentMode } from "../agents/atoms" @@ -187,6 +188,8 @@ export function DetailsSidebar({ switch (widgetId) { case "info": return Box + case "usage": + return Gauge case "todo": return ListTodo case "plan": @@ -343,6 +346,9 @@ export function DetailsSidebar({ ) + case "usage": + return + case "todo": return ( diff --git a/src/renderer/features/details-sidebar/sections/usage-widget.tsx b/src/renderer/features/details-sidebar/sections/usage-widget.tsx new file mode 100644 index 00000000..27e65fb5 --- /dev/null +++ b/src/renderer/features/details-sidebar/sections/usage-widget.tsx @@ -0,0 +1,247 @@ +"use client" + +import { memo, useState, useCallback } from "react" +import { RefreshCw, AlertCircle, Gauge, ChevronDown } from "lucide-react" +import { cn } from "@/lib/utils" +import { trpc } from "@/lib/trpc" + +/** + * Get status level based on percentage + * - green (safe): < 50% + * - orange (moderate): 50-80% + * - red (critical): > 80% + */ +function getStatusLevel(percentage: number): "safe" | "moderate" | "critical" { + if (percentage < 50) return "safe" + if (percentage < 80) return "moderate" + return "critical" +} + +/** + * Get color classes based on status level + */ +function getStatusColor(status: "safe" | "moderate" | "critical"): string { + switch (status) { + case "safe": + return "bg-muted-foreground/60" + case "moderate": + return "bg-orange-500" + case "critical": + return "bg-red-500" + } +} + +/** + * Format reset time as "Resets in X" or "Resets Monday 12:59 PM" + */ +function formatResetTime(isoString: string | null): string { + if (!isoString) return "" + + try { + const resetDate = new Date(isoString) + const now = new Date() + const diffMs = resetDate.getTime() - now.getTime() + + if (diffMs < 0) return "Resets now" + + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + // For times under 24 hours, show relative time + if (diffDays === 0) { + if (diffHours > 0) { + const remainingMins = diffMins % 60 + return remainingMins > 0 + ? `Resets in ${diffHours}h ${remainingMins}m` + : `Resets in ${diffHours}h` + } + return `Resets in ${diffMins}m` + } + + // For times over 24 hours, show day and time + const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + const day = dayNames[resetDate.getDay()] + const hours = resetDate.getHours() + const minutes = resetDate.getMinutes() + const ampm = hours >= 12 ? "PM" : "AM" + const hour12 = hours % 12 || 12 + const timeStr = minutes > 0 + ? `${hour12}:${minutes.toString().padStart(2, "0")} ${ampm}` + : `${hour12} ${ampm}` + + return `Resets ${day} ${timeStr}` + } catch { + return "" + } +} + +/** + * Progress bar component with color coding + */ +const UsageProgressBar = ({ + percentage, + label, + resetTime, +}: { + percentage: number + label: string + resetTime: string | null +}) => { + const status = getStatusLevel(percentage) + const colorClass = getStatusColor(status) + const resetLabel = formatResetTime(resetTime) + + return ( +
+
+ {label} + {Math.round(percentage)}% +
+
+
+
+ {resetLabel && ( + {resetLabel} + )} +
+ ) +} + +/** + * Usage Widget for Overview Sidebar + * Displays Claude Code usage stats with progress bars + */ +export const UsageWidget = memo(function UsageWidget() { + const [isRefreshing, setIsRefreshing] = useState(false) + const [isModelExpanded, setIsModelExpanded] = useState(true) + + // Fetch usage data - always try, backend returns error if not connected + const { data: result, refetch, isLoading } = trpc.claudeUsage.getUsage.useQuery(undefined, { + refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes + staleTime: 30 * 1000, // Consider data stale after 30 seconds + refetchOnMount: "always", // Always refetch when component mounts + refetchOnWindowFocus: true, // Refetch when window regains focus + }) + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true) + try { + await refetch() + } finally { + setIsRefreshing(false) + } + }, [refetch]) + + const usageData = result?.data + const error = result?.error + + // Show nothing if not connected (don't clutter sidebar) + if (!isLoading && !usageData && error === "Not connected to Claude Code") { + return null + } + + return ( +
+
+ {/* Header */} +
+ + Usage + + {/* Refresh button */} + +
+ + {/* Content */} +
+ {/* Loading state */} + {isLoading && !usageData && ( +
+ + Loading usage... +
+ )} + + {/* Error state */} + {error && !usageData && ( +
+ + {error} +
+ )} + + {/* Usage data */} + {usageData && ( + <> + {/* 5-hour session usage */} + + + {/* 7-day weekly usage */} + + + {/* Model breakdown - expandable */} +
+ + {isModelExpanded && ( +
+ + +
+ )} +
+ + )} +
+
+
+ ) +}) diff --git a/src/renderer/features/details-sidebar/widget-settings-popup.tsx b/src/renderer/features/details-sidebar/widget-settings-popup.tsx index 94566cbf..c99147fb 100644 --- a/src/renderer/features/details-sidebar/widget-settings-popup.tsx +++ b/src/renderer/features/details-sidebar/widget-settings-popup.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from "react" import { useAtom } from "jotai" -import { GripVertical, Box, TerminalSquare, ListTodo } from "lucide-react" +import { GripVertical, Box, TerminalSquare, ListTodo, Gauge } from "lucide-react" import { Button } from "@/components/ui/button" import { Popover, @@ -30,6 +30,8 @@ function getWidgetIcon(widgetId: WidgetId) { switch (widgetId) { case "info": return Box + case "usage": + return Gauge case "todo": return ListTodo case "plan": From f9f51c8d26bc69e2384913079094ff54cae85b61 Mon Sep 17 00:00:00 2001 From: Varma D Aadi Narayana Date: Mon, 26 Jan 2026 22:23:55 +0530 Subject: [PATCH 2/2] fix: chat input inactive after creating new chat Use reactive Zustand selectors instead of getState() for sub-chat store values (activeSubChatId, openSubChatIds, pinnedSubChatIds, allSubChats). The previous non-reactive getState() calls caused the component to not re-render when the store initialized, leaving tabsToRender empty and showing a disabled input placeholder for newly created chats. --- src/renderer/features/agents/main/active-chat.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index 525d13ff..f83b567b 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -4539,10 +4539,13 @@ export function ChatView({ }) }, [chatId, setUnseenChanges]) - // Get sub-chat state from store (using getState() to avoid re-renders on state changes) - const activeSubChatId = useAgentSubChatStore.getState().activeSubChatId - const openSubChatIds = useAgentSubChatStore.getState().openSubChatIds - const pinnedSubChatIds = useAgentSubChatStore.getState().pinnedSubChatIds + // Get sub-chat state from store (reactive selectors to re-render when store changes) + // IMPORTANT: Using reactive selectors here is critical for new chats where activeSubChatId + // starts as null and is set after the store initializes from the useEffect below. + // Using getState() would cause the component to not re-render, showing a disabled input. + const activeSubChatId = useAgentSubChatStore((state) => state.activeSubChatId) + const openSubChatIds = useAgentSubChatStore((state) => state.openSubChatIds) + const pinnedSubChatIds = useAgentSubChatStore((state) => state.pinnedSubChatIds) // Clear sub-chat "unseen changes" indicator when sub-chat becomes active useEffect(() => { @@ -4556,7 +4559,7 @@ export function ChatView({ return prev }) }, [activeSubChatId, setSubChatUnseenChanges]) - const allSubChats = useAgentSubChatStore.getState().allSubChats + const allSubChats = useAgentSubChatStore((state) => state.allSubChats) // tRPC utils for optimistic cache updates const utils = api.useUtils()