diff --git a/src/__tests__/main/ipc/handlers/system.test.ts b/src/__tests__/main/ipc/handlers/system.test.ts index b61445fe4..ee14724e9 100644 --- a/src/__tests__/main/ipc/handlers/system.test.ts +++ b/src/__tests__/main/ipc/handlers/system.test.ts @@ -253,6 +253,8 @@ describe('system IPC handlers', () => { 'power:removeReason', // Clipboard handlers 'clipboard:writeImage', + // Usage handlers + 'usage:getClaudeUsage', ]; for (const channel of expectedChannels) { diff --git a/src/main/ipc/handlers/system.ts b/src/main/ipc/handlers/system.ts index c49d79067..00208c86d 100644 --- a/src/main/ipc/handlers/system.ts +++ b/src/main/ipc/handlers/system.ts @@ -15,6 +15,7 @@ */ import { ipcMain, dialog, shell, clipboard, nativeImage, BrowserWindow, App } from 'electron'; +import * as os from 'os'; import * as path from 'path'; import * as fsSync from 'fs'; import Store from 'electron-store'; @@ -659,6 +660,45 @@ export function registerSystemHandlers(deps: SystemHandlerDependencies): void { ipcMain.handle('power:removeReason', async (_event, reason: string) => { powerManager.removeBlockReason(reason); }); + + // ============ Claude Usage Handlers ============ + + // Read Claude usage data from PAI cache file, falling back to Anthropic OAuth API. + // utilization values from the API are 0–100 integers (not fractional 0–1). + ipcMain.handle('usage:getClaudeUsage', async () => { + const cachePath = path.join(os.homedir(), '.claude', 'MEMORY', 'STATE', 'usage-cache.json'); + + // Try reading from the PAI cache file first (already kept fresh by PAI hooks) + try { + const raw = await fsSync.promises.readFile(cachePath, 'utf-8'); + const data = JSON.parse(raw); + return { success: true, data }; + } catch { + // Cache not available — fall back to calling the Anthropic OAuth API directly + } + + // Fallback: read credentials and call the API + try { + const credsPath = path.join(os.homedir(), '.claude', '.credentials.json'); + const credsRaw = await fsSync.promises.readFile(credsPath, 'utf-8'); + const creds = JSON.parse(credsRaw); + const token = creds?.claudeAiOauth?.accessToken; + if (!token) return { success: false, error: 'No OAuth token found' }; + + const response = await fetch('https://api.anthropic.com/api/oauth/usage', { + headers: { + Authorization: `Bearer ${token}`, + 'anthropic-beta': 'oauth-2025-04-20', + }, + }); + if (!response.ok) return { success: false, error: `API error: ${response.status}` }; + const data = await response.json(); + return { success: true, data }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return { success: false, error: message }; + } + }); } /** diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts index e91a1f1f8..07bb2171b 100644 --- a/src/main/preload/index.ts +++ b/src/main/preload/index.ts @@ -29,6 +29,7 @@ import { createPowerApi, createUpdatesApi, createAppApi, + createUsageApi, } from './system'; import { createSshRemoteApi } from './sshRemote'; import { createLoggerApi } from './logger'; @@ -191,6 +192,9 @@ contextBridge.exposeInMainWorld('maestro', { // WakaTime API (CLI check, API key validation) wakatime: createWakatimeApi(), + + // Claude Usage API (session limits, weekly limits) + usage: createUsageApi(), }); // Re-export factory functions for external consumers (e.g., tests) @@ -264,6 +268,8 @@ export { createDirectorNotesApi, // WakaTime createWakatimeApi, + // Usage + createUsageApi, }; // Re-export types for TypeScript consumers @@ -302,6 +308,9 @@ export type { AppApi, ShellInfo, UpdateStatus, + ClaudeUsageData, + ClaudeUsagePeriod, + ClaudeUsageResult, } from './system'; export type { // From sshRemote diff --git a/src/main/preload/system.ts b/src/main/preload/system.ts index 756850773..fc7af8905 100644 --- a/src/main/preload/system.ts +++ b/src/main/preload/system.ts @@ -206,6 +206,42 @@ export function createAppApi() { }; } +/** + * Claude usage data shape from the Anthropic OAuth usage API + */ +export interface ClaudeUsagePeriod { + utilization: number; + resets_at: string; +} + +export interface ClaudeUsageData { + five_hour?: ClaudeUsagePeriod | null; + seven_day?: ClaudeUsagePeriod | null; + seven_day_sonnet?: ClaudeUsagePeriod | null; + extra_usage?: { + is_enabled: boolean; + monthly_limit: number; + used_credits: number; + utilization: number; + } | null; +} + +export interface ClaudeUsageResult { + success: boolean; + data?: ClaudeUsageData; + error?: string; +} + +/** + * Creates the usage API object for preload exposure + */ +export function createUsageApi() { + return { + getClaudeUsage: (): Promise => + ipcRenderer.invoke('usage:getClaudeUsage'), + }; +} + export type DialogApi = ReturnType; export type FontsApi = ReturnType; export type ShellsApi = ReturnType; diff --git a/src/renderer/components/SessionList/ClaudeUsageWidget.tsx b/src/renderer/components/SessionList/ClaudeUsageWidget.tsx new file mode 100644 index 000000000..4ba8d1c78 --- /dev/null +++ b/src/renderer/components/SessionList/ClaudeUsageWidget.tsx @@ -0,0 +1,159 @@ +import { memo, useEffect, useState, useCallback } from 'react'; +import type { Theme } from '../../types'; + +interface UsagePeriod { + utilization: number; + resets_at: string; +} + +interface ClaudeUsage { + five_hour?: UsagePeriod | null; + seven_day?: UsagePeriod | null; + extra_usage?: { + is_enabled: boolean; + monthly_limit: number; + used_credits: number; + utilization: number; + } | null; +} + +interface ClaudeUsageWidgetProps { + theme: Theme; +} + +/** Returns remaining time as a human-readable string, e.g. "4h 12m" */ +function formatTimeUntil(isoString: string): string { + const now = Date.now(); + const target = new Date(isoString).getTime(); + const diffMs = target - now; + if (diffMs <= 0) return 'resetting...'; + const totalMin = Math.floor(diffMs / 60000); + const hours = Math.floor(totalMin / 60); + const mins = totalMin % 60; + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +} + +/** Returns a Tailwind-compatible color based on utilization percentage */ +function utilizationColor(pct: number): string { + if (pct >= 80) return '#ef4444'; // red-500 + if (pct >= 50) return '#f59e0b'; // amber-500 + return '#22c55e'; // green-500 +} + +interface UsageBarProps { + label: string; + sublabel: string; + utilization: number; + theme: Theme; +} + +function UsageBar({ label, sublabel, utilization, theme }: UsageBarProps) { + const pct = Math.min(100, Math.max(0, utilization)); + const color = utilizationColor(pct); + + return ( +
+
+ + {label} + + + {pct.toFixed(0)}% + +
+
+
+
+ + {sublabel} + +
+ ); +} + +const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes, matching PAI cache TTL + +export const ClaudeUsageWidget = memo(function ClaudeUsageWidget({ + theme, +}: ClaudeUsageWidgetProps) { + const [usage, setUsage] = useState(null); + const [error, setError] = useState(false); + + const fetchUsage = useCallback(async () => { + try { + const result = await window.maestro.usage.getClaudeUsage(); + if (result.success && result.data) { + setUsage(result.data as ClaudeUsage); + setError(false); + } else { + setError(true); + } + } catch { + setError(true); + } + }, []); + + useEffect(() => { + fetchUsage(); + const interval = setInterval(fetchUsage, POLL_INTERVAL_MS); + return () => clearInterval(interval); + }, [fetchUsage]); + + if (error || !usage) return null; + + const fiveHour = usage.five_hour; + const sevenDay = usage.seven_day; + + if (!fiveHour && !sevenDay) return null; + + return ( +
+
+ + Claude Usage + + +
+ + {fiveHour && ( + + )} + + {sevenDay && ( + + )} +
+ ); +}); diff --git a/src/renderer/components/SessionList/SessionList.tsx b/src/renderer/components/SessionList/SessionList.tsx index 38ad6fbd2..6d958a592 100644 --- a/src/renderer/components/SessionList/SessionList.tsx +++ b/src/renderer/components/SessionList/SessionList.tsx @@ -31,6 +31,7 @@ import { SessionContextMenu } from './SessionContextMenu'; import { HamburgerMenuContent } from './HamburgerMenuContent'; import { CollapsedSessionPill } from './CollapsedSessionPill'; import { SidebarActions } from './SidebarActions'; +import { ClaudeUsageWidget } from './ClaudeUsageWidget'; import { SkinnySidebar } from './SkinnySidebar'; import { LiveOverlayPanel } from './LiveOverlayPanel'; import { useSessionCategories } from '../../hooks/session/useSessionCategories'; @@ -1178,6 +1179,9 @@ function SessionListInner(props: SessionListProps) { /> )} + {/* CLAUDE USAGE WIDGET — only shown when sidebar is expanded */} + {leftSidebarOpen && } + {/* SIDEBAR BOTTOM ACTIONS */} Promise<{ available: boolean; version?: string }>; validateApiKey: (key: string) => Promise<{ valid: boolean }>; }; + + // Claude Usage API (session limits, weekly limits from Anthropic OAuth) + usage: { + getClaudeUsage: () => Promise<{ + success: boolean; + data?: { + five_hour?: { utilization: number; resets_at: string } | null; + seven_day?: { utilization: number; resets_at: string } | null; + seven_day_sonnet?: { utilization: number; resets_at: string } | null; + extra_usage?: { + is_enabled: boolean; + monthly_limit: number; + used_credits: number; + utilization: number; + } | null; + }; + error?: string; + }>; + }; } declare global {