Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/__tests__/main/ipc/handlers/system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ describe('system IPC handlers', () => {
'power:removeReason',
// Clipboard handlers
'clipboard:writeImage',
// Usage handlers
'usage:getClaudeUsage',
];

for (const channel of expectedChannels) {
Expand Down
40 changes: 40 additions & 0 deletions src/main/ipc/handlers/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
}
});
}

/**
Expand Down
9 changes: 9 additions & 0 deletions src/main/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
createPowerApi,
createUpdatesApi,
createAppApi,
createUsageApi,
} from './system';
import { createSshRemoteApi } from './sshRemote';
import { createLoggerApi } from './logger';
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -264,6 +268,8 @@ export {
createDirectorNotesApi,
// WakaTime
createWakatimeApi,
// Usage
createUsageApi,
};

// Re-export types for TypeScript consumers
Expand Down Expand Up @@ -302,6 +308,9 @@ export type {
AppApi,
ShellInfo,
UpdateStatus,
ClaudeUsageData,
ClaudeUsagePeriod,
ClaudeUsageResult,
} from './system';
export type {
// From sshRemote
Expand Down
36 changes: 36 additions & 0 deletions src/main/preload/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClaudeUsageResult> =>
ipcRenderer.invoke('usage:getClaudeUsage'),
};
}

export type DialogApi = ReturnType<typeof createDialogApi>;
export type FontsApi = ReturnType<typeof createFontsApi>;
export type ShellsApi = ReturnType<typeof createShellsApi>;
Expand Down
159 changes: 159 additions & 0 deletions src/renderer/components/SessionList/ClaudeUsageWidget.tsx
Original file line number Diff line number Diff line change
@@ -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));
Comment on lines +51 to +52
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Verify utilization is on a 0–100 scale, not 0–1

The code clamps utilization to [0, 100] and applies thresholds of 50 and 80, treating the value as a percentage integer/float. If the Anthropic OAuth API actually returns a fraction (0–1, which is a common REST convention for utilization ratios), then pct would always be between 0 and 1 — progress bars would render as invisible slivers (~0–1% wide) and the displayed percentage would always show 0% or 1%. The widget would silently appear broken.

A comment or assertion documenting the expected range (e.g. // API returns 0–100) would protect against future confusion, and a multiplication guard would make the intent explicit.

const color = utilizationColor(pct);

return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium opacity-60" style={{ color: theme.colors.text }}>
{label}
</span>
<span className="text-[10px] font-mono font-bold" style={{ color }}>
{pct.toFixed(0)}%
</span>
</div>
<div
className="h-1 rounded-full overflow-hidden"
style={{ backgroundColor: `${theme.colors.text}20` }}
>
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: color }}
/>
</div>
<span className="text-[9px] opacity-40" style={{ color: theme.colors.text }}>
{sublabel}
</span>
</div>
);
}

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<ClaudeUsage | null>(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 (
<div
className="px-3 py-2 border-t flex flex-col gap-2"
style={{ borderColor: theme.colors.border }}
title="Claude usage limits — click Usage Dashboard (Alt+Meta+U) for full stats"
>
<div className="flex items-center justify-between mb-0.5">
<span
className="text-[9px] font-semibold uppercase tracking-wider opacity-40"
style={{ color: theme.colors.text }}
>
Claude Usage
</span>
<button
type="button"
onClick={fetchUsage}
className="text-[9px] opacity-30 hover:opacity-60 transition-opacity"
style={{ color: theme.colors.text }}
title="Refresh usage data"
>
</button>
</div>

{fiveHour && (
<UsageBar
label="5-Hour Session"
sublabel={`resets in ${formatTimeUntil(fiveHour.resets_at)}`}
utilization={fiveHour.utilization}
theme={theme}
/>
)}

{sevenDay && (
<UsageBar
label="7-Day Limit"
sublabel={`resets in ${formatTimeUntil(sevenDay.resets_at)}`}
utilization={sevenDay.utilization}
theme={theme}
/>
)}
</div>
);
});
4 changes: 4 additions & 0 deletions src/renderer/components/SessionList/SessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1178,6 +1179,9 @@ function SessionListInner(props: SessionListProps) {
/>
)}

{/* CLAUDE USAGE WIDGET — only shown when sidebar is expanded */}
{leftSidebarOpen && <ClaudeUsageWidget theme={theme} />}

{/* SIDEBAR BOTTOM ACTIONS */}
<SidebarActions
theme={theme}
Expand Down
19 changes: 19 additions & 0 deletions src/renderer/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2732,6 +2732,25 @@ interface MaestroAPI {
checkCli: () => 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 {
Expand Down